Automancer docs

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:

  1. During the parsing phase, only operations between constants or literals are evaluated.
  2. During the adoption phase, shorthand arguments from the previous and current phase are resolved.
  3. During the runtime phase, variables that do not depend on external data are evaluated.
  4. 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>]
  • Asserting an expression's final phase
    • Compile-time – comptime[<value>]
    • Runtime – runtime[<value>]

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 using setattr().
  • 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 to x and y.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.

NameEnvironmentTypePhaseUntrustedDescription
devicesDevicesSee [Devices]
envSystemdict[str, str]1Alias for os.environ.
ExperimentPathSystemtype3Opaque variant of pathlib.Path where the current directory is the experiment's directory.
mathSystemmodule1Alias for math.
openSystem(str, int) -> IO3Variant of open() where the current directory is the experiment's directory.
PathSystemtype3Alias for pathlib.Path.
timeSystem() -> float3Alias for time.time. Not a dynamic value.
unitSystemquantops.UnitRegistry1Unit registry instance, see Defining quantities.
usernameSystemstr1The 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.