Extending quchip¶
An extension declares local physics. quchip projects its operators into the resolved basis and embeds them in the chip Hilbert space. It then applies frames and RWA and converts the result for the selected backend. Extension code returns quchip expressions and need not choose a numerical backend.
Choose an extension type¶
Physics |
Author writes |
Installed reference |
|---|---|---|
Static device |
parameters and |
|
Static coupling |
endpoints, parameters, and |
|
Component-owned time dependence |
|
|
Device control |
|
|
Coupling control |
|
|
Classical hardware effect |
|
|
Scheduled waveform |
|
|
Unscheduled coefficient |
|
|
Device, drive, coupling, or bath loss |
|
|
Custom local space |
|
|
Third-party conversion |
|
scqubits mappings |
Specialized examples are importable from quchip.extensions. Their tests cover
analytical physics, serialization, projection, RWA, and JAX paths where each
property applies.
The reference files mirror the concepts they extend:
quchip/extensions/
devices.py
couplings.py
drives.py
envelopes.py
signals.py
spaces.py
Loss examples live with their owning device, drive, or coupling.
Static devices¶
Declare parameters and the Hamiltonian in the device’s local operator vocabulary. The constructor, validation, serialization, and JAX pytree are synthesized from the fields.
from typing import Any
from quchip import FockDevice, LocalOps, PhysicsExpr, Scalar, parameter
class Mode(FockDevice):
_default_levels = 8
freq: Scalar = parameter(positive=True, unit="GHz")
def local_hamiltonian(self, op: LocalOps, p: Any) -> PhysicsExpr:
return p.freq * op.n
p.freq is symbolic during authoring. The engine binds the instance value when
it resolves the model for a backend.
Component-owned time dependence¶
Use time_terms() when a device or coupling changes with time without a
scheduled control pulse.
from typing import Any
from quchip import CosineCoefficient, Scalar, TimeDependentTerm, parameter
class FrequencyModulatedMode(Mode):
modulation_amplitude: Scalar = parameter(unit="GHz")
modulation_frequency: Scalar = parameter(positive=True, unit="GHz")
def time_terms(self, op, p: Any) -> tuple[TimeDependentTerm, ...]:
return (
TimeDependentTerm(
operator=op.n,
coefficient=CosineCoefficient(
amplitude=p.modulation_amplitude,
frequency=p.modulation_frequency,
),
),
)
A coupling uses the same shape with both endpoint vocabularies:
from quchip import CouplingModel
class ModulatedExchange(CouplingModel):
static_strength: Scalar = parameter(unit="GHz")
modulation_amplitude: Scalar = parameter(unit="GHz")
modulation_frequency: Scalar = parameter(positive=True, unit="GHz")
def interaction(self, a, b, p):
return p.static_strength * (a.a * b.adag + a.adag * b.a)
def time_terms(self, a, b, p):
exchange = a.a * b.adag + a.adag * b.a
return (
TimeDependentTerm(
operator=exchange,
coefficient=CosineCoefficient(
amplitude=p.modulation_amplitude,
frequency=p.modulation_frequency,
),
),
)
These terms are projected into the basis selected from the static Hamiltonian. quchip does not construct an instantaneous moving basis.
Drives and envelopes¶
A drive maps a delivered classical signal to local quantum physics. A drive instance names the device or coupling connected to that physical line.
from quchip import DeviceDrive
class ChargeLikeDrive(DeviceDrive):
def hamiltonian(self, target, signal):
return signal.i * target.charge_coupling_operator()
mode = Mode(freq=5.0, label="q")
line = ChargeLikeDrive(mode, label="xy")
signal.i and signal.q are the physical in-phase and quadrature components
after gain, delay, filtering, distortion, and crosstalk. A drive can couple
them to different observables or use nonlinear combinations:
class IQDrive(DeviceDrive):
def hamiltonian(self, target, signal):
return (
signal.i * target.x_control_operator()
- signal.q * target.y_control_operator()
)
Override signal(pulse, target) only when constructing the scheduled analytic
signal itself needs drive-specific physics. Carriers are optional for every
drive. Approximation choices belong to the chip and engine, not the drive.
Declare drive values with parameter() and structural choices with
setting(). Constructors and serialization are synthesized. Define a scheduled
waveform by subclassing Envelope and implementing value(t). Define an
unscheduled scalar coefficient by subclassing TimeCoefficient and
implementing value(t). Both implementations should use quchip.qnp to remain
JAX-traceable.
from quchip import Envelope, qnp
class CosineEnvelope(Envelope):
duration: Scalar = parameter(positive=True, unit="ns")
amplitude: Scalar = parameter(default=1.0)
def value(self, local_time):
return 0.5 * self.amplitude * (
1.0 - qnp.cos(2.0 * qnp.pi * local_time / self.duration)
)
Use a complex envelope for IQ control. GaussianDRAG implements
\(E(t) = I(t) + i\beta\,dI/dt\), with signed \(\beta\) in ns. An IQ envelope is one
complex control signal, not two drive lines.
An envelope owns local shape and relative I/Q only. Pass global phase to
sequence.schedule(..., phase=...). Timing and carrier belong to that
scheduled pulse as well.
Control equipment transforms complete analytic signals before hamiltonian().
A delayed crosstalk copy therefore keeps the source carrier, both quadratures,
and the carrier phase accumulated during the delay.
Dissipation¶
Declare a configurable loss rate with parameter(noise=True). Return the
unscaled jump operator and its rate separately:
from quchip import CollapseChannel, parameter
class TwoPhotonLossMode(Mode):
two_photon_loss_rate: Scalar = parameter(
nonnegative=True,
unit="1/ns",
noise=True,
)
def dissipation(self, op, p):
return super().dissipation(op, p) + (
CollapseChannel(
operator=op.a @ op.a,
rate=p.two_photon_loss_rate,
name="two_photon_loss",
),
)
Rates use 1/ns. The backend applies sqrt(rate) when constructing a
Lindblad operator. Devices, drives, and couplings author channels on their
local support. A Bath can author a channel across several targets.
Custom spaces and interop¶
A custom local space defines its dimension and the matrices available by name.
Its LocalSpace.matrix() implementation must return a matrix with that fixed
dimension. See quchip.extensions.SpinHalf for a complete two-level example.
Named ports and observables use the same vocabulary: an operator declared as
op["dipole"] is available through device.local_operator("dipole") and
network.port(..., operator="dipole"). The conventional names "a", "a_dag",
"n", "X", "Y", "Z", and "I" keep their device-hook meanings.
Default T1 and thermal channels use lowering_operator() and
raising_operator(). T2 uses number_operator(). Override those hooks to
select your model’s transitions and dephasing operator, or declare the
complete channels in dissipation(). Matrix-element normalization sets how a
channel’s rate relates to measured lifetimes. quchip does not infer selection
rules or dipole strengths from energy levels. The energy-level index used by
frames is separate from these physical operators.
For an intrinsically finite model, override truncation_boundary() to return
None. For a numerical cutoff, supply a TruncationBoundary. An undeclared
custom cutoff is reported unavailable.
Units remain GHz, ns and mK for every model: enter 1 MHz as 0.001 GHz and 1
microsecond as 1000 ns. Frames give one reference frequency per device.
Multitone terms can keep explicit time dependence. Local spaces are finite.
Subclass ModelMapping when a third-party object needs an explicit
conversion. Set source for import, target and library for export, and
implement only the directions the mapping supports. Importing
quchip.extensions does not load optional scqubits modules.
Control equipment¶
Subclass SignalTransform for a classical hardware effect that acts on complete
analytic signals. Implement apply(signals) and return a new signal map. Declare
numeric fields with parameter() and structural fields with setting(). Opt into
persistence with serializable=True, and import the extension before loading a
saved chip that uses it. Sweeps rebind parameters on independent transforms, whose
generated constructors use keyword arguments. Override validate() for
relationships between fields. Construction and binding call it after assigning all
candidate values, and numerical validation must keep tracing.
from quchip.control import SignalTransform
from quchip.declarative import parameter, setting, qnp
class CableLoss(SignalTransform, serializable=True):
line: str = setting()
loss_db: float = parameter()
def apply(self, signals):
factor = qnp.power(10.0, -self.loss_db / 20.0)
return {
key: signal.scaled(factor) if key[0] == self.line else signal
for key, signal in signals.items()
}
def referenced_lines(self):
return (self.line,)
This lets wiring and partitioning validate the reference. Construct the example
with CableLoss(line="drive", loss_db=3.0).
Transforms receive the output of the preceding transform. CrosstalkMatrix
reads one shared input snapshot so its off-diagonal paths mix simultaneously.
Checks for a new extension¶
Test only the properties the extension claims:
an analytical spectrum, operator identity, or decay law;
native and projected basis behavior where projection applies;
full and RWA behavior for excitation-changing terms;
units and amplitude normalization;
serialization;
a finite, nonzero JAX gradient through each differentiable parameter;
the complete
ChiporQuantumSequencepath;both backends only when backend lowering changes.
If the engine must branch on an extension’s concrete class, the model needs a public physical capability instead.