Using expressions
Introduction
Expressions are small Python snippets that can be used in various locations in a protocol. They make it possible to record data, wait for a condition to be met, or perform a computation.
Expressions can be used in PCRL by wrapping a Python snippet with the {{ ... }}
syntax, such as {{ 2 + 3 }}
. They can be employed in most places where a value is expected.
Mixing values from different phases
Expressions are compiled and evaluated gradually such that any part of an expression containing a value available at compile-time will be evaluated at compile-time, while the rest will be evaluated at runtime.
There four different phases at which data can be acquired:
- During the parsing phase, only operations between constants or literals are evaluated.
- During the adoption phase, shorthand arguments from the previous and current phase are resolved.
- During the runtime phase, variables that do not depend on external data are evaluated.
- During the query phase, all remaining variables are evaluated.
An example for each phase is shown below:
wait: {{ (3 + 7) * unit.sec }}
wait: {{ arg * unit.sec }}
wait: {{ index * unit.sec }}
wait: {{ devices.Foo.bar * unit.sec }}
When adding these, the resulting expression is still evaluated gradually in order to catch errors as soon as possible while providing robust type checking.
wait: {{ (3 + 7 + arg + index + devices.Foo.bar) * unit.sec }}
The compilation algorithm also prevents unnecessary data fetching when upstream control flow excludes a part of the expression in a previous phase. Here, only one of Foo1
or Foo2
will be queried depending on the value of index
which was obtained in the previous phase.
wait: {{ devices.Foo1.bar if index > 2 else devices.Foo2.bar }}
The fourth phase may be executed every time corresponding variables change if the expression's consumer supports it, making the expression ‘reactive’. This is not supported by the timer process, but is crucial to the expect
block which is re-evaluated as often as necessary.
expect: {{ devices.Foo.bar > 3 }}
Manipulating dynamic expressions
The behavior of dynamic expressions can be manipulated using expression directives. Directives rely on a non-standard syntax and must be used carefully as they can have unintended consequences.
- Changing an expression's dynamism
- Enforced frequency –
freq[<value>, <frequency>]
- Enforced static –
static[<value>]
- Hysteresis –
hyst[<value>, <high>, <low>]
- Enforced frequency –
- Asserting an expression's final phase
- Compile-time –
comptime[<value>]
- Runtime –
runtime[<value>]
- Compile-time –
The freq
directive allows an expression to be re-evaluated at a given frequency. It is particularly useful when combined with the time()
function as it allows time to be obtained dynamically. For example, to simulate waiting for 5 seconds:
until: {{ freq[time(), 100 * ureg.ms] > time() + 5 * ureg.s }}
The right hand-side of the comparison is only evaluated once, while the left-hand side is evaluated every 100 ms.
Assertions are another type of directive that help ensure that an expression will have evaluated by the end of a given phase. They do not have any impact on the protocol itself, but can be used to catch errors early on.
# Error, 'index' is only available in the runtime phase
wait: {{ comptime[index * 2 * ureg.sec] }}
# Ok
wait: {{ index * comptime[2 * ureg.sec] }}
Understanding expression pitfalls
While expressions are very powerful, they have several downsides that users need to keep in mind:
- Expressions prevent certain errors from being caught at compile-time. Despite expressions are type-checked by Automancer, certain errors, such as a out-of-bounds value, cannot be detected before being evaluated. It is therefore important to keep a minimal number of expressions and test them thoroughly.
- Expressions that rely on external variables can have a significant delay. Many devices report data with a delay from a few milliseconds to a few seconds, which can impact any action taken from that data. Furthermore, performing computations by calling
time()
will report a value which doesn't match the time at which the data was obtained, leading to possible bugs.
Using binding expressions
Binding expressions are a special kind of expression used to reference a location for the output of an operation. For example, after capturing a picture, we might want to process the captured data rather than saving it directly in a file. This can be achieved using a binding expression, in the form @{{ ... }}
.
actions:
- Camera.capture:
exposure: 300 ms
output: @{{ data }}
In this example, we are saving the captured data into the data
variable to process it later on. The type of the variable is set by the capture module.
There are five binding expression types:
- Named bindings write the received value to a variable and are written in the form
${{ <var> }}
, e.g.${{ foo }}
. - Attribute bindings write the value to an attribute and are written in the form
${{ <expr>.<attr> }}
, e.g.${{ foo.bar }}
. The attribute will be set usingsetattr()
. - Property bindings write the value to a property and are written in the form
${{ <expr>[<expr>] }}
, e.g.${{ foo['bar'] }}
or${{ foo[5] }}
. - Null bindings discard the value and are written as
${{ _ }}
. They are variant of named bindings where the variable's name is_
. Unlike regular Python, the data will be actually discarded rather than set to the_
variable. - Tuple bindings are a special type of binding used to match a tuple. They are composed of zero or more children bindings which will be matched to their corresponding sub-values. In the expression
@{{ x, y.a, _ }}
, the first and second items of the tuple will be set tox
andy.a
, respectively a named and an attribute binding. The third item will be discarded. An empty tuple${{ () }}
is also valid when receiving an empty tuple.
List bindings are not supported.
Sub-expressions contained inside bindings are not limited to simple variables or literals. Consider the following examples:
@{{ {}['a'] }}
– Here the received value will be discarded because the dictionary is not saved. A warning will be issued in this situation.@{{ obj.data[index * 2 + 1] }}
@{{ pt.x, (_, pt.y), pt.z }}
All expressions are dynamic, thus executed at runtime. Static components can still be used as described in the previous section, for instance %{{ items[static(index * 2)] }}
.
For details on how to consume bindings, see [...].
Using mixed expressions
A mixed expression associates a regular expression with a binding expression. They are common in loops where the list to loop over is provided on the regular expression and the iteration variable is provided on the binding expression. All mixed expressions contain a root logical or operation |
which separates the two expressions.
loop: {{ item | some_list }}
loop: {{ index, item | enumerate(some_list) }}
repeat: {{ index | 6 }}
Using global variables
Expressions have access to all variables returned by globals()
, as well as a few additional ones, detailed in this table.
Name | Environment | Type | Phase | Untrusted | Description |
---|---|---|---|---|---|
devices | Devices | – | See [Devices] | ||
env | System | dict[str, str] | 1 | Alias for os.environ . | |
ExperimentPath | System | type | 3 | ✔ | Opaque variant of pathlib.Path where the current directory is the experiment's directory. |
math | System | module | 1 | Alias for math . | |
open | System | (str, int) -> IO | 3 | Variant of open() where the current directory is the experiment's directory. | |
Path | System | type | 3 | ✔ | Alias for pathlib.Path . |
time | System | () -> float | 3 | ✔ | Alias for time.time . Not a dynamic value. |
unit | System | quantops.UnitRegistry | 1 | Unit registry instance, see Defining quantities. | |
username | System | str | 1 | The user's name. |
Note that when working with paths, evaluation may not finish before the runtime phase as the experiment's directory is not known before that phase.