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}\]

ballandbeamsystem

Julia Environment

For this example, we will need the following packages:

ModuleDescription
JuliaSimModelOptimizerThe high-level library used to formulate our problem and perform automated model discovery
ModelingToolkitThe symbolic modeling environment
ModelingToolkitStandardLibraryLibrary for using standard modeling components
OrdinaryDiffEqThe numerical differential equation solvers
CSV and DataFramesWe will read our experimental data from .csv
DataSetsWe will load our experimental data from datasets on JuliaHub
DataInterpolationsLibrary for creating interpolations from the data
PlotsThe 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.5, v = 0)
    @parameters m2 = 3.0
    @named mass2 = Mass(; m = m2, s = 0.5, 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 =  complete(structural_simplify(model1))

model2 = SystemModel()
sys2 =  complete(structural_simplify(model2))

\[ \begin{align} \frac{\mathrm{d} coupsys_{+}mass2_{+}s\left( t \right)}{\mathrm{d}t} =& coupsys_{+}mass2_{+}v\left( t \right) \\ \frac{\mathrm{d} coupsys_{+}mass1_{+}s\left( t \right)}{\mathrm{d}t} =& coupsys_{+}mass1_{+}v\left( t \right) \\ \frac{\mathrm{d} coupsys_{+}mass1_{+}v\left( t \right)}{\mathrm{d}t} =& \frac{coupsys_{+}mass1_{+}f\left( t \right)}{coupsys_{+}mass1_{+}m} \\ \frac{\mathrm{d} coupsys_{+}mass2_{+}v\left( t \right)}{\mathrm{d}t} =& \frac{coupsys_{+}mass2_{+}f\left( t \right)}{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)
Example block output

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 35 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.00061 │    3.99926 │          2505.11 │           10.0175 │        ⋯
└─────────────────┴────────────┴──────────────────┴───────────────────┴─────────
                                                                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)
Example block output
plot(experiment2, prob, r, show_data = true, layout = (2, 1), size = (1000, 600), ms = 0.1)
Example block output

We can see it fits the data well!