HamiltonZero / src /hamiltonzero /hamiltonian.py
Aleksei Ustimenko
Initial HamiltonZero release
5ccb4fd
Raw
History Blame Contribute Delete
10.1 kB
# Copyright (c) 2026 Simulacra Research Inc.
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Hashable, Iterable, Sequence
import networkx as nx
import numpy as np
_PHI = 0.5 * (1.0 + np.sqrt(5.0))
def _exchange_matrix(value: Any) -> np.ndarray:
array = np.asarray(value, dtype=np.float32)
if array.ndim == 0:
return np.eye(3, dtype=np.float32) * array
if array.shape == (3,):
return np.diag(array)
if array.shape == (3, 3):
return array
raise ValueError("exchange must be a scalar, length-3 vector, or 3x3 matrix")
def _field_vector(value: Any) -> np.ndarray:
array = np.asarray(value, dtype=np.float32)
if array.ndim == 0:
return np.asarray((0.0, 0.0, float(array)), dtype=np.float32)
if array.shape == (3,):
return array
raise ValueError("field must be a scalar z-field or length-3 vector")
def _pair_alpha(exchange: np.ndarray) -> float:
if not np.any(exchange):
return 0.0
left, singular_values, right_t = np.linalg.svd(exchange)
operator_norm = float(singular_values[0])
nuclear_norm = float(singular_values.sum())
trace_component = float(np.trace(exchange) / 3.0)
trace_residual = exchange - trace_component * np.eye(3, dtype=exchange.dtype)
trace_singular_values = np.linalg.svd(trace_residual, compute_uv=False)
rotation = left @ right_t
polar_component = float(np.trace(rotation.T @ exchange) / 3.0)
polar_residual = exchange - polar_component * rotation
polar_singular_values = np.linalg.svd(polar_residual, compute_uv=False)
full_bound = min(
(_PHI / 2.0) * operator_norm,
0.5 * nuclear_norm,
0.75 * abs(trace_component) + (_PHI / 2.0) * float(trace_singular_values[0]),
0.75 * abs(trace_component) + 0.5 * float(trace_singular_values.sum()),
0.75 * abs(polar_component) + (_PHI / 2.0) * float(polar_singular_values[0]),
0.75 * abs(polar_component) + 0.5 * float(polar_singular_values.sum()),
)
symmetric = 0.5 * (exchange + exchange.T)
antisymmetric = exchange - symmetric
scale = max(float(np.linalg.norm(exchange)), 1e-9)
if float(np.linalg.norm(antisymmetric)) > 1e-9 * scale:
return full_bound
eigenvalues = np.linalg.eigvalsh(symmetric)
if not (np.all(eigenvalues >= -1e-12) or np.all(eigenvalues <= 1e-12)):
return full_bound
return min(full_bound, 0.5 * operator_norm)
def _mu_safe(exchange: np.ndarray, field: np.ndarray) -> float:
row_bound = np.zeros(exchange.shape[0], dtype=np.float64)
for left in range(exchange.shape[0]):
for right in range(left + 1, exchange.shape[0]):
pair_bound = _pair_alpha(exchange[left, right])
row_bound[left] += pair_bound
row_bound[right] += pair_bound
field_bound = np.linalg.norm(field, axis=-1)
return float(np.max(row_bound + (2.0 / 3.0) * field_bound, initial=0.0))
@dataclass(frozen=True, slots=True)
class SpinHamiltonian:
_coupling: np.ndarray
_J: np.ndarray
_h: np.ndarray
nodes: tuple[Hashable, ...]
_mu: float | None = None
_needs_fwl2: bool | None = None
_category: str | None = None
_tag: str | None = None
_topology_class: str | None = None
_j_class: str | None = None
def __post_init__(self) -> None:
coupling = np.asarray(self._coupling, dtype=np.float32)
exchange = np.asarray(self._J, dtype=np.float32)
field = np.asarray(self._h, dtype=np.float32)
n_spins = len(self.nodes)
if coupling.shape != (n_spins, n_spins):
raise ValueError("coupling must have shape [N,N]")
if exchange.shape != (n_spins, n_spins, 3, 3):
raise ValueError("J must have shape [N,N,3,3]")
if field.shape != (n_spins, 3):
raise ValueError("h must have shape [N,3]")
if not np.array_equal(coupling, coupling.T):
raise ValueError("coupling must be symmetric")
if np.any(np.diag(coupling)) or np.any(
exchange[np.arange(n_spins), np.arange(n_spins)]
):
raise ValueError("self-couplings are not supported")
if not np.allclose(exchange, exchange.transpose(1, 0, 3, 2), atol=0.0):
raise ValueError("J[j,i] must equal transpose(J[i,j])")
mu = None if self._mu is None else float(self._mu)
if mu is not None and (not np.isfinite(mu) or mu < 0.0):
raise ValueError("mu must be finite and non-negative")
object.__setattr__(self, "_coupling", coupling)
object.__setattr__(self, "_J", exchange)
object.__setattr__(self, "_h", field)
object.__setattr__(self, "_mu", mu)
object.__setattr__(
self,
"_needs_fwl2",
None if self._needs_fwl2 is None else bool(self._needs_fwl2),
)
@classmethod
def from_networkx(
cls,
graph: nx.Graph,
*,
J: Any = 1.0,
h: Any = 0.0,
edge_attribute: str = "J",
node_attribute: str = "h",
nodes: Iterable[Hashable] | None = None,
mu: float | None = None,
) -> "SpinHamiltonian":
if graph.is_directed() or graph.is_multigraph():
raise TypeError("expected a simple undirected NetworkX graph")
order = tuple(graph.nodes if nodes is None else nodes)
if len(order) != graph.number_of_nodes() or set(order) != set(graph.nodes):
raise ValueError("nodes must contain every graph node exactly once")
indices = {node: index for index, node in enumerate(order)}
n_spins = len(order)
coupling = np.zeros((n_spins, n_spins), dtype=np.float32)
exchange = np.zeros((n_spins, n_spins, 3, 3), dtype=np.float32)
field = np.zeros((n_spins, 3), dtype=np.float32)
for node, index in indices.items():
public_field = graph.nodes[node].get(node_attribute, h)
field[index] = -_field_vector(public_field)
for left_node, right_node, attributes in graph.edges(data=True):
left = indices[left_node]
right = indices[right_node]
public_exchange = _exchange_matrix(attributes.get(edge_attribute, J))
coupling[left, right] = coupling[right, left] = 1.0
exchange[left, right] = -0.5 * public_exchange
exchange[right, left] = -0.5 * public_exchange.T
return cls(
coupling,
exchange,
field,
order,
mu,
graph.graph.get("needs_fwl2"),
graph.graph.get("category"),
graph.graph.get("tag"),
graph.graph.get("topology_class"),
graph.graph.get("j_class"),
)
@classmethod
def from_arrays(
cls,
J: Any,
h: Any | None = None,
*,
coupling: Any | None = None,
nodes: Sequence[Hashable] | None = None,
mu: float | None = None,
) -> "SpinHamiltonian":
exchange = np.asarray(J, dtype=np.float32)
if exchange.ndim == 2 and exchange.shape[0] == exchange.shape[1]:
exchange = (
exchange[:, :, None, None] * np.eye(3, dtype=np.float32)[None, None]
)
elif exchange.ndim == 3 and exchange.shape[-1] == 3:
promoted = np.zeros((*exchange.shape[:2], 3, 3), dtype=np.float32)
diagonal = np.arange(3)
promoted[:, :, diagonal, diagonal] = exchange
exchange = promoted
if exchange.ndim != 4 or exchange.shape[-2:] != (3, 3):
raise ValueError("J must have shape [N,N,3] or [N,N,3,3]")
n_spins = exchange.shape[0]
if exchange.shape[1] != n_spins:
raise ValueError("J site axes must be square")
public_field = np.zeros((n_spins, 3), dtype=np.float32)
if h is not None:
h_array = np.asarray(h, dtype=np.float32)
if h_array.ndim == 0:
public_field[:, 2] = h_array
elif h_array.shape == (3,):
public_field[:] = h_array
elif h_array.shape == (n_spins,):
public_field[:, 2] = h_array
elif h_array.shape == (n_spins, 3):
public_field = h_array
else:
raise ValueError("h must be scalar, [3], [N], or [N,3]")
if coupling is None:
coupling_array = np.any(exchange != 0.0, axis=(-1, -2)).astype(np.float32)
else:
coupling_array = np.asarray(coupling, dtype=np.float32)
node_order = tuple(range(n_spins)) if nodes is None else tuple(nodes)
return cls(
coupling_array,
-0.5 * exchange,
-public_field,
node_order,
mu,
)
@property
def n_spins(self) -> int:
return len(self.nodes)
@property
def coupling(self) -> np.ndarray:
return self._coupling.copy()
@property
def J(self) -> np.ndarray:
return -2.0 * self._J.copy()
@property
def h(self) -> np.ndarray:
return -self._h.copy()
@property
def mu(self) -> float:
return _mu_safe(self._J, self._h) if self._mu is None else self._mu
def model_arrays(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
return self._coupling.copy(), self._J.copy(), self._h.copy()
def to_dict(self) -> dict[str, Any]:
result = {
"convention": "textbook",
"nodes": list(self.nodes),
"coupling": self._coupling.tolist(),
"J": self.J.tolist(),
"h": self.h.tolist(),
"mu": self.mu,
}
if self._needs_fwl2 is not None:
result["needs_fwl2"] = self._needs_fwl2
for name in ("category", "tag", "topology_class", "j_class"):
value = getattr(self, f"_{name}")
if value is not None:
result[name] = value
return result
__all__ = ["SpinHamiltonian"]