Skip to content

QProgram

QProgram is a Python DSL for describing pulse-level quantum experiments. A program says what the chip should do; a platform decides how to run it. The package is the language plus everything that can be settled without an instrument attached: the AST, the .qp text format, the capability protocol a platform validates programs against, a reference executor written in Python, and the hooks vendor packages register themselves through. Its only runtime dependencies are numpy and xarray.

A first program

import qprogram as qp

schema = qp.BusSchema.transmon()
q = schema.q

program = qp.QProgram(label="rabi", schema=schema)
gain = program.variable("gain", units="V")

with program.average(shots=1000):
    with program.sweep(gain).from_range(0.0, 1.0, 0.01):
        program.set_gain(q[0].drive, gain)
        program.play(q[0].drive, "pi_pulse")
        program.sync()
        m0 = program.measure(q[0].readout, "readout", "weights")

# Plug in calibrated waveforms at the very end.
resolved = program.with_waveforms(
    {
        "pi_pulse": qp.waveforms.IQDrag(amplitude=0.5, duration=40, sigma=8, beta=0.1),
        "readout": qp.waveforms.IQPair(qp.waveforms.Square(1.0, 2000), qp.waveforms.Square(0.0, 2000)),
        "weights": qp.waveforms.IQPair(qp.waveforms.Square(1.0, 2000), qp.waveforms.Square(1.0, 2000)),
    }
)

# Run it. `qp.simulate` is the reference software executor that ships with the
# package; hardware platforms implement the same `PlatformProtocol` interface.
result = qp.simulate(resolved)
data = result.get(m0)  # xarray.DataArray with named dimensions

data comes back with dimensions ("gain", "IQ") and shape (101, 2). Dimensions are named after the enclosing loops, outermost first, so the sweep over gain becomes an axis of 101 points; the trailing IQ axis carries coordinates ["I", "Q"]. The 1000 shots of the average block are reduced rather than kept.

What the package does and does not do

QProgram compiles nothing and talks to no instrument. It builds a description of an experiment, checks that description against what a platform says it can do, and hands it over. Lowering to instrument code, scheduling, and calibration all sit on the platform side of qp.PlatformProtocol.

The same QProgram therefore runs on any platform that implements that protocol. What portability costs is that the core vocabulary can only be the part every platform can be asked to support. Instrument-specific work (markers, active reset, triggers, slow-control parameters) lives in optional vendor packages that register their operations at import time, so adding an instrument does not change qprogram, and a program that uses one runs only where that package is installed. Its .qp file records the dependency as a require line and refuses to load without it, which is the trade-off taken on purpose: a loud ParseError rather than a file that loads with an operation silently missing.

Programs are ordinary Python objects. Blocks are containers, operations are leaves, both are nodes, and program.body.walk() walks them, so a program can be assembled by a function, a loop, or a comprehension and inspected afterwards without a parser in the way. The cost is that Python control flow runs while the program is being built and leaves no trace in the AST: a Python for unrolls into repeated nodes, a loop that has to survive into execution is program.sweep(...) or program.average(...), and a branch on a measurement result is program.if_(...). The transformers (rebind, with_waveforms, expand) deep-copy the program rather than mutate it, so a node held from before a transform is not a node of the result.

qp.save(program, "exp.qp") writes a line-oriented text file that reviews and diffs like source, and qp.load reads it back. The round trip is pinned by the test suite in two directions: qp.loads(qp.dumps(program)) carries the same label, description, variables, and body under structural equality, and re-emitting that program reproduces the text byte for byte. Numbers are written through repr and arrays are never truncated, so the values that come back are the values that went in. What the file preserves is the program, not the document: the parser strips comments and the writer emits none, so a hand-edited .qp file loses its annotations on the next save, and measurement handles come back as new objects to be looked up by name through QProgram.measurement_handles. Anything the format has no representation for is refused rather than approximated. An unregistered operation class, a 2-D array, and a Fragment handed to qp.dumps directly each raise SerializationError.

A bus is addressed by a BusRef that a BusSchema produces. qp.BusSchema.transmon() and the other presets return typed subclasses, so schema.q[0].drive completes in an editor and a kind the element does not expose fails while the program is being built rather than at execution: a dynamically built schema reports 'q' has no bus 'flux'. Available: drive, readout. The schema also owns the mapping from element, index, and kind to the bus string, through qp.BusNaming (the default pattern "{element}{index}/{kind}" gives q0/drive), so no naming convention is built into the language. Presets are typed but fixed; schemas built with add_element or composed with schema_a + schema_b work at run time and carry no static type. Plain strings remain valid buses, and a platform validates them against its default bus profile instead of a per-bus one.

