Variables and expressions¶
Anywhere QProgram accepts a number, it also accepts a Variable or an
Expression. This is how a sweep works: a Sweep binds one variable to a
SweepSource, the runtime writes a value into that variable on each iteration,
and every expression built on top of it re-evaluates against the new binding.
Building an expression is pure data construction. 100 + t allocates an AST
node and does nothing else; no arithmetic runs until something calls
evaluate() on it.
Declaring variables¶
QProgram.variable(id, *, label=None, units=None, description=None) returns
the Variable and records it on the program:
import qprogram as qp
program = qp.QProgram(label="ramsey")
freq = program.variable("freq", label="Drive frequency", units="Hz")
dur = program.variable("dur", units="ns")
amp = program.variable("amp")
id is positional and required. The other three are keyword-only and default
to None. Each call appends to program.variables, which reports them in
declaration order, and a repeated id raises ValidationError with
Variable 'freq' is already declared on this QProgram.
Two other calls declare variables on your behalf. QProgram.get_parameter
returns a fresh variable for the value the runtime reads, with an id derived
from f"{bus}_{parameter}" (non-word characters replaced by underscores, a
numeric suffix on collision) and the original bus.parameter string kept as
the label. Fragment.variable declares a fragment-local variable, renamed onto
the host program as {fragment}_{id} when the call is expanded; see
Fragments.
qp.Variable("freq") builds the same object directly and works fine as an
expression leaf, but it belongs to no program. The .qp writer emits one var
line per entry in program.variables and resolves every referenced variable
through that table, so serializing a program that reaches an undeclared
variable fails with a KeyError on the id rather than a SerializationError.
Declare through program.variable.
Identifier rules¶
The id is written verbatim as the identifier in .qp files (for freq in
Range(start=...), get_parameter "drive_q0" "lo_frequency" -> lo_freq), so it
has to be safe to embed unquoted. Three rules apply: the id matches
[A-Za-z_][A-Za-z0-9_]*, it is unique within one QProgram, and it is not one
of the reserved keywords.
The pattern and reserved rules both raise
InvalidVariableIdError,
which also subclasses ValueError. Its reserved attribute says which rule
tripped, and the message differs accordingly:
try:
program.variable("2q")
except qp.InvalidVariableIdError as e:
print(e.id, e.reserved)
# 2q False
# "Variable id '2q' is invalid: must match [A-Za-z_][A-Za-z0-9_]* ..."
try:
program.variable("where")
except qp.InvalidVariableIdError as e:
print(e.id, e.reserved)
# where True
# "Variable id 'where' is reserved for future QProgram syntax ..."
The keyword list is reserved against syntax the .qp format may grow: a
Variable("if") becomes ambiguous the moment the format has an if block, so
the id is rejected now to keep files that parse today parsing later.
Reservations are case-sensitive, which makes If, Where, and True valid
ids. Duplicate ids are the third rule and raise ValidationError, not
InvalidVariableIdError.
Anything richer than an identifier belongs in label and description:
phi = program.variable(
"phi",
label="Phase offset",
units="rad",
description="NCO phase for the echo arm",
)
label, units, and description¶
All three are free-form strings and none of them affects execution. They are
written into the var line of a .qp file, parsed back from it, and carried
across fragment expansion when a fragment-local variable is renamed onto the
host program. Nothing else reads them: validation, the capability token
vocabulary, the executor, and the result objects all ignore units.
That means units="ns" records what the numbers mean and converts nothing. A
variable swept over Range(0, 200, 4) and passed to wait carries
nanoseconds because wait takes nanoseconds, not because the variable says so.
A program declaring one annotated variable and one bare one serializes like
this, and the file round-trips back to a program equal to the original:
#!QProgram 1.0
metadata:
label: "ramsey"
body:
var t label="Delay" units="ns" description="Free-evolution delay"
var amp
for t in Range(start=0.0, stop=200.0, step=4.0):
set_frequency "drive_q0" (5000000000.0 + (t * 1000.0))
set_gain "drive_q0" minimum((amp / 2), 0.5)
wait "drive_q0" (100 + t)
play "drive_q0" Gaussian(amplitude=amp, duration=40, sigma=8)
measure "readout_q0" "readout" "weights" name="m0"
Only the attributes a variable actually carries are emitted, so amp declares
as a bare var amp. See
Variable declarations for
the quoting and escaping rules.
The current value¶
Each variable holds one value, and evaluate() reads it from the instance.
Before anything binds it, that value is the UNASSIGNED sentinel, a falsy
singleton whose repr is UNASSIGNED:
set_value stores whatever number you give it with no checking. The executor
calls it twice over: once per loop iteration for the variable a Sweep binds,
and once per get_parameter for the value read back from the platform. The
sweep path coerces to float, so a variable swept over an integer Range
reads back as a float; the get_parameter path passes the platform's parameter
store value through unchanged, and reads 0.0 for a key the store does not
hold.
Nothing in the package calls reset(). A variable therefore keeps the last
value bound to it after a run finishes, which matters whenever you read a
variable back after execution:
delay = program.variable("delay", units="ns")
with program.sweep(delay, qp.Range(0, 20, 10)):
program.wait("drive_q0", 100 + delay)
program.measure("readout_q0", "readout", "weights")
delay.value # UNASSIGNED
result = qp.simulate(program)
delay.value # 20.0, the last value the sweep bound
Calling set_value from your own code is worth doing when you want to evaluate
an expression or render a waveform in plain Python for plotting or debugging.
Expression nodes¶
Ten concrete node types make up the AST. Every one of them is an Expression,
carries the same evaluate() and variables() methods, and has a .qp form
the writer and parser agree on.
| Node | Built by | .qp form |
|---|---|---|
Constant |
a numeric literal in expression position | 100, 5e9 |
Variable |
program.variable(...) |
the bare id |
MeasurementRef |
handle.state inside a comparison |
m0.state |
BinaryOp |
+, -, *, / |
(a + b) |
UnaryOp |
unary -, unary + |
(-a) |
Comparison |
<, <=, >, >=, qp.eq, qp.ne |
(a < b) |
LogicalBinaryOp |
&, \|, qp.and_, qp.or_ |
(a and b) |
LogicalNot |
~, qp.not_ |
(not a) |
MathFunc |
qp.sin and friends, and abs() |
sin(a) |
Where |
qp.where |
where(c, a, b) |
Variable and MeasurementRef are the two bindings the runtime writes to.
MeasurementRef points at a field of a measurement result and exists so that a
conditional can branch on a classified state; "state" is the only field it
accepts, because that is the only one a branch can test. You normally get one
from the handle.state proxy rather than constructing it, and
Control flow covers that side of it.
Arithmetic¶
The four binary operators and both unary signs work on any expression, in
either operand order. A numeric literal on the other side is wrapped as a
Constant:
t = program.variable("t")
100 + t # BinaryOp("+", Constant(100), t)
t - 50 # BinaryOp("-", t, Constant(50))
amp * 2 # BinaryOp("*", amp, Constant(2))
(t + 100) / 2 # BinaryOp("/", BinaryOp("+", t, Constant(100)), Constant(2))
-amp # UnaryOp("-", amp)
bool is rejected everywhere a number is wrapped, because True would coerce
silently to 1 and hide the mistake: qp.Constant(True) raises TypeError
with Constant value must be int or float, got bool. Any other non-numeric
operand raises Cannot use str in an Expression; expected Expression,
handle.<field>, int, or float.
**, %, and // are not overloaded and raise the ordinary Python
unsupported operand type(s) error, since the .qp format has no form for
them. Write t * t for a square, and reach for qp.exp and qp.log for
anything else.
Comparisons and logical combination¶
<, <=, >, and >= build Comparison nodes. == and != do not:
Variable.__eq__ compares ids and has to keep returning a plain bool, or
variables could not live in the sets variables() returns, or serve as dict
keys. Use qp.eq and qp.ne for the expression-building form.
and, or, and not are Python keywords and cannot be overloaded at all, so
the NumPy and SymPy convention applies: &, |, and ~, or the named helpers
qp.and_, qp.or_, and qp.not_.
amp < 0.5 # Comparison("<", amp, Constant(0.5))
qp.eq(t, 100) # Comparison("==", t, Constant(100))
qp.ne(amp, 0) # Comparison("!=", amp, Constant(0))
(amp < 0.5) & (t > 100) # LogicalBinaryOp("and", ...)
qp.or_(amp < 0.0, amp > 1.0) # LogicalBinaryOp("or", ...)
~(amp < 0.5) # LogicalNot(...)
The parentheses in (amp < 0.5) & (t > 100) are not optional. & and | bind
tighter than the comparison operators in Python, so amp < 0.5 & t > 100
parses as amp < (0.5 & t) > 100 and fails on the wrong thing.
Logical operands must already be expressions; numbers are not coerced, because
a logical operand is a condition rather than a value. Passing a bare bool
gets a message that names the usual cause:
qp.and_(amp < 0.5, True)
# TypeError: LogicalBinaryOp right operand must be an Expression; got bool
# — if you wrote `var == literal` or `var != literal`, use
# qprogram.eq(var, literal) / qprogram.ne(...) instead, since Variable's
# `==` returns a plain bool, not a Comparison.
LogicalBinaryOp never short-circuits. Both operands are always evaluated, so
an unbound variable in either half propagates UNASSIGNED regardless of which
side it sits on, and an unbound-variable diagnostic does not depend on operand
order.
Comparisons and logical nodes are data, not booleans, and Expression blocks
the accident that would otherwise follow. __bool__ raises rather than
reporting that a Comparison instance is truthy:
if amp < 0.5: # TypeError
...
# TypeError: Expression has no truth value — use
# .evaluate()/.evaluate_or_raise() to compute it, or
# qprogram.where(cond, then, else_) to build a conditional expression.
The same guard is why min(a, b) on two variables raises, and why qp.minimum
exists.
Math functions and where¶
Nine math functions build MathFunc nodes. qp.sin, qp.cos, qp.tan,
qp.exp, qp.log, and qp.sqrt take one operand; qp.minimum and
qp.maximum take two or more and raise TypeError with
minimum() requires at least two arguments below that; and the built-in
abs() produces MathFunc("abs", ...) through __abs__. The .qp name of
each node is its MathFunc.name, which is the helper's own name.
qp.sin(freq * 2 * 3.14159)
qp.sqrt(amp)
qp.log(amp) # natural log
abs(t - 100)
qp.minimum(amp, 0.5)
qp.maximum(amp, 0.0, 1.0)
Evaluation runs through NumPy, imported lazily so that building an expression
does not pay for it. The transcendental functions return Python floats, while
abs, minimum, and maximum preserve an integer input. They also inherit
NumPy's out-of-domain behavior instead of raising: qp.log(0).evaluate()
returns -inf with a RuntimeWarning, and qp.sqrt(-1).evaluate() returns
nan.
qp.where(condition, then, else_) is the ternary. The condition has to be an
expression, while the two branches accept numbers and get wrapped. Only the
chosen branch is evaluated, so the branch not taken may reference a variable
that happens to be unbound:
qp.where(t > 100, amp, 0.0)
cond = program.variable("cond")
used = program.variable("used")
unused = program.variable("unused")
cond.set_value(1)
used.set_value(42)
qp.where(qp.eq(cond, 1), used, unused).evaluate() # 42
An unbound condition still makes the whole node UNASSIGNED, and
variables() reports all three subtrees including the branch evaluation skips.
Where expressions are accepted¶
Every operation that takes a number takes an expression in the same position:
program.wait("drive_q0", 100 + t) # int | Expression
program.set_frequency("drive_q0", 5e9 + freq * 1e6) # float | Expression
program.set_gain("drive_q0", amp / 2)
program.set_offset("drive_q0", -amp)
program.set_parameter("drive_q0", "lo_frequency", freq + 1e6)
program.play(
"drive_q0",
qp.waveforms.Gaussian(amplitude=amp, duration=40 + t, sigma=8),
)
Waveform constructors accept an expression on every numeric parameter, and
Waveform.envelope() resolves it when it renders the samples; see
Waveforms. The .qp format is narrower than the AST here. A
constructor argument may be a number, a quoted string, or a bare variable
reference, and the parser rejects anything else, so Gaussian(amplitude=amp)
round-trips but Gaussian(amplitude=amp / 2) writes without complaint and then
fails to parse back. Errors has the detail.
Evaluating an expression¶
evaluate() walks the tree, reads each variable's current value, and returns a
number:
expr = freq * 2 + 100
expr.evaluate() # UNASSIGNED (freq has no value)
freq.set_value(50)
expr.evaluate() # 200
freq.reset()
expr.evaluate() # UNASSIGNED
UNASSIGNED propagates: any unbound variable anywhere in the tree makes the
whole expression UNASSIGNED, and it takes precedence over an arithmetic
failure that a bound value would have hit. (t / 0).evaluate() raises
ZeroDivisionError once t is bound and returns UNASSIGNED while it is not.
Comparisons and logical nodes evaluate to a bool, which is an int subclass,
so a comparison used as a numeric operand behaves as 0 or 1.
evaluate_or_raise¶
evaluate_or_raise() returns the number or raises
UnassignedVariableError
instead of handing back the sentinel. Use it where the caller has no way to
carry on without a value:
expr.evaluate_or_raise() # raises while freq is unbound
freq.set_value(50)
expr.evaluate_or_raise() # 200
try:
(freq + amp).evaluate_or_raise()
except qp.UnassignedVariableError as e:
print(e.expression) # (Variable('freq') + Variable('amp'))
print(e.free_variables) # {Variable('amp'), Variable('freq')}
free_variables is expression.variables(), collected when the error is
constructed. It is every variable the tree references, not only the unbound
ones, so a partly bound expression reports all of them.
Waveforms call evaluate_or_raise on each parameter internally, which is why
qp.waveforms.Gaussian(amplitude=amp, duration=40, sigma=8).envelope() raises
until amp is bound.
Free variables¶
expression.variables() returns the set of variables the tree references.
Every node unions its children's sets, so one call covers the whole subtree:
(freq + 100).variables() # {freq}
(freq + dur * 2 - 50).variables() # {freq, dur}
qp.Constant(5).variables() # set()
Operation.variables() and Block.variables() are built on this: they walk
public attributes, descend into expressions, waveform parameters, and nested
lists, and union what they find. That is how the compiler works out which
variables an operation depends on. Sweep.variables() adds the variable it
binds to whatever its body reports, and Parallel.variables() unions in each
composed loop's own, because those loop headers sit outside the shared body the
inherited walk covers.
MeasurementRef.variables() returns an empty set. A measurement reference is a
different kind of binding, written by the runtime when the measurement produces
a result rather than by a loop, so it takes no part in the variable walk.
Capability tokens¶
An operation reports the capabilities it needs from required_capabilities(),
and an operation that carries an expression adds one expr.* token per node
kind in it. qp.protocol.expression_tokens does the recursion:
op = qp.operations.Wait(bus="drive_q0", duration=100 + t * 2)
sorted(op.required_capabilities())
# ['expr.binary_op', 'expr.constant', 'expr.variable', 'op.wait']
The node kinds map to expr.constant, expr.variable,
expr.measurement_ref, expr.binary_op, expr.unary_op, expr.comparison,
expr.logical_and_or, expr.logical_not, expr.where, and one
expr.math.<name> per math function. A plain numeric literal contributes
nothing, because it is not a node until it is wrapped.
The operations that contribute expression tokens are wait, set_frequency,
set_phase, set_gain, set_offset, and set_parameter, each on its numeric
arguments, plus Conditional on each arm's condition. Play does not. Its
tokens describe the waveform (waveform.single, waveform.gaussian, and so
on), so an expression inside a waveform parameter contributes no expr.* token
even though Play.variables() still finds the variable in it.
expr.* tokens are checked against the platform's own capability set rather
than the slot the operation routes to, since they describe which node kinds the
platform's compiler can lower, not what any one instrument can do. A missing
token becomes a missing-capability diagnostic naming the token and the
profile that lacks it. Capabilities covers the routing.
Equality and identity¶
Expression nodes compare structurally, with one twist: variables compare by
id.
v1 = qp.Variable("freq")
v2 = qp.Variable("freq")
v1 == v2 # True; same id
v1 is v2 # False
hash(v1) == hash(v2) # True
hash is over ("Variable", id), so two variables with the same id collapse
to one entry in the set variables() returns. That is what makes a whole
program survive deepcopy and a qp.loads(qp.dumps(...)) round-trip and still
compare equal: after a round-trip the original Python objects are gone, and the
ids are all that is left to match on. QProgram itself defines no __eq__, so
the comparison to make is reloaded.body == program.body.
Everything else is plain structural equality over the fields the node holds.
MeasurementRef compares by (handle.name, field) for the same
survive-the-round-trip reason.
qp.Constant(5) == qp.Constant(5) # True
freq * 2 + 100 == freq * 2 + 100 # True
qp.sin(freq) == qp.cos(freq) # False
When to use which method¶
| Goal | Method |
|---|---|
| Collect the variables in a tree without binding anything | expression.variables() |
| Evaluate, tolerating unbound variables | expression.evaluate() |
| Evaluate, fail if anything is unbound | expression.evaluate_or_raise() |
Get a number from an int \| float \| Expression argument |
x.evaluate_or_raise() if isinstance(x, qp.Expression) else x |
Most user code calls none of these. They are what the platform, the serializer, and the test suite reach for; the code that builds a program only builds nodes.