Source code for quchip.declarative.models

"""Declarative base classes for device and coupling physics models.

:class:`DeviceModel` and :class:`CouplingModel` let a subclass declare its
physics parameters as annotated class attributes via
:func:`~quchip.declarative.parameters.parameter` and implement only the
Hamiltonian expression; both synthesize their own ``__init__``, JAX pytree
registration (for :class:`DeviceModel`), and post-construction sign
validation from the declared fields.
"""

from __future__ import annotations

import inspect
import weakref
from typing import Any, cast, dataclass_transform

import jax.tree_util as jtu

from quchip.chip.coupling_base import BaseCoupling
from quchip.declarative.parameters import (
    Parameter,
    build_declared_signature,
    parameter_fields,
    resolve_declared_params,
    serializable_value,
    validate_sign,
)
# ``_NOISE_FIELDS`` is the single source of truth for the noise-parameter set
# (BaseDevice owns it). These are JAX-traceable scalars (or ``None``), so they
# must travel as pytree *children* rather than aux data — otherwise gradients
# can't flow through them.
from quchip.devices.base import _NOISE_FIELDS, BaseDevice
from quchip.utils.state_versioning import _wrap_init_for_finish


def _serialize_declared_params(obj: Any, data: dict[str, Any]) -> dict[str, Any]:
    """Write each serializable declared parameter of *obj* into *data*, in place."""
    for name, spec in type(obj).__quchip_param_fields__.items():
        if spec.serialize:
            data[name] = serializable_value(getattr(obj, name))
    return data


_MISSING = object()


def _validate_explicit_tunable_param_names(
    cls: type[DeviceModel], names: Any, param_fields: dict[str, Parameter]
) -> None:
    """Validate an explicit ``tunable_param_names`` declaration at class-definition time.

    Must be a tuple of unique strings, each resolving to a declared
    :func:`~quchip.declarative.parameters.parameter` field or a genuine class
    attribute/property of *cls* — checked via :func:`inspect.getattr_static`
    so a property is never invoked. Derived defaults (see
    :func:`_resolve_tunable_param_names`) are valid by construction and never
    reach this function.
    """
    if not isinstance(names, tuple):
        raise TypeError(
            f"{cls.__name__}.tunable_param_names must be a tuple of parameter-name strings, got {names!r}."
        )
    seen: set[str] = set()
    for name in names:
        if not isinstance(name, str):
            raise TypeError(f"{cls.__name__}.tunable_param_names entries must be strings, got {name!r}.")
        if name in seen:
            raise ValueError(f"{cls.__name__}.tunable_param_names has a duplicate entry {name!r}.")
        seen.add(name)
        if name not in param_fields and inspect.getattr_static(cls, name, _MISSING) is _MISSING:
            raise ValueError(
                f"{cls.__name__}.tunable_param_names names {name!r}, which is not a declared parameter() "
                f"field or class attribute of {cls.__name__}. Available parameter() fields: "
                f"{sorted(param_fields)}."
            )


def _tunable_param_names_explicit_in_lineage(cls: type[DeviceModel]) -> bool:
    """Whether *cls* or an ancestor (up to, not including ``BaseDevice``) explicitly curated ``tunable_param_names``."""
    for base in cls.__mro__:
        if base is BaseDevice:
            break
        if base.__dict__.get("_tunable_param_names_explicit", False):
            return True
    return False


def _resolve_tunable_param_names(cls: type[DeviceModel], param_fields: dict[str, Parameter]) -> None:
    """Resolve ``cls.tunable_param_names``: keep an explicit declaration, else derive from declared fields.

    An explicit declaration on *cls* itself (present in ``cls.__dict__``
    before this hook touches it) is validated here, at class-definition
    time. An explicit declaration inherited from an ancestor — including an
    empty tuple — remains authoritative and is *not* re-derived, so a
    subclass of an explicitly-curated parent inherits that exact curation
    unless it redeclares. Otherwise ``tunable_param_names`` is set to every
    declared ``parameter()`` field, in declaration order.
    """
    if "tunable_param_names" in cls.__dict__:
        _validate_explicit_tunable_param_names(cls, cls.__dict__["tunable_param_names"], param_fields)
        cls._tunable_param_names_explicit = True
        return
    if _tunable_param_names_explicit_in_lineage(cls):
        return
    cls.tunable_param_names = tuple(param_fields.keys())


