Introduction

Finesse 3 introduces a new way in which to run simulations. The primary idea is to allow multiple numerical studies/simulations to be performed on a model in a more adaptable and computationally efficient way.

It is worthwhile revisiting briefly what this involved in Finesse 2 and Pykat to see why we made this change. In Finesse 2 the only analyses that could be performed were noxaxis, xaxis, and x2axis. All these did was simulate the model over some linear or logarithmic parameter range. If you wanted to explore some parameter space in a non uniform way or many more dimensions, you would have to use noxaxis and call the Finesse 2 C binary multiple times in Python, via a for-loop, for example. Computing multiple xaxis at a time was also not possible due to the structure of the C code.

Calling the v2 binary builds the model each time it is invoked; it allocated memory, scanning some parameters, computing detectors outputs, writing to an ASCII output file, freeing memory, then loading the output file into Python for processing. Obviously for a large model like a LIGO file each binary call was quite costly when using many higher-order modes and locks. Take a common example such as computing multiple input and output transfer functions. In v2 only a single input could be computed at once. So for each excitation the model would have to be built, the operating point found with locks (which was costly), and the transfer functions calculated. That state of the simulation would be thrown away each time between binary calls.

Using actions to compose an analyses to perform on a model is a solution to the problems mentioned above. They allow a user to build custom analyses from simpler blocks that would have taken many lines of code in Pykat and Finesse 2. This page aims to introduce the basics of actions and how to start composing more complicated analyses.

The ‘xaxis’ and ‘noxaxis’ action

In Finesse 3 we have introduced the concept of an analysis that is to be performed on a model. An analysis can be simple or very complex. They are composed of single or multiple atomic simulation tasks we call actions. For example, xaxis is now an action. Its task is to sweep some parameter and computes all the detector outputs and returns a solution object with its result, and finally reset all the values it has changed during the sweep. Take the following simple example consisting of a laser beam incident on a photodetector reading out the power measured as we vary the power:

Note

Action KatScript uses function style syntax. This allows actions to be nested, to span multiple lines, and to contain comments.

import finesse
model = finesse.Model()
model.parse("""
l l1 P=1
pd P l1.p1.o
xaxis(l1.P, lin, 0, 1, 10)
""")
print(model.analysis)
<finesse.analysis.actions.axes.Xaxis object at 0x7ff741058ca0>

Only a single root analysis can be specified in KatScript; this is placed in the analysis member. When you run a model it will perform whatever analysis is set in analysis. We can see from the xaxis used above that it was set to an Xaxis object. Running the model returns a solution generated by the analysis - if one is generated. An Xaxis action produces an ArraySolution. The detector outputs can be retrieved using their names as shown below.

sol = model.run()
print(sol)
print(sol['P'])
- Solution Tree
● xaxis - ArraySolution
[0.  0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1. ]

You can quickly replace which analysis you want to run by parsing a new one. To generate a single data point for the current state of the model you can use the noxaxis action as was possible in Finesse 2.

model.parse("noxaxis()")
sol = model.run()
print(model.analysis)
print(sol)
print(sol['P'])
<finesse.analysis.actions.axes.Noxaxis object at 0x7ff740f08e50>
- Solution Tree
● noxaxis - ArraySolution
1.0000000000000002

Solutions

Solutions replace the the .out file generated in Finesse 2, there we were very limited by the type of data we could output. In Finesse 3 solutions are introduce to fix this issue and enable a wide variety of outputs to be produced.

As mentioned, each action may or may not produce a solution. Some actions just perform some task but do not generate any particular output. Others like xaxis generate an array of detector outputs. Others like freqresp can compute multiple-input-multiple-output transfer function matrices.

Each solution generated is domain specific and can contain very different data so it is worthwhile referring to the documentation of each action and its solution to determine how to use it. Broadly those solutions that generate data also contain helper functions such as plot() to quickly make standard plots.

Analysis with multiple actions - ‘series’

The real power now comes from being able to chain together multiple actions to perform in a single analysis. This enables us to run more complex simulations without having to resort to writing a lot of boilerplate code that was required in Pykat. Say we want to run two xaxis actions but only build the model once (which was not possible in Pykat). For this we use the series action. This runs each action it is given sequentially in the same simulation.

