Symbolics

Finesse 3 introduces a new symbolic math feature that can be used to easily define mathematical expressions, transformations, and build mode complex models. For those coming from Finesse 2, this engine has replaced commands such as func, var, put, and set with a more powerful framework.

The symbolic engine provided in Finesse is custom written and, whilst similar to packages such as Mathematica and Sympy, is not as feature complete or powerful. The symbolics have both a KatScript and Python interface to provide maximum flexibility. Here we will detail some of the main use cases for symbolic expressions in Finesse.

Referencing parameters

The most common use case for symbols is to quickly allow you to link different elements’ parameters together. Take this simple example of two lasers, using KatScript, we can quickly make l2 always have the same power as l1 by simply writing:

import finesse
model = finesse.script.parse("""
l l1 P=1
l l2 P=l1.P
""")

model.l1.P, model.l2.P
(<l1.P=1.0 @ 0x7f051b5b9400>, <l2.P=l1.P @ 0x7f051b5b9700>)

We can see that l1.P has a value of 1 whereas l2.P has a value of l1.P. To explore this more, we can print the value of the parameter:

model.l2.P.value, type(model.l2.P.value)
(<Symbolic='l1.P' @ 0x7f051b5af3d0>, finesse.parameter.ParameterRef)

We can see this is a Symbolic object which is a ParameterRef type, which means it is a symbol that references the current value of l1.P. If we want to get the current value of a symbol we can simply evaluate it:

model.l2.P.value.eval()
1.0

and we see we get a value of 1. If l1.P changes and we evaluate it again, we see we get the new value.

model.l1.P = 10
model.l2.P.value.eval()
10.0

We can also achieve this using the Python API:

model = finesse.script.parse("""
l l1 P=1
l l2 P=2
""")

print(model.l1.P.ref)
model.l2.P = model.l1.P.ref
print(model.l2.P)
l1.P
l1.P W

Note that here we can take any ModelParameter and get a symbolic reference to it by using the .ref attribute. These references can be used to make more complicated expressions if you need to:

import numpy as np
expression = 10 * model.l2.P.ref + np.cos(model.l1.P.ref)

print(expression)
print(expression.eval())
((10*l2.P)+cos(l1.P))
10.54030230586814

Note above we can easily do standard mathematical operations on symbols in Python and also in KatScript:

model = finesse.script.parse("""
l l1 P=1
l l2 P=10*cos(l1.P)
""")

KatScript will always assume you are making a symbolic operation, there is no need to specify .ref in KatScript. The benefit of this behaviour is that when you run simulations the second laser power will automatically follow the expressions when the power of l1 is changed.

Another common use case is if you wanted to vary the reflectivity or transmission of a mirror. For example, you can sweep the transmission of a mirror and always keep the reflectivity the correct value:

model = finesse.script.parse("""
l l1 P=1
m m1 R=1-m1.T T=0 L=0
link(l1, m1)

xaxis(m1.T, lin, 0, 1, 100)
""")

# or with Python
model.m1.R = 1 - model.m1.T.ref

Preserving intent

One feature of the Finesse symbolics which differs from many other Symbolic packages is that there is no automated simplification. The key idea here is that we want to record exactly what the user originally intended. This for example, is useful for keeping track of factors of two or pi, etc, which when cancelled or simplified maybe lost.

model = finesse.script.parse("""
l l1 P=2*2/2-pi+pi
""")

model.l1.P
<l1.P=((2.0-π)+π) @ 0x7f051b4d6940>

two-arg vs N-arg operators

In our symbolic computation everything is broken down into operations and their arguments. These in turn then form a tree structure. Two varieties exist, binary-trees or operators accepting only two arguments, and a general tree structure, where operators can have N-arguments. The Finesse symbolic feature actually support both. The binary-tree structure is used by default, and generates an expression tree that fully preserves the original intent of the user. The binary type of tree is not ideal for performing symbolic simplification procedures though. Therefore there is also a generic tree structure option as well. General Finesse users should not really be able to tell which is being used at any particular time, nor should it make large differences in the behaviour of the code. It may be important for users performing more complex analysis though. More details on symbolic computation can be read up on in [20], which the Finesse implementation is based upon.

In general the binary tree structure is apparent when you print longer expressions (You can also check the symbols _is_narg_expression_tree attribute):

a = finesse.symbols.Variable('a')
y = 2+a+2+a+2*a*a/2
y
<Symbolic='((((2+a)+2)+a)+((2*a)*a)/2)' @ 0x7f051b4f42e0>

You should note that the brackets collect together always two terms in each operation. you can convert between the two tree structures using .to_nary_add_mul() and .to_binary_add_mul(). These are named as such as the general tree structure only really applies to the add and multiplication operator.

a = finesse.symbols.Variable('a')
y = 2+a+2+a + 2*a*a/2
y.to_nary_add_mul()
<Symbolic='(1*(a)**(2)+2+2+a+a)' @ 0x7f051b4f43d0>

During the above conversion processes some obvious simplification happens. Negation and division operators also do not exist in the N-argument trees. Those can always be represented by multiply and power operators which makes simplification and comparing two expressions significantly easier.

a = finesse.symbols.Variable('a')
y = 1/a
print(y)
print(y.to_nary_add_mul())
1/a
(a)**(-1)

This however highlights why the N-argument option is not the default, as we have lost the intent of a division. This has subtle numeric implications as a floating point division is not numerically identical to a power operation in all cases.

Basic simplification

In cases where you do want some basic simplification of Finesse symbolics there are only a few options, but they should cover most basic algebraic requirements. As needs evolve more may be added:

  • expand - expand operators

  • collect - collect together like terms

model = finesse.script.parse("""
var b 1
l l1 P=b+2-b+(b+b)
""")

print(model.l1.P.value)
print(model.l1.P.value.expand())
print(model.l1.P.value.collect())
(((b.value+2)-b.value)+(b.value+b.value))
(-b.value+2+b.value+b.value+b.value)
(2+2*b.value)

The simplification should also work on functions:

(np.cos(a) - np.cos(a)).collect()
0