Experiment profiles are your secret to bioreactor automation
Last summer, we released experiment profiles as a way to headless-ly control your Pioreactor from a file. We described them as "scripts (like a movie script) that you can write to automate activities in your Pioreactor cluster." Initially, the actions you could run were simple: start, stop, pause, resume, & update jobs. You couldn't pull in live data into profiles to make dynamic decisions, or repeat actions.
With the software release 24.1.25, and the next upcoming release, experiment profiles changes from just a "movie script" to a "live-action, audience participating, theatrical performance"!
Conditional actions and expressions
We added an if
directive to conditionally perform an action based on live data from your cluster. For example, you may want to increase stirring or bubbling if the OD reading is above some target. This example action looks like:
pioreactors:
pio1:
jobs:
stirring:
actions:
- type: start
hours_elapsed: 0.0
options:
target_rpm: 400.0
- update:
hours_elapsed: 10.0
if: pio1:od_reading:od1.od >= 1.5 # here's the if statement!
options:
target_rpm: 600.0
And yes, you can use boolean operators like `and`, `or`, brackets, and `not` to chain boolean expressions. The idea is to be able to reference any `published_setting` from any job in these expressions. For example: the string pio1:od_reading:od1.od
reads: "look at the published setting `od1` from job `od_reading` on `pio1` Pioreactor. That's a json-blob. Look inside at field `od` and compare it to 1.5".
This ability to pull live data is so powerful. We've also extended the `options` tag to allow these expressions. This allows for updating Pioreactor settings using simple rules with live data. Below is an example for updating the stirring RPM based on how much OD has changed.
pioreactors:
worker1:
jobs:
stirring:
actions:
- type: start
hours_elapsed: 0
options:
target_rpm: 500
- type: update
hours_elapsed: 12
options:
target_rpm: ${{ worker1:stirring:target_rpm + worker1:od_reading:od1.od * 10 }}
Note that these expressions need to be surrounded by `${{ ... }}` to distinguish them from regular strings. `if` statements can use `${{ ... }}` or not (they are always evaluated as expressions)
A looping directive: `repeat`
Looping enables you to monitor state continuously and act on it, or vary a parameter until a target is hit, or run actions over and over again for ever, all via profiles. We've introduced a new action, `repeat`, to control looping. Here's the high-level syntax for the action:
- type: repeat
hours_elapsed: [float]
if: [optional expression]
repeat_every_hours: [float]
while: [optional expression]
max_hours: [optional float]
actions: [list of actions]
The fields `type`, `hours_elapsed` and `if` are familiar. Let's go through the rest:
- `repeat_every_hours`: this is how long, in hours, the loop will take. For example, if you are starting a pump for ten seconds every hour, you will set `repeat_every_hours` to `1`.
- `while`: this is an expression that controls when to stop a loop, conditionally. It will run before the first loop executes, and check again before each additional execution of the loop. This field is optional, and defaults to True if not specified.
- `max_hours`: this optional field controls how long your loops will execute for. If you only want to run the loop every hour _for 10 hours_, then `max_hours` is `10`. If not specified, then it will run forever, or until `while` is false (if `while` is even specified).
- `actions`: this is a list of actions (like `start`, `update`, etc.) that determine the behaviour of the loop. These actions use `hours_elapsed` differently: in these actions, `hours_elapsed` refers to the start of the loop.
Examples!
In the below profile, we start stirring with target RPM 400. After 1 hour, we enter the `repeat` directive. The `while` loop checks each Pioreactor to see if their RPM is less than 1000, and if so, execute the list of actions. The list of actions says to increase RPM by 100, and then 15m later reduce RPM by 50. The the loop repeats after another 15 minutes.
experiment_profile_name: demo_stirring_repeat
metadata:
author: Cam Davidson-Pilon
description: A simple climbing RPM example
common:
jobs:
stirring:
actions:
- type: start
hours_elapsed: 0.0
options:
target_rpm: 400.0
- type: repeat
hours_elapsed: 1
while: ::stirring:target_rpm <= 1000
repeat_every_hours: 0.5
actions:
- type: update
hours_elapsed: 0.0
options:
target_rpm: ${{::stirring:target_rpm + 100}}
- type: update
hours_elapsed: 0.25
options:
target_rpm: ${{::stirring:target_rpm - 50}}
Here's another of profile of scaling RPM in proportion to nOD:
experiment_profile_name: increasing_rpm_with_od
metadata:
author: Cam Davidson-Pilon
description: A simple profile that increases RPM with increasing nOD
pioreactors:
testing_unit:
jobs:
stirring:
actions: # after 1 hour, every 1 hour, we increase the RPM in proportion to increases in nOD
- type: repeat
hours_elapsed: 1
interval: 1
actions:
- type: update
hours_elapsed: 0
options:
target_rpm: ${{testing_unit:stirring:target_rpm + 10 * (testing_unit:growth_rate_calculating:od_filtered.od_filtered - 1) }}
Conclusion
With these changes, we feel confident saying experiment profiles are nearly finished. Bugs will be cleaned up, some small features, and a better UI to edit and create profiles is needed, but we like the power level and flexibility of experiment profiles now.
Users can accomplish many things with profiles, without needing to dip into Python. As a bonus: since the YAML structure is so simple, we can use ChatGPT-like models to help users quickly write a profile with little chance for error. In fact, we've just done that! You can use our Pioreactor GPT to help write custom experiment profiles for your experiments.