quchip.devices.base

Base device model for quchip.

A device is a truncated-Hilbert-space quantum system owned by a chip. Subclasses declare their local Hamiltonian on a Fock basis of size levels.

Contract

  • Hamiltonian ownership. A device owns its local Hamiltonian only; couplings and drives own theirs. hamiltonian() must return an operator acting on this device’s truncated Hilbert space.

  • JAX traceability. Every parameter passed to a subclass’s __init__ (frequency, anharmonicity, T1/T2, thermal population, …) may be a JAX tracer. Validation routines must never force concretization on a traced value; use quchip.utils.jax_utils.maybe_concrete_scalar() to peek at concrete scalars only.

  • Approximation transparency. Each concrete model must document its approximation level and cite a reference. Examples: Resonator states “non-interacting harmonic mode”; DuffingTransmon states “Duffing expansion valid in the transmon regime E_J >> E_C”.

Channels offered to drives

Drives sit on top of a device and emit local Hamiltonians built from standard bosonic / projection operators the device exposes:

  • lowering_operator() (a) and raising_operator() (a_dag) — used by charge / coupling-type drives.

  • number_operator() (n_hat = a_dag @ a) — used by number-coupled (dispersive) drives.

  • sigma_x, sigma_y, sigma_z — the qubit subspace projections onto |0>, |1> (cached; invalidated automatically when levels changes).

State versioning

The engine caches assembled Hamiltonians keyed on state_version. Once construction finishes, every public mutation (anything not prefixed with _ and not label) increments _state_version so caches are invalidated deterministically. This machinery — the seed, the __setattr__ tracking hook, state_version, and _finish_init — is owned by the shared StateVersioned mixin; BaseDevice only contributes its untracked-name set (label) and the levels cache-invalidation hook (_on_attr_set()). Tracking is switched on automatically exactly once after the outermost __init__ returns, so subclasses no longer call _finish_init by hand.

Auto-labeling

Subclasses set _type_prefix (e.g. "duffing", "resonator") and a shared counter in quchip.utils.labeling yields labels like "duffing_0", "resonator_0". Reset between tests via quchip.utils.labeling.reset_label_counters().

Serialization

to_dict() writes a JSON-safe snapshot (type fully-qualified name, levels, label, concrete noise parameters). Deserialization dispatch to the registered concrete subclass is owned by the shared Registrable mixin — the registry is populated automatically at subclass-definition time, with no manual registration step.

Units (immutable)

  • Frequencies: GHz, ordinary (not angular).

  • Times: ns.

  • Temperature: mK.

  • Energies: GHz (with hbar = 1).

Example

>>> from quchip.devices import DuffingTransmon, Resonator
>>> from quchip.chip import Chip
>>> q = DuffingTransmon(freq=5.0, anharmonicity=-0.25, levels=3, label="q")
>>> r = Resonator(freq=7.0, levels=6, label="r")
>>> chip = Chip(devices=[q, r])
>>> float((q.freq * q.number_operator()).norm())

Classes

BaseDevice(levels[, label, T1, T2, ...])

Abstract truncated-Hilbert-space quantum device.

NoiseChannel(name, params, build)

Declarative spec for one family of Lindblad collapse channels.

class quchip.devices.base.NoiseChannel(name, params, build)[source]

Bases: object

Declarative spec for one family of Lindblad collapse channels.

A device class composes its dissipation from a tuple of these in _noise_channels; BaseDevice.collapse_operators() concatenates each channel’s build(device) in declaration order. Adding a noise type to a device is therefore one declaration — a parameter field plus a channel entry — with no method override and no change outside the device’s own class.

Parameters:
  • name (str) – Human-readable channel-family name (diagnostics only).

  • params (tuple[str, ...]) – Device attribute names this channel consumes — declarative metadata; the build reads the attributes directly.

  • build (callable) – build(device) -> list[Operator]: the channel’s collapse operators, empty when its parameters are unset.

name: str
params: tuple[str, ...]
build: Callable[[Any], list[Any]]
class quchip.devices.base.BaseDevice(levels, label=None, *, T1=None, T2=None, thermal_population=None)[source]

Bases: StateVersioned, Registrable, ABC

Abstract truncated-Hilbert-space quantum device.