model = finesse.Model()
model.parse("""
l l1 P=1
pd P l1.p1.o
series(
    xaxis(l1.P, lin, 0, 1, 10),
    xaxis(l1.P, log, 1, 10, 10)
)
""")
print(model.analysis.plan())
○ start
╰──● series
   ├──○ xaxis
   ╰──○ xaxis

We can print the analysis’ plan on what it will try and run and we see it will try and run two xaxis actions.

sol = model.run()
print(sol)
display(sol)
- Solution Tree
○ series
├──● xaxis - ArraySolution
╰──● xaxis - ArraySolution
<BaseSolution of series @ 0x7ff740657860 children=2>

Running it we now see our solution is a bit more complicated and it has two children, as we have two array solutions. The solutions are essentially a tree structure, where each solution can have multiple children, depending on what analysis you create.

sol.children
[<ArraySolution of series/xaxis @ 0x7ff740f12b80 children=0>,
 <ArraySolution of series/xaxis @ 0x7ff740f12c40 children=0>]

The child solutions can also be accessed in multiple ways, by numerical index:

sol[0], sol[1]
(<ArraySolution of series/xaxis @ 0x7ff740f12b80 children=0>,
 <ArraySolution of series/xaxis @ 0x7ff740f12c40 children=0>)

Or by name:

sol['xaxis']
<finesse.solutions.array.ArraySolutionSet at 0x7ff781f38790>

You’ll notice that this last option returns an ArraySolutionSet which is combined set of array solutions generated by an xaxis. This requires the number of steps to be the same in each xaxis used as a single large array is generated. You can then access outputs from this combined set:

sol['xaxis']['P']
array([[ 0.        ,  0.1       ,  0.2       ,  0.3       ,  0.4       ,
         0.5       ,  0.6       ,  0.7       ,  0.8       ,  0.9       ,
         1.        ],
       [ 1.        ,  1.25892541,  1.58489319,  1.99526231,  2.51188643,
         3.16227766,  3.98107171,  5.01187234,  6.30957344,  7.94328235,
        10.        ]])

We can give actions names for easier access too:

model = finesse.Model()
model.parse("""
l l1 P=1
pd P l1.p1.o
series(
    xaxis(l1.P, lin, 0, 1, 10, name='xaxis1'),
    xaxis(l1.P, log, 1, 10, 10, name='xaxis2')
)
""")
sol = model.run()
print(sol['xaxis1'])
print(sol['xaxis2'])
- Solution Tree
● xaxis1 - ArraySolution
- Solution Tree
● xaxis2 - ArraySolution

Actions and model state changes

The series action internally passes the state of the model and simulation from one action to the next. The model state refers to the value of all the model elements, parameters, settings, and model layout. This allows one action to change the state of the model, then the next one acts on that. We can try this with the same example from above:

model = finesse.Model()
model.parse("""
l l1 P=1
pd P l1.p1.o
series(
    xaxis(l1.P, lin, 0, 1, 1, relative=true, name='xaxis1'),
    xaxis(l1.P, lin, 0, 10, 1, relative=true, name='xaxis2')
)
""")
sol = model.run()
print(sol['xaxis1']['P'])
print(sol['xaxis2']['P'])
[1. 2.]
[ 1. 11.]

The xaxis actions change the laser power relative to its initial value (xaxis* in KatScript 2). You’ll see from the output that the initial value is the same. This is because the xaxis actions revert the state of the model after it has swept the parameters. If we wanted to change the state of the model so that the laser power is different we can use the change action:

model = finesse.Model()
model.parse("""
l l1 P=1
pd P l1.p1.o
series(
    xaxis(l1.P, lin, 0, 1, 1, relative=true, name='xaxis1'),
    change(l1.P=2),
    xaxis(l1.P, lin, 0, 10, 1, relative=true, name='xaxis2')
)
""")
sol = model.run()
print(sol['xaxis1']['P'])
print(sol['xaxis2']['P'])
[1. 2.]
[ 2. 12.]

Now we see the second solution starts from 2. The change action can alter several variables at once change(l1.p=2, l1.phase=45).