def _synthesize_device_init(cls: Any) -> Any:
    """Build a positional ``__init__`` for a declarative device subclass.

    Declared parameters become positional-or-keyword arguments in
    declaration order (with their declared defaults); ``levels`` (default
    from ``cls._default_levels``), ``label`` and the noise kwargs follow as
    keyword-only. The body forwards to :meth:`DeviceModel.__init__`,
    which resolves and validates the parameters, so authors get a clean
    signature without hand-writing one.
    """
    param_fields = cls.__quchip_param_fields__
    trailing = (
        inspect.Parameter("levels", inspect.Parameter.KEYWORD_ONLY, default=cls._default_levels),
        inspect.Parameter("label", inspect.Parameter.KEYWORD_ONLY, default=None),
        *(inspect.Parameter(name, inspect.Parameter.KEYWORD_ONLY, default=None) for name in _NOISE_FIELDS),
    )
    signature = build_declared_signature(param_fields, trailing, owner=cls)

    def __init__(self: Any, *args: Any, **kwargs: Any) -> None:
        bound = signature.bind(self, *args, **kwargs)
        bound.apply_defaults()
        arguments = dict(bound.arguments)
        arguments.pop("self")
        levels = arguments.pop("levels")
        label = arguments.pop("label")
        noise = {name: arguments.pop(name) for name in _NOISE_FIELDS}
        DeviceModel.__init__(self, levels=levels, label=label, **noise, **arguments)

    __init__.__signature__ = signature  # type: ignore[attr-defined]
    __init__.__qualname__ = f"{cls.__qualname__}.__init__"
    __init__.__doc__ = f"Initialize {cls.__name__} from its declared parameters."
    return __init__


