Getting started
In order to start using SCRIMP, you have to work in the conda environment scrimp from the installation by running conda activate scrimp
.
To understand the coding philosophy of SCRIMP, let us consider the 1D wave equation with Neumann boundary control as a first example
where \(w\) denotes the deflection from the equilibrium position of a string, \(\rho\) is its mass density and \(T\) the Young’s modulus. Note the minus sign on the control at the left end side, standing for the outward normal to the domain \((0,1)\).
The physics giving this equation has to be restated in the port-Hamiltonian formalism first.
Port-Hamiltonian framework
Let \(\alpha_q := \partial_x w\) denotes the strain and \(\alpha_p := \rho \partial_t w\) the linear momentum. One can express the total mechanical energy lying in the system \(\mathcal{H}\), the Hamiltonian, as
The variables \(\alpha_q\) and \(\alpha_p\) are known as the state variables, or in the present case since \(\mathcal{H}\) represents an energy, the energy variables.
Computing the variational derivative of \(\mathcal{H}\) with respect to these variables leads to the co-state variables, or in our case the co-energy variables, i.e.
that is the stress and the velocity respectively.
Newton’s second law and Schwarz’s lemma give the following dynamics
Of course, trivial substitutions in this system would lead again to the initial string equation in second-order form. However, by keeping the system as is, an important structure appears. Indeed, the matrix of operators above is formally skew-symmetric. In other words, for all test functions \(f_q\) and \(f_p\) (compactly supported \(C^\infty\) functions), one has thanks to integration by parts
Together with the boundary Neumann condition, and defining collocated Dirichlet observations, this defines a (Stokes-) Dirac structure, where solutions along time, i.e. trajectories, will belong.
The port-Hamiltonian system representing a (linear) vibrating string with Neumann boundary control and Dirichlet boundary observation then writes
The two first blocks, giving in particular the dynamics, define the Dirac structure of the system. The third block is known as the constitutive relations, and is needed to ensure uniqueness of solutions.
The importance of the Dirac structure relies, in particular, in the fact that it encloses the power balance satisfied by the Hamiltonian. Indeed, along the trajectories, one has
In other words, the Dirac structure encodes the way the system communicates with its environment. In the present example, it says that the variation of the total mechanical energy is given by the power supplied to the system at the boundaries.
Each couple \((\partial_t \alpha_q, e_q)\), \((\partial_t \alpha_p, e_p)\), \((u_L, y_L)\) and \((u_R, y_R)\) is a port of the port-Hamiltonian system, and is associated to a physically meaningful term in the power balance.
Structure-preserving discretization
The objective of a structure-preserving discretization method is to obtain a finite-dimensional Dirac structure that encloses a discrete version of the power balance. There is several ways to achieve this goal, but SCRIMP focuses on a particular application of the Mixed Finite Element Mehod, called the Partitioned Finite Element Method.
Remark: The 1D case does simplify the difficulties coming from the boundary terms. Indeed, here the functional spaces for the controls \(u_L\), \(u_R\) and the observations \(y_L\), \(y_R\) are nothing but \(\mathbb{R}\).
Let \(\varphi_q\) and \(\varphi_p\) be smooth test functions, and \(\delta_{mx}\) denote the Kronecker symbol. One can write the weak formulation of the Dirac Structure as follows
Integrating by parts the second line make the controls appear
Now, let \((\varphi_q^i)_{1 \le i \le N_q}\) and \((\varphi_p^k)_{1 \le k \le N_p}\) be two finite families of approximations for the \(q\)-type port and the \(p\)-type port respectively, typically finite element families, and write the discrete weak formulation with those families, one has for all \(1 \le i \le N_q\) and all \(1 \le k \le N_p\)
which rewrites in matrix form
where \(\underline{\alpha_\star}(t) := \begin{pmatrix} \alpha_\star^1(t) & \cdots & \alpha_\star^{N_\star} \end{pmatrix}^\top\), \(\underline{e_\star}(t) := \begin{pmatrix} e_\star^1(t) & \cdots & e_\star^{N_\star} \end{pmatrix}^\top\), and
Abusing the language, the left-hand side will be called the flow of the Dirac structure in SCRIMP, while the right-hand side will be called the effort.
Now one can approximate the constitutive relations in those families by projection of their weak formulations
from which one can deduce the matrix form of the discrete weak formulation of the constitutive relation
where
Finally, the discrete Hamiltonian \(\mathcal{H}^d\) is defined as the evaluation of \(\mathcal{H}\) on the approximation of the state variables
The discrete power balance is then easily deduced from the above matrix formulations, thanks to the symmetry of \(M\) and the skew-symmetry of \(J\)
Remark: The discrete system that has to be solved numerically is a Differential Algebraic Equation (DAE). There exists some case (as in this example), where one can write the co-state formulation of the system by substituting the constitutive relations at the continuous level to get a more classical Ordinary Differential Equation (ODE)
where this time the mass matrices on the left-hand side are both weighted with respect to the physical parameters
Coding within SCRIMP
The following code is available in the file wave_1D.py
of the
sandbox folder of scrimp.
To start, import SCRIMP and create a distributed port-Hamiltonian
system (DPHS) called, e.g., wave
import scrimp as S
wave = S.DPHS("real")
Then, define the domain \(\Omega = (0,1)\), with a mesh-size parameter \(h\), and add it to the DPHS
domain = S.Domain("Interval", {"L": 1., "h": 0.01})
wave.set_domain(domain)
This creates a mesh of the interval \(\Omega = (0,1)\).
Important to keep in mind: the domain is composed of regions
,
denoted by integers. The built-in geometry of an interval available in
the code returns 1 for the domain \(\Omega\), 10 for the left-end
and 11 for the right-end. Informations about available geometries and
the indices of their regions can be found in the documentation or via
the function built_in_geometries()
available in
scrimp.utils.mesh
.
On this domain, we define two states and add them to the DPHS
alpha_q = S.State("q", "Strain", "scalar-field")
alpha_p = S.State("p", "Linear momentum", "scalar-field")
wave.add_state(alpha_q)
wave.add_state(alpha_p)
and the two associated co-states
e_q = S.CoState("e_q", "Stress", alpha_q)
e_p = S.CoState("e_p", "Velocity", alpha_p)
wave.add_costate(e_q)
wave.add_costate(e_p)
These latter calls create automatically two non-algebraic ports,
named after their respective state. Note that we simplify the
notations and do not write alpha_q
and alpha_p
but q
and
p
for the sake of readability.
Finally, we create and add the two control-observation ports with
left_end = S.Control_Port("Boundary control (left)", "U_L", "Normal force", "Y_L", "Velocity", "scalar-field", region=10)
right_end = S.Control_Port("Boundary control (right)", "U_R", "Normal force", "Y_R", "Velocity", "scalar-field", region=11)
wave.add_control_port(left_end)
wave.add_control_port(right_end)
Note the crucial keyword region
to restrict each port to its end.
By default, it would apply everywhere.
Syntaxic note: although \(y\) is the observation in the theory
of port-Hamiltonian systems, it is also the second space variable for
N-D problems. This name is thus reserved for this latter aim and
forbidden in all definitions of a DPHS. Nevertheless, the code being
case-sensitive, it is possible to name the observation Y
. To avoid
mistakes, we take the habit to always use this syntax, this is why we
denoted our controls and observations with capital letters even if the
problem does not occur in this 1D example.
To be able to write the discrete weak formulation of the system, one need to set four finite element families, associated to each port. Only two arguments are mandatory: the name of the port and the degree of the approximations.
V_q = S.FEM("q", 2)
V_p = S.FEM("p", 1)
V_L = S.FEM("Boundary control (left)", 1)
V_R = S.FEM("Boundary control (right)", 1)
This will construct a family of Lagrange finite elements (default choice) for each port, with the prescribed order. Remember that the boundary is only 2 disconnected points in this 1D case, so the only possibility for the finite element is 1 degree of freedom on each of them: Lagrange elements of order 1 is the easy way to do that.
Of course, this FEM must be added to the DPHS
wave.add_FEM(V_q)
wave.add_FEM(V_p)
wave.add_FEM(V_L)
wave.add_FEM(V_R)
Finally, the physical parameters of the experiment have to be defined. In SCRIMP, a parameter is associated to a port.
T = S.Parameter("T", "Young's modulus", "scalar-field", "1", "q")
rho = S.Parameter("rho", "Mass density", "scalar-field", "1 + x*(1-x)", "p")
wave.add_parameter(T)
wave.add_parameter(rho)
The first argument will be the string that can be used in forms, the second argument is a human-readable description, the third one set the kind of the parameter, the fourth one is the mathematical expression defining the parameter, and finally the fifth argument is the name of the associated port.
It is now possible to write the weak forms defining the system. Only
the non-zero blocks are mandatory. Furthermore, the place of the block
is automatically determined by GetFEM. The syntax follow a simple rule:
the unknown trial function q
is automatically associated to the test
function Test_q
(note the capital T on Test
), and so on.
Like we did for each call, the first step is to create the object, then to add it to the DPHS. As there is a lot of bricks, let us make a loop using a python list
bricks = [
# M matrix, on the flow side
S.Brick("M_q", "q * Test_q", [1], dt=True, position="flow"),
S.Brick("M_p", "p * Test_p", [1], dt=True, position="flow"),
S.Brick("M_Y_L", "Y_L * Test_Y_L", [10], position="flow"),
S.Brick("M_Y_R", "Y_R * Test_Y_R", [11], position="flow"),
# J matrix, on the effort side
S.Brick("D", "Grad(e_p) * Test_q", [1], position="effort"),
S.Brick("-D^T", "-e_q * Grad(Test_p)", [1], position="effort"),
S.Brick("B_L", "-U_L * Test_p", [10], position="effort"),
S.Brick("B_R", "U_R * Test_p", [11], position="effort"),
S.Brick("-B_L^T", "e_p * Test_Y_L", [10], position="effort"),
S.Brick("-B_R^T", "-e_p * Test_Y_R", [11], position="effort"),
# Constitutive relations
S.Brick("-M_e_q", "-e_q * Test_e_q", [1]),
S.Brick("CR_q", "q*T * Test_e_q", [1]),
S.Brick("-M_e_p", "-e_p * Test_e_p", [1]),
S.Brick("CR_p", "p/rho * Test_e_p", [1]),
]
for brick in bricks:
wave.add_brick(brick)
The first argument of a brick is a human-readable name, the second one
is the form, the third is a list (hence the [ and ]) of integers,
listing all the regions where the form applies. The optional parameter
dt=True
is to inform SCRIMP that this block matrix will apply on
the time-derivative of the unknown trial function, and finally the
option parameter position="flow"
informs SCRIMP that this block
is a part of the flow side of the Dirac structure,
position="effort"
do the same for the effort side, and without
this keyword, SCRIMP places the brick as part of the constitutive
relations.
Syntaxic note: the constitutive relations have to be written under an implicit formulation \(F = 0\). Keep in mind that a minus sign will often appear because of that.
The port-Hamiltonian system is now fully stated. It remains to set the controls and the initial values of the states before solving
expression_left = "-sin(2*pi*t)"
expression_right = "0."
wave.set_control("Boundary control (left)", expression_left)
wave.set_control("Boundary control (right)", expression_right)
q_init = "2.*np.exp(-50.*(x-0.5)*(x-0.5))"
p_init = "0."
wave.set_initial_value("q", q_init)
wave.set_initial_value("p", p_init)
We can now solve the system (with default experiment parameters)
wave.solve()
To end, one can also add the Hamiltonian terms and plot the contribution of each port to the balance equation
wave.hamiltonian.set_name("Mechanical energy")
terms = [
S.Term("Kinetic energy", "0.5*p*p/rho", [1]),
S.Term("Potential energy", "0.5*q*T*q", [1]),
]
for term in terms:
wave.hamiltonian.add_term(term)
wave.plot_Hamiltonian()
One can appreciate the structure-preserving property by looking at the dashed line, showing the evolution of
And now? It is time to see more examples.