Skip to content

Commit 111ec0e

Browse files
committed
increase flexibility in disturbance-argument codegen
1 parent 6cdf9ae commit 111ec0e

File tree

5 files changed

+149
-33
lines changed

5 files changed

+149
-33
lines changed

docs/src/tutorials/disturbance_modeling.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,15 @@ y &= g(x, u, p, t)
119119
\end{aligned}
120120
```
121121

122-
To make use of the model defined above for state estimation, we may want to generate a Julia function for the dynamics ``f`` and the output equations ``g`` that we can plug into, e.g., a nonlinear version of a Kalman filter or a particle filter, etc. MTK contains utilities to do this, namely, [`ModelingToolkit.generate_control_function`](@ref) and [`ModelingToolkit.build_explicit_observed_function`](@ref) (described in more details in ["Input output"](@ref inputoutput)). These functions take keyword arguments `disturbance_inputs` and `disturbance_argument`, that indicate which variables in the model are considered part of ``w``, and whether or not these variables are to be added as function arguments to ``f``, i.e., whether we have ``f(x, u, p, t)`` or ``f(x, u, p, t, w)``. If we do not include the disturbance inputs as function arguments, MTK will assume that the ``w`` variables are all zero, but any dynamics associated with these variables, such as disturbance models, will be included in the generated function. This allows a state estimator to estimate the state of the disturbance model, provided that this state is [observable](https://en.wikipedia.org/wiki/Observability) from the measured outputs of the system.
122+
To make use of the model defined above for state estimation, we may want to generate a Julia function for the dynamics ``f`` and the output equations ``g`` that we can plug into, e.g., a nonlinear version of a Kalman filter or a particle filter, etc. MTK contains utilities to do this, namely, [`ModelingToolkit.generate_control_function`](@ref) and [`ModelingToolkit.build_explicit_observed_function`](@ref) (described in more details in ["Input output"](@ref inputoutput)).
123+
124+
These functions support two types of disturbance inputs:
125+
126+
- **Unknown disturbances** (`disturbance_inputs`): Variables that are part of ``w`` but NOT added as function arguments to ``f``. MTK assumes these variables are zero, but any dynamics associated with them (such as disturbance models) are included in the generated function. This allows a state estimator to estimate the state of the disturbance model, provided that this state is [observable](https://en.wikipedia.org/wiki/Observability) from measured outputs.
127+
128+
- **Known disturbances** (`known_disturbance_inputs`): Variables that are part of ``w`` AND added as function arguments to ``f``, resulting in ``f(x, u, p, t, w)``. Use this when disturbances can be measured or are otherwise known.
129+
130+
You can mix and match: some disturbances can be unknown while others are known. For example, `generate_control_function(sys, inputs; disturbance_inputs=[w1], known_disturbance_inputs=[w2, w3])` generates a function `f(x, u, p, t, [w2, w3])` where `w1` is set to zero but its dynamics are preserved, while `w2` and `w3` must be provided as arguments.
123131

124132
Below, we demonstrate
125133

@@ -187,7 +195,7 @@ outputs = [P.inertia1.phi, P.inertia2.phi, P.inertia1.w, P.inertia2.w]
187195
(f_oop, f_ip), x_sym,
188196
p_sym,
189197
io_sys = ModelingToolkit.generate_control_function(
190-
model_with_disturbance, inputs, disturbance_inputs; disturbance_argument = true)
198+
model_with_disturbance, inputs; known_disturbance_inputs = disturbance_inputs)
191199
192200
g = ModelingToolkit.build_explicit_observed_function(
193201
io_sys, outputs; inputs)
@@ -196,7 +204,7 @@ op = ModelingToolkit.inputs(io_sys) .=> 0
196204
x0 = ModelingToolkit.get_u0(io_sys, op)
197205
p = MTKParameters(io_sys, op)
198206
u = zeros(1) # Control input
199-
w = zeros(length(disturbance_inputs)) # Disturbance input
207+
w = zeros(length(disturbance_inputs)) # Disturbance input (known disturbances are provided as arguments)
200208
@test f_oop(x0, u, p, t, w) == zeros(5)
201209
@test g(x0, u, p, 0.0) == [0, 0, 0, 0]
202210

src/inputoutput.jl

Lines changed: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -156,28 +156,35 @@ has_var(ex, x) = x ∈ Set(get_variables(ex))
156156
"""
157157
(f_oop, f_ip), x_sym, p_sym, io_sys = generate_control_function(
158158
sys::System,
159-
inputs = unbound_inputs(sys),
160-
disturbance_inputs = disturbances(sys);
161-
implicit_dae = false,
162-
simplify = false,
163-
split = true,
159+
inputs = unbound_inputs(sys),
160+
disturbance_inputs = disturbances(sys);
161+
known_disturbance_inputs = nothing,
162+
implicit_dae = false,
163+
simplify = false,
164+
split = true,
164165
)
165166
166167
For a system `sys` with inputs (as determined by [`unbound_inputs`](@ref) or user specified), generate functions with additional input argument `u`
167168
168169
The returned functions are the out-of-place (`f_oop`) and in-place (`f_ip`) forms:
169170
```
170-
f_oop : (x,u,p,t) -> rhs
171-
f_ip : (xout,x,u,p,t) -> nothing
171+
f_oop : (x,u,p,t) -> rhs # basic form
172+
f_oop : (x,u,p,t,w) -> rhs # with known_disturbance_inputs
173+
f_ip : (xout,x,u,p,t) -> nothing # basic form
174+
f_ip : (xout,x,u,p,t,w) -> nothing # with known_disturbance_inputs
172175
```
173176
174177
The return values also include the chosen state-realization (the remaining unknowns) `x_sym` and parameters, in the order they appear as arguments to `f`.
175178
176-
If `disturbance_inputs` is an array of variables, the generated dynamics function will preserve any state and dynamics associated with disturbance inputs, but the disturbance inputs themselves will (by default) not be included as inputs to the generated function. The use case for this is to generate dynamics for state observers that estimate the influence of unmeasured disturbances, and thus require unknown variables for the disturbance model, but without disturbance inputs since the disturbances are not available for measurement. To add an input argument corresponding to the disturbance inputs, either include the disturbance inputs among the control inputs, or set `disturbance_argument=true`, in which case an additional input argument `w` is added to the generated function `(x,u,p,t,w)->rhs`.
179+
# Disturbance Handling
180+
181+
- `disturbance_inputs`: Unknown disturbance inputs. The generated dynamics will preserve any state and dynamics associated with these disturbances, but the disturbance inputs themselves will not be included as function arguments. This is useful for state observers that estimate unmeasured disturbances.
182+
183+
- `known_disturbance_inputs`: Known disturbance inputs. The generated dynamics will preserve state and dynamics, and the disturbance inputs will be added as an additional input argument `w` to the generated function: `(x,u,p,t,w)->rhs`.
177184
178185
# Example
179186
180-
```
187+
```julia
181188
using ModelingToolkit: generate_control_function, varmap_to_vars, defaults
182189
f, x_sym, ps = generate_control_function(sys, expression=Val{false}, simplify=false)
183190
p = varmap_to_vars(defaults(sys), ps)
@@ -188,6 +195,7 @@ f[1](x, inputs, p, t)
188195
"""
189196
function generate_control_function(sys::AbstractSystem, inputs = unbound_inputs(sys),
190197
disturbance_inputs = disturbances(sys);
198+
known_disturbance_inputs = nothing,
191199
disturbance_argument = false,
192200
implicit_dae = false,
193201
simplify = false,
@@ -196,30 +204,55 @@ function generate_control_function(sys::AbstractSystem, inputs = unbound_inputs(
196204
split = true,
197205
kwargs...)
198206
isempty(inputs) && @warn("No unbound inputs were found in system.")
207+
208+
# Handle backward compatibility for disturbance_argument
209+
if disturbance_argument
210+
Base.depwarn("The `disturbance_argument` keyword argument is deprecated. Use `known_disturbance_inputs` instead. " *
211+
"For `disturbance_argument=true`, pass `known_disturbance_inputs=disturbance_inputs, disturbance_inputs=nothing`. " *
212+
"For `disturbance_argument=false`, use `disturbance_inputs` as before.",
213+
:generate_control_function)
214+
if known_disturbance_inputs !== nothing
215+
error("Cannot specify both `disturbance_argument=true` and `known_disturbance_inputs`")
216+
end
217+
known_disturbance_inputs = disturbance_inputs
218+
disturbance_inputs = nothing
219+
end
220+
221+
# Collect all disturbance inputs for mtkcompile
222+
all_disturbances = vcat(
223+
disturbance_inputs === nothing ? [] : disturbance_inputs,
224+
known_disturbance_inputs === nothing ? [] : known_disturbance_inputs
225+
)
226+
199227
if !isscheduled(sys)
200-
sys = mtkcompile(sys; inputs, disturbance_inputs, split)
228+
sys = mtkcompile(sys; inputs, disturbance_inputs=all_disturbances, split)
201229
end
202-
if disturbance_inputs !== nothing
203-
# add to inputs for the purposes of io processing
204-
inputs = [inputs; disturbance_inputs]
230+
231+
# Add all disturbances to inputs for the purposes of io processing
232+
if !isempty(all_disturbances)
233+
inputs = [inputs; all_disturbances]
205234
end
206235

207236
dvs = unknowns(sys)
208237
ps = parameters(sys; initial_parameters = true)
209238
ps = setdiff(ps, inputs)
239+
240+
# Remove unknown disturbances from inputs (we don't want them as actual inputs to the dynamics)
210241
if disturbance_inputs !== nothing
211-
# remove from inputs since we do not want them as actual inputs to the dynamics
212242
inputs = setdiff(inputs, disturbance_inputs)
213-
# ps = [ps; disturbance_inputs]
214243
end
244+
215245
inputs = map(value, inputs)
216-
disturbance_inputs = unwrap.(disturbance_inputs)
246+
247+
# Prepare disturbance arrays for substitution and function arguments
248+
unknown_disturbances = disturbance_inputs === nothing ? [] : unwrap.(disturbance_inputs)
249+
known_disturbances = known_disturbance_inputs === nothing ? [] : unwrap.(known_disturbance_inputs)
217250

218251
eqs = [eq for eq in full_equations(sys)]
219252

220-
if disturbance_inputs !== nothing && !disturbance_argument
221-
# Set all disturbance *inputs* to zero (we just want to keep the disturbance state)
222-
subs = Dict(disturbance_inputs .=> 0)
253+
# Set unknown disturbance inputs to zero (we just want to keep the disturbance state)
254+
if !isempty(unknown_disturbances)
255+
subs = Dict(unknown_disturbances .=> 0)
223256
eqs = [eq.lhs ~ substitute(eq.rhs, subs) for eq in eqs]
224257
end
225258
check_operator_variables(eqs, Differential)
@@ -231,8 +264,9 @@ function generate_control_function(sys::AbstractSystem, inputs = unbound_inputs(
231264
p = reorder_parameters(sys, ps)
232265
t = get_iv(sys)
233266

234-
if disturbance_argument
235-
args = (dvs, inputs, p..., t, disturbance_inputs)
267+
# Construct args with known disturbances if provided
268+
if !isempty(known_disturbances)
269+
args = (dvs, inputs, p..., t, known_disturbances)
236270
else
237271
args = (dvs, inputs, p..., t)
238272
end
@@ -245,7 +279,8 @@ function generate_control_function(sys::AbstractSystem, inputs = unbound_inputs(
245279
f = eval_or_rgf.(f; eval_expression, eval_module)
246280
f = GeneratedFunctionWrapper{(
247281
3 + implicit_dae, length(args) - length(p) + 1, is_split(sys))}(f...)
248-
ps = setdiff(parameters(sys), inputs, disturbance_inputs)
282+
# Return parameters excluding both control inputs and all disturbances
283+
ps = setdiff(parameters(sys), inputs, all_disturbances)
249284
(; f = (f, f), dvs, ps, io_sys = sys)
250285
end
251286

src/systems/codegen.jl

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -982,6 +982,8 @@ Generates a function that computes the observed value(s) `ts` in the system `sys
982982
- `output_type = Array` the type of the array generated by a out-of-place vector-valued function
983983
- `param_only = false` if true, only allow the generated function to access system parameters
984984
- `inputs = nothing` additinoal symbolic variables that should be provided to the generated function
985+
- `disturbance_inputs = nothing` symbolic variables representing unknown disturbance inputs (removed from parameters, not added as function arguments)
986+
- `known_disturbance_inputs = nothing` symbolic variables representing known disturbance inputs (removed from parameters, added as function arguments)
985987
- `checkbounds = true` checks bounds if true when destructuring parameters
986988
- `op = Operator` sets the recursion terminator for the walk done by `vars` to identify the variables that appear in `ts`. See the documentation for `vars` for more detail.
987989
- `throw = true` if true, throw an error when generating a function for `ts` that reference variables that do not exist.
@@ -1005,16 +1007,18 @@ The signatures will be of the form `g(...)` with arguments:
10051007
10061008
- `output` for in-place functions
10071009
- `unknowns` if `param_only` is `false`
1008-
- `inputs` if `inputs` is an array of symbolic inputs that should be available in `ts`
1010+
- `inputs` if `inputs` is an array of symbolic inputs that should be available in `ts`
10091011
- `p...` unconditionally; note that in the case of `MTKParameters` more than one parameters argument may be present, so it must be splatted
10101012
- `t` if the system is time-dependent; for example systems of nonlinear equations will not have `t`
1013+
- `known_disturbance_inputs` if provided; these are disturbance inputs that are known and provided as arguments
10111014
1012-
For example, a function `g(op, unknowns, p..., inputs, t)` will be the in-place function generated if `return_inplace` is true, `ts` is a vector,
1013-
an array of inputs `inputs` is given, and `param_only` is false for a time-dependent system.
1015+
For example, a function `g(op, unknowns, p..., inputs, t, known_disturbances)` will be the in-place function generated if `return_inplace` is true, `ts` is a vector,
1016+
an array of inputs `inputs` is given, `known_disturbance_inputs` is provided, and `param_only` is false for a time-dependent system.
10141017
"""
10151018
function build_explicit_observed_function(sys, ts;
10161019
inputs = nothing,
10171020
disturbance_inputs = nothing,
1021+
known_disturbance_inputs = nothing,
10181022
disturbance_argument = false,
10191023
expression = false,
10201024
eval_expression = false,
@@ -1095,22 +1099,36 @@ function build_explicit_observed_function(sys, ts;
10951099
ps = setdiff(ps, inputs) # Inputs have been converted to parameters by io_preprocessing, remove those from the parameter list
10961100
inputs = (inputs,)
10971101
end
1102+
# Handle backward compatibility for disturbance_argument
1103+
if disturbance_argument
1104+
Base.depwarn("The `disturbance_argument` keyword argument is deprecated. Use `known_disturbance_inputs` instead. " *
1105+
"For `disturbance_argument=true`, pass `known_disturbance_inputs=disturbance_inputs, disturbance_inputs=nothing`. " *
1106+
"For `disturbance_argument=false`, use `disturbance_inputs` as before.",
1107+
:build_explicit_observed_function)
1108+
if known_disturbance_inputs !== nothing
1109+
error("Cannot specify both `disturbance_argument=true` and `known_disturbance_inputs`")
1110+
end
1111+
known_disturbance_inputs = disturbance_inputs
1112+
disturbance_inputs = nothing
1113+
end
1114+
1115+
# Remove disturbance inputs from parameters (both known and unknown)
10981116
if disturbance_inputs !== nothing
1099-
# Disturbance inputs may or may not be included as inputs, depending on disturbance_argument
11001117
ps = setdiff(ps, disturbance_inputs)
11011118
end
1102-
if disturbance_argument
1103-
disturbance_inputs = (disturbance_inputs,)
1119+
if known_disturbance_inputs !== nothing
1120+
ps = setdiff(ps, known_disturbance_inputs)
1121+
known_disturbance_inputs = (known_disturbance_inputs,)
11041122
else
1105-
disturbance_inputs = ()
1123+
known_disturbance_inputs = ()
11061124
end
11071125
ps = reorder_parameters(sys, ps)
11081126
iv = if is_time_dependent(sys)
11091127
(get_iv(sys),)
11101128
else
11111129
()
11121130
end
1113-
args = (dvs..., inputs..., ps..., iv..., disturbance_inputs...)
1131+
args = (dvs..., inputs..., ps..., iv..., known_disturbance_inputs...)
11141132
p_start = length(dvs) + length(inputs) + 1
11151133
p_end = length(dvs) + length(inputs) + length(ps)
11161134
fns = build_function_wrapper(

test/downstream/test_disturbance_model.jl

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,10 @@ measurement2 = ModelingToolkit.build_explicit_observed_function(
167167
io_sys, [io_sys.y.output.u], inputs = ModelingToolkit.inputs(io_sys)[1:1],
168168
disturbance_inputs = ModelingToolkit.inputs(io_sys)[2:end],
169169
disturbance_argument = true)
170+
# Test new interface with known_disturbance_inputs (equivalent to above)
171+
measurement3 = ModelingToolkit.build_explicit_observed_function(
172+
io_sys, [io_sys.y.output.u], inputs = ModelingToolkit.inputs(io_sys)[1:1],
173+
known_disturbance_inputs = ModelingToolkit.inputs(io_sys)[2:end])
170174

171175
op = ModelingToolkit.inputs(io_sys) .=> 0
172176
x0 = ModelingToolkit.get_u0(io_sys, op)
@@ -177,21 +181,25 @@ d = zeros(3)
177181
@test f[1](x, u, p, t, d) == zeros(5)
178182
@test measurement(x, u, p, 0.0) == [0, 0, 0, 0]
179183
@test measurement2(x, u, p, 0.0, d) == [0]
184+
@test measurement3(x, u, p, 0.0, d) == [0] # Test new interface
180185

181186
# Add to the integrating disturbance input
182187
d = [1, 0, 0]
183188
@test sort(f[1](x, u, p, 0.0, d)) == [0, 0, 0, 1, 1] # Affects disturbance state and one velocity
184189
@test measurement2(x, u, p, 0.0, d) == [0]
190+
@test measurement3(x, u, p, 0.0, d) == [0] # Test new interface
185191

186192
d = [0, 1, 0]
187193
@test sort(f[1](x, u, p, 0.0, d)) == [0, 0, 0, 0, 1] # Affects one velocity
188194
@test measurement(x, u, p, 0.0) == [0, 0, 0, 0]
189195
@test measurement2(x, u, p, 0.0, d) == [0]
196+
@test measurement3(x, u, p, 0.0, d) == [0] # Test new interface
190197

191198
d = [0, 0, 1]
192199
@test sort(f[1](x, u, p, 0.0, d)) == [0, 0, 0, 0, 0] # Affects nothing
193200
@test measurement(x, u, p, 0.0) == [0, 0, 0, 0]
194201
@test measurement2(x, u, p, 0.0, d) == [1] # We have now disturbed the output
202+
@test measurement3(x, u, p, 0.0, d) == [1] # Test new interface
195203

196204
## Further downstream tests that the functions generated above actually have the properties required to use for state estimation
197205
#

test/input_output_handling.jl

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,53 @@ end
216216
u = [rand()]
217217
d = [rand()]
218218
@test f[1](x, u, p, t, d) -x + u + [d[]^2]
219+
220+
## Test new known_disturbance_inputs parameter (equivalent to disturbance_argument=true)
221+
@variables x(t)=0 u(t)=0 [input=true] d(t)=0
222+
eqs = [
223+
D(x) ~ -x + u + d^2
224+
]
225+
226+
@named sys = System(eqs, t)
227+
f, dvs,
228+
ps,
229+
io_sys = ModelingToolkit.generate_control_function(
230+
sys, [u];
231+
known_disturbance_inputs = [d],
232+
simplify, split)
233+
234+
@test isequal(dvs[], x)
235+
@test isempty(ps)
236+
237+
p = [rand()]
238+
x = [rand()]
239+
u = [rand()]
240+
d = [rand()]
241+
@test f[1](x, u, p, t, d) -x + u + [d[]^2]
242+
243+
## Test mixed known and unknown disturbances
244+
@variables x(t)=0 u(t)=0 [input=true] d1(t)=0 d2(t)=0
245+
eqs = [
246+
D(x) ~ -x + u + d1^2 + d2^3
247+
]
248+
249+
@named sys = System(eqs, t)
250+
f, dvs,
251+
ps,
252+
io_sys = ModelingToolkit.generate_control_function(
253+
sys, [u], [d1]; # d1 is unknown (set to zero)
254+
known_disturbance_inputs = [d2], # d2 is known (function argument)
255+
simplify, split)
256+
257+
@test isequal(dvs[], x)
258+
@test isempty(ps)
259+
260+
p = [rand()]
261+
x = [rand()]
262+
u = [rand()]
263+
d2_val = [rand()]
264+
# d1 is set to zero, so only u and d2 affect the dynamics
265+
@test f[1](x, u, p, t, d2_val) -x + u + [d2_val[]^3]
219266
end
220267
end
221268

0 commit comments

Comments
 (0)