Skip to content

Commit 070a375

Browse files
authored
add stable noise sources and tutorial (#11)
1 parent 68122c4 commit 070a375

File tree

8 files changed

+269
-23
lines changed

8 files changed

+269
-23
lines changed

Project.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,24 @@ authors = ["Fredrik Bagge Carlson"]
44
version = "0.1.0"
55

66
[deps]
7+
DiffEqCallbacks = "459566f4-90b8-5000-8ac3-15dfb0a30def"
78
JuliaSimCompiler = "8391cb6b-4921-5777-4e45-fd9aab8cb88d"
89
ModelingToolkit = "961ee093-0014-501f-94e3-6117800e7a78"
910
ModelingToolkitStandardLibrary = "16a59e39-deab-5bd0-87e4-056b12336739"
1011
OrdinaryDiffEq = "1dea7af3-3e70-54e6-95c3-0bf5283fa5ed"
1112
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
13+
StableRNGs = "860ef19b-820b-49d6-a774-d7a799459cd3"
1214

1315
[compat]
16+
DiffEqCallbacks = "~3.8"
1417
julia = "1.10"
18+
StableRNGs = "1"
19+
JuliaSimCompiler = "0.1.19"
20+
ModelingToolkit = "9"
21+
ModelingToolkitStandardLibrary = "2"
22+
OrdinaryDiffEq = "6.89"
23+
Random = "1"
24+
1525

1626
[extras]
1727
ControlSystemsBase = "aaaaaaaa-a6ca-5380-bf3e-84a91bcd477e"

docs/Project.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
[deps]
2+
ControlSystemIdentification = "3abffc1c-5106-53b7-b354-a47bfc086282"
23
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
34
JuliaSimCompiler = "8391cb6b-4921-5777-4e45-fd9aab8cb88d"
45
ModelingToolkit = "961ee093-0014-501f-94e3-6117800e7a78"

docs/make.jl

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ makedocs(;
2626
pages = [
2727
"Home" => "index.md",
2828
"Tutorials" => [
29-
"Getting started with Sampled-Data Systems" => "SampledData.md",
29+
"Getting started with Sampled-Data Systems" => "tutorials/SampledData.md",
30+
"Noise" => "tutorials/noise.md",
3031
],
3132
"Examples" => [
3233
"Controlled DC motor" => "examples/dc_motor_pi.md",

docs/src/SampledData.md renamed to docs/src/tutorials/SampledData.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,22 @@ A sampled-data system contains both continuous-time and discrete-time components
1414
## Clocks, operators and difference equations
1515
A clock can be seen as an *event source*, i.e., when the clock ticks, an event is generated. In response to the event the discrete-time logic is executed, for example, a control signal is computed. For basic modeling of sampled-data systems, the user does not have to interact with clocks explicitly, instead, the modeling is performed using the operators
1616

17-
- [`Sample`](@ref)
18-
- [`Hold`](@ref)
19-
- [`ShiftIndex`](@ref)
17+
- [`ModelingToolkit.Sample`](@ref)
18+
- [`ModelingToolkit.Hold`](@ref)
19+
- [`ModelingToolkit.ShiftIndex`](@ref)
20+
21+
or their corresponding components
22+
23+
- [`ModelingToolkitSampledData.Sampler`](@ref)
24+
- [`ModelingToolkitSampledData.ZeroOrderHold`](@ref)
2025

2126
When a continuous-time variable `x` is sampled using `xd = Sample(x, dt)`, the result is a discrete-time variable `xd` that is defined and updated whenever the clock ticks. `xd` is *only defined when the clock ticks*, which it does with an interval of `dt`. If `dt` is unspecified, the tick rate of the clock associated with `xd` is inferred from the context in which `xd` appears. Any variable taking part in the same equation as `xd` is inferred to belong to the same *discrete partition* as `xd`, i.e., belonging to the same clock. A system may contain multiple different discrete-time partitions, each with a unique clock. This allows for modeling of multi-rate systems and discrete-time processes located on different computers etc.
2227

23-
To make a discrete-time variable available to the continuous partition, the [`Hold`](@ref) operator is used. `xc = Hold(xd)` creates a continuous-time variable `xc` that is updated whenever the clock associated with `xd` ticks, and holds its value constant between ticks.
28+
To make a discrete-time variable available to the continuous partition, the `Hold` operator is used. `xc = Hold(xd)` creates a continuous-time variable `xc` that is updated whenever the clock associated with `xd` ticks, and holds its value constant between ticks.
2429

25-
The operators [`Sample`](@ref) and [`Hold`](@ref) are thus providing the interface between continuous and discrete partitions.
30+
The operators `Sample` and `Hold` are thus providing the interface between continuous and discrete partitions.
2631

27-
The [`ShiftIndex`](@ref) operator is used to refer to past and future values of discrete-time variables. The example below illustrates its use, implementing the discrete-time system
32+
The `ShiftIndex` operator is used to refer to past and future values of discrete-time variables. The example below illustrates its use, implementing the discrete-time system
2833

2934
```math
3035
x(k+1) = 0.5x(k) + u(k)
@@ -48,7 +53,7 @@ eqs = [
4853
A few things to note in this basic example:
4954

5055
- The equation `x(k+1) = 0.5x(k) + u(k)` has been rewritten in terms of negative shifts since positive shifts are not yet supported.
51-
- `x` and `u` are automatically inferred to be discrete-time variables, since they appear in an equation with a discrete-time [`ShiftIndex`](@ref) `k`.
56+
- `x` and `u` are automatically inferred to be discrete-time variables, since they appear in an equation with a discrete-time `ShiftIndex` `k`.
5257
- `y` is also automatically inferred to be a discrete-time-time variable, since it appears in an equation with another discrete-time variable `x`. `x,u,y` all belong to the same discrete-time partition, i.e., they are all updated at the same *instantaneous point in time* at which the clock ticks.
5358
- The equation `y ~ x` does not use any shift index, this is equivalent to `y(k) ~ x(k)`, i.e., discrete-time variables without shift index are assumed to refer to the variable at the current time step.
5459
- The equation `x(k) ~ 0.5x(k-1) + u(k-1)` indicates how `x` is updated, i.e., what the value of `x` will be at the *current* time step in terms of the *past* value. The output `y`, is given by the value of `x` at the *current* time step, i.e., `y(k) ~ x(k)`. If this logic was implemented in an imperative programming style, the logic would thus be
@@ -106,11 +111,10 @@ eqs = [
106111
]
107112
```
108113

109-
(see also [ModelingToolkitStandardLibrary](https://docs.sciml.ai/ModelingToolkitStandardLibrary/stable/) for a discrete-time transfer-function component.)
110114

111115
## Initial conditions
112116

113-
The initial condition of discrete-time variables is defined using the [`ShiftIndex`](@ref) operator, for example
117+
The initial condition of discrete-time variables is defined using the `ShiftIndex` operator, for example
114118

115119
```julia
116120
ODEProblem(model, [x(k-1) => 1.0], (0.0, 10.0))

docs/src/tutorials/noise.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Measurement noise and corruption
2+
Measurement noise is practically always present in signals originating from real-world sensors. In a sampled-data system, analyzing the influence of measurement noise using simulation is relatively straight forward. Below, we add Gaussian white noise to the speed sensor signal in the DC motor example. The noise is added using the [`NormalNoise`](@ref) block.
3+
4+
This block has two modes of operation
5+
1. If `additive = false` (default), the block has the connector `output` only, and this output is the noise signal.
6+
2. If `additive = true`, the block has the connectors `input` and `output`, and the output is the sum of the input and the noise signal, i.e., the noise is _added_ to the input signal. This mode makes it convenient to add noise to a signal in a sampled-data system.
7+
8+
## Example: Noise
9+
```@example NOISE
10+
using ModelingToolkit
11+
using ModelingToolkit: t_nounits as t
12+
using ModelingToolkitStandardLibrary.Electrical
13+
using ModelingToolkitStandardLibrary.Mechanical.Rotational
14+
using ModelingToolkitStandardLibrary.Blocks
15+
using ModelingToolkitSampledData
16+
using JuliaSimCompiler
17+
using OrdinaryDiffEq
18+
using Plots
19+
20+
R = 0.5 # [Ohm] armature resistance
21+
L = 4.5e-3 # [H] armature inductance
22+
k = 0.5 # [N.m/A] motor constant
23+
J = 0.02 # [kg.m²] inertia
24+
f = 0.01 # [N.m.s/rad] friction factor
25+
tau_L_step = -0.3 # [N.m] amplitude of the load torque step
26+
nothing # hide
27+
28+
z = ShiftIndex()
29+
30+
@mtkmodel NoisyClosedLoop begin
31+
@components begin
32+
ground = Ground()
33+
source = Voltage()
34+
ref = Blocks.Step(height = 1, start_time = 0, smooth = false)
35+
sampler = Sampler(dt = 0.002)
36+
noise = NormalNoise(sigma = 0.1, additive = true)
37+
pi_controller = DiscretePIDStandard(
38+
K = 1, Ti = 0.035, u_max = 10, with_D = false)
39+
zoh = ZeroOrderHold()
40+
R1 = Resistor(R = R)
41+
L1 = Inductor(L = L)
42+
emf = EMF(k = k)
43+
fixed = Fixed()
44+
load = Torque()
45+
load_step = Blocks.Step(height = tau_L_step, start_time = 1.3)
46+
inertia = Inertia(J = J)
47+
friction = Damper(d = f)
48+
speed_sensor = SpeedSensor()
49+
angle_sensor = AngleSensor()
50+
end
51+
52+
@equations begin
53+
connect(fixed.flange, emf.support, friction.flange_b)
54+
connect(emf.flange, friction.flange_a, inertia.flange_a)
55+
connect(inertia.flange_b, load.flange)
56+
connect(inertia.flange_b, speed_sensor.flange, angle_sensor.flange)
57+
connect(load_step.output, load.tau)
58+
connect(ref.output, pi_controller.reference)
59+
connect(speed_sensor.w, sampler.input)
60+
connect(sampler.output, noise.input)
61+
connect(noise.output, pi_controller.measurement)
62+
connect(pi_controller.ctr_output, zoh.input)
63+
connect(zoh.output, source.V)
64+
connect(source.p, R1.p)
65+
connect(R1.n, L1.p)
66+
connect(L1.n, emf.p)
67+
connect(emf.n, source.n, ground.g)
68+
end
69+
end
70+
71+
72+
@named noisy_model = NoisyClosedLoop()
73+
noisy_model = complete(noisy_model)
74+
ssys = structural_simplify(IRSystem(noisy_model)) # Conversion to an IRSystem from JuliaSimCompiler is required for sampled-data systems
75+
76+
noise_prob = ODEProblem(ssys, [unknowns(noisy_model) .=> 0.0; noisy_model.pi_controller.I(z-1) => 0; noisy_model.pi_controller.eI(z-1) => 0; noisy_model.noise.y(z-1) => 0], (0, 2.0))
77+
noise_sol = solve(noise_prob, Tsit5())
78+
79+
figy = plot(noise_sol, idxs=noisy_model.noise.y, label = "Measured speed", )
80+
plot!(noise_sol, idxs=noisy_model.inertia.w, ylabel = "Angular Vel. [rad/s]",
81+
label = "Actual speed", legend=:bottomleft, dpi=600, l=(2, :blue))
82+
figu = plot(noise_sol, idxs=noisy_model.source.V.u, label = "Control signal [V]", )
83+
plot(figy, figu, plot_title = "DC Motor with Discrete-time Speed Controller")
84+
```
85+
86+
## Linear analysis of noise
87+
Propagation of Gaussian noise through linear time-invariant systems is well understood, the stationary covariance of the output can be computed by solving a Lyapunov equation. Unfortunately, ModelingToolkit models that contain both continuous time and discrete time components cannot yet be linearized and linear analysis is thus made slightly harder. Below, we instead use a data-driven linearization approach where we use recorded signals from the simulation and fit a linear model using subspace-based identification. The function `subspaceid` below is provided by the package [ControlSystemIdentification.jl](https://baggepinnen.github.io/ControlSystemIdentification.jl/stable/).
88+
89+
We let the angular velocity of the inertia be the output, and the output of the noise block as well as the output of the load disturbance be the inputs.
90+
91+
```@example NOISE
92+
using ControlSystemIdentification, ControlSystemsBase
93+
Tf = 20
94+
prob2 = remake(noise_prob, p=Dict(noisy_model.load_step.height=>0.0), tspan=(0.0, Tf))
95+
noise_sol = solve(prob2, Tsit5())
96+
tv = 0:0.002:Tf
97+
y = noise_sol(tv, idxs=noisy_model.inertia.w) |> vec
98+
un = noise_sol(tv, idxs=noisy_model.noise.y)-y |> vec
99+
ud = noise_sol(tv, idxs=noisy_model.load_step.output.u) |> vec
100+
d = iddata(y', [un ud]', 0.002)
101+
lsys,_ = newpem(d, 4, focus=:simulation, zeroD=false)
102+
```
103+
With an LTI model available, we can ask for the theoretical output covariance we should obtain if we feed a white noise signal with covariance matrix ``0.1^2 I`` through the noise input of the system. We compare this to the actual output covariance obtained from the simulation (discarding the initial transient as well as the transient caused by the load disturbance).
104+
```@example NOISE
105+
sqrt(covar(lsys[1,1],0.1^2*I)), std(y[[50:648; 750:end]])
106+
```
107+
108+
## Noise filtering
109+
No discrete-time filter components are available yet. You may, e.g.
110+
- Add exponential filtering using `xf(k) ~ (1-α)xf(k-1) + α*x(k)`, where `α` is the filter coefficient and `x` is the signal to be filtered.
111+
- Add moving average filtering using `xf(k) ~ 1/N sum(i->x(k-i), i=0:N-1)`, where `N` is the number of samples to average over.
112+
113+
## Colored noise
114+
Colored noise can be achieved by filtering white noise through a filter with the desired spectrum. No components are available for this yet.
115+
116+
## Internal details
117+
Internally, a random number generator from [StableRNGs.jl](https://github.com/JuliaRandom/StableRNGs.jl) is used to produce reproducible streams of random numbers. Each draw of a random number is seeded by `hash(t, hash(seed))`, where `seed` is a parameter in the noise source component, and `t` is the current simulation time. This ensures that
118+
1. The user can alter the stream of random numbers with `seed`.
119+
2. Multiple calls to the random number generator at the same time step all return the same number.
120+
121+
## Quantization
122+
Not yet available.

src/ModelingToolkitSampledData.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
module ModelingToolkitSampledData
22
using ModelingToolkit
33
using JuliaSimCompiler
4+
using StableRNGs
45

56
export get_clock
67
export DiscreteIntegrator, DiscreteDerivative, Delay, Difference, ZeroOrderHold, Sampler,

src/discrete_blocks.jl

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -144,35 +144,68 @@ A discrete-time noise source that returns a normally-distributed value at each c
144144
# Parameters
145145
- `mu = 0`: Mean
146146
- `sigma = 1`: Standard deviation
147+
- `seed = 1`: Seed for the random number generator
147148
148149
# Structural parameters
149-
- `rng`: A random number generator, defaults to `Random.Xoshiro()`.
150150
- `z`: The `ShiftIndex` used to indicate clock partition.
151151
152152
# Connectors:
153153
- `output`
154154
"""
155155
@mtkmodel NormalNoise begin
156+
@structural_parameters begin
157+
z = ShiftIndex()
158+
additive = false
159+
end
156160
@components begin
157161
output = RealOutput()
162+
if additive
163+
input = RealInput()
164+
end
158165
end
159166
@variables begin
160167
y(t), [description = "Output variable"]
168+
if additive
169+
u(t), [description = "Input variable"]
170+
end
171+
n(t), [description = "Internal noise variable"]
161172
end
162173
@parameters begin
163174
mu = 0
164175
sigma = 1
165-
end
166-
@structural_parameters begin
167-
z = ShiftIndex()
168-
rng = Random.Xoshiro()
176+
seed = 1
169177
end
170178
@equations begin
171-
y(z) ~ mu + sigma*Symbolics.term(randn, rng; type=Real)
172179
output.u ~ y
180+
n(z) ~ mu + sigma*Symbolics.term(seeded_randn, seed, t; type=Real)
181+
# n(z) ~ mu + sigma*Symbolics.term(randn, rng; type=Real)
182+
if additive
183+
y(z) ~ u(z) + n(z) + 1e-100*y(z-1) # The 0*y(z-1) is a workaround for a bug in the compiler, to force the y variable to be a discrete-time state variable
184+
u ~ input.u
185+
else
186+
y(z) ~ n(z) + 1e-100*y(z-1)
187+
end
173188
end
174189
end
175190

191+
"""
192+
seeded_randn(seed, t)
193+
194+
Internal function. This function seeds the seed parameter as well as the current simulation time.
195+
"""
196+
function seeded_randn(seed, t)
197+
rng = StableRNGs.StableRNG(hash(t, hash(seed)))
198+
randn(rng)
199+
end
200+
"""
201+
seeded_rand(seed, t)
202+
203+
Internal function. This function seeds the seed parameter as well as the current simulation time.
204+
"""
205+
function seeded_rand(seed, t)
206+
rng = StableRNGs.StableRNG(hash(t, hash(seed)))
207+
rand(rng)
208+
end
176209

177210
"""
178211
UniformNoise()
@@ -182,6 +215,7 @@ A discrete-time noise source that returns a uniformly distributed value at each
182215
# Parameters
183216
- `l = 0`: Lower bound
184217
- `u = 1`: Upper bound
218+
- `seed = 1`: Seed for the random number generator
185219
186220
# Structural parameters
187221
- `rng`: A random number generator, defaults to `Random.Xoshiro()`.
@@ -191,26 +225,70 @@ A discrete-time noise source that returns a uniformly distributed value at each
191225
- `output`
192226
"""
193227
@mtkmodel UniformNoise begin
228+
@structural_parameters begin
229+
z = ShiftIndex()
230+
rng = Random.Xoshiro()
231+
additive = false
232+
end
194233
@components begin
195234
output = RealOutput()
235+
if additive
236+
input = RealInput()
237+
end
196238
end
197239
@variables begin
198240
y(t), [description = "Output variable"]
241+
n(t), [description = "Internal noise variable"]
199242
end
200243
@parameters begin
201244
l = 0
202245
u = 1
203-
end
204-
@structural_parameters begin
205-
z = ShiftIndex()
206-
rng = Random.Xoshiro()
246+
seed = 1
207247
end
208248
@equations begin
209-
y(z) ~ l + (u-l)*Symbolics.term(rand, rng; type=Real)
210249
output.u ~ y
250+
n(z) ~ l + (u-l)*Symbolics.term(seeded_rand, seed, t; type=Real)
251+
# y(z) ~ l + (u-l)*Symbolics.term(rand, rng; type=Real)
252+
253+
if additive
254+
y(z) ~ input.u(z) + n(z) + 1e-100*y(z-1) # The 0*y(z-1) is a workaround for a bug in the compiler, to force the y variable to be a discrete-time state variable
255+
else
256+
y(z) ~ n(z) + 1e-100*y(z-1)
257+
end
211258
end
212259
end
213260

261+
"""
262+
GenericNoise()
263+
264+
A discrete-time noise source that at each clock tick returns a random value distributed according to the provided distribution.
265+
266+
# Structural parameters
267+
- `rng`: A random number generator, defaults to `Random.Xoshiro()`.
268+
- `z`: The `ShiftIndex` used to indicate clock partition.
269+
- `d`: The distribution to sample from.`
270+
271+
# Connectors:
272+
- `output`
273+
"""
274+
# @mtkmodel GenericNoise begin
275+
# @components begin
276+
# output = RealOutput()
277+
# end
278+
# @variables begin
279+
# y(t), [description = "Output variable"]
280+
# end
281+
# @structural_parameters begin
282+
# z = ShiftIndex()
283+
# rng = Random.Xoshiro()
284+
# d
285+
# end
286+
# @equations begin
287+
# y(z) ~ Symbolics.term(rand, rng, d; type=Real)
288+
# output.u ~ y
289+
# end
290+
# end
291+
214292
"""
215293
ZeroOrderHold()
216294

0 commit comments

Comments
 (0)