It is important to remember that series does not deep-copy the model it is asked to run on, therefore any state changes made will still be present at the end of the analysis.

model = finesse.Model()
model.parse("""
l l1 P=1
pd P l1.p1.o
series(
    change(l1.P=2),
    noxaxis(),
    change(l1.P=3),
    noxaxis()
)
""")
sol = model.run()
print(model.l1.P)
3.0 W

We see that the laser power in our original model is now 3W instead of 1W. The reason that actions can leave behind state changes is so that more advanced actions can be used. For example, running locks, optimising parameters, or finding the correcting operating point of an interferometer. Overall reducing the amount of times a model is copied is ideal as it is computationally expensive for larger models.

Analysis with multiple actions - ‘parallel’

Note

In the alpha version, the parallel action is not multithreaded. This will be added at a later date.

Along with series there is also parallel. This creates copies of the model and restarts the simulation for each action in the parallel arguments. Essentially this is equivalent to writing python code to deep-copy the model object and run each action one after another. Eventually, this will automatically handle running multiple simulation concurrently, via multiprocessing, threads, or distributing them to some computing cluster. As the parallel action copies the model, any state changes made will not be present in the initial model that was run, unlike series.

Take our previous example, using parallel we see that our relative change in the laser power is not doing anything different.

model = finesse.Model()
model.parse("""
l l1 P=1
pd P l1.p1.o
parallel(
    change(l1.P=2),
    xaxis(l1.P, lin, 0, 1, 1, relative=true, name='xaxis1'),
    xaxis(l1.P, lin, 0, 1, 1, relative=true, name='xaxis2')
)
""")
sol = model.run()
print(sol['xaxis1']['P'])
print(sol['xaxis2']['P'])
[1. 2.]
[1. 2.]

To run multiple actions in parallel we need to build up our action using series. You should note that the solution has a more complicated structure now.

model = finesse.Model()
model.parse("""
l l1 P=1
pd P l1.p1.o
parallel(
    series(
        change(l1.P=2),
        xaxis(l1.P, lin, 0, 1, 1, relative=true, name='xaxis1')
    ),
    series(
        change(l1.P=10),
        xaxis(l1.P, lin, 0, 1, 1, relative=true, name='xaxis1')
    )
)
""")
sol = model.run()
print(sol)
- Solution Tree
○ parallel - ParallelSolution
├──○ series
│  ╰──● xaxis1 - ArraySolution
╰──○ series
   ╰──● xaxis1 - ArraySolution

Care must be taken when extracting out solutions from these states. There are various ways to extract each one, or select multiple

display(sol[0, 'xaxis1']) # the first parallel action xaxis solution
display(sol['series']) # a tuple of the solutions generated by a series
display(tuple(s['xaxis1'] for s in sol['series'])) # Generator syntax to extract each xaxis solution
<ArraySolution of parallel/series/xaxis1 @ 0x7ff740588640 children=0>
<finesse.solutions.base.SolutionSet at 0x7ff740f08ee0>
(<ArraySolution of parallel/series/xaxis1 @ 0x7ff740588640 children=0>,
 <ArraySolution of parallel/series/xaxis1 @ 0x7ff74051f280 children=0>)

Improved indexing will likely be added in later versions. Currently slices can only be used in the final index which means sol[:, 'xaxis1'] in the above scenario would not work.

Extracting model states from an analysis

In some instances we might want to perform a complex analysis on a model which involves copying it but still access the final state of the model generated. For example, an analysis that might perform multiple optimisations in a parallel action where we want to get the final model states.

To return these internally generated states we can set the return_state flag in run:

model = finesse.Model()
model.parse("""
l l1 P=1
pd P l1.p1.o
parallel(
    change(l1.P=2),
    change(l1.P=3)
)
""")
sol, state = model.run(return_state=True)
print("Initial model =", model)
print(state)
Initial model = <finesse.model.Model object at 0x7ff740521070>
○ AnalysisState <finesse.model.Model object at 0x7ff740521070>
├──○ AnalysisState <finesse.model.Model object at 0x7ff740521fa0>
╰──○ AnalysisState <finesse.model.Model object at 0x7ff7405351f0>

