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.