"""Coupling base class and registry.
"""
# This module is separate from the concrete couplings, so declarative models can
# subclass `BaseCoupling` without a circular import.
from __future__ import annotations
import copy
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, ClassVar
from quchip.backend.protocol import Operator
from quchip.declarative.dissipation import CollapseChannel
from quchip.devices.base import BaseDevice
from quchip.utils.labeling import auto_label, resolve_label
from quchip.utils.registry import Registrable
from quchip.utils.state_versioning import StateVersioned
if TYPE_CHECKING:
from quchip.engine.ir import DroppedTerm
[docs]
class BaseCoupling(StateVersioned, Registrable, ABC, registry_root=True):
"""Abstract base for a two-body coupling between :class:`BaseDevice`s.
Subclasses own their local interaction Hamiltonian ``H_int`` on
``H_a ⊗ H_b``. The engine embeds this local form into the full chip tensor
space, and couplings never use the engine directly.
Subclasses register automatically through the shared
:class:`~quchip.utils.registry.Registrable` mixin, without a manual
registration step. Import the module that defines the subclass so that the
registration runs.
Parameters
----------
device_a, device_b : BaseDevice or str
The two coupled devices. Label strings are *late-bound*: the coupling
keeps the string, and :class:`Chip` resolves it to the matching device
instance at construction time. Until then, the coupling cannot build an
interaction Hamiltonian.
label : str, optional
Human-readable label. If omitted, quchip derives it from
``_type_prefix`` (e.g. ``"cap_0"`` for :class:`Capacitive`).
Notes
-----
All coupling parameters (``g``, any tunable envelope, and others) must be
JAX-traceable, so sweeps and gradient-based optimization work without
forced concretization.
"""
_type_prefix: ClassVar[str] = "coupling"
# Whether dressed-specification fitting estimates this exchange strength
# from a dispersive pull, rather than binding the pull as a direct scalar.
reduces_to_crosskerr: ClassVar[bool] = False
#: Observable represented by this coupling's declared scalar when the
#: enclosing chip is passed to ``fit_a_dress`` as a dressed
#: specification. Concrete coupling models override this when their
#: inverse-design quantity is a dressed interaction observable.
default_fit_observable: ClassVar[str] = "coupling_strength"
# The endpoint device references are structural — a rebinding during
# ``copy()`` is not a physics change — so they must not bump state_version.
# Mutation tracking, the seed, ``state_version`` and ``_finish_init`` are
# owned by the shared StateVersioned mixin; tracking switches on
# automatically once construction finishes (no manual ``_finish_init``).
_untracked_names = frozenset({"device_a", "device_b"})
def __init__(
self,
device_a: BaseDevice | str,
device_b: BaseDevice | str,
*,
label: str | None = None,
) -> None:
"""Store endpoint references and assign a stable coupling label."""
for name, dev in (("device_a", device_a), ("device_b", device_b)):
if not isinstance(dev, (BaseDevice, str)):
raise TypeError(
f"{name} must be a BaseDevice or label string, got {type(dev).__name__}."
)
self.device_a = device_a
self.device_b = device_b
self.label = label if label is not None else auto_label(type(self)._type_prefix)
[docs]
def copy(self, device_map: dict[str, BaseDevice]) -> "BaseCoupling":
"""Copy authored values and rebind endpoints.
Parameters
----------
device_map : dict[str, BaseDevice]
Destination devices keyed by label.
"""
from quchip.declarative.parameters import copy_authored_fields
cloned = copy.copy(self)
copy_authored_fields(self, cloned)
object.__setattr__(cloned, "device_a", device_map[self.device_a_label])
object.__setattr__(cloned, "device_b", device_map[self.device_b_label])
return cloned
[docs]
def parameter_values(self) -> dict[str, Any]:
"""Return this coupling's bindable values by local field name."""
fields = getattr(type(self), "__quchip_param_fields__", None)
if fields is not None:
return {name: getattr(self, name) for name in fields}
return {self.coupling_strength_name: self.coupling_strength}
[docs]
def set_parameter_value(self, name: str, value: Any) -> None:
"""Apply one local parameter value on an isolated coupling copy.
Parameters
----------
name : str
Local parameter name.
value : Any
Replacement value in the parameter's declared units.
"""
fields = getattr(type(self), "__quchip_param_fields__", None)
if fields is not None and name in fields:
setattr(self, name, value)
return
if fields is None and name == self.coupling_strength_name:
self.set_coupling_strength(value)
return
raise KeyError(name)
@property
def device_a_label(self) -> str:
"""Label of the first coupled device (works before and after binding)."""
return resolve_label(self.device_a)
@property
def device_b_label(self) -> str:
"""Label of the second coupled device (works before and after binding)."""
return resolve_label(self.device_b)
@property
def is_resolved(self) -> bool:
"""True if both device references are bound to :class:`BaseDevice` instances."""
return isinstance(self.device_a, BaseDevice) and isinstance(self.device_b, BaseDevice)
def __repr__(self) -> str:
"""Return a minimal endpoint summary (default for full-control subclasses)."""
return f"{type(self).__name__}('{self.device_a_label}' <-> '{self.device_b_label}', label={self.label!r})"
def _resolve_devices(self, device_map: dict[str, BaseDevice]) -> None:
"""Validate both endpoints before binding label references to chip devices."""
endpoints = {}
for attr in ("device_a", "device_b"):
current = getattr(self, attr)
label = resolve_label(current)
resolved = device_map.get(label)
if resolved is None:
raise ValueError(
f"Coupling {self!r} references device {label!r} which is not in the device list. "
f"Available labels: {list(device_map)}"
)
if not isinstance(current, str) and current is not resolved:
raise ValueError(
f"Coupling {self.label!r} references a different device object labeled {label!r}. "
"Use the declared chip device or its label."
)
endpoints[attr] = resolved
for attr, resolved in endpoints.items():
object.__setattr__(self, attr, resolved)
@property
@abstractmethod
def coupling_strength(self) -> float:
"""Scalar coupling strength in GHz."""
...
@property
def coupling_strength_name(self) -> str:
"""Display name of the scalar :attr:`coupling_strength` parameter.
The default is ``"g"``, the conventional coupling-strength symbol and
the attribute name on :class:`~quchip.chip.couplings.Coupling`.
:class:`~quchip.declarative.models.CouplingModel` overrides it with the
name of its first declared parameter field. A subclass with a different
primary-scalar convention overrides it directly.
"""
return "g"
[docs]
def set_coupling_strength(self, value: Any) -> None:
"""Write the primary scalar named by :attr:`coupling_strength_name`.
Examples are ``g``, ``g_0``, and ``chi``. Override this method if the
writable attribute has a different name.
Parameters
----------
value : Any
Coupling strength in GHz.
"""
setattr(self, self.coupling_strength_name, value)
[docs]
def default_dressed_target(self) -> tuple[str, Any]:
"""Return this edge's component-owned inverse-design constraint.
The method reads the value directly from the declared coupling scalar
and does not evaluate a chip-level observable.
"""
return self.default_fit_observable, self.coupling_strength
[docs]
@abstractmethod
def interaction_hamiltonian(self) -> Operator:
"""Return the full ``H_int`` on the local ``H_a ⊗ H_b`` subspace.
Couplings author one complete interaction, and the selected engine
approximation determines which resolved bands are retained.
"""
...
[docs]
def physics_notes(self) -> list[str]:
"""Return human-readable declarations of this coupling's approximations."""
return [
f"Coupled devices: '{self.device_a_label}' ↔ '{self.device_b_label}'",
]
[docs]
def dropped_terms(self) -> list["DroppedTerm"]:
"""Return advisory records for terms that this coupling's own model removes.
The assembly reports RWA band drops generically. This hook is for
*other* approximations that a coupling applies in
:meth:`interaction_hamiltonian`. By default, no terms are dropped.
"""
return []
def _time_terms(self) -> tuple[Any, ...]:
"""Return normalized time-dependent terms (default: none)."""
return ()
[docs]
def collapse_channels(self) -> tuple[CollapseChannel, ...]:
"""Return the local Lindblad channels that this coupling adds."""
return ()
def _collapse_channels_with_paths(
self,
) -> tuple[tuple[CollapseChannel, tuple[str, ...]], ...]:
"""Return normalized channels with inferred paths (none for legacy couplings)."""
return ()
[docs]
def to_dict(self) -> dict[str, Any]:
"""Serialize structural fields into a JSON-safe dictionary."""
data = super().to_dict()
data["device_a_label"] = self.device_a_label
data["device_b_label"] = self.device_b_label
data["label"] = self.label
return data