Capabilities are declared per slot, a slot being a (bus, domain) pair, with the domains real-time (rt) and host-side (host). qp.validate(program, caps) returns the Diagnostics together with an ExecutionPlan recording which domains each node can run in, and qp.explain(program, caps) renders the same result as a tree with a [rt|host], [rt], [host], or [--] column per node, so an unsupported operation is reported against the node that carries it instead of as one rejection of the whole program. The check is static and no better than the descriptor behind it: a Profile bundles capability tokens, numeric limits, and predicates, the validator ignores limit keys it does not recognize, and nothing in it looks at calibration, so a program can validate clean and still fail on the device.

The layers a program passes through

The stages are building, serialization, validation, optimization, execution, and result collection, of which only building and execution are compulsory. Serialization is a detour off the AST rather than a stage every program passes through: qp.dumps and qp.loads can be skipped entirely, or used as the only interchange between the process that writes a program and the one that runs it. Validation against a platform's capabilities comes next, and qp.optimize(program, caps) is an opt-in rewrite that applies the one reordering the validator otherwise reports as the "reorderable-averaging" info hint, lifting a host-side sweep out of an average so that the averaging itself can run in real time. It is opt-in because the rewrite groups all shots of a sweep point together instead of interleaving passes, which changes nothing for a stationary system and does change results under drift.

Execution is the stage QProgram does not own. qp.PlatformProtocol requires a platform to supply resource discovery (get_bus_schema, get_buses, get_parameters, get_global_parameters), a PlatformCapabilities descriptor, and execute. Its validate, plan, and explain methods have working defaults that delegate to the core validator, so a platform with no opinion of its own reports the same diagnostics a user gets from qp.validate, and stream raises NotImplementedError until a platform overrides it. The convention is that execute validates first and raises UnsupportedOperationError on any diagnostic of severity "error"; a platform is not forced to, and one that skips the check surfaces its own compiler errors in place of structured diagnostics. qp.simulate runs a qp.ReferencePlatform over the program in Python and is the executable definition of the language's semantics; whatever runs the program, results arrive as one record per measurement in a QProgramResult.

Vendor extensions

A vendor extension is a separate package that depends on qprogram and registers itself on import through three independent hooks: a runtime namespace (a qp.VendorNamespace subclass passed to QProgram.register_vendor, which is what makes a call such as program.fake_inst.beep(...) resolve), a typed mixin so that the same call completes in an editor, and serialization registry entries (qp.register_vendor_operation, qp.register_vendor_block, qp.register_vendor_version) that give its nodes a .qp form and a version for the require line. Capability tokens and profiles are registered the same way, through qp.register_capability_tokens and qp.register_profile. A package that also declares a qprogram.vendors entry point can be activated by the parser on demand, which is what lets a .qp file that names it load in a fresh interpreter. The Architecture and Building a vendor extension pages work through the pattern.

Versions and compatibility

The package is pre-1.0, so the Python API can change between releases without a deprecation cycle. The .qp format carries its own version and is at 1.0, where only the major component is binding: the writer emits 1.0, a 1.1 file still loads on this parser, and a 2.0 file raises ParseError with Unsupported format version 2.0. Accepting a newer minor is deliberate, and the cost is that a file using grammar this parser does not know fails somewhere in its body instead of at the header. A file with no header at all fails immediately with Missing #!QProgram header.

Vendor compatibility is checked one require line at a time, before any of the body is built, so a rejected file leaves no partially loaded program: the majors must match, the installed minor must be at least the one the file asks for, a patch component is accepted and ignored, and a vendor that is installed but not yet imported is activated through its qprogram.vendors entry point. Format version and the require line has the message each failure produces and the argument that turns activation off.

Pages by task

The API reference is generated from the source, .qp file format describes the on-disk grammar, and src/qprogram/grammar/qp.lark is the normative machine-readable form of that grammar, kept in step with the production parser by the test suite.

If you want to ... Read
install QProgram and run something Getting started
understand the moving parts Core ideas
sweep parameters with loops Control flow
run a program without hardware Running programs
know which programs a platform will accept Capabilities, diagnostics, and profiles
read the file format .qp file format
build your own vendor package Building a vendor extension
browse the full API API reference