# ``@dataclass_transform`` is a static-analyzer hint: it tells type checkers
# to treat ``freq: Scalar = parameter(...)`` field declarations like
# dataclass fields, so a synthesized ``__init__`` accepting ``freq`` as a
# ``Scalar`` argument type-checks even though ``parameter()`` returns a
# ``Parameter`` instance. ``__init_subclass__`` below does the runtime work:
# it synthesizes a positional ``__init__`` from the declared fields unless
# the subclass hand-writes its own (see ``_synthesize_device_init``).
# TODO: dissolve the generated stubs (tools/gen_device_stubs.py) by declaring
# levels/label/noise as keyword-only specifier fields so PEP 681 describes the
# full constructor natively; that also fixes user-authored models in their own
# IDEs, which stubs in this repo never can.
[docs] @dataclass_transform(field_specifiers=(Parameter,)) class DeviceModel(BaseDevice): """Declarative base for physics device models. Subclasses declare their parameters as annotated class attributes using :func:`parameter` (e.g. ``freq: Scalar = parameter(positive=True)``) and implement :meth:`local_hamiltonian`. The declared parameters become positional-or-keyword ``__init__`` arguments and JAX pytree leaves so the full instance is traceable / differentiable / sweepable end-to-end. The :meth:`hamiltonian` adapter compiles the declarative expression returned by :meth:`local_hamiltonian` into an operator for the active default backend. Examples -------- >>> from quchip.declarative import DeviceModel, parameter, Scalar >>> class DuffingOscillator(DeviceModel): ... freq: Scalar = parameter(positive=True, unit="GHz") ... anharmonicity: Scalar = parameter(unit="GHz") ... def local_hamiltonian(self, op): ... return self.freq * op.n + 0.5 * self.anharmonicity * op.n @ (op.n - op.I) >>> device = DuffingOscillator(freq=5.0, anharmonicity=-0.3, levels=4) >>> device.freq 5.0 """ #: Declared approximation-regime statement surfaced by #: :meth:`physics_notes` — the mechanism that keeps a model's stated #: validity range attached to the class rather than buried in a #: docstring a caller may not read. approximation: str | None = None #: Whether this device represents a computational qubit, as opposed to #: e.g. a bus resonator or a coupler element. computational: bool = False # Per-class Fock-truncation default baked into the synthesized ``__init__``. # Subclasses override (e.g. ``Resonator`` → 10, ``KerrCavity`` → 30). _default_levels: int = 2 __quchip_param_fields__: dict[str, Parameter] = {} # Whether *this class's own body* declared ``tunable_param_names`` # explicitly, as opposed to getting the derived default. The ancestor # check lives in ``_tunable_param_names_explicit_in_lineage``, which walks # the MRO reading this marker per class; never read directly by user code. _tunable_param_names_explicit: bool = False def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) # Cache resolved parameter fields once per class. ``__init__`` and # the pytree closures both read this — no per-instance re-walk of # the MRO. param_fields: dict[str, Parameter] = parameter_fields(cls) cls.__quchip_param_fields__ = param_fields _resolve_tunable_param_names(cls, param_fields) # Children carried through pytree round-trip. Order is stable. # Declared parameters first (in declaration order), then noise # params, then the reference-frequency override. All three groups # are JAX-traceable scalars (or, for the override, ``None``) — they # MUST be children, not aux data, for gradients to flow. param_names: tuple[str, ...] = tuple(param_fields.keys()) children_names: tuple[str, ...] = param_names + _NOISE_FIELDS + ("_reference_freq_override",) def _flatten(obj: Any) -> tuple[tuple[Any, ...], tuple[Any, ...]]: # ``getattr`` defaults to ``None`` so an instance that never set # ``_reference_freq_override`` (it has a class-level default, # never guaranteed as an instance attribute) still flattens. children = tuple(getattr(obj, name, None) for name in children_names) # Aux data must be hashable (jit cache key). ``levels`` and # ``label`` are structural; ``children_names`` makes unflatten # unambiguous. aux = (int(obj.levels), obj.label, children_names) return children, aux def _unflatten(aux: tuple[Any, ...], children: tuple[Any, ...]) -> Any: levels, label, names = aux obj = cls.__new__(cls) # Install structural state via object.__setattr__ to bypass # the tracked-mutation hook in BaseDevice.__setattr__. Do not # call __init__ — validation may force concretization on a # traced value. object.__setattr__(obj, "_state_version", 0) object.__setattr__(obj, "_tracking_enabled", False) object.__setattr__(obj, "levels", levels) object.__setattr__(obj, "label", label) object.__setattr__(obj, "_owner_chips", weakref.WeakSet()) object.__setattr__(obj, "_connected_drives", []) for name, value in zip(names, children): object.__setattr__(obj, name, value) object.__setattr__(obj, "_tracking_enabled", True) return obj jtu.register_pytree_node(cls, _flatten, _unflatten) # Synthesize a positional ``__init__`` from the declared fields unless # the subclass hand-writes its own, keeping extension authors in full # control when they need it. if "__init__" not in cls.__dict__: cls.__init__ = _synthesize_device_init(cls) # type: ignore[method-assign] # The synthesized init is installed *after* the __init_subclass__ # super-chain ran (where StateVersioned wraps a hand-written # __init__), so wrap it here to auto-fire _finish_init exactly once # after construction. Idempotent if already wrapped. _wrap_init_for_finish(cls) def __init__( self, *, levels: int = 2, label: str | None = None, T1: Any = None, T2: Any = None, thermal_population: Any = None, **params: Any, ) -> None: """Initialize the device from declared parameters and noise kwargs.""" values = resolve_declared_params(type(self), params) super().__init__( levels=levels, label=label, T1=T1, T2=T2, thermal_population=thermal_population, ) for name, value in values.items(): setattr(self, name, value) self.validate() # Mutation tracking is switched on automatically by the StateVersioned # init wrapper once the outermost __init__ returns.
[docs] def validate(self) -> None: """Cross-field validation hook, run at the end of construction. Default is a no-op. Subclasses override to enforce constraints that span multiple declared parameters (e.g. ``2 * edge <= duration``). Checks must be gated on *concrete* scalars via :func:`quchip.utils.jax_utils.maybe_concrete_scalar` so traced parameters never force concretization. """
def _validate_param_write(self, name: str, value: Any) -> None: """Extend the base noise-field checks with declared sign constraints. The same :func:`validate_sign` the constructor's resolver runs, so e.g. ``r.quality_factor = -5_000.0`` fails after the fact exactly as it would at construction (concrete scalars only; tracers pass). """ super()._validate_param_write(name, value) spec = type(self).__quchip_param_fields__.get(name) if spec is not None: validate_sign(name, spec, value) def __repr__(self) -> str: """Return a constructor-like summary: label, declared params, levels.""" parts = [f"label={self.label!r}"] parts += [f"{name}={getattr(self, name)}" for name in type(self).__quchip_param_fields__] parts.append(f"levels={self.levels}") return f"{type(self).__name__}({', '.join(parts)})"
[docs] def local_hamiltonian(self, op: Any) -> Any: """Return this device's local Hamiltonian as a declarative expression. Parameters ---------- op : LocalOps Operator namespace for this device's endpoint, exposing ``a``, ``adag``, ``n``, ``I`` and the Pauli handles as composable :class:`~quchip.declarative.expr.PhysicsExpr` nodes. Returns ------- PhysicsExpr The local Hamiltonian expression, in ordinary-frequency units (GHz). """ raise NotImplementedError
[docs] def hamiltonian(self) -> Any: """Compile :meth:`local_hamiltonian` for the active default backend.""" from quchip.backend import get_default_backend from quchip.declarative.expr import compile_expr from quchip.declarative.ops import LocalOps backend = get_default_backend() op = LocalOps(label=self.label, levels=self.levels) expr = self.local_hamiltonian(op) return compile_expr(expr, self.declarative_ops(), backend)
[docs] def to_dict(self) -> dict[str, Any]: """Serialize common device state plus declared parameter values.""" return _serialize_declared_params(self, super().to_dict())
[docs] @classmethod def from_dict(cls, d: dict[str, Any]) -> "DeviceModel": """Reconstruct the device from :meth:`to_dict` output.""" fields = cls.__quchip_param_fields__ params = {name: d[name] for name, spec in fields.items() if spec.serialize and name in d} return cls( levels=int(d.get("levels", 2)), label=d.get("label"), **cls._noise_kwargs_from_dict(d), **params, )._restore_reference_freq(d)
[docs] def physics_notes(self) -> list[str]: """Return base device notes plus the declared approximation, if any.""" notes = super().physics_notes() if self.approximation: notes.append(self.approximation) return notes
[docs] @dataclass_transform(field_specifiers=(Parameter,)) class CouplingModel(BaseCoupling): """Declarative two-body coupling base. Subclasses declare physics parameters via :func:`parameter` and implement :meth:`interaction` (returning a :class:`~quchip.declarative.expr.PhysicsExpr` over the two endpoint operators). The RWA is applied structurally by the chip and engine via :meth:`~quchip.chip.coupling_base.BaseCoupling.rwa_keeps_band`; only the *parametric* RWA structures remain author-declared, because pump sideband selection depends on frequency intent rather than operator structure: which sideband a pump activates (red: Δa+Δb=0 exchange; blue: |Δa+Δb|=2 two-photon) depends on where the pump frequency sits relative to traced device frequencies, and a structural rule cannot infer it without branching on traced values. Optional overrides: - :meth:`time_dependent` — parametric (time-dependent) modulation, as a :class:`PhysicsExpr` carrying a single dynamic source. :attr:`coupling_strength` defaults to the *first declared parameter field* (suited for the common case of one ``g``-like scalar). Override the property in subclasses with a different convention. .. note:: Coupling instances are not registered as JAX pytrees and cannot be passed as dynamic ``jax.jit`` / ``jax.vmap`` / ``jax.grad`` arguments. Coupling parameters remain differentiable when the coupling (and the devices or chip it couples) is constructed from traced arguments inside the transformed function. Examples -------- >>> from quchip.declarative import CouplingModel, parameter, Scalar >>> class ExchangeCoupling(CouplingModel): ... g: Scalar = parameter(unit="GHz") ... def interaction(self, a, b): ... return self.g * (a.a * b.adag + a.adag * b.a) >>> c = ExchangeCoupling("q0", "q1", g=0.01) >>> c.coupling_strength 0.01 """ __quchip_param_fields__: dict[str, Parameter] = {} def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) cls.__quchip_param_fields__ = parameter_fields(cls) def __init__( self, device_a: Any, device_b: Any, *, label: str | None = None, rwa: bool | None = None, **params: Any, ) -> None: """Initialize a declarative coupling between two devices or labels.""" values = resolve_declared_params(type(self), params) super().__init__(device_a, device_b, label=label) self._rwa = rwa for name, value in values.items(): setattr(self, name, value) # Tracking auto-enables via the StateVersioned init wrapper post-construction. def __setattr__(self, name: str, value: Any) -> None: """Give post-construction writes the same declared-sign validation as construction. Mirrors :meth:`~quchip.devices.base.BaseDevice.__setattr__`: once ``_tracking_enabled`` (from :class:`~quchip.utils.state_versioning.StateVersioned`) goes live after construction, every non-private write runs :func:`~quchip.declarative.parameters.validate_sign` against the declared field spec before the value lands. Concrete scalars only; tracers pass. """ if getattr(self, "_tracking_enabled", False) and not name.startswith("_"): spec = type(self).__quchip_param_fields__.get(name) if spec is not None: validate_sign(name, spec, value) super().__setattr__(name, value) @property def coupling_strength(self) -> Any: """Primary scalar coupling strength, defaulting to the first parameter.""" fields = type(self).__quchip_param_fields__ first = next(iter(fields), None) return getattr(self, first) if first is not None else 0.0 @property def coupling_strength_name(self) -> str: """Display name of :attr:`coupling_strength`, defaulting to the first parameter field.""" fields = type(self).__quchip_param_fields__ first = next(iter(fields), None) return first if first is not None else "g" def __repr__(self) -> str: """Return a constructor-like summary: endpoints, declared params, label. Default for extension authors; mirrors :meth:`DeviceModel.__repr__`. Built-ins with richer summaries override it. """ parts = [f"'{self.device_a_label}' <-> '{self.device_b_label}'"] parts += [f"{name}={getattr(self, name)}" for name in type(self).__quchip_param_fields__] parts.append(f"label={self.label!r}") return f"{type(self).__name__}({', '.join(parts)})" # --- Physics overrides for subclasses ---
[docs] def interaction(self, a: Any, b: Any) -> Any: """Return the full two-body interaction expression. Parameters ---------- a, b : EndpointOps Operator namespaces for the two coupled endpoints. Same-endpoint operators compose with ``@``; cross-endpoint operators combine with ``*`` (tensor product). Returns ------- PhysicsExpr The interaction Hamiltonian expression, in ordinary-frequency units (GHz). """ raise NotImplementedError
[docs] def time_dependent(self, a: Any, b: Any) -> Any: """Return an optional time-dependent interaction expression. Parameters ---------- a, b : EndpointOps Operator namespaces for the two coupled endpoints. Returns ------- PhysicsExpr or None An expression carrying exactly one dynamic source (envelope or modulation), or ``None`` when the coupling is purely static. """ return None
[docs] def rwa_time_dependent(self, a: Any, b: Any) -> Any: """Return the RWA time-dependent expression, defaulting to full form. Mirrors the parametric RWA split for the dynamic term: subclasses whose parametric drive keeps only the co-rotating operators under RWA override this. Parameters ---------- a, b : EndpointOps Operator namespaces for the two coupled endpoints. """ return self.time_dependent(a, b)
[docs] def parametric_interaction(self, a: Any, b: Any) -> Any: """Return the parametric interaction structure, or ``None`` when this coupling is not modulable. The coupling-side mirror of the device drive-dispatch protocols: a :class:`~quchip.control.drive.ParametricDrive` accepts any coupling whose hook returns a :class:`~quchip.declarative.expr.PhysicsExpr`. """ _ = (a, b) return None
[docs] def rwa_parametric_interaction(self, a: Any, b: Any) -> Any: """RWA-retained parametric structure; defaults to the full form.""" return self.parametric_interaction(a, b)
[docs] def parametric_operator(self, chip: Any) -> Any | None: """Compile the parametric structure for *chip*'s resolved RWA policy. Returns the backend-native operator on the local two-body space, or ``None`` when :meth:`parametric_interaction` declines. Valid only once the coupling is chip-resolved (same contract as :meth:`interaction_hamiltonian`). """ from quchip.declarative.expr import compile_expr a_ops, b_ops = self._endpoint_ops() rwa = chip.resolve_rwa(self) expr = self.rwa_parametric_interaction(a_ops, b_ops) if rwa else self.parametric_interaction(a_ops, b_ops) if expr is None: return None self._check_endpoint_order(expr, "rwa_parametric_interaction" if rwa else "parametric_interaction") from quchip.backend import get_default_backend return compile_expr(expr, self._endpoint_lookup(), get_default_backend())
# --- Compilation --- @property def _resolved_a(self) -> BaseDevice: """``device_a`` narrowed to a concrete device. ``device_a``/``device_b`` are typed ``BaseDevice | str`` on the base class to accept a label at construction time, but every caller below only runs post-``clone_for_chip``, which replaces the label with the resolved device object before any physics compilation starts. """ return cast(BaseDevice, self.device_a) @property def _resolved_b(self) -> BaseDevice: """``device_b`` narrowed to a concrete device (see :attr:`_resolved_a`).""" return cast(BaseDevice, self.device_b) def _endpoint_lookup(self) -> dict[tuple[str, str], Any]: """Return the merged ``(label, op-name) -> operator`` lookup for both resolved endpoints.""" return {**self._resolved_a.declarative_ops(), **self._resolved_b.declarative_ops()} def _endpoint_ops(self) -> tuple[Any, Any]: """Return the ``(a, b)`` operator namespaces for this coupling's resolved endpoints.""" from quchip.declarative.ops import EndpointOps return ( EndpointOps(label=self.device_a_label, levels=self._resolved_a.levels), EndpointOps(label=self.device_b_label, levels=self._resolved_b.levels), ) def _check_endpoint_order(self, expr: Any, method_name: str) -> None: """Reject a two-endpoint expression whose labels are not in ``(a, b)`` order. :func:`~quchip.declarative.expr.compile_expr`'s tensor branch preserves the expression tree's argument order into the backend ``tensor()`` call, and the chip and engine embed the compiled two-body operator positionally against ``(device_a_label, device_b_label)`` (``Chip.hamiltonian``, ``embed_two_body``, ``stage2_assembly``'s canonical-operator metadata) without reading ``expr.labels`` back out of the compiled backend operator. An expression authored as ``b.op * a.op`` therefore compiles to a Hilbert-space-reversed operator that mis-embeds without any shape-mismatch error whenever the two endpoints share a dimension. """ endpoint_order = (self.device_a_label, self.device_b_label) if set(expr.labels) == set(endpoint_order) and expr.labels != endpoint_order: raise TypeError( f"{type(self).__name__}.{method_name}() built its expression with endpoint order " f"{expr.labels!r}; expected {endpoint_order!r}. Compose cross-endpoint terms with " "device_a's operator first, e.g. `a.<op> * b.<op>`." )
[docs] def interaction_hamiltonian(self) -> Any: """Compile the full interaction expression for the default backend.""" from quchip.backend import get_default_backend from quchip.declarative.expr import compile_expr backend = get_default_backend() a_ops, b_ops = self._endpoint_ops() expr = self.interaction(a_ops, b_ops) self._check_endpoint_order(expr, "interaction") return compile_expr(expr, self._endpoint_lookup(), backend)
[docs] @classmethod def from_dict(cls, d: dict[str, Any], device_a: Any, device_b: Any) -> "CouplingModel": """Reconstruct a coupling from :meth:`to_dict` output. Default implementation: forward declared parameters straight into ``__init__``. Subclasses with bespoke serialization (e.g. envelope modulations) override this. """ fields = cls.__quchip_param_fields__ params = {name: d[name] for name in fields if name in d} return cls( device_a=device_a, device_b=device_b, label=d.get("label"), rwa=d.get("rwa"), **params, )
[docs] def to_dict(self) -> dict[str, Any]: """Serialize common coupling state plus declared parameter values.""" data = _serialize_declared_params(self, super().to_dict()) data["rwa"] = self._rwa return data
def _validate_dynamic_expr(self, expr: Any, method_name: str) -> None: """Require exactly one dynamic source on a compiled time-dependent expression. Applied to whichever expression :meth:`dynamic_interaction_terms` actually compiles: the full :meth:`time_dependent` form, and again to :meth:`rwa_time_dependent`'s replacement when the chip resolves RWA, so a malformed RWA override cannot skip the check the full form passed. """ from quchip.declarative.expr import PhysicsExpr if not isinstance(expr, PhysicsExpr) or len(expr.dynamic_sources) != 1: raise ValueError( f"{type(self).__name__}.{method_name}() must return an expression containing " "exactly one dynamic source." )
[docs] def dynamic_interaction_terms(self, chip: Any) -> list[tuple[Any, Any]]: """Compile the optional single-source dynamic interaction term. Parameters ---------- chip : Chip Owning chip, consulted via :meth:`Chip.resolve_rwa` to select the static or RWA operator structure of the dynamic term. Returns ------- list of (operator, modulation) One ``(static_operator, scalar_modulation)`` pair when :meth:`time_dependent` returns an expression, else an empty list. """ from quchip.declarative.expr import compile_expr a_ops, b_ops = self._endpoint_ops() expr = self.time_dependent(a_ops, b_ops) if expr is None: return [] self._validate_dynamic_expr(expr, "time_dependent") method_name = "time_dependent" if chip.resolve_rwa(self): expr = self.rwa_time_dependent(a_ops, b_ops) self._validate_dynamic_expr(expr, "rwa_time_dependent") method_name = "rwa_time_dependent" self._check_endpoint_order(expr, method_name) from quchip.backend import get_default_backend from quchip.engine.ir import as_scalar_modulation source = expr.dynamic_sources[0].source mod_signal = as_scalar_modulation(source, owner=type(self).__name__) static_expr = expr.without_dynamic_sources() backend = get_default_backend() return [(compile_expr(static_expr, self._endpoint_lookup(), backend), mod_signal)]