Building Models with Discrete Data, Interpolations, and Lookup Tables
There are 4 ways to include data as part of a model.
- using
ModelingToolkitStandardLibrary.Blocks.Interpolation
- using
ModelingToolkitStandardLibrary.Blocks.ParametrizedInterpolation
- using a custom component with external data (not recommended)
- using
ModelingToolkitStandardLibrary.Blocks.SampledData
(legacy)
This tutorial demonstrate each case and explain the pros and cons of each.
Interpolation
Block
The ModelingToolkitStandardLibrary.Blocks.Interpolation
component is easy to use and is performant. It is similar to using callable parameters, but it provides a block interface with RealInput
and RealOutput
connectors. The Interpolation
is compatible with interpolation types from DataInterpolation
.
ModelingToolkitStandardLibrary.Blocks.Interpolation
— FunctionInterpolation(interp_type, u, x, args...; name)
Represent function interpolation symbolically as a block component. By default interpolation types from DataInterpolations.jl
are supported, but in general any callable type that builds the interpolation object via itp = interpolation_type(u, x, args...)
and calls the interpolation with itp(t)
should work. This does not need to represent an interpolation, it can be any type that satisfies the interface, such as lookup tables.
Arguments:
interp_type
: the type of the interpolation. ForDataInterpolations
,
these would be any of the available interpolations, such as LinearInterpolation
, ConstantInterpolation
or CubicSpline
.
u
: the data used for interpolation. ForDataInterpolations
this will be anAbstractVector
x
: the values that each data points correspond to, usually the times corresponding to each value inu
.args
: any other arguments needed to build the interpolation
Keyword arguments:
name
: the name of the component
Parameters:
interpolator
: the symbolic representation of the interpolation object, callable asinterpolator(t)
Connectors:
input
: aRealInput
connector corresponding to the input variableoutput
: aRealOutput
connector corresponding to the interpolated value
Here is an example on how to use it. Let's consider a mass-spring-damper system, where we have an external force as an input. We then generate some example data in a DataFrame
that would represent a measurement of the input. In a more realistic case, this DataFrame
would be read from a file.
using ModelingToolkit
using ModelingToolkit: t_nounits as t, D_nounits as D
using ModelingToolkitStandardLibrary.Blocks
using DataInterpolations
using OrdinaryDiffEq
using DataFrames
using Plots
function MassSpringDamper(; name)
@named input = RealInput()
@variables f(t) x(t)=0 dx(t)=0 ddx(t)
@parameters m=10 k=1000 d=1
eqs = [f ~ input.u
ddx * 10 ~ k * x + d * dx + f
D(x) ~ dx
D(dx) ~ ddx]
ODESystem(eqs, t; name, systems = [input])
end
function MassSpringDamperSystem(data, time; name)
@named src = Interpolation(LinearInterpolation, data, time)
@named clk = ContinuousClock()
@named model = MassSpringDamper()
eqs = [connect(clk.output, src.input)
connect(src.output, model.input)]
ODESystem(eqs, t, [], []; name, systems = [src, clk, model])
end
function generate_data()
dt = 4e-4
time = 0:dt:0.1
data = sin.(2 * pi * time * 100)
return DataFrame(; time, data)
end
df = generate_data() # example data
@named system = MassSpringDamperSystem(df.data, df.time)
sys = structural_simplify(system)
prob = ODEProblem(sys, [], (0, df.time[end]))
sol = solve(prob)
plot(sol)
Note that in the case of the Interpolation
block, the data
and the time
act like structural parameters.
As such, we can also build the interpolation object outside of the model
my_interpolation = LinearInterpolation(df.data, df.time)
@mtkmodel MassSpringDamperSystem2 begin
@components begin
src = Interpolation(itp=my_interpolation)
clk = ContinuousClock()
model = MassSpringDamper()
end
@equations begin
connect(src.input, clk.output)
connect(src.output, model.input)
end
end;
@mtkbuild sys = MassSpringDamperSystem2()
prob = ODEProblem(sys, [], (0, df.time[end]))
sol = solve(prob, Tsit5())
plot(sol)
Note that the interpolation is constructed outside of the model, so we cannot use remake
to change the data. For that usecase, see the ParametrizedInterpolation
.
ParametrizedInterpolation
Block
The ModelingToolkitStandardLibrary.Blocks.ParametrizedInterpolation
component is similar to Interpolation
, but as the name suggests, it is parametrized by the data, allowing one to change the underlying data without rebuilding the model as the data is represented via vector parameters. The main advantage of this block over the Interpolation
one is that one can use it for optimization problems. Currently, this supports forward mode AD via ForwardDiff, but due to the increased flexibility of the types in the component, this is not as fast as the Interpolation
block, so it is recommended to use only when the added flexibility is required.
ModelingToolkitStandardLibrary.Blocks.ParametrizedInterpolation
— FunctionParametrizedInterpolation(interp_type, u, x, args...; name, t = ModelingToolkit.t_nounits)
Represent function interpolation symbolically as a block component, with the interpolation data represented parametrically. By default interpolation types from DataInterpolations.jl
are supported, but in general any callable type that builds the interpolation object via itp = interpolation_type(u, x, args...)
and calls the interpolation with itp(t)
should work. This does not need to represent an interpolation, it can be any type that satisfies the interface, such as lookup tables.
Arguments:
interp_type
: the type of the interpolation. ForDataInterpolations
,
these would be any of the available interpolations, such as LinearInterpolation
, ConstantInterpolation
or CubicSpline
.
u
: the data used for interpolation. ForDataInterpolations
this will be anAbstractVector
x
: the values that each data points correspond to, usually the times corresponding to each value inu
.args
: any other arguments beeded to build the interpolation
Keyword arguments:
name
: the name of the component
Parameters:
data
: the symbolic representation of the data passed at construction time viau
.ts
: the symbolic representation of times corresponding to the data passed at construction time viax
.
Connectors:
input
: aRealInput
connector corresponding to the independent variableoutput
: aRealOutput
connector corresponding to the interpolated value
Here is an example on how to use it
using ModelingToolkit
using ModelingToolkit: t_nounits as t, D_nounits as D
using ModelingToolkitStandardLibrary.Blocks
using DataInterpolations
using OrdinaryDiffEq
using DataFrames
using Plots
function MassSpringDamper(; name)
@named input = RealInput()
vars = @variables f(t) x(t)=0 dx(t) [guess = 0] ddx(t)
pars = @parameters m=10 k=1000 d=1
eqs = [f ~ input.u
ddx * 10 ~ k * x + d * dx + f
D(x) ~ dx
D(dx) ~ ddx]
ODESystem(eqs, t, vars, pars; name, systems = [input])
end
function MassSpringDamperSystem(data, time; name)
@named src = ParametrizedInterpolation(LinearInterpolation, data, time)
@named clk = ContinuousClock()
@named model = MassSpringDamper()
eqs = [connect(model.input, src.output)
connect(clk.output, src.input)]
ODESystem(eqs, t; name, systems = [src, clk, model])
end
function generate_data()
dt = 4e-4
time = 0:dt:0.1
data = sin.(2 * pi * time * 100)
return DataFrame(; time, data)
end
df = generate_data() # example data
@named system = MassSpringDamperSystem(df.data, df.time)
sys = structural_simplify(system)
prob = ODEProblem(sys, [], (0, df.time[end]))
sol = solve(prob)
plot(sol)
If we want to run a new data set, this requires only remaking the problem and solving again
prob2 = remake(prob, p = [sys.src.data => ones(length(df.data))])
sol2 = solve(prob2)
plot(sol2)
Note that when changing the data, the length of the new data must be the same as the length of the original data.
Custom Component with External Data
The below code shows how to include data using a Ref
and registered get_sampled_data
function. This example uses a very basic function which requires non-adaptive solving and sampled data. As can be seen, the data can easily be set and changed before solving.
using ModelingToolkit
using ModelingToolkit: t_nounits as t, D_nounits as D
using ModelingToolkitStandardLibrary.Blocks
using OrdinaryDiffEq
const rdata = Ref{Vector{Float64}}()
dt = 4e-4
time = 0:dt:0.1
# Data Sets
data1 = sin.(2 * pi * time * 100)
data2 = cos.(2 * pi * time * 50)
function get_sampled_data(t)
i = floor(Int, t / dt) + 1
x = rdata[][i]
return x
end
Symbolics.@register_symbolic get_sampled_data(t)
function System(; name)
vars = @variables f(t)=0 x(t)=0 dx(t)=0 ddx(t)=0
pars = @parameters m=10 k=1000 d=1
eqs = [f ~ get_sampled_data(t)
ddx * 10 ~ k * x + d * dx + f
D(x) ~ dx
D(dx) ~ ddx]
ODESystem(eqs, t, vars, pars; name)
end
@named system = System()
sys = structural_simplify(system)
prob = ODEProblem(sys, [], (0, time[end]))
rdata[] = data1
sol1 = solve(prob, ImplicitEuler(); dt, adaptive = false)
ddx1 = sol1[sys.ddx]
rdata[] = data2
sol2 = solve(prob, ImplicitEuler(); dt, adaptive = false)
ddx2 = sol2[sys.ddx]
1-element Vector{Float64}:
0.1
The drawback of this method is that the solution observables can be linked to the data Ref
, which means that if the data changes then the observables are no longer valid. In this case ddx
is an observable that is derived directly from the data. Therefore, sol1[sys.ddx]
is no longer correct after the data is changed for sol2
.
# the following test will fail
@test all(ddx1 .== sol1[sys.ddx]) #returns false
Additional code could be added to resolve this issue, for example by using a Ref{Dict}
that could link a parameter of the model to the data source. This would also be necessary for parallel processing.
SampledData
Component
To resolve the issues presented above, the ModelingToolkitStandardLibrary.Blocks.SampledData
component can be used which allows for a resusable ODESystem
and self contained data which ensures a solution which remains valid for it's lifetime. Now it's possible to also parallelize the call to solve()
.
using ModelingToolkit
using ModelingToolkit: t_nounits as t, D_nounits as D
using ModelingToolkitStandardLibrary.Blocks
using OrdinaryDiffEq
function System(; name)
src = SampledData(Float64, name = :src)
vars = @variables f(t)=0 x(t)=0 dx(t)=0 ddx(t)=0
pars = @parameters m=10 k=1000 d=1
eqs = [f ~ src.output.u
ddx * 10 ~ k * x + d * dx + f
D(x) ~ dx
D(dx) ~ ddx]
ODESystem(eqs, t, vars, pars; systems = [src], name)
end
@named system = System()
sys = structural_simplify(system, split = false)
s = complete(system)
dt = 4e-4
time = 0:dt:0.1
data1 = sin.(2 * pi * time * 100)
data2 = cos.(2 * pi * time * 50)
prob = ODEProblem(sys, [], (0, time[end]); split = false, tofloat = false, use_union = true)
defs = ModelingToolkit.defaults(sys)
function get_prob(data)
defs[s.src.buffer] = Parameter(data, dt)
# ensure p is a uniform type of Vector{Parameter{Float64}} (converting from Vector{Any})
p = Parameter.(ModelingToolkit.varmap_to_vars(defs, parameters(sys); tofloat = false))
remake(prob; p, build_initializeprob = false)
end
prob1 = get_prob(data1)
prob2 = get_prob(data2)
sol1 = Ref{ODESolution}()
sol2 = Ref{ODESolution}()
@sync begin
@async sol1[] = solve(prob1, ImplicitEuler())
@async sol2[] = solve(prob2, ImplicitEuler())
end
Note, in the above example, we can build the system with an empty SampledData
component, only setting the expected data type: @named src = SampledData(Float64)
. It's also possible to initialize the component with real sampled data: @named src = SampledData(data, dt)
. Additionally note that before running an ODEProblem
using the SampledData
component, one must be careful about the parameter vector Type. The SampledData
component contains a buffer
parameter of type Parameter
, therefore we must generate the problem using tofloat=false
. This will initially give a parameter vector of type Vector{Any}
with a mix of numbers and Parameter
type. We can convert the vector to a uniform Parameter
type by running p = Parameter.(p)
. This will wrap all the single values in a Parameter
which will be mathematically equivalent to a Number
.