Skip to content

Commit c686f68

Browse files
authored
Merge pull request #163 from SciML/bgc/input
Input component and Hydraulic changes
2 parents 5582140 + 7ca521c commit c686f68

File tree

13 files changed

+847
-170
lines changed

13 files changed

+847
-170
lines changed

Project.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ julia = "1.6"
1717

1818
[extras]
1919
ControlSystemsBase = "aaaaaaaa-a6ca-5380-bf3e-84a91bcd477e"
20+
DataInterpolations = "82cc6244-b520-54b8-b5a6-8a565e85f1d0"
2021
OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed"
2122
SafeTestsets = "1bc83da4-3b8d-516f-aca4-4fe02f6d838f"
2223
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
2324

2425
[targets]
25-
test = ["OrdinaryDiffEq", "SafeTestsets", "Test", "ControlSystemsBase"]
26+
test = ["OrdinaryDiffEq", "SafeTestsets", "Test", "ControlSystemsBase", "DataInterpolations"]

docs/pages.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ pages = [
55
"Custom Components" => "tutorials/custom_component.md",
66
"Thermal Conduction Model" => "tutorials/thermal_model.md",
77
"DC Motor with Speed Controller" => "tutorials/dc_motor_pi.md",
8+
"Input Component" => "tutorials/input_component.md",
89
],
910
"About Acausal Connections" => "connectors/connections.md",
1011
"API" => [

docs/src/tutorials/input_component.md

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
# Running Models with Discrete Data
2+
3+
There are 2 ways to include data as part of a model.
4+
5+
1. using `ModelingToolkitStandardLibrary.Blocks.TimeVaryingFunction`
6+
2. using a custom component with external data or sensor
7+
3. using `ModelingToolkitStandardLibrary.Blocks.Input`
8+
9+
This tutorial demonstrate each case and explain the pros and cons of each.
10+
11+
## TimeVaryingFunction Component
12+
13+
This component is easy to use and is performative. However the data is locked to the `ODESystem` and can only be changed by building a new `ODESystem`. Therefore, running a batch of data would not be efficient. Below is an example of how to use the `TimeVaryingFunction` with `DataInterpolations` to build the function from discrete data.
14+
15+
```julia
16+
using ModelingToolkit
17+
using ModelingToolkitStandardLibrary.Blocks
18+
using DataInterpolations
19+
using OrdinaryDiffEq
20+
21+
@parameters t
22+
D = Differential(t)
23+
24+
function System(f; name)
25+
src = TimeVaryingFunction(f)
26+
27+
vars = @variables f(t)=0 x(t)=0 dx(t)=0 ddx(t)=0
28+
pars = @parameters m=10 k=1000 d=1
29+
30+
eqs = [f ~ src.output.u
31+
ddx * 10 ~ k * x + d * dx + f
32+
D(x) ~ dx
33+
D(dx) ~ ddx]
34+
35+
ODESystem(eqs, t, vars, pars; systems = [src], name)
36+
end
37+
38+
dt = 4e-4
39+
time = 0:dt:0.1
40+
data = sin.(2 * pi * time * 100) # example data
41+
42+
f = LinearInterpolation(data, time)
43+
44+
@named system = System(f)
45+
sys = structural_simplify(system)
46+
prob = ODEProblem(sys, [], (0, time[end]))
47+
sol = solve(prob, ImplicitEuler())
48+
```
49+
50+
If we want to run a new data set, this requires building a new `LinearInterpolation` and `ODESystem` followed by running `structural_simplify`, all of which takes time. Therefore, to run serveral pieces of data it's better to re-use an `ODESystem`. The next couple methods will demonstrate this.
51+
52+
## Custom Component with External Data
53+
54+
The below code shows how to include data using a `Ref` and registered `input` 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.
55+
56+
```julia
57+
const rdata = Ref{Vector{Float64}}()
58+
59+
# Data Sets
60+
data1 = sin.(2 * pi * time * 100)
61+
data2 = cos.(2 * pi * time * 50)
62+
63+
function input(t)
64+
i = floor(Int, t / dt) + 1
65+
x = rdata[][i]
66+
67+
return x
68+
end
69+
70+
Symbolics.@register_symbolic input(t)
71+
72+
function System(; name)
73+
vars = @variables f(t)=0 x(t)=0 dx(t)=0 ddx(t)=0
74+
pars = @parameters m=10 k=1000 d=1
75+
76+
eqs = [f ~ input(t)
77+
ddx * 10 ~ k * x + d * dx + f
78+
D(x) ~ dx
79+
D(dx) ~ ddx]
80+
81+
ODESystem(eqs, t, vars, pars; name)
82+
end
83+
84+
@named system = System()
85+
sys = structural_simplify(system)
86+
prob = ODEProblem(sys, [], (0, time[end]))
87+
88+
rdata[] = data1
89+
sol1 = solve(prob, ImplicitEuler(); dt, adaptive = false);
90+
ddx1 = sol1[sys.ddx]
91+
92+
rdata[] = data2
93+
sol2 = solve(prob, ImplicitEuler(); dt, adaptive = false)
94+
ddx2 = sol2[sys.ddx]
95+
```
96+
97+
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`. 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.
98+
99+
```julia
100+
# the following test will fail
101+
@test all(ddx1 .== sol1[sys.ddx]) #returns false
102+
```
103+
104+
## Input Component
105+
106+
To resolve the issues presented above, the `Input` 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 parallize the solving.
107+
108+
```julia
109+
function System(; name)
110+
src = Input(Float64)
111+
112+
vars = @variables f(t)=0 x(t)=0 dx(t)=0 ddx(t)=0
113+
pars = @parameters m=10 k=1000 d=1
114+
115+
eqs = [f ~ src.output.u
116+
ddx * 10 ~ k * x + d * dx + f
117+
D(x) ~ dx
118+
D(dx) ~ ddx]
119+
120+
ODESystem(eqs, t, vars, pars; systems = [src], name)
121+
end
122+
123+
@named system = System()
124+
sys = structural_simplify(system)
125+
s = complete(system)
126+
prob = ODEProblem(sys, [], (0, time[end]); tofloat = false)
127+
defs = ModelingToolkit.defaults(sys)
128+
129+
function get_prob(data)
130+
defs[s.src.buffer] = Parameter(data, dt)
131+
# ensure p is a uniform type of Vector{Parameter{Float64}} (converting from Vector{Any})
132+
p = Parameter.(ModelingToolkit.varmap_to_vars(defs, parameters(sys); tofloat = false))
133+
remake(prob; p)
134+
end
135+
136+
prob1 = get_prob(data1)
137+
prob2 = get_prob(data2)
138+
139+
sol1 = Ref{ODESolution}()
140+
sol2 = Ref{ODESolution}()
141+
@sync begin
142+
@async sol1[] = solve(prob1, ImplicitEuler())
143+
@async sol2[] = solve(prob2, ImplicitEuler())
144+
end
145+
```
146+
147+
Note, in the above example, we can build the system with an empty `Input` component, only setting the expected data type: `@named src = Input(Float64)`. It's also possible to initialize the component with real data: `@named src = Input(data, dt)`. Additionally note that before running an `ODEProblem` using the `Input` component that the parameter vector should be a uniform type so that Julia is not slowed down by type instability. Because the `Input` component contains the `buffer` parameter of type `Parameter`, 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 all `Parameter` type by running `p = Parameter.(p)`. This will wrap all the single values in a `Parameter` type which will be mathmatically equivalent to a `Number`.

src/Blocks/Blocks.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export Log, Log10
1818
include("math.jl")
1919

2020
export Constant, TimeVaryingFunction, Sine, Cosine, ContinuousClock, Ramp, Step, ExpSine,
21-
Square, Triangular
21+
Square, Triangular, Parameter, Input
2222
include("sources.jl")
2323

2424
export Limiter, DeadZone, SlewRateLimiter

src/Blocks/sources.jl

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,3 +411,160 @@ end
411411
# - Pulse Generate pulse signal of type Real
412412
# - SawTooth Generate saw tooth signal
413413
# - Trapezoid Generate trapezoidal signal of type Real
414+
415+
function linear_interpolation(x1::T, x2::T, t1::T, t2::T, t) where {T <: Real}
416+
if t1 != t2
417+
slope = (x2 - x1) / (t2 - t1)
418+
intercept = x1 - slope * t1
419+
420+
return slope * t + intercept
421+
else
422+
@assert x1==x2 "x1 ($x1) and x2 ($x2) should be equal if t1 == t2"
423+
424+
return x2
425+
end
426+
end
427+
428+
struct Parameter{T <: Real}
429+
data::Vector{T}
430+
ref::T
431+
n::Int
432+
end
433+
434+
function Base.isequal(x::Parameter, y::Parameter)
435+
b0 = x.n == y.n
436+
if b0
437+
b1 = all(x.data .== y.data)
438+
b2 = x.ref == y.ref
439+
return b1 & b2
440+
else
441+
return false
442+
end
443+
end
444+
445+
Base.:*(x::Number, y::Parameter) = x * y.ref
446+
Base.:*(y::Parameter, x::Number) = Base.:*(x, y)
447+
Base.:*(x::Parameter, y::Parameter) = x.ref * y.ref
448+
449+
Base.:/(x::Number, y::Parameter) = x / y.ref
450+
Base.:/(y::Parameter, x::Number) = y.ref / x
451+
Base.:/(x::Parameter, y::Parameter) = x.ref / y.ref
452+
453+
Base.:+(x::Number, y::Parameter) = x + y.ref
454+
Base.:+(y::Parameter, x::Number) = Base.:+(x, y)
455+
Base.:+(x::Parameter, y::Parameter) = x.ref + y.ref
456+
457+
Base.:-(x::Number, y::Parameter) = x - y.ref
458+
Base.:-(y::Parameter, x::Number) = y.ref - x
459+
Base.:-(x::Parameter, y::Parameter) = x.ref - y.ref
460+
461+
Base.:^(x::Number, y::Parameter) = Base.power_by_squaring(x, y.ref)
462+
Base.:^(y::Parameter, x::Number) = Base.power_by_squaring(y.ref, x)
463+
Base.:^(x::Parameter, y::Parameter) = Base.power_by_squaring(x.ref, y.ref)
464+
465+
Base.isless(x::Parameter, y::Number) = Base.isless(x.ref, y)
466+
Base.isless(y::Number, x::Parameter) = Base.isless(y, x.ref)
467+
468+
Base.copy(x::Parameter{T}) where {T} = Parameter{T}(copy(x.data), x.ref, x.n)
469+
470+
function Base.show(io::IO, m::MIME"text/plain", p::Parameter)
471+
if !isempty(p.data)
472+
print(io, p.data)
473+
else
474+
print(io, p.ref)
475+
end
476+
end
477+
478+
Parameter(x::Parameter) = x
479+
function Parameter(x::T; tofloat = true) where {T <: Real}
480+
if tofloat
481+
x = float(x)
482+
P = typeof(x)
483+
else
484+
P = T
485+
end
486+
487+
return Parameter(P[], x, 0)
488+
end
489+
Parameter(x::Vector{T}, dt::T) where {T <: Real} = Parameter(x, dt, length(x))
490+
491+
function input(t, memory::Parameter{T}) where {T}
492+
if t < 0
493+
t = zero(t)
494+
end
495+
496+
if isempty(memory.data)
497+
if T isa Float16
498+
return NaN16
499+
elseif T isa Float32
500+
return NaN32
501+
elseif T isa Float64
502+
return NaN64
503+
else
504+
return zero(T)
505+
end
506+
end
507+
508+
i1 = floor(Int, t / memory.ref) + 1 #expensive
509+
i2 = i1 + 1
510+
511+
t1 = (i1 - 1) * memory.ref
512+
x1 = @inbounds getindex(memory.data, i1)
513+
514+
if t == t1
515+
return x1
516+
else
517+
if i2 > memory.n
518+
i2 = memory.n
519+
i1 = i2 - 1
520+
end
521+
522+
t2 = (i2 - 1) * memory.ref
523+
x2 = @inbounds getindex(memory.data, i2)
524+
return linear_interpolation(x1, x2, t1, t2, t)
525+
end
526+
end
527+
528+
get_sample_time(memory::Parameter) = memory.ref
529+
Symbolics.@register_symbolic get_sample_time(memory)
530+
531+
Symbolics.@register_symbolic input(t, memory)
532+
533+
function first_order_backwards_difference(t, memory)
534+
Δt = get_sample_time(memory)
535+
x1 = input(t, memory)
536+
x0 = input(t - Δt, memory)
537+
538+
return (x1 - x0) / Δt
539+
end
540+
541+
function Symbolics.derivative(::typeof(input), args::NTuple{2, Any}, ::Val{1})
542+
first_order_backwards_difference(args[1], args[2])
543+
end
544+
545+
Input(T::Type; name) = Input(T[], zero(T); name)
546+
function Input(data::Vector{T}, dt::T; name) where {T <: Real}
547+
Input(; name, buffer = Parameter(data, dt))
548+
end
549+
550+
"""
551+
Input(; name, buffer)
552+
553+
data input component.
554+
555+
# Parameters:
556+
- `buffer`: a `Parameter` type which holds the data and sample time
557+
558+
# Connectors:
559+
- `output`
560+
"""
561+
@component function Input(; name, buffer)
562+
pars = @parameters begin buffer = buffer end
563+
vars = []
564+
systems = @named begin output = RealOutput() end
565+
eqs = [
566+
output.u ~ input(t, buffer),
567+
]
568+
return ODESystem(eqs, t, vars, pars; name, systems,
569+
defaults = [output.u => input(0.0, buffer)]) #TODO: get initial value from buffer
570+
end

src/Hydraulic/IsothermalCompressible/IsothermalCompressible.jl

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,20 @@ Library to model iso-thermal compressible liquid fluid flow
33
"""
44
module IsothermalCompressible
55

6-
using ModelingToolkit, Symbolics, IfElse
6+
using ModelingToolkit, Symbolics
7+
78
using ...Blocks: RealInput, RealOutput
8-
using ...Mechanical.Translational: MechanicalPort
9+
using ...Mechanical.Translational: MechanicalPort, Mass
10+
11+
using IfElse: ifelse
912

1013
@parameters t
1114
D = Differential(t)
1215

1316
export HydraulicPort, HydraulicFluid
1417
include("utils.jl")
1518

16-
export Source, InputSource, Cap, Pipe, FixedVolume, DynamicVolume
19+
export Source, InputSource, Cap, Tube, FixedVolume, DynamicVolume
1720
include("components.jl")
1821

1922
end

0 commit comments

Comments
 (0)