Concrete subclasses must:

  1. Set _type_prefix (used for auto-labeling).

  2. Expose a freq attribute — the bare 0 -> 1 transition frequency in GHz. Any JAX-traceable scalar is fine.

  3. Implement hamiltonian() returning an operator on the truncated Fock basis.

Noise parameters (all optional; None means the channel is absent):

  • T1 — relaxation time (ns); emission channel at rate 1/T1.

  • T2 — total 0-1 coherence time (ns, requires T2 <= 2*T1); adds pure dephasing at gamma_phi = 1/T2 - 1/(2*T1).

  • thermal_population — unitless bath occupation ; adds thermal absorption and enhances emission.

They are ordinary attributes: set them at construction or at any time after — the next simulate/solve rebuilds collapse operators from current values (no rebuild, no cache poking), and post-construction writes get the same validation as the constructor. Setting a parameter back to None removes its channel.

Mutation tracking is enabled automatically once construction finishes (see StateVersioned); subclasses do not call _finish_init themselves.

Optional overrides:

  • _noise_channels — declare channels beyond T1/T2 (append a NoiseChannel; collapse_operators() composes the declared channels automatically).

  • to_dict() / from_dict() — for extra parameters.

  • computationalTrue if the device represents a computational qubit (default False).

See module docstring for the full contract.

Parameters:
tunable_param_names: ClassVar[tuple[str, ...]] = ()

Bare parameters this device exposes as differentiable / tunable scalars. fit_a_dress walks this tuple to discover what it is allowed to optimize on each device, decoupling the inverse-design surface from any specific device model. Three states, keyed on whether the value is explicitly declared:

  • No explicit declaration anywhere in the DeviceModel lineage — the default is derived: every declared parameter() field, in declaration order (see DeviceModel.__init_subclass__).

  • Explicit tuple on the class or an ancestor — exact curation, validated at class-definition time; authoritative and inherited until a subclass explicitly replaces it.

  • Explicit empty tuple — deliberately freezes the device (and its subclasses, until one replaces it) out of inverse design.

On a plain (non-DeviceModel) BaseDevice subclass there is no derivation; the default stays empty unless the subclass declares its own tuple — e.g. Fluxonium uses ("E_C", "E_J", "E_L", "phi_ext").

tunable_params()[source]

Return {name: current_value} for every bare parameter the device exposes for fitting / sweeping.

The default implementation walks tunable_param_names and reads each attribute. Subclasses with derived bare parameters (e.g. circuit-level devices whose freq is computed from E_C/E_J/E_L) should override the class attribute rather than this method — overrides are the right hook only when the list itself is not static (e.g. flux-tunable devices that gain phi_ext only at certain operating points).

Return type:

dict[str, Any]

set_tunable_param(name, value)[source]

Update a bare parameter named in tunable_params().

Default implementation uses setattr() so any direct attribute (freq, anharmonicity, E_C, …) works without ceremony. Subclasses with derived properties that need to back-propagate to private state should override this.

Parameters:
Return type:

None

tunable_param_bounds(name, value)[source]

Return (lower, upper) bounds for a tunable parameter at a seed value.

These bounds are consumed by the inverse-design optimizer to keep searches physical. The default uses well-named conventions that cover the common circuit-QED parameters:

  • freq, E_C, E_J, E_L: positive, [0.5·s, 1.5·s] around a positive seed (s).

  • anharmonicity: sign-preserving — negative seeds bound in (2·s, -ε), positive seeds in (ε, 2·s).

  • phi_ext: in [-0.5, 0.5] (one full flux period symmetric around the integer-flux point).

Subclasses override for parameters with other physical constraints. Raises ValueError for unknown names rather than silently optimizing over an unbounded axis, and for a value that is not a concrete real scalar (bounds for a JAX tracer are undefined — the optimizer needs a concrete numeric seed).

Parameters:
Return type:

tuple[float, float]

property connected_drives: list['BaseDrive']

Drives wired to this device, as a fresh list (mutation-safe copy).

copy()[source]

Structural copy detached from drive wiring (used by sweep cloning).

Return type:

BaseDevice

property dressed_freq: float | None

Chip-derived dressed 0→1 transition frequency in GHz, or None without a chip context.

property drive_freq: float

Operational 0→1 drive frequency in GHz.

When the device belongs to exactly one chip this is the chip-derived dressed frequency. Standalone devices fall back to their bare freq because no chip Hamiltonian exists to dress against. Returned values may be JAX tracers during traced / differentiated flows.