The returned state object is another tree structure. It is the root state of the analysis tree that is internally generated. What we can do with this tree is dig into the children and extract the model. Currently there is no accessing the states via the names of the actions, which may be implemented at a later date.

state.children[0].model.l1.P
<l1.P=2.0 @ 0x7ff74051fa00>

Action events

Certain actions have events which you can run actions on when they occur. Please refer to the documentation on each action for detailed descriptions of any events they may expose.

Let’s look at two events which will are commonly used in the xaxis action, pre_step and post_step. These events are called before and after each step along the axis. Therefore we are able to run an action after each step, such as running locks, or computing a whole other xaxis action. Here we simply demonstrate this using the print_model_attr action, which just prints the current value of some model value. We see the first pre-step is the initial value of the laser power.

import finesse
model = finesse.Model()
model.parse("""
l l1 P=1
pd P l1.p1.o
xaxis(
    l1.P, lin, 10, 100, 3,
    pre_step=print_model_attr(l1.P),
    post_step=print_model_attr(l1.P)
)
""")
sol = model.run()
l1.P=10.0
l1.P=10.0
l1.P=40.0
l1.P=40.0
l1.P=70.0
l1.P=70.0
l1.P=100.0
l1.P=100.0

In this example we run a whole other xaxis for each post-step along the initial xaxis.

import finesse
model = finesse.Model()
model.parse("""
l l1 P=1
pd P l1.p1.o
xaxis(
    l1.P, lin, 10, 100, 10,
    post_step=xaxis(
        l1.phase, lin, 0, 90, 100
    )
)
""")
sol = model.run()
print(sol)
- Solution Tree
● xaxis - ArraySolution
╰──○ post_step
   ├──● xaxis - ArraySolution
   ├──● xaxis - ArraySolution
   ├──● xaxis - ArraySolution
   ·
   ·
   ├──● xaxis - ArraySolution
   ├──● xaxis - ArraySolution
   ╰──● xaxis - ArraySolution

If we look at the solution generated we see there is an additional entry called post_step where all our xaxis solutions are now stored. We are now able to access them all as a set of array solutions extracting detector outputs from all of the solutions

sol['post_step', 'xaxis']['P']
array([[ 10.,  19.,  10., ...,  10.,  10.,  10.],
       [ 19.,  19.,  28., ...,  19.,  19.,  19.],
       [ 28.,  28.,  28., ...,  28.,  28.,  28.],
       ...,
       [ 82.,  82.,  82., ...,  82.,  82.,  82.],
       [ 91.,  91.,  91., ...,  91.,  91.,  91.],
       [100., 100., 100., ..., 100., 100., 100.]])

Or the solution objects individually as an iterable:

sol['post_step', :]
[<ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050a880 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050a940 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050aa00 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050aac0 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050ab80 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050ac40 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050ad00 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050adc0 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050ae80 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050af40 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050e040 children=0>]

Or via the usual solution tree node object:

sol['post_step'].children
[<ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050a880 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050a940 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050aa00 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050aac0 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050ab80 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050ac40 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050ad00 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050adc0 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050ae80 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050af40 children=0>,
 <ArraySolution of series/xaxis/post_step/xaxis @ 0x7ff74050e040 children=0>]

Python API

As with the rest of Finesse 3 any feature that is accessible via KatScript can also be accessed and interacted with via the Python API. All the built-in actions are available in finesse.analysis.actions. Each KatScript command is associated with a similarly named class in this module. The class names usually use a longer CamelCase style.

Reusing our simple example, the Xaxis action can be used in a similar manner. Rather than adding the analysis to the model we just run it directly and provide the model to run it on.

import finesse
from finesse.analysis.actions import Xaxis

model = finesse.Model()
model.parse("""
l l1 P=1
pd P l1.p1.o
""")
sweep_power = Xaxis('l1.P', 'lin', 0, 1, 10)
sol = model.run(sweep_power)
print(sol['P'])
[0.  0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1. ]

Which produces exactly the same result as the KatScript interface. The Python API allows you to programatically build up actions in scripts if needed. Action events are similarly set using the keyword-argument of the action classes.