Lowering onto hardware¶
This page is for the person writing the platform: the code that takes a
validated QProgram and drives real Qblox instruments with it. It covers
what each module of this package owns, what runs when the package is
imported, what a platform has to do with each of the six operations, which
conversions the DSL leaves to the platform on purpose, and how the
capability profile limits what a platform may accept.
The core DSL's building a vendor extension page is the general template. This page is the qblox half of it.
The five modules¶
src/qprogram_qblox/
├── __init__.py # the registration calls, __version__, the pre-combined QProgram
├── operations.py # six Operation subclasses: the AST nodes
├── namespace.py # QbloxNamespace: one typed method per operation
├── mixin.py # QbloxMixin: the .qblox property, for autocomplete
└── profiles.py # capability tokens, QBLOX_DEFAULT_V1, two predicates
| Module | What it owns |
|---|---|
operations.py |
The six node classes. Each holds its arguments as plain attributes and declares the capability tokens that instance needs. This is the module a compiler reads. |
namespace.py |
The builder surface. One typed method per operation, each constructing a node and handing it to VendorNamespace._append (or _append_measurement for acquire). No logic beyond that. |
mixin.py |
The .qblox property, which instantiates QbloxNamespace once and caches it on the program instance. Autocomplete only; the runtime lookup works without it. |
profiles.py |
The vendor.qblox.* token registrations, the QBLOX_DEFAULT_V1 profile, and the two predicates that encode hardware constraints. |
__init__.py |
The glue: the four registration steps, the package version, and QProgram, the pre-combined class with .qblox typed. |
Imports run one way. operations.py and profiles.py import from
qprogram and nothing else in this package; namespace.py imports the
operations; mixin.py imports the namespace; __init__.py imports all
four. A compiler that only wants the node classes can import
qprogram_qblox.operations directly, though it then misses the registration
side effects described below.
profiles.py importing no local operations is not an accident: both
predicates constrain core operations (Play and Wait) on a
qblox-driven bus. The qblox operations themselves carry no constraints
beyond their tokens.
What runs at import¶
The package hooks into the core three ways: the runtime namespace, the typed mixin, and the serialization registry. Two of the three need a call; the mixin is only a class definition. The capability profile is a fourth registration, on the protocol side rather than the builder side.
Importing qprogram_qblox is the activation step, and it is the only one.
__init__.py performs four registrations, in this order.
- Runtime namespace.
QProgram.register_vendor("qblox", QbloxNamespace)puts the class in the core builder's vendor registry, soprogram.qbloxresolves throughQProgram.__getattr__on any program, the plain base class included. - Protocol version.
register_vendor_version("qblox", __version__)records the version, which__init__.pyreads from the installed distribution metadata. This is the number the parser checks a file'srequire qblox 0.1line against: same major, file minor not ahead of installed minor. - Operations. One
register_vendor_operation("qblox", name, cls)call per class.acquireadditionally passes the core measurement callbacks, so its handle serializes as aname="..."keyword like every other measurement. - Profile.
_register()fromprofiles.pyputsQBLOX_DEFAULT_V1on the global profile registry, whereCompilerCapabilities.from_profilefinds it by name.
One more registration runs before all four, as a side effect of importing
profiles.py at the top of __init__.py: register_capability_tokens adds
the six vendor.qblox.* tokens. It has to run before the profile object is
constructed, because Profile.__post_init__ rejects a capability set naming
a token nobody registered. That is the check that turns a typo into an
import error instead of a silent validation miss.
The typed mixin registers nothing. Nothing in mixin.py runs at import
beyond the class definition, because .qblox is a property that builds the
namespace on first access.
Everything above is observable:
import qprogram as qp
import qprogram_qblox
from qprogram.protocol import CAPABILITY_REGISTRY, resolve_profile
from qprogram.serialization import registry
print("version: ", qprogram_qblox.__version__)
print("vendor: ", registry.get_vendor_version("qblox"))
print("acquire: ", registry.get_operation_class("qblox", "acquire").__name__)
print("set_markers name:", registry.get_operation_vendor_name(qprogram_qblox.SetMarkers))
print("profile: ", resolve_profile("qblox-default-v1").name)
print("tokens: ", sorted(t for t in CAPABILITY_REGISTRY if t.startswith("vendor.qblox")))
program = qp.QProgram() # the base class, no mixin in sight
program.qblox.set_markers("drive_q0", "0001")
print("namespace:", type(program.qblox).__name__)
version: 0.1.0
vendor: 0.1.0
acquire: Acquire
set_markers name: ('qblox', 'set_markers')
profile: qblox-default-v1
tokens: ['vendor.qblox.acquire', 'vendor.qblox.set_acquisition_rotation', 'vendor.qblox.set_acquisition_threshold', 'vendor.qblox.set_markers', 'vendor.qblox.set_trigger', 'vendor.qblox.wait_trigger']
namespace: QbloxNamespace
A platform never has to trigger the import itself. The
[project.entry-points."qprogram.vendors"] table in pyproject.toml maps
the vendor name qblox to the module qprogram_qblox, and qprogram.loads
imports it on demand when a file's header requires a vendor that is not
registered yet. loads(text, auto_activate=False) opts out of that and
rejects the file instead.
Reading the program¶
A platform reads the AST, not the text. program.body.walk() yields every
node in pre-order, blocks and operations together, and isinstance against
the classes in qprogram_qblox.operations is the whole dispatch:
import qprogram as qp
from qprogram_qblox import QProgram
from qprogram_qblox.operations import Acquire, SetMarkers, SetTrigger, WaitTrigger
schema = qp.BusSchema.transmon()
q = schema.q
program = QProgram(schema=schema)
program.qblox.set_markers(q[0].drive, "0001")
program.qblox.set_trigger(q[0].drive, duration=40, outputs=[1, 2], position="end")
program.qblox.wait_trigger(q[0].drive, duration=100, port=1)
program.qblox.acquire(q[0].readout, "readout_weights", fields=("iq", "raw"))
for node in program.body.walk():
if isinstance(node, SetMarkers):
print("set_markers ", node.bus, node.mask)
elif isinstance(node, SetTrigger):
print("set_trigger ", node.bus, node.duration, node.outputs, node.position)
elif isinstance(node, WaitTrigger):
print("wait_trigger", node.bus, node.duration, node.port)
elif isinstance(node, Acquire):
print("acquire ", node.bus, node.weights, node.handle.name, node.fields)
set_markers q0/drive 0001
set_trigger q0/drive 40 [1, 2] end
wait_trigger q0/drive 100 1
acquire q0/readout readout_weights q0/readout/m0 ('iq', 'raw')
Three things come from the core Operation base and need no per-class code:
node.buses(), node.waveforms() and node.variables(), driven by the
BUS_ATTRS and WAVEFORM_ATTRS class attributes. Use them for the passes
that do not care which operation they are looking at: allocating sequencers
per bus, collecting waveforms to upload, or finding the variables a loop has
to bind.
Two things to do before lowering anything. Call program.expand() if
program.fragments is non-empty, so every fragment call becomes a plain
block. Then call validate(program, caps) and honor the result: raise on an
error diagnostic, warn on a warning, pass info through. The plan
returned alongside the diagnostics says, per node instance, which domains
can run it, which is what tells you whether a loop compiles into the
sequencer or steps from the host.
What each operation asks of the platform¶
| Operation | Wire form | What the platform does |
|---|---|---|
acquire |
qblox.acquire q[0].readout "weights" name="q0/readout/m0" |
Arm an acquisition on the bus's sequencer. Play nothing. |
set_markers |
qblox.set_markers "drive_q0" "0001" |
Drive the four marker outputs of that bus's module with the mask. |
set_trigger |
qblox.set_trigger q[0].drive 40 outputs=[1, 2] |
Assert the named trigger outputs for the duration, at the requested edge of the operation. |
wait_trigger |
qblox.wait_trigger q[0].drive 100 port=1 |
Block the sequencer until a trigger arrives on the port, giving up after the timeout. |
set_acquisition_threshold |
qblox.set_acquisition_threshold q[0].readout 0.42 |
Write the sequencer's thresholded-acquisition threshold. No sequencer instruction. |
set_acquisition_rotation |
qblox.set_acquisition_rotation q[0].readout angle |
Write thresholded_acq_rotation, converted to degrees. No sequencer instruction. |
Keyword arguments appear on the wire only when they differ from their
default, so a set_trigger at the default position="start" writes without
a position keyword and reloads to the same node. A measurement's name=
is the exception: it is always written, so a reloaded program keeps the keys
its results are stored under.
acquire¶
Acquire(bus, weights, handle, fields=(MeasurementField.IQ,)) is a
MeasurementOperation. It acquires without playing, which is the difference
from core measure: use it when a separate play (or another bus) provides
the readout pulse.
weightsis either anIQWaveformto upload as integration weights or astrnaming one. A name is a promise the caller keeps, not the platform:program.with_waveforms(library)resolves names against aWaveformLibrarybefore execution, and a platform receives the resolved program. Astrstill reaching the platform is a program that was never bound.handle.nameis the key of the record in the result. Keep it: it is whatQProgramResult.get(handle)looks up, and it is the same name the.qpfile carries.fieldsis a tuple of field names in canonical order (state,iq,raw), deduplicated at construction. The platform produces exactly those arrays.iqis the default, and the primary array (MeasurementResult.data) isiqwhen requested and the first field in canonical order otherwise.- The node carries no integration length and no acquisition index.
Integration length is bus configuration, so it reaches the platform
through the core
program.set_parameter(bus, name, value)under whatever name the platform exposes, or through the platform's own setup, not through this node. Acquisition indices are the platform's bookkeeping.
set_markers¶
SetMarkers(bus, mask). The mask is a four-character string of 0 and 1,
where "0001" enables marker 1.
The node does not check the string. Nothing in the DSL knows how wide a marker port is, so a platform validates the width and the alphabet itself and rejects the rest, or its profile adds a predicate that turns a bad mask into a diagnostic before execution starts. Which physical output each character drives is the module's business, and the DSL takes no position on it.
The node also says nothing about duration. It carries a mask and a bus; when the mask takes effect relative to the surrounding pulses is a scheduling decision the platform makes.
set_trigger¶
SetTrigger(bus, duration, outputs=None, position="start").
durationis nanoseconds. Converting to sequencer clock cycles, and honoring the module's timing granularity, is the platform's job.outputsis a list of output numbers, a single number, orNone.Nonemeans the caller did not choose, so the platform picks its default. Numbering is the instrument's.positionis"start"or"end"of the operation, as a plain string. The node accepts any string. Reject an unrecognized one rather than guessing.
duration is typed int. A varying trigger duration is outside the
operation's contract: the node declares no expr.* token for it, so nothing
checks whether a platform can vary it between iterations.
wait_trigger¶
WaitTrigger(bus, duration, port=None). The sequencer blocks until an
external trigger arrives.
durationis the timeout in nanoseconds, again converted to cycles by the platform.portis the trigger input port, orNonefor the platform's default.
What happens when the timeout expires is not in the node. Pick a policy, and document it: continuing and failing the shot are both defensible, and a program cannot tell them apart from the DSL side.
set_acquisition_threshold¶
SetAcquisitionThreshold(bus, value) is host-side only. It emits no
sequencer instruction. The platform writes an instrument parameter at
execution time, once per occurrence, in program order relative to the
operations around it.
value is the threshold in volts after integration, and it accepts an
Expression. If the instrument parameter is defined on another scale, for
instance in integrated ADC counts, the platform converts. The node stays in
the DSL's unit for the same reason the rotation does, described next.
set_acquisition_rotation¶
SetAcquisitionRotation(bus, angle) is the other half of thresholded
acquisition, and also host-side only: the integrated IQ point is rotated by
angle so the two populations separate along one axis, then compared
against the threshold. Setting one without the other is legal; a calibrated
discrimination normally writes both.
angle is in radians. The instrument parameter is in degrees. The DSL
uses radians for every angle, matching core set_phase. The QCoDeS
parameter thresholded_acq_rotation takes degrees in [0, 360). So the
platform converts, and normalizes into range:
import math
angle = math.pi / 4 # radians, as the DSL recorded it
rotation_deg = math.degrees(angle) % 360.0
print(rotation_deg) # 45.0, in the unit the instrument wants
The modulo is not decoration. A calibration sweep that runs past 2π, or
one that starts below zero, produces angles outside the parameter's range,
and % maps them back without changing the physical rotation.
The conversion lives on the platform side, not in the AST node, and that is
a deliberate choice. The .qp file records what the user asked for, in the
DSL's own units. A program written against radians stays readable, keeps
comparing equal to the program that produced it, and stays portable: a
second backend whose parameter wants radians, or milliradians, or turns,
reads the same file and applies its own conversion. Bake degrees into the
node instead and the file starts describing one instrument's API rather than
the experiment.
There is a second reason, and it is the subject of the next section: a swept angle has no literal value at build time, so there is nothing to convert until the loop runs.
Conversions the DSL does not perform¶
| The node holds | The instrument wants | Who converts |
|---|---|---|
angle in radians |
degrees in [0, 360) |
Platform: math.degrees(angle) % 360.0 |
value in volts after integration |
whatever scale the parameter uses | Platform |
duration in nanoseconds |
sequencer clock cycles | Platform, honoring the module's granularity |
mask as a four-character string |
marker bits, in the module's order | Platform |
weights as a name |
uploaded samples | The caller, with program.with_waveforms(library) |
The rule behind the table: a node holds what the user wrote, in the DSL's
units, and the platform converts at the boundary. Two things pay for that.
The .qp file stays a description of an experiment rather than of one
vendor's API, which is what makes the same file run on a second backend. And
a program keeps round-tripping unchanged, because nothing in the writer or
the parser has to know an instrument's unit conventions.
The DSL does check the things it can know about. A bus reference is validated against the schema that produced it, a waveform's channel kind has to match the bus, and the profile's limits and predicates run before execution. Anything that depends on an instrument's API, or on a value that only exists once a loop is running, is left to the platform on purpose.
Swept arguments evaluate per iteration¶
Two arguments in this package accept an Expression:
set_acquisition_threshold's value and set_acquisition_rotation's
angle. Sweeping the rotation is how it is normally calibrated. Everything
else takes a plain value.
An Expression is not a value. A Variable inside it carries a value only
while the loop that binds it is on its current iteration, and the executor
sets that value once per iteration. So the platform reads it inside the
iteration:
from qprogram.variable import Expression
angle = 0.5 # a node's attribute, either a float or an Expression
value = angle.evaluate_or_raise() if isinstance(angle, Expression) else angle
print(value)
Then convert the evaluated value, per iteration. Converting once, at
compile time, is the bug this section exists to prevent: on an unassigned
variable evaluate_or_raise raises, and on an assigned one it silently
freezes the first point of the sweep into every iteration.
The reference executor makes the whole loop runnable in a few lines. Its
vendor_op_handlers argument maps an operation class to a callable that
receives the node and the parameter store, and an operation with a handler
skips the executor's own eager evaluation, so the handler owns it:
import math
import qprogram as qp
from qprogram.executor import ReferencePlatform
from qprogram.variable import Expression
from qprogram_qblox import QProgram
from qprogram_qblox.operations import SetAcquisitionRotation
written: list[float] = []
def apply_rotation(op: SetAcquisitionRotation, params: dict[str, float]) -> None:
"""Stand in for ``sequencer.thresholded_acq_rotation(...)``."""
angle = op.angle.evaluate_or_raise() if isinstance(op.angle, Expression) else op.angle
degrees = math.degrees(angle) % 360.0
params[f"{op.bus}.thresholded_acq_rotation"] = degrees
written.append(degrees)
schema = qp.BusSchema.transmon()
q = schema.q
program = QProgram(schema=schema)
angle = program.variable("angle", units="rad")
with program.sweep(angle).from_linspace(0.0, math.pi, 3):
program.qblox.set_acquisition_rotation(q[0].readout, angle)
program.qblox.acquire(q[0].readout, "weights")
platform = ReferencePlatform(schema=schema, vendor_op_handlers={SetAcquisitionRotation: apply_rotation})
platform.execute(program)
print([round(deg, 1) for deg in written])
Three degrees written for three iterations, from one node. A real platform does the same thing in whichever loop it runs the sweep in.
Which loop that is matters. If the platform steps the sweep from the host,
one parameter write per iteration is exactly right. If it compiles the sweep
into the sequencer, the angle never reaches the host at all, and a parameter
write cannot be part of a real-time sequence. A platform that treats these
two operations as host-side only says so in its capabilities: it puts
vendor.qblox.set_acquisition_threshold and
vendor.qblox.set_acquisition_rotation in the host half of the bus slot
and leaves them out of the rt half, and it ships a predicate yielding a
DomainConstraint on the binding loop when the value is swept. That drops
the loop to host-side dispatch while the operations around it stay
real-time. The core reference platform does exactly this for the core
set_parameter and get_parameter, and it is the pattern to copy.
QBLOX_DEFAULT_V1 itself makes no such split: it is one capability set, and
the platform decides which slots to put it in.
What the profile constrains¶
QBLOX_DEFAULT_V1 is the bus-level answer to "will this program run here?".
It declares four things:
- Capabilities. The tokens a qblox-driven bus accepts: nine core bus
operations, thirteen waveform tokens, the three measurement fields, and
the six
vendor.qblox.*operations. A program using a token the set omits gets onemissing-capabilityerror per use site. - Limits.
min_wait_duration_ns = 4, checked by the core validator. - Predicates. Two, described below.
- Vendor versions.
{"qblox": (0, 1, 0)}, informational: a record of the extension version the profile was written against.
A platform materializes it into a slot and pairs it with the core-shipped
qprogram-base-v1 at the platform slot:
import qprogram as qp
from qprogram.protocol import BusCapabilities, CompilerCapabilities, PlatformCapabilities
from qprogram_qblox import QProgram # importing anything from the package registers the profile
qblox_caps = CompilerCapabilities.from_profile("qblox-default-v1")
base_caps = CompilerCapabilities.from_profile("qprogram-base-v1")
caps = PlatformCapabilities(
bus={},
platform=BusCapabilities(rt=base_caps, host=base_caps),
default_bus_profile=BusCapabilities(rt=qblox_caps, host=qblox_caps),
)
print(len(qblox_caps.capabilities), "tokens,", qblox_caps.limits)
A real platform maps (element_kind, bus_kind) selectors to per-bus slots
instead of leaning on default_bus_profile, and it fills rt and host
separately where the two differ. It may also tighten any limit at that point
with from_profile(..., limit_overrides={...}), and it may withhold tokens
to hand a narrower grant to one client. What it cannot do is widen: a token
that was never registered cannot be named in a profile at all.
The split between the two slots decides where a token is looked up.
Bus-touching operations, waveforms and measure.fields.* are checked
against the bus slot, which is why they are in this profile. Blocks, sweep
sources and expr.* tokens are checked against the platform slot, which is
why this profile has none of them: a swept angle on a qblox bus needs
expr.variable from qprogram-base-v1, not from here. Put the
vendor.qblox.* tokens on the platform slot by mistake and every qblox
operation fails validation, because that is not where they are looked up.
min_wait_duration_ns¶
The limit applies to the core wait, on any bus this profile covers:
program = QProgram()
program.wait("drive_q0", 2)
for diag in qp.validate(program, caps)[0]:
print(diag.severity, diag.code, "|", diag.message)
An arbitrary sweep cannot drive a wait duration¶
The first predicate is a hard error. A qblox wait instruction takes one
integer cycle count from a register that advances by a fixed step, so a
Range or a Linspace fits and an arbitrary array of values does not.
Host-side dispatch does not rescue it either, since the instruction still
has to be emitted per shot, so the predicate yields a Diagnostic rather
than a DomainConstraint:
import numpy as np
program = QProgram()
duration = program.variable("duration")
with program.sweep(duration).from_values(np.array([40, 100, 220])):
program.wait("drive_q0", duration)
for diag in qp.validate(program, caps)[0]:
print(diag.severity, diag.code)
The message names the variable and says what to use instead: a linear
source, or a constant. Every source declares KIND, and the predicate reads
it through ctx.sweep_kind_of, so a new arbitrary-valued source is covered
the day it appears without touching this package.
A swept Drag sigma forces its loop host-side¶
The second predicate is a soft restriction. A qblox sequencer can re-arm a
real-time loop with a new amplitude or duration, but a Drag envelope's
sigma shapes samples that were computed at upload, so varying it means
re-uploading the waveform once per iteration. The Play is still a
real-time operation; what cannot stay real-time is the loop. So the
predicate yields a DomainConstraint excluding "rt" from the loop that
binds the variable, found with ctx.binding_loop_of:
from qprogram.waveforms import IQDrag
program = QProgram()
sigma = program.variable("sigma")
with program.sweep(sigma).from_range(4, 12, 2):
program.play("drive_q0", IQDrag(amplitude=0.5, duration=40, sigma=sigma, beta=0.1))
program.qblox.acquire("readout_q0", "weights")
print(qp.explain(program, caps))
plan — errors: 0 · warnings: 1 · info: 0
body
└─ for sigma in Range(start=4.0, stop=12.0, step=2.0): [host] ~ forced-host: Variable 'sigma' sweeps IQDrag.sigma in a contained Play, which qblox cannot real-time-update; the loop dispatches per shot host-side instead
├─ play "drive_q0" IQDrag(amplitude=0.5, duration=40, sigma=sigma, beta=0.1) [rt|host]
└─ qblox.acquire "readout_q0" "weights" name="m0" [rt|host]
The loop is [host], its children are [rt|host], and the diagnostic is a
warning: the program runs, one real-time shot per host-side iteration, and
the platform re-uploads the waveform between them. This is the shape every
"hardware cannot vary this, but the host can step it" constraint takes. A
constraint has to target a block, never an operation; an operation-targeted
one is reported as bad-domain-constraint.
A platform that adds its own constraints does it the same way, in its own profile, and both sets of predicates run.
Adding an operation¶
Five edits, in dependency order. The example is a hypothetical
qblox.set_integration_length.
1. The node class, in operations.py:
from __future__ import annotations
from qprogram.operations.operation import Operation
from qprogram.variable import Expression
class SetIntegrationLength(Operation):
"""Set the acquisition integration length on a readout bus.
Args:
bus (str): Readout bus whose integration length to set.
length (int | Expression): Integration length in ns.
"""
def __init__(self, bus: str, length: int | Expression) -> None:
self.bus = bus
self.length = length
def required_capabilities(self) -> set[str]:
"""Return ``vendor.qblox.set_integration_length`` plus the ``length`` expression tokens."""
from qprogram.protocol import expression_tokens # ruff: ignore[import-outside-top-level]
return {"vendor.qblox.set_integration_length"} | expression_tokens(self.length)
Attributes go in __init__ order: that is the order the writer emits
positional arguments in and the parser binds them back. BUS_ATTRS defaults
to ("bus",), so an operation with one bus attribute named bus declares
nothing. Declare it when the attribute has another name or the operation
touches several buses, and declare WAVEFORM_ATTRS for waveform arguments,
as Acquire does for its weights. Accepting an Expression means adding
expression_tokens(...) to the token set, and it means every consumer has
to evaluate per iteration, as described above.
2. The namespace method, in namespace.py, which is the only public way
to build the node:
from qprogram.vendor import VendorNamespace
class QbloxNamespace(VendorNamespace):
...
def set_integration_length(self, bus: str, length: int | Expression) -> None:
"""Set the acquisition integration length in ns.
Args:
bus (str): Readout bus whose integration length to set.
length (int | Expression): Integration length in ns. Accepts an
:class:`~qprogram.Expression` so it can be swept.
"""
self._append(SetIntegrationLength(bus=bus, length=length))
_append runs every bus attribute through the program's schema check before
appending, so a bus from a foreign schema cannot slip in. A measurement
operation calls _append_measurement instead, which allocates the name,
builds the node and returns the handle. Add the import of the new class to
the group at the top of the module.
3. The capability token, in profiles.py. Add it to the
register_capability_tokens call that already runs above the profile:
from qprogram.protocol import register_capability_tokens
register_capability_tokens("vendor.qblox.set_integration_length")
The call is variadic and idempotent, so the existing one takes the new name
as one more argument. It has to stay above the Profile, which rejects a
capability set naming an unregistered token.
4. Profile membership, also in profiles.py. Registering a token only
makes it spellable; the profile is what advertises it:
Skip this edit and the operation exists, serializes, and fails validation
with missing-capability on every use.
5. The serializer registration, in __init__.py, next to the others,
plus the class in __all__:
from qprogram.serialization.registry import register_vendor_operation
register_vendor_operation("qblox", "set_integration_length", SetIntegrationLength)
That is all the serialization work. The default writer reflects on
__init__ and the default parser binds by signature, so the operation reads
and writes without a line of format code:
import qprogram as qp
text = """#!QProgram 1.0
require qblox 0.1
body:
qblox.set_integration_length "readout_q0" 2000
"""
program = qp.loads(text)
print(type(program.body.elements[0]).__name__)
print(qp.dumps(program) == text)
An operation with a shape the default reflection cannot express, such as a
measurement handle or a variadic argument list, passes explicit serialize=
and parse= callbacks to register_vendor_operation. acquire is the
worked example.
The tests that come with it¶
One per module the change touched, matching the suite's existing layout:
tests/test_operations.py: construction, the attributes,buses(),waveforms()andvariables(), structural equality against an identical node, andrequired_capabilities()for both a literal and anExpressionargument.tests/test_namespace.py: the method appends the right node to the active block, passes its arguments through, and validates its bus.tests/test_serialization.py: a round trip throughdumpsandloadsthat is byte-stable, and therequire qblox 0.1line in the output.tests/test_registration.py: the operation resolves from the registry under("qblox", "set_integration_length").tests/test_profile.py: a program using the operation validates clean againstQBLOX_DEFAULT_V1, and the token is in the profile's capability set.
Then the docs: the operation belongs in Operations, and the class in the API reference. The full checklist is on the contributing page.