Capabilities and profiles¶
A platform declares what it supports as a PlatformCapabilities descriptor, and
qp.validate checks a program against that declaration before anything reaches
an instrument. This package ships one bundle for that declaration:
qdac-default-v1, the bus-level profile for a QDAC-driven channel.
If the protocol itself is new to you, read the core guide's
capabilities page
first. This page covers what qdac-default-v1 puts in it.
What the profile carries¶
Importing the package registers the profile, so it resolves by name:
import qprogram as qp
from qprogram_qdac.profiles import QDAC_DEFAULT_V1
QDAC_DEFAULT_V1.name # 'qdac-default-v1'
QDAC_DEFAULT_V1.version # (0, 1, 0)
QDAC_DEFAULT_V1.extends # None
QDAC_DEFAULT_V1.limits # {'min_dwell_ns': 100}
QDAC_DEFAULT_V1.vendor_versions # {'qdac': (0, 1, 0)}
len(QDAC_DEFAULT_V1.capabilities) # 16
extends is None: the profile declares its own token set rather than
inheriting one. vendor_versions records which qdac extension version the
profile was written against, mirroring a .qp file's require qdac 0.1 line.
Materialize it into a descriptor the same way as any other profile:
qdac_cc = qp.CompilerCapabilities.from_profile("qdac-default-v1")
qdac_cc.profile # 'qdac-default-v1'
qdac_cc.supports("vendor.qdac.set_offset") # True
qdac_cc.supports("waveform.iq") # False
Capability tokens¶
Sixteen tokens, in two groups.
Four vendor operation tokens, one per operation this package ships. Each is the
identity token that operation's required_capabilities() returns, so dropping
one from a platform's set is how that platform says it does not implement the
operation:
| Token | Required by |
|---|---|
vendor.qdac.set_offset |
program.qdac.set_offset(...) |
vendor.qdac.set_trigger |
program.qdac.set_trigger(...) |
vendor.qdac.wait_trigger |
program.qdac.wait_trigger(...) |
vendor.qdac.play |
program.qdac.play(...) |
Twelve waveform tokens, because play reaches the DAC through a bus and
waveform tokens live on bus profiles. waveform.single is the channel-kind
token that play always claims, and the other eleven are the per-class tokens
of the envelopes the waveform engine renders:
sorted(t for t in QDAC_DEFAULT_V1.capabilities if t.startswith("waveform."))
# ['waveform.arbitrary', 'waveform.chained', 'waveform.cosine', 'waveform.flat_top',
# 'waveform.gaussian', 'waveform.ramp', 'waveform.sech', 'waveform.sine',
# 'waveform.single', 'waveform.snz', 'waveform.square', 'waveform.tukey']
There is no waveform.iq and no IQ-specific class token. QDAC is
single-channel, so an IQ envelope on a qdac bus is rejected on its per-class
token:
from qprogram.waveforms import IQDrag
from qprogram_qdac import QProgram
base_cc = qp.CompilerCapabilities.from_profile("qprogram-base-v1")
caps = qp.PlatformCapabilities(
bus={},
platform=qp.BusCapabilities(rt=base_cc, host=base_cc),
default_bus_profile=qp.BusCapabilities(rt=None, host=qdac_cc),
)
program = QProgram(label="iq-on-qdac")
program.qdac.play("flux_q0", IQDrag(0.5, 40, 8, 0.1))
print(qp.explain(program, caps))
plan for 'iq-on-qdac' — errors: 1 · warnings: 0 · info: 0
body
└─ qdac.play "flux_q0" IQDrag(amplitude=0.5, duration=40, sigma=8, beta=0.1) [--] !! missing-capability: 'Play' requires capability 'waveform.iq_drag' which is not supported by 'qdac-default-v1' (host)
[--] in the domain column means no domain can run the node.
Tokens the profile deliberately omits¶
qdac-default-v1 carries no block.*, sweep.*, or expr.* tokens:
[t for t in ("block.sweep", "sweep.linear", "expr.variable") if t in QDAC_DEFAULT_V1.capabilities]
# []
Those belong to the platform slot, not to a bus. Blocks always route to
caps.platform, and expr.* tokens are always checked against caps.platform
regardless of where the operation carrying them routes. set_offset with a
swept expression is the case that matters here:
from qprogram_qdac.operations import SetOffset
program = QProgram()
bias = program.variable("bias")
sorted(SetOffset("flux_q0", bias).required_capabilities())
# ['expr.variable', 'vendor.qdac.set_offset']
sorted(SetOffset("flux_q0", bias * 0.5 + 0.1).required_capabilities())
# ['expr.binary_op', 'expr.constant', 'expr.variable', 'vendor.qdac.set_offset']
The expr.* half of that set is checked against the platform slot, which the
core qprogram-base-v1 profile fills. That is why the qdac bus profile does not
need them.
Limits¶
One limit, and the core validator does not check it:
| Limit | Value | Meaning |
|---|---|---|
min_dwell_ns |
100 |
Floor on play's dwell, below which the waveform engine's output interpolation breaks down. |
The validator reads four limit keys (max_loop_nesting, max_parallel_loops,
max_measurements, min_wait_duration_ns) and ignores every other key, so
min_dwell_ns is carried for platforms that enforce it themselves, either in
their compiler or through a predicate of their own. A dwell under the floor
validates clean:
from qprogram.waveforms import Ramp
program = QProgram(label="fast-dwell")
program.qdac.play("flux_q0", Ramp(0.0, 1.0, 1000), dwell=1)
qp.validate(program, caps)[0] # []
A device that knows its own floor tightens the value without republishing the profile:
tight = qp.CompilerCapabilities.from_profile(
"qdac-default-v1",
limit_overrides={"min_dwell_ns": 200},
)
tight.limits # {'min_dwell_ns': 200}
from_profile also takes extra_predicates=(...), which is where a platform
adds its own min_dwell_ns check.
Predicates¶
The profile ships two predicates. They run against every visited node, and both filter on node type first, so a program with no qdac operations is unaffected.
One reclassifies, one rejects. A qdac operation that reads a loop-bound
variable pushes the loop that binds it host-side, which the core classifier
reports as a forced-host warning; a set_trigger that arms no outputs is
a hard qdac.empty-trigger-outputs error. The two subsections below give
the exact firing condition and the message for each.
Swept variables force the loop host-side¶
Fires when the visited node is one of the four qdac operation classes
(SetOffset, SetTrigger, WaitTrigger, Play) and at least one variable
it references has a binding loop. The check walks node.variables(), which
descends into expression arguments and into waveform parameters, so a
play(bus, Ramp(0.0, top, 1000)) is caught exactly like a
set_offset(bus, bias).
Yields a soft DomainConstraint, one per distinct binding loop, with
exclude={"rt"} and a reason naming the operation and the variable. The
constraint targets the binding loop block, not the operation: it is the loop
that has to move host-side, while the operation itself stays classified as it
was. QDAC has no FPGA, so every parameter change goes through the host's
slow-control plane and cannot be a per-iteration register write.
What the user sees depends on the block's support before the constraint applies.
With the wiring that matches the hardware, rt=None and the profile in the
host half, the loop is already host-side by op-children consensus. There is no
{rt, host} to reduce, so nothing surfaces at all:
program = QProgram(label="flux-sweep")
bias = program.variable("bias", units="V")
with program.sweep(bias, qp.Range(0.0, 0.5, 0.05)):
program.qdac.set_offset("flux_q0", bias)
print(qp.explain(program, caps))
plan for 'flux-sweep' — errors: 0 · warnings: 0 · info: 0
body
└─ for bias in Range(start=0.0, stop=0.5, step=0.05): [host]
└─ qdac.set_offset "flux_q0" bias [host]
Put the profile in both halves of the bus slot and the operation becomes
[rt|host]. Now the constraint has something to subtract, the sweep drops to
{host}, and the core classifier reports it as a forced-host warning naming
the predicate's reason:
both_halves = qp.PlatformCapabilities(
bus={},
platform=qp.BusCapabilities(rt=base_cc, host=base_cc),
default_bus_profile=qp.BusCapabilities(rt=qdac_cc, host=qdac_cc),
)
for diagnostic in qp.validate(program, both_halves)[0]:
print(diagnostic)
print(qp.explain(program, both_halves))
[warning] forced-host: Block 'Sweep' falls back to host-side execution: qdac.SetOffset references loop-bound variable 'bias'; qdac has no FPGA, so the loop must dispatch host-side.. (at body[0])
plan for 'flux-sweep' — errors: 0 · warnings: 1 · info: 0
body
└─ for bias in Range(start=0.0, stop=0.5, step=0.05): [host] ~ forced-host: qdac.SetOffset references loop-bound variable 'bias'; qdac has no FPGA, so the loop must dispatch host-side
└─ qdac.set_offset "flux_q0" bias [rt|host]
The diagnostic code is forced-host at severity="warning", and it comes from
the core classifier rather than from the predicate. ~ is how explain marks a
warning. A program that only ever hits this warning still runs: the loop
dispatches from the host, one upload per iteration.
An empty trigger-output set is an error¶
Fires when the visited node is a SetTrigger and its outputs tuple is
empty. Arming zero outputs configures a trigger that fires onto nothing.
Yields a hard Diagnostic, severity="error", code
qdac.empty-trigger-outputs. It is an error in every domain, so no fallback
rescues it and the node's support ends up empty:
program = QProgram(label="trigger-mistake")
program.qdac.set_trigger("flux_q0", 50, position="step")
for diagnostic in qp.validate(program, caps)[0]:
print(diagnostic)
print(qp.explain(program, caps))
[error] qdac.empty-trigger-outputs: SetTrigger has no outputs configured. Specify at least one output index, e.g. outputs={1} or outputs=[1, 2]. (at body[0])
plan for 'trigger-mistake' — errors: 1 · warnings: 0 · info: 0
body
└─ qdac.set_trigger "flux_q0" 50 position="step" [--] !! qdac.empty-trigger-outputs: SetTrigger has no outputs configured. Specify at least one output index, e.g. outputs={1} or outputs=[1, 2].
Naming at least one output clears it:
program = QProgram(label="trigger-ok")
program.qdac.set_trigger("flux_q0", 50, position="step", outputs={1})
qp.validate(program, caps)[0] # []
Wiring the profile into a platform¶
qdac-default-v1 is a bus-slot profile. It fills one half of one
BusCapabilities, and it fills nothing else:
- Bus slots. Attach it to the buses the QDAC drives, typically the flux
buses of a transmon schema. Put it in the
hosthalf and leavertasNone: qdac has no FPGA, so no qdac operation belongs in a real-time sequencer program. Every qdac operation then classifies as{host}by construction. - Platform slot. Fill it with the core
qprogram-base-v1profile. That is whereblock.*,sweep.*, andexpr.*live, and qdac contributes nothing there because it has no bus-less operations. - Default bus profile. The fallback for raw-string buses and for buses with
no
busentry. Point it at whichever vendor owns the unmapped buses.
A worked descriptor, with the QDAC on flux and a real-time vendor on drive and readout:
from qprogram.buses import BusSchema
schema = BusSchema.flux_tunable_transmon()
q = schema.q
qdac_slot = qp.BusCapabilities(
rt=None,
host=qp.CompilerCapabilities.from_profile("qdac-default-v1"),
)
# Stand-in for the fast vendor's own profile, spelled out so the example runs.
fast_cc = qp.CompilerCapabilities(
profile="acme-rt-v1",
version=(0, 1, 0),
capabilities=frozenset({"op.play", "op.measure", "waveform.alias", "waveform.iq", "measure.fields.iq"}),
limits={},
predicates=(),
vendor_versions={},
)
fast_slot = qp.BusCapabilities(rt=fast_cc, host=None)
platform_caps = qp.PlatformCapabilities(
bus={
("q", "drive"): fast_slot,
("q", "readout"): fast_slot,
("q", "flux"): qdac_slot,
},
platform=qp.BusCapabilities(rt=base_cc, host=base_cc),
default_bus_profile=qdac_slot,
)
The keys of bus are (element_kind, bus_kind) pairs, so ("q", "flux")
covers q[0].flux, q[1].flux, and every other flux bus of the q element.
Mixing QDAC with a real-time vendor¶
The shape that works puts the slow bias outside the fast inner loop: a host-side
sweep sets a flux point, and a real-time average block runs the pulses at that
point.
program = QProgram(label="flux-spectroscopy", schema=schema)
bias = program.variable("bias", units="V")
with program.sweep(bias, qp.Range(-0.2, 0.2, 0.1)):
program.qdac.set_offset(q[0].flux, bias)
with program.average(1000):
program.play(q[0].drive, "pi_pulse")
program.measure(q[0].readout, "readout", "weights")
print(qp.explain(program, platform_caps))
plan for 'flux-spectroscopy' — errors: 0 · warnings: 0 · info: 0
body
└─ for bias in Range(start=-0.2, stop=0.2, step=0.1): [host]
├─ qdac.set_offset q[0].flux bias [host]
└─ average 1000: [rt]
├─ play q[0].drive "pi_pulse" [rt]
└─ measure q[0].readout "readout" "weights" name="q0/readout/m0" [rt]
Flatten that and it breaks. A host-only qdac operation and a real-time-only pulse as siblings in one block leave the block with no domain that runs both:
program = QProgram(label="mixed", schema=schema)
bias = program.variable("bias", units="V")
with program.sweep(bias, qp.Range(-0.2, 0.2, 0.1)):
program.qdac.set_offset(q[0].flux, bias)
program.play(q[0].drive, "pi_pulse")
print(qp.explain(program, platform_caps))
plan for 'mixed' — errors: 1 · warnings: 0 · info: 0
body
└─ for bias in Range(start=-0.2, stop=0.2, step=0.1): [--] !! mixed-domain: Block 'Sweep' has op-children with incompatible domain singletons: [('SetOffset', ['host']), ('Play', ['rt'])]
├─ qdac.set_offset q[0].flux bias [host]
└─ play q[0].drive "pi_pulse" [rt]
mixed-domain is a core diagnostic code, not a qdac one. The fix is structural:
wrap the real-time operations in their own block so the sweep sees a block-child
rather than an operation-child with a conflicting domain.
Quick reference¶
| You want to ... | Use |
|---|---|
| Materialize the profile | qp.CompilerCapabilities.from_profile("qdac-default-v1") |
| Attach it to a bus | qp.BusCapabilities(rt=None, host=qdac_cc) |
| Fill the platform slot | qp.CompilerCapabilities.from_profile("qprogram-base-v1") |
| Tighten the dwell floor for one device | from_profile("qdac-default-v1", limit_overrides={"min_dwell_ns": 200}) |
| Add a check the profile does not make | from_profile("qdac-default-v1", extra_predicates=(my_pred,)) |
| Ask whether a token is supported | qdac_cc.supports("vendor.qdac.play") |
| See the plan and the diagnostics | qp.explain(program, caps), qp.validate(program, caps) |
Diagnostic codes¶
| Code | Severity | Source |
|---|---|---|
qdac.empty-trigger-outputs |
error |
This package's profile. |
forced-host |
warning |
Core, from this package's DomainConstraint. |
missing-capability |
error |
Core, when a token is absent from the routed slot. |
mixed-domain |
error |
Core, on a block with conflicting operation-children. |
See also¶
- Operations covers the four operations and which of their arguments can be swept.
- Lowering onto hardware covers what a platform does with a validated plan.
- The core guide's capabilities page covers the protocol itself: routing, the two domains, and every core diagnostic code.