property reference_freq: Any

Readout / rotating-frame reference frequency in GHz — the device’s LO.

This is the frequency the default (frame="rotating") frame co-rotates at and the reference the readout is reported in: result.expect is expressed in this frame in every integration frame, and in the default rotating frame result.states are too. So transverse observables (<a>, <sigma_x>) come back as the slow demodulated envelope a lab readout produces — non-oscillatory when the device sits at its reference, and turning at omega - reference_freq when detuned (idle Ramsey). Diagonal observables (populations, <n>) are frame-invariant and unaffected either way.

Defaults to drive_freq (the dressed 0->1 frequency), so an unset device co-rotates at its own transition — bit-identical to the prior behavior. Set it to model a control/LO reference that differs from the qubit frequency (a calibration detuning). It is a frame / readout reference only: it does not detune drives — the drive carrier is a separate choice, so a real LO error must also set the drive frequency. May be a JAX tracer in traced / differentiated / swept flows. Assign None to restore the default.

to_dict()[source]

JSON-safe serialization; subclasses extend with their own parameters.

Return type:

dict[str, Any]

abstractmethod hamiltonian()[source]

Return the device Hamiltonian on the truncated Hilbert space.

Return type:

Any

physics_notes()[source]

Return human-readable declarations of this device’s approximations.

Each entry names a non-obvious assumption, approximation, or truncation that a user of this device should be aware of — e.g. “Hilbert truncation: 3 levels”, a model regime (Duffing), or a noise-channel selection (charge- vs flux-coupled T1).

The baseline entry is _truncation_note(), since every BaseDevice has some form of Hilbert-space truncation. A pure-dephasing note is added when T2 is set, since the number-operator dephasing model carries non-obvious assumptions. Subclasses super().physics_notes() and append their own model-specific notes; no registry / engine-side dispatch is needed.

Return type:

list[str]

dynamic_terms()[source]

Time-dependent device terms on the local Hilbert space (default: none).

Subclasses modelling tunable / parametrically modulated devices (tunable transmons, flux-biased fluxonium, parametrically driven modes, …) override this to emit (local_operator, modulation) pairs. Each local_operator acts on the device’s own truncated Fock space and is in ordinary GHz; the engine embeds it, applies the single 2π factor, and attaches the modulation as a DynamicTerm. modulation must be a ScalarModulation wrapping a JAX-traceable signal program.

Return type:

list[tuple[Any, Any]]

lowering_operator()[source]

Bosonic lowering operator a on the truncated Fock basis.

Return type:

Any

raising_operator()[source]

Bosonic raising operator a† on the truncated Fock basis.

Return type:

Any

number_operator()[source]

Number operator = a†a on the truncated Fock basis.

Return type:

Any

identity()[source]

Identity operator on the truncated Fock basis.

Return type:

Any

local_operator(name)[source]

Map an operator-name string to this device’s own local operator.

Recognized names: "X" / "Y" / "Z" (Pauli projections on the computational |0>, |1> subspace), "n" (number), "a" (lowering), "a_dag" (raising), "I" (identity). The device owns this vocabulary, so a subclass exposing extra named operators overrides this method — extending _LOCAL_OPERATOR_NAMES and delegating to super().local_operator(name) for the base set — and the chip’s observable surface (Chip.observable(), Chip.e_ops()) gains the operator without any engine or Chip change.

Parameters:

name (str)

Return type:

Any

declarative_ops()[source]

Map declarative (label, op-name) keys to backend operators.

This is the single lookup the declarative layer consumes when compiling a PhysicsExpr: a DeviceModel uses it for its local Hamiltonian, and a coupling merges the maps of both endpoints. Keys mirror the operator handles exposed by LocalOps (a, adag, n, I, and the computational-subspace sigma_* family, so spin-like models are first-class citizens of the declarative surface).

Return type:

dict[tuple[str, str], Any]

basis_state(n)[source]

Fock basis state |n> on the truncated Hilbert space.

Parameters:

n (int)

Return type:

Any

coherent_state(alpha)[source]

Coherent state |alpha> on the truncated Fock basis.

Parameters:

alpha (complex)

Return type:

Any

plot_energy_levels(*, ax=None, **kwargs)[source]

Plot the device’s bare energy-level ladder (delegates to quchip.viz).

Parameters:
Return type:

Any

plot_wavefunction(n, *, ax=None, **kwargs)[source]

Plot the n-th eigenstate wavefunction (delegates to quchip.viz).

Parameters:
Return type:

Any

property sigma_x: Any

|0><1| + |1><0| on the computational |0>, |1> subspace.

property sigma_y: Any

-i|0><1| + i|1><0| on the computational |0>, |1> subspace.

property sigma_z: Any

|0><0| - |1><1| on the computational |0>, |1> subspace.

property sigma_plus: Any

|1><0|.

Type:

Raising operator on the computational |0>, |1> subspace

property sigma_minus: Any

|0><1|.

Type:

Lowering operator on the computational |0>, |1> subspace

projector(i, j)[source]

|i><j| on the Fock basis.

Use projector(i, i) for the population projector |i><i| and projector(i, j) for |i><j|. No subspace approximation — the operator acts on the full truncated Hilbert space.

Parameters:
Return type:

Any

transition(i, j)[source]

Transition operator |i><j| + |j><i| between Fock levels i and j.

Acts like sigma_x on the two-level subspace {|i>, |j>}. Useful for qudit work and erasure-protected subspaces where the computational pair is not {|0>, |1>}.

Parameters:
Return type:

Any

property computational: bool

Whether this device is a computational qubit. Override in subclasses.

collapse_operators()[source]

Lindblad collapse operators composed from the class’s declared noise channels.

Concatenates each NoiseChannel in _noise_channels in declaration order. The built-in channels cover T1 / T2 / thermal_population (see _thermal_emission_channel() and _pure_dephasing_channel() for the physics and normalization conventions); subclasses add channels by extending the tuple.

References

Breuer & Petruccione, Theory of Open Quantum Systems (Oxford, 2002), Ch. 3. For circuit-QED conventions see Krantz et al., Applied Physics Reviews 6, 021318 (2019), §V.

Return type:

list[Any]

classmethod noise_parameter_names()[source]

Names of this class’s dissipation parameters, in declaration order.

The deduplicated union of each declared NoiseChannel’s params — exactly the attributes set_noise() may touch. Hamiltonian parameters are never included.

Return type:

tuple[str, …]

intrinsic_decay_rate()[source]

Total lowering-channel (downward) Lindblad rate, in 1/ns, or None with no decay channel.

Reports the actual sum of squared amplitudes of the lowering-operator collapse channel(s) collapse_operators() builds from _thermal_emission_channel(), matching that construction exactly rather than approximating it:

  • T1 set (thermal_population set or not): (n̄+1)/T1 — the sqrt(gamma*(n̄+1))·a channel’s rate, gamma = 1/T1; defaults to 0 when thermal_population is unset, so this reduces to plain 1/T1.

  • T1 unset, thermal_population set: n̄+1 — the same channel with gamma = 1 (_thermal_emission_channel()’s unitless-bath-occupation branch).

  • Neither set: None — no lowering channel.

Subclasses whose collapse_operators() combine several lowering-operator channels (e.g. Resonator’s Q-derived photon loss alongside T1) override this to report the summed rate, so a caller reading a single scalar decay rate (e.g. quchip.chip.transformations.eliminate_device’s Purcell fold) does not have to special-case per-device channel structure.

This is the downward rate only — the sqrt(gamma*n̄)·a† upward (thermal-absorption) channel is not represented; a caller that needs to know whether that channel is present reads thermal_population directly. Whether a channel exists, and which formula applies, is a static decision (is T1/thermal_population set?), never a traced-zero comparison on the resulting rate, which would concretize a traced value and break differentiability.

Return type:

Any | None

connect(drive)[source]

Register a drive as connected: idempotent on identity, replace-on-relabel.

A drive’s label is its stable identity as a control line (chip.wire already rejects duplicate labels within one equipment). Clone-and-rewire flows — chip.clone(), eliminate()’s equipment reattachment, chip.partition() — build a fresh drive object bound to the same label when re-wiring a device that already carries a connected drive, so a same-label, different-object entry marks a stale copy of the same line rather than a second physical line. That stale entry is replaced in place (position preserved); a drive with a distinct label is always appended as an independent line.

Parameters:

drive (BaseDrive)

Return type:

None