Taking experiment profiles to their conclusion (next release)

:wave:

Last release we introduced if and “expressions” in experiment profiles. To quickly recap: if is a directive that can be attached to actions to conditionally execute the action or not. Expressions can be used in if statements and options to dynamically pull live data. For example: ${{ worker1:stirring:target_rpm + 10 }} gets the current RPM in worker1, and adds 10 to it.

Here’s an update for the next release:

Using expressions and if in the common block

Previously we only allowed if and expressions in the pioreactors block. This was mostly due to us needing to determine how an expression should be evaluated for all workers in a Pioreactor cluster. The obvious choice is that an expression should executed for each worker. This causes some technical limitations, which we’ve solved, but also needs syntax. How do you specify that you want to run a dynamic expression for each worker? We decided to use the syntax ::<job>:<setting> to tell the backend “this should be evaluated for all workers”. For example:

   common:
     jobs:
       stirring:
         actions:
           - type: start
             hours_elapsed: 0.0
           - type: update
             hours_elapsed: 1.0
             if: ${{ ::stirring:$state == ready }}  # checks each worker if worker's stirring is "ready"
             options:
                target_rpm: ${{ ::stirring:target_rpm + 100 }} # if, so updates the worker's RPM by 100

A looping directive: repeat

When we cleaned up the profile code a few weeks back, we fortunately picked a good abstraction where we can add the most useful feature to profiles: looping. 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: <optional 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

   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}}

(below screenshot is sped up so I’m not waiting for hours)

Here’s another of profile of scaling RPM in proportion to nOD:

experiment_profile_name: demo_stirring_repeat

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.

2 Likes