Buses and schemas¶
Every operation targets a bus by name, and a plain string is a valid name:
import qprogram as qp
program = qp.QProgram(label="rabi")
program.play("drive_q0", "pi_pulse")
program.measure("readout_q0", "readout", "weights")
That is a complete program. Two things you give up by typing strings: there is
no tab-completion, and nothing checks either the name or the kind of waveform
you put on it. Type "drvie_q0" by accident and you find out at execution
time, on hardware.
A BusSchema closes both gaps without changing what ends up in the AST.
What a schema declares¶
A schema records element kinds and, for each element kind, the bus kinds that element exposes, the channel each bus carries, and whether it has an ADC. It says nothing about how many qubits the chip has, so any index resolves:
schema = qp.BusSchema.transmon()
q = schema.q
q[0].drive # "q0/drive", a BusRef
q[3].readout # "q3/readout"
q[42].drive # "q42/drive"
schema.elements maps element name to an ElementSchema, whose buses is
{kind: (channel, acquires)} in declaration order and whose bus_names is
just the kinds. schema.naming is the BusNaming the schema resolves strings
through, and schema.KIND is a class-level tag the presets set
("transmon", "fluxonium_coupled", and so on) and nothing else reads.
A BusRef is a real str subclass, so it works everywhere QProgram expects a
bus name; in a .qp file it emits as an element[idx].kind path rather than a
quoted name. It carries six extra fields:
bus = q[0].readout
bus # "q0/readout"
isinstance(bus, str) # True
bus.element # "q"
bus.idx # 0, or a tuple for a multi-index element
bus.kind # "readout"
bus.channel # "IQ"
bus.acquires # True
bus.schema # the BusSchema that produced it
The index field is idx rather than index because a str subclass must not
shadow the inherited str.index method. The fields live in __slots__, so an
instance carries them without gaining a per-instance __dict__, and BusRef
overrides __reduce__ so copy.deepcopy and pickle rebuild the metadata:
the inherited str.__reduce_ex__ would pass the string value alone back to a
constructor that wants six more arguments.
Building a ref by hand is how you describe a bus that lives outside any
schema. qp.BusRef("aux0/rf", "aux", 0, "rf", "single", acquires=False)
leaves schema at None, so a program bound to any schema accepts it, while
its channel and acquires still drive the checks below.
Built-in presets¶
The presets return typed subclasses, so an IDE can complete the bus kinds. The
qubit element is always named q and the coupler element, where there is one,
is always named c with a single flux bus on a real-valued channel and no
ADC.
| Preset | Returns | q buses |
c buses |
|---|---|---|---|
qp.BusSchema.transmon() |
TransmonSchema |
drive (IQ), readout (IQ, acquires) |
none |
qp.BusSchema.transmon_coupled() |
TransmonCoupledSchema |
drive (IQ), readout (IQ, acquires) |
flux (single) |
qp.BusSchema.flux_tunable_transmon() |
FluxTunableTransmonSchema |
drive (IQ), readout (IQ, acquires), flux (single) |
none |
qp.BusSchema.flux_tunable_transmon_coupled() |
FluxTunableTransmonCoupledSchema |
drive (IQ), readout (IQ, acquires), flux (single) |
flux (single) |
qp.BusSchema.fluxonium() |
FluxoniumSchema |
drive (IQ), readout (IQ, acquires), flux_x (single), flux_z (single) |
none |
qp.BusSchema.fluxonium_coupled() |
FluxoniumCoupledSchema |
drive (IQ), readout (IQ, acquires), flux_x (single), flux_z (single) |
flux (single) |
Every preset takes an optional naming argument and nothing else; the schema
classes live in qp.buses under those names if you want to subclass one or
name one in a type annotation.
A coupler sits between qubits, so its index is usually a tuple, and a tuple index joins with an underscore:
coupled = qp.BusSchema.flux_tunable_transmon_coupled()
coupled.c[0, 1].flux # "c0_1/flux"
coupled.c[3].flux # "c3/flux", a single integer works too
Bus naming¶
BusNaming holds one format string, and its resolve(element, index, kind)
substitutes the three pieces by keyword. The default pattern is
BusNaming.DEFAULT_PATTERN, "{element}{index}/{kind}", which is why
q[0].readout reads "q0/readout". A tuple index is joined with underscores
before substitution. Pass a naming to any preset, to BusSchema(), or to
BusSchema.combine, and every ref that schema produces adopts it:
named = qp.BusSchema.flux_tunable_transmon(naming=qp.BusNaming("{kind}_{element}{index}_bus"))
named.q[0].drive # "drive_q0_bus"
named.q[0].readout # "readout_q0_bus"
qp.BusNaming().resolve("c", (0, 1), "flux") # "c0_1/flux"
The three supported placeholders are {element}, {index} and {kind}, and
the pattern is not validated when the BusNaming is constructed. A bad
pattern raises the first time a ref is resolved: KeyError for a placeholder
outside the three ("{element}-{port}" raises KeyError: 'port'),
ValueError for a malformed format string or a format specification the
substituted text cannot satisfy (every piece arrives as text, so {index:d}
fails), and IndexError for a positional placeholder such as {0}.
Nothing requires the pattern to use all three placeholders, and a pattern that
omits {kind} collapses every bus on an element onto one name:
BusNaming("{element}{index}") resolves both q[0].drive and q[0].readout
to "q0". A non-default pattern is written into the naming: line of the
.qp schema block, so it survives a round-trip; the default pattern is left
out of the file.
Dynamic schemas¶
For a one-off or exotic layout, build the schema by hand. There is no static
typing on the result, because bus access goes through __getattr__ rather
than declared properties, but everything else works the same:
dynamic = qp.BusSchema()
dynamic.add_element(
"q",
buses={
"drive": ("IQ", False),
"readout": ("IQ", True),
"charge": ("single", False),
},
)
dynamic.add_element("resonator", buses={"probe": ("IQ", True)})
dynamic.q[0].charge # "q0/charge"
dynamic.resonator[2].probe # "resonator2/probe"
Each entry in buses is a (channel, acquires) pair: channel is "single"
or "IQ", and acquires is True when the bus has an ADC. Registering an
element name twice replaces the earlier declaration rather than merging into
it, so the last call wins.
Combining schemas¶
Two schemas add together, which is how a chip schema and a separate control family (an RF switch, a set of couplers) end up in one place:
switch = qp.BusSchema()
switch.add_element("switch", buses={"rf": ("single", False)})
combined = qp.BusSchema.flux_tunable_transmon() + switch
combined.q[0].flux # "q0/flux"
combined.switch[0].rf # "switch0/rf"
Either operand may be a schema instance or a schema class, so
qp.buses.FluxTunableTransmonSchema + MyChipSchema works as well; the class
form is provided by a metaclass, because __add__ in a class body governs
instances only. BusSchema.combine(*schemas, naming=None) is the same
operation spelled out, and it is what you want for three or more schemas in
one call or for choosing the naming explicitly.
The result is a plain BusSchema holding the union of the inputs' elements, so
combined.q[0].drive resolves at runtime with no static typing, the same
trade-off as add_element. Build your refs from the combined schema rather
than from the originals: a ref's schema back-pointer has to match the schema
attached to the program.
combine raises ValueError when it is called with no schemas, when the
inputs disagree on their naming pattern and no naming is passed
(cannot combine schemas with different naming patterns [...]), and when two
inputs declare the same element name with different buses
(cannot combine schemas: element 'q' is defined differently [...], which is
what transmon() + flux_tunable_transmon() produces, since both define q).
Re-declaring an identical element is allowed and merges once.
Channel types and acquisition¶
ChannelType is Literal["single", "IQ"]: a single real-valued output, or a
pair of I and Q outputs. What the channel gates is the waveform an operation
carries, not the operation itself, and the two waveform hierarchies are
disjoint, so the test is exact. An "IQ" bus takes an IQWaveform (IQPair,
IQDrag, IQRotation, IQZero, Modulated) and rejects a Waveform; a
"single" bus takes a Waveform (Square, FlatTop, Gaussian, and the
rest) and rejects an IQWaveform.
play checks its waveform, and measure checks both its readout pulse and
its integration weights. Every other bus-carrying operation (wait, sync,
set_frequency, set_phase, reset_phase, set_gain, set_offset,
set_parameter, get_parameter) accepts either channel type. set_offset in
particular does not compare offset_path1 against the bus channel, so a
second path on a single-channel bus passes both the builder and validate
without comment.
acquires is read by measure, which refuses a bus without an ADC, and again
when a fragment is expanded, because a bus that arrived as a fragment parameter
could not be checked while the fragment body was being written.
Checks that run when you build a program¶
The schema-identity check compares the ref's schema back-pointer against the
program's, so it only fires for a ref a schema produced. The channel and
acquisition checks read channel and acquires off the ref itself, so they
fire for a hand-built BusRef too. Missing elements and missing bus kinds
fail earlier still, when the accessor is read:
| Check | Fires when | Result |
|---|---|---|
| element lookup | schema.<name> names no registered element |
AttributeError: No element 'resonator' in schema. Available: q |
| bus kind, dynamic schema | schema.q[0].<kind> is not declared for that element |
AttributeError: 'q' has no bus 'flux'. Available: drive |
| bus kind, typed schema | the accessor class defines no such property | AttributeError: 'TransmonQubitBuses' object has no attribute 'flux' |
| schema identity | the ref's schema is not the program's schema |
ValidationError: BusRef 'q0/drive' (element='q', kind='drive') comes from a different BusSchema ... |
| acquisition | measure on a ref with acquires=False |
ValidationError: Bus 'q0/drive' does not support acquisition (acquires=False). ... |
| channel, IQ bus | a single-channel Waveform on an "IQ" bus |
ValidationError: Bus 'q0/drive' is an IQ channel but received a single-channel Waveform (Square). ... |
| channel, single bus | an IQWaveform on a "single" bus |
ValidationError: Bus 'q0/flux' is a single channel but received an IQWaveform (IQDrag). ... |
Each mistake therefore surfaces on the line that made it:
ftt = qp.BusSchema.flux_tunable_transmon()
q = ftt.q
prog = qp.QProgram(label="checks", schema=ftt)
prog.play(q[0].drive, qp.waveforms.IQDrag(0.5, 40, 8, 0.1)) # OK
prog.play(q[0].drive, qp.waveforms.Square(0.5, 100)) # ValidationError
prog.play(q[0].flux, qp.waveforms.FlatTop(0.5, 200, 20)) # OK
prog.play(q[0].flux, qp.waveforms.IQDrag(0.5, 40, 8, 0.1)) # ValidationError
prog.measure(q[0].drive, "readout", "weights") # ValidationError, no ADC
prog.measure(q[0].readout, "readout", "weights") # OK
q[0].flux_x # AttributeError, a flux-tunable transmon has one flux bus
ftt.resonator # AttributeError, this schema has no resonator element
measure runs its checks in the order schema identity, acquisition, readout
pulse channel, weights channel, then measurement-name allocation, so the
first thing wrong with the call is the thing reported. Vendor operations
appended through a VendorNamespace get the schema-identity check on every
BusRef attribute they carry, including refs inside lists, but not the
channel and acquisition checks, which the play and measure builders run on
their own arguments.
A string waveform alias carries no channel, so it is not checked when the
operation is appended. It is checked when with_waveforms resolves the alias
to a concrete waveform, against the bus the operation targets, which is the
same ValidationError arriving later. The bus it resolves against is the one
the operation carries at that moment, so a
rebind run first changes both the check and,
for a WaveformLibrary with per-bus entries, which waveform the alias
resolves to.
Plain strings always bypass validation¶
Raw strings still work and skip every check. This is on purpose: you can keep a program mostly schema-backed and slot in a one-off bus by name without declaring it.
prog.play("raw_bus", qp.waveforms.Square(0.5, 100)) # OK, no validation
prog.measure("raw_bus", "readout", "weights") # OK, no validation
prog.play(q[0].drive, "pi_pulse") # OK, the alias is checked later
Two other behaviors follow the same split. Auto-allocated measurement names
are per bus for a BusRef (q0/readout/m0, q0/readout/m1) and share one
global counter for raw-string buses (m0, m1); see
Measurements and results. And a platform's capabilities are
declared per (element, bus kind) slot, so a schema-backed BusRef routes to
the profile for its own kind of bus while a raw string always routes to
default_bus_profile; see Capabilities and validation.
One schema per program¶
A QProgram holds at most one BusSchema, passed at construction:
If you build a program with schema=schema_a and then call
program.play(schema_b.q[0].drive, ...), QProgram refuses with a
ValidationError at the call site. The comparison is by identity, not by
structure, so two separately constructed transmon() schemas count as
different. That bus would serialize fine but mean something different on load,
so the builder rejects it rather than letting it through.
You can omit the schema entirely. In that case the first schema-backed ref the program sees is adopted as the program's schema, and every later ref is compared against it. A fragment carries a schema the same way, and calling one reconciles the two: a fragment built against a schema lends it to a program that has none, and two different schemas raise. See Fragments.
Defining your own typed schema¶
For a chip type the presets do not cover, write a typed schema class. The
pattern is what the presets do internally, and the base classes are the
underscore-prefixed ones in qp.buses: an accessor exposes one property per
bus kind, a factory turns an index into an accessor, and the schema exposes one
property per element.
class MyQubitBuses(qp.buses._TypedElementAccessor):
@property
def drive(self) -> qp.BusRef:
return self._ref("drive", "IQ")
@property
def readout(self) -> qp.BusRef:
return self._ref("readout", "IQ", acquires=True)
@property
def charge(self) -> qp.BusRef:
return self._ref("charge", "single")
class MyQubitFactory(qp.buses._TypedElementFactory):
_accessor_cls = MyQubitBuses
def __getitem__(self, index: int) -> MyQubitBuses:
return MyQubitBuses(self._element, index, self._naming, self._parent)
class MyChipSchema(qp.BusSchema):
KIND = "my_chip"
def __init__(self, naming: qp.BusNaming | None = None) -> None:
super().__init__(naming=naming)
self.add_element(
"q",
{
"drive": ("IQ", False),
"readout": ("IQ", True),
"charge": ("single", False),
},
)
self.add_element("c", {"flux": ("single", False)})
@property
def q(self) -> MyQubitFactory:
return MyQubitFactory("q", self._naming, self)
@property
def c(self) -> qp.buses.CouplerFactory:
return qp.buses.CouplerFactory("c", self._naming, self)
A factory takes (element, naming, schema) and an accessor takes
(element, index, naming, schema); that last argument is the back-pointer
every BusRef carries in bus.schema, and it is what lets a program reject a
reference built from a different schema. _ref(kind, channel, acquires=False)
does the rest: it resolves the name through the schema's naming and fills in
the metadata, with acquires keyword-only.
Call add_element for every element, including the ones your typed properties
already cover. The properties are what a reader writes, but the elements
dictionary is what the .qp writer emits and what combine merges, and
_ref never consults it: a class with a q property and no
add_element("q", ...) resolves q[0].drive happily, writes an empty
schema: block, and then fails to reload with
ParseError: Line 7: inline schema has no element declarations. Accept
naming in the constructor too, so the class works with combine and with
rebind(naming=...), both of which re-declare elements under another pattern.
User-defined classes serialize through the same inline form as the presets
(see the .qp schema declaration).
The Python class identity does not survive a round-trip: a loaded program gets
a plain BusSchema with KIND back at "", holding the same elements, bus
kinds and naming. Runtime access and the validation behavior are unchanged,
and the typed properties are gone.
Re-resolving a bus coordinate¶
qp.buses.resolve_ref(schema, element, index, kind) turns a structural
coordinate back into a BusRef. It is two attribute reads with a subscript
between them, getattr(getattr(schema, element)[index], kind), which is why it
works identically for a typed preset and for a dynamic schema: the typed path
lands on a declared property, the dynamic path on __getattr__. The ref comes
back resolved under schema's naming and pointing at schema, and the
failures are the accessor failures from the table above, an AttributeError
for an unknown element or bus kind.
qp.buses.resolve_ref(coupled, "q", 0, "readout") # "q0/readout"
qp.buses.resolve_ref(coupled, "c", (0, 1), "flux") # "c0_1/flux"
Two callers use it. The .qp parser calls it for every element[i].kind path
in a file, which is how a loaded program gets real BusRefs rather than
strings. QProgram.rebind calls it for every ref it rewrites, which is how
re-indexing a qubit or moving a program onto another chip's schema stays
checked against that schema. The naming-only case goes through
qp.buses.naming_substituted_schema(schema, naming), which returns a dynamic
copy of schema with the same elements declared under a new BusNaming.
Rebinding buses in a program¶
QProgram.rebind rewrites the bus on every operation in a program by
re-resolving each one structurally instead of substituting strings. Its
parameters are all keyword-only:
program.rebind(
schema=None,
elements=None,
naming=None,
strings=None,
allow_unported_strings=False,
)
schema is the schema the refs are resolved against, and it defaults to the
program's own, which is the re-index-within-one-chip case; pass another schema
to move the program onto another chip. elements maps (element, idx) to
(element, idx), so {("q", 0): ("q", 1)} moves every operation on qubit 0
onto qubit 1 while every pair the map does not list passes through unchanged;
an index is an int or a tuple, the same shape as BusRef.idx. naming
re-resolves the refs under a different BusNaming, through
qp.buses.naming_substituted_schema, and it needs a schema to substitute into:
on a program that has none it raises ValidationError: rebind(naming=...)
requires the program to have a schema to re-resolve against. strings covers
raw-string buses, described below.
Every schema-backed ref goes through qp.buses.resolve_ref with its remapped
element and index and its own bus kind, so the rebound bus is a BusRef again,
carrying the channel and acquires the target schema declares and still
emitting as an element[idx].kind path in a .qp file. A coordinate the target
schema does not declare fails rather than resolving to a plausible name:
rebinding a play on q[0].drive onto a transmon coupler raises
AttributeError: 'CouplerBuses' object has no attribute 'drive', because a
coupler declares only flux. Auto-allocated measurement names are re-derived
from the new bus while user-supplied names are left alone; see
Measurements and results.
rebind returns a new QProgram and mutates nothing. The copy is deep, and
fragment calls are expanded into it first when the program has any, so the
result holds the inlined bodies rather than the calls; see
Fragments. The schema is copied along with the program unless
you pass one, and the schema-identity check compares by identity, so a ref held
from the original schema cannot be appended to the rebound program; read the
schema back off the result if you want to keep building.
schema = qp.BusSchema.transmon()
q = schema.q
program = qp.QProgram(label="rabi", schema=schema)
program.play(q[0].drive, "pi_pulse")
handle = program.measure(q[0].readout, "readout", "weights")
handle.name # "q0/readout/m0"
ported = program.rebind(elements={("q", 0): ("q", 1)})
sorted(ported.buses) # ["q1/drive", "q1/readout"]
sorted(program.buses) # ["q0/drive", "q0/readout"], the original is untouched
ported.measurement_handles()[0].name # "q1/readout/m0"
renamed = program.rebind(naming=qp.BusNaming("{kind}_{element}{index}_bus"))
sorted(renamed.buses) # ["drive_q0_bus", "readout_q0_bus"]
A raw-string bus carries no element, index or kind, so there is nothing to
re-resolve it from, and rebind will not guess. A raw string that strings
does not cover fails the whole call:
ValidationError: rebind left raw-string bus(es) unported: 'aux_line'. Raw strings carry no schema metadata to re-resolve — map them via strings={...} (map a name to itself to keep it), or pass allow_unported_strings=True to leave them in place.
The two ways past that differ in what the call records. strings renames the
buses it lists, and mapping a name to itself keeps it while saying in the source
that keeping it was the intent. allow_unported_strings=True lifts the failure
for every uncovered string at once, and what it costs is that record: the
strings stay pointing at the old buses in a program whose schema-backed refs
have all moved, and neither the call nor the result says which ones were left
behind.
mixed = qp.QProgram(label="mixed", schema=schema)
mixed.play(q[0].drive, "pi_pulse")
mixed.play("aux_line", qp.waveforms.Square(0.5, 100))
mixed.rebind(elements={("q", 0): ("q", 1)}) # ValidationError
mixed.rebind(elements={("q", 0): ("q", 1)}, strings={"aux_line": "aux_rf"}) # renamed
mixed.rebind(elements={("q", 0): ("q", 1)}, strings={"aux_line": "aux_line"}) # kept
mixed.rebind(elements={("q", 0): ("q", 1)}, allow_unported_strings=True) # kept
Three levels of bus referencing¶
| Approach | Setup | Validation | Tab-completion |
|---|---|---|---|
"drive_q0" raw string |
none | none | no |
qp.BusSchema.transmon().q[0].drive |
one line | channel, ADC | yes |
platform.get_bus_schema() |
from platform | channel, ADC | yes |
All three produce a str at the AST level. Mix them freely. The third is the
one to reach for when a platform is in the loop: get_bus_schema is on
PlatformProtocol, so the schema comes from the same object that will run the
program, and the bus names it produces are the ones that platform expects.