Parameter Estimation with Multiple Experiments
In this example, we will perform parameter calibration with two different experiments. The system we will calibrate is a double-mass system, where two masses are connected by a flexible shaft. An input force excites the first mass, which in turn drives the second mass through the flexible shaft. The flexibility of the shaft, modeled as a spring and damper, leads to a resonance, the properties of which we are interested in learning using data from experiments. This system is a common model of a servo mechanism, a mechanical system that uses feedback to control of a motor to accurately position a load mass. Examples of this include:
- Industrial robots
- 3D printers
- Tracking cameras and telescopes
- Overhead cranes
The mass that can be excited, representing the moving mass of the actuator, is equipped with a position sensor. However, the driven mass, the mass of the load we are interested in positioning, is not. In order to obtain sufficiently informative data, the second mass is instead temporarily equipped with an accelerometer. Extra temporary instrumentation is common in a lab setting, where the additional sensors are used to identify a model or to verify the performance of a state estimator etc.
A complicating factor in a setup like this is that it is difficult to accurately estimate both masses $m$ and spring stiffness $k$ at the same time. Typically, only the ratio of them is easily identifiable. This can be understood from looking at the expression for the resonance frequency $\omega$ of a simple mass-spring system, $\omega = \sqrt{k/m}$. However, if we perform two different experiments, where we change the mass by a known value, i.e., by adding a known quantity of additional mass, we can tease $k$ and $m$ apart. We will leverage this method here in order to accurately estimate all parameters of the system. We have the following parameters to estimate:
- Masses, $m_1, m_2$
- Transmission spring stiffness, $k$
- Transmission damping, $b$
The two experiments will be carried out as follows:
- Experiment 1 drives the unloaded system with a known force $F$.
- Experiment 2 adds load $\Delta m$ to the second mass, i.e., the effective mass of the load is this $m_2 + \Delta m$ where $m_2$ is unknown while $\Delta m$ is known. The same force is applied to the system as in Experiment 1.
From the classical Newton's 2nd Law of Motion, we can generate the following set of equations from the conservation equations:
\[\begin{aligned} m_1 \ddot{x_1} + b_1 \dot{x_1} &= k (x_2 - x_1) + b_2 (\dot{x_2} - \dot{x_1}) + F_1 \\ (m_2 + Δm) \ddot{x_2} + b_2 (\dot{x_2} - \dot{x_1}) + k (x_2 - x_1) &= 0.0 \\ \end{aligned}\]
Julia Environment
For this example, we will need the following packages:
Module | Description |
---|---|
JuliaSimModelOptimizer | The high-level library used to formulate our problem and perform automated model discovery |
ModelingToolkit | The symbolic modeling environment |
ModelingToolkitStandardLibrary | Library for using standard modeling components |
OrdinaryDiffEq | The numerical differential equation solvers |
CSV and DataFrames | We will read our experimental data from .csv |
DataSets | We will load our experimental data from datasets on JuliaHub |
DataInterpolations | Library for creating interpolations from the data |
Plots | The plotting and visualization library |
using JuliaSimModelOptimizer
using ModelingToolkit
import ModelingToolkit: D_nounits as D, t_nounits as t
using ModelingToolkitStandardLibrary.Blocks: RealInput, RealOutput, TimeVaryingFunction
using ModelingToolkitStandardLibrary.Mechanical.TranslationalPosition: Mass, Spring, Damper, AccelerationSensor, Force, Fixed
using OrdinaryDiffEq
using CSV, DataFrames
using DataSets
import DataInterpolations: CubicSpline
using Plots
Model Setup
Since the spring stiffness is numerically very large compared to the other parameters, and our apriori uncertainty about this parameter spans potentially several orders of magnitude, we let $log(k)$ be the parameter optimized by the solver. This brings the parameter into a similar numerical range as the other parameters, and makes the optimization problem more well-conditioned.
tvec = collect(0:0.01:200)
input_func_1 = CubicSpline(cos.(0.5 * tvec .^ 2), tvec) # A simple periodic driving force with increasing frequency
function coupledsystem()
@named mass1 = Mass(; m = 1.5, s = 0.0, v = 0)
@parameters m2 = 3.0
@named mass2 = Mass(; m = m2, s = 0.0, v = 0)
@named spring = Spring(; k = 2700.0)
@named damper1 = Damper(; d = 9.0)
@named damper2 = Damper(; d = 15.0)
@named wall = Fixed()
@named force = Force()
@named F1 = RealInput()
@named A2 = RealOutput()
@named accsensor = AccelerationSensor()
eqs = [
connect(wall.flange, damper1.flange_a),
connect(damper1.flange_b, mass1.flange),
connect(mass1.flange, damper2.flange_a),
connect(damper2.flange_b, mass2.flange),
connect(mass1.flange, spring.flange_a),
connect(spring.flange_b, mass2.flange),
connect(mass1.flange, force.flange),
connect(force.f, F1),
connect(accsensor.flange, mass2.flange),
connect(A2, accsensor.output),
]
@named coupsys = ODESystem(eqs, t, [], [m2]; systems=[mass1, mass2, spring, damper1, damper2, wall, force, F1, accsensor, A2])
return coupsys
end
function SystemModel(; name = :model)
f1 = input_func_1
coupsys = coupledsystem()
src1 = TimeVaryingFunction(f1; name=:src1)
eqs = [connect(coupsys.F1, src1.output)]
ODESystem(eqs, t; systems=[coupsys, src1], name)
end
model1 = SystemModel()
sys1 = structural_simplify(model1)
model2 = SystemModel()
sys2 = structural_simplify(model2)
\[ \begin{align} \frac{\mathrm{d} \mathtt{coupsys.mass2.s}\left( t \right)}{\mathrm{d}t} &= \mathtt{coupsys.mass2.v}\left( t \right) \\ \frac{\mathrm{d} \mathtt{coupsys.mass1.s}\left( t \right)}{\mathrm{d}t} &= \mathtt{coupsys.mass1.v}\left( t \right) \\ \frac{\mathrm{d} \mathtt{coupsys.mass1.v}\left( t \right)}{\mathrm{d}t} &= \frac{\mathtt{coupsys.mass1.f}\left( t \right)}{\mathtt{coupsys.mass1.m}} \\ \frac{\mathrm{d} \mathtt{coupsys.mass2.v}\left( t \right)}{\mathrm{d}t} &= \frac{\mathtt{coupsys.mass2.f}\left( t \right)}{\mathtt{coupsys.mass2.m}} \end{align} \]
Data Setup
Next, we will load some data. This data is generated from simulations. For increased realism, we simulated measurement noise on both velocity and acceleration measurements. Accelerometers have a tendency to be fairly noisy, reflected in the large amount of noise added to the acceleration measurements.
data1 = open(IO, dataset("juliasimtutorials/coupled_experiments_data_1")) do io
CSV.read(io, DataFrame)
end
data2 = open(IO, dataset("juliasimtutorials/coupled_experiments_data_2")) do io
CSV.read(io, DataFrame)
end
Now, lets plot the data.
plot(data1[:, "timestamp"], data1[:, "coupsys.mass1.v"], label = "Noisy velocity measurements", layout=(2, 1), size = (1000, 600), sp=1)
plot!(data1[:, "timestamp"], data1[:, "coupsys.accsensor.a"], label = "Noisy acceleration measurements", sp=2)
Defining Experiments and InverseProblem
We will set up both the Experiment
corresponding to Δm
present or absent with their corresponding data. We will also use DiscreteFixedGainPEM
for simulations to be guided by the data which helps in the calibration process.
experiment1 = Experiment(data1, sys1, loss_func = meansquaredl2loss, alg = Tsit5(),
params = [sys1.coupsys.mass2.m => sys1.coupsys.m2],
model_transformations = [DiscreteFixedGainPEM(0.05)]
)
Δm = 10.0 # We add 10kg of mass to the load in the second experiment
experiment2 = Experiment(data2, sys2, loss_func = meansquaredl2loss, alg = Tsit5(),
params = [sys2.coupsys.mass2.m => sys2.coupsys.m2 + Δm],
model_transformations = [DiscreteFixedGainPEM(0.05)]
)
Experiment for model with parameters:
coupsys₊mass2₊m => 10.0 + coupsys₊m2.
The simulation of this experiment is given by:
ODEProblem with uType Vector{Float64} and tType Float64. In-place: true
timespan: (0.0, 100.0)
Next, we specify an InverseProblem
of interest to us where we specify the parameters we want to recover and their bounds. We pass both the experiments as a vector.
prob = InverseProblem([experiment1, experiment2], [sys1.coupsys.mass1.m => (0.0, 10.0), sys1.coupsys.m2 => (0.0, 10.0), sys1.coupsys.spring.k => (1e1, 1e4, :log), sys1.coupsys.damper1.d => (0.0, 20.0), sys1.coupsys.damper2.d => (0.0, 20.0)])
InverseProblem with 2 experiments with 5 elements in the search space.
Calibration
Now, lets use SingleShooting
for calibration. To do this, we first define an algorithm alg
and then call calibrate
with the prob
and alg
.
alg = SingleShooting(maxiters = 30, optimizer = IpoptOptimizer(tol = 1e-5, acceptable_tol = 1e-4))
r = calibrate(prob, alg)
Calibration result computed in 34 seconds. Final objective value: 0.00509347.
Optimization ended with Success Return Code and returned.
┌─────────────────┬────────────┬──────────────────┬───────────────────┬─────────
│ coupsys₊mass1₊m │ coupsys₊m2 │ coupsys₊spring₊k │ coupsys₊damper1₊d │ coupsy ⋯
├─────────────────┼────────────┼──────────────────┼───────────────────┼─────────
│ 1.00062 │ 3.99926 │ 2505.12 │ 10.0174 │ ⋯
└─────────────────┴────────────┴──────────────────┴───────────────────┴─────────
1 column omitted
We can see the calibrated parameters closely match with their true values, i.e, k = 2500, b1 = 10.0, b2 = 10.0, m2 = 4.0
.
Visualization
Lets also plot the simulation with the calibrated parameters.
plot(experiment1, prob, r, show_data = true, layout = (2, 1), size = (1000, 600), ms = 0.1)
plot(experiment2, prob, r, show_data = true, layout = (2, 1), size = (1000, 600), ms = 0.1)
We can see it fits the data well!