device_control 0.2.0.2 → 0.3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +43 -7
- data/VERSION +1 -1
- data/lib/device_control.rb +92 -59
- data/test/device_control.rb +220 -60
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: af9b824c6799b18921c32a5aaaff2438f2552fff2b4d6bb3c72300da0867fb7e
|
4
|
+
data.tar.gz: cffbe03e20d0b3e9bc513d35eba821f8a11b6ea973cf5ea09b4e76eee9ad9b0e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: cf33d6d28e0ae5d2ccfa955ca84305d526e127ba9b4d82c6573b203757f0e2a4996bbf057d8b070853f06b827a71970587d2574fa3d7c17c0f087a2fb9c2ca5f
|
7
|
+
data.tar.gz: 399e0470a633162355183d6a79c76ba368d7f236f4540d4e6c32c953b4f5abc4e7b85e371f2994abf4d58a08b4a8aba4db388cacae3fc141d174455d0488a4c6
|
data/README.md
CHANGED
@@ -205,7 +205,8 @@ end
|
|
205
205
|
```
|
206
206
|
|
207
207
|
A Controller names its input `measure`, and it introduces a `setpoint`, and
|
208
|
-
the difference between setpoint and measure is the error.
|
208
|
+
the difference between setpoint and measure is the error. For now, just output
|
209
|
+
the error.
|
209
210
|
|
210
211
|
#### Thermostat
|
211
212
|
|
@@ -219,11 +220,10 @@ class Thermostat < Controller
|
|
219
220
|
end
|
220
221
|
```
|
221
222
|
|
222
|
-
Notice,
|
223
|
-
(or: *is it too cold?*). You can run it either or both ways,
|
224
|
-
you can simply pick one orientation and remain logically
|
225
|
-
|
226
|
-
Now consider:
|
223
|
+
Notice, this thermostat essentially answers the question: *is it hot enough?*
|
224
|
+
(or equivalently: *is it too cold?*). You can run it either or both ways,
|
225
|
+
but note that you can simply pick one orientation and remain logically
|
226
|
+
consistent; consider:
|
227
227
|
|
228
228
|
```ruby
|
229
229
|
|
@@ -248,9 +248,45 @@ temp = 24.9
|
|
248
248
|
So the **heat knob** goes to 1 when its thermostat goes *below* setpoint.
|
249
249
|
The **cool knob** goes to 1 when its thermostat goes *above* setpoint.
|
250
250
|
|
251
|
+
#### Flexstat
|
252
|
+
|
253
|
+
If we really need a flexible thermostat in terms of output values, consider:
|
254
|
+
|
255
|
+
```ruby
|
256
|
+
class Flexstat < Thermostat
|
257
|
+
def self.cold_val(hot_val)
|
258
|
+
case hot_val
|
259
|
+
when true, false
|
260
|
+
!hot_val
|
261
|
+
when 0,1
|
262
|
+
hot_val == 0 ? 1 : 0
|
263
|
+
when Numeric
|
264
|
+
0
|
265
|
+
when :on, :off
|
266
|
+
hot_val == :on ? :off : :on
|
267
|
+
else
|
268
|
+
raise "#{hot_val.inspect} not recognized"
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
attr_reader :cold_val, :hot_val
|
273
|
+
|
274
|
+
def initialize(setpoint, hot_val: false, cold_val: nil)
|
275
|
+
super(setpoint)
|
276
|
+
|
277
|
+
@hot_val = hot_val
|
278
|
+
@cold_val = cold_val.nil? ? self.class.cold_val(hot_val) : cold_val
|
279
|
+
end
|
280
|
+
|
281
|
+
def output
|
282
|
+
super ? @cold_val : @hot_val
|
283
|
+
end
|
284
|
+
end
|
285
|
+
```
|
286
|
+
|
251
287
|
# Finale
|
252
288
|
|
253
289
|
If you've made it this far, congratulations! For further reading:
|
254
290
|
|
255
|
-
* [lib/device_control.rb](lib/device_control.rb#
|
291
|
+
* [lib/device_control.rb](lib/device_control.rb#L165)
|
256
292
|
* [test/device_control.rb](test/device_control.rb)
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.
|
1
|
+
0.3.0.1
|
data/lib/device_control.rb
CHANGED
@@ -113,6 +113,18 @@ module DeviceControl
|
|
113
113
|
end
|
114
114
|
end
|
115
115
|
|
116
|
+
# now consider e.g.
|
117
|
+
# h = Heater.new(1000)
|
118
|
+
# ht = Thermostat.new(20)
|
119
|
+
# c = Cooler.new(1000)
|
120
|
+
# ct = Thermostat.new(25)
|
121
|
+
# temp = 26.4
|
122
|
+
# heat_knob = ht.update(temp) ? 1 : 0
|
123
|
+
# heating_watts = h.update(heat_knob)
|
124
|
+
# cool_knob = ct.update(temp) ? 0 : 1
|
125
|
+
# cooling_watts = c.update(cool_knob)
|
126
|
+
# etc
|
127
|
+
|
116
128
|
class Flexstat < Thermostat
|
117
129
|
def self.cold_val(hot_val)
|
118
130
|
case hot_val
|
@@ -129,7 +141,11 @@ module DeviceControl
|
|
129
141
|
end
|
130
142
|
end
|
131
143
|
|
132
|
-
|
144
|
+
attr_reader :cold_val, :hot_val
|
145
|
+
|
146
|
+
def initialize(setpoint, hot_val: false, cold_val: nil)
|
147
|
+
super(setpoint)
|
148
|
+
|
133
149
|
@hot_val = hot_val
|
134
150
|
@cold_val = cold_val.nil? ? self.class.cold_val(hot_val) : cold_val
|
135
151
|
end
|
@@ -139,60 +155,17 @@ module DeviceControl
|
|
139
155
|
end
|
140
156
|
end
|
141
157
|
|
142
|
-
#
|
143
|
-
#
|
144
|
-
#
|
145
|
-
#
|
146
|
-
#
|
147
|
-
#
|
148
|
-
# heat_knob = ht.update(temp) ? 1 : 0
|
149
|
-
# heating_watts = h.update(heat_knob)
|
150
|
-
# cool_knob = ct.update(temp) ? 0 : 1
|
151
|
-
# cooling_watts = c.update(cool_knob)
|
152
|
-
# etc
|
153
|
-
|
154
|
-
# A StatefulController tracks its error over time: current, last, accumulated
|
158
|
+
# A PIDController is a Controller that tracks its error over time
|
159
|
+
# in order to calculate:
|
160
|
+
# Proportion (current error)
|
161
|
+
# Integral (accumulated error)
|
162
|
+
# Derivative (error slope, last_error)
|
163
|
+
# The sum of these terms is the output
|
155
164
|
#
|
156
|
-
class
|
165
|
+
class PIDController < Controller
|
157
166
|
HZ = 1000
|
158
167
|
TICK = Rational(1) / HZ
|
159
168
|
|
160
|
-
attr_accessor :dt
|
161
|
-
attr_reader :error, :last_error, :sum_error
|
162
|
-
|
163
|
-
def initialize(setpoint, dt: TICK)
|
164
|
-
super(setpoint)
|
165
|
-
@dt = dt
|
166
|
-
@error, @last_error, @sum_error = 0.0, 0.0, 0.0
|
167
|
-
end
|
168
|
-
|
169
|
-
# update @error, @last_error, and @sum_error
|
170
|
-
def input=(val)
|
171
|
-
@measure = val
|
172
|
-
@last_error = @error
|
173
|
-
@error = @setpoint - @measure
|
174
|
-
if @error * @last_error <= 0 # zero crossing; reset the accumulated error
|
175
|
-
@sum_error = @error * @dt
|
176
|
-
else
|
177
|
-
@sum_error += @error * @dt
|
178
|
-
end
|
179
|
-
end
|
180
|
-
|
181
|
-
def to_s
|
182
|
-
[super,
|
183
|
-
format("Error: %+.3f\tLast: %+.3f\tSum: %+.3f",
|
184
|
-
@error, @last_error, @sum_error),
|
185
|
-
].join("\n")
|
186
|
-
end
|
187
|
-
end
|
188
|
-
|
189
|
-
# A PIDController is a StatefulController that calculates
|
190
|
-
# * Proportion (current error)
|
191
|
-
# * Integral (accumulated error)
|
192
|
-
# * Derivative (error slope, last_error)
|
193
|
-
# The sum of these terms is the output
|
194
|
-
#
|
195
|
-
class PIDController < StatefulController
|
196
169
|
# Ziegler-Nichols method for tuning PID gain knobs
|
197
170
|
# https://en.wikipedia.org/wiki/Ziegler%E2%80%93Nichols_method
|
198
171
|
ZN = {
|
@@ -222,10 +195,21 @@ module DeviceControl
|
|
222
195
|
{ kp: kp, ti: ti, td: td, ki: ki, kd: kd }
|
223
196
|
end
|
224
197
|
|
225
|
-
attr_accessor :
|
198
|
+
attr_accessor :dt, :low_pass_ticks,
|
199
|
+
:error, :last_error, :sum_error,
|
200
|
+
:kp, :ki, :kd,
|
201
|
+
:p_range, :i_range, :d_range, :o_range, :e_range
|
202
|
+
attr_reader :mavg
|
226
203
|
|
227
|
-
def initialize(setpoint, dt: TICK)
|
228
|
-
super
|
204
|
+
def initialize(setpoint, dt: TICK, low_pass_ticks: 0)
|
205
|
+
super(setpoint)
|
206
|
+
@dt = dt
|
207
|
+
@error, @last_error, @sum_error = 0.0, 0.0, 0.0
|
208
|
+
if low_pass_ticks > 0
|
209
|
+
@mavg = MovingAverage.new(low_pass_ticks)
|
210
|
+
else
|
211
|
+
@mavg = nil
|
212
|
+
end
|
229
213
|
|
230
214
|
# gain / multipliers for PID; tunables
|
231
215
|
@kp, @ki, @kd = 1.0, 1.0, 1.0
|
@@ -235,22 +219,43 @@ module DeviceControl
|
|
235
219
|
@i_range = (-Float::INFINITY..Float::INFINITY)
|
236
220
|
@d_range = (-Float::INFINITY..Float::INFINITY)
|
237
221
|
@o_range = (-Float::INFINITY..Float::INFINITY)
|
222
|
+
@e_range = (-Float::INFINITY..Float::INFINITY)
|
238
223
|
|
239
224
|
yield self if block_given?
|
240
225
|
end
|
241
226
|
|
227
|
+
# update @error, @last_error, and @sum_error
|
228
|
+
def input=(val)
|
229
|
+
@measure = val
|
230
|
+
@last_error = @error
|
231
|
+
@error = @setpoint - @measure
|
232
|
+
# Incorporate @ki here for better behavior when @ki is updated
|
233
|
+
# It's a good idea to clamp the accumulated error so that if we start
|
234
|
+
# way under setpoint, we don't accumulate so much error that we spend
|
235
|
+
# too much time overshooting to counteract it
|
236
|
+
@sum_error =
|
237
|
+
(@sum_error + @ki * @error * @dt).clamp(@e_range.begin, @e_range.end)
|
238
|
+
# update mavg here to ensure only one update per PID input
|
239
|
+
@mavg.input = self.derivative if @mavg
|
240
|
+
end
|
241
|
+
|
242
242
|
def output
|
243
|
+
drv = @mavg ? @mavg.output : self.derivative
|
243
244
|
(self.proportion +
|
244
245
|
self.integral +
|
245
|
-
|
246
|
+
drv).clamp(@o_range.begin, @o_range.end)
|
246
247
|
end
|
247
248
|
|
248
249
|
def proportion
|
249
250
|
(@kp * @error).clamp(@p_range.begin, @p_range.end)
|
250
251
|
end
|
251
252
|
|
253
|
+
# It may seem funny to clamp both @sum_error and the integral term, but
|
254
|
+
# we may want different values for these clamps. @e_range is just to
|
255
|
+
# make sure we don't create a mountain to chew through. @i_range gives
|
256
|
+
# additional flexibility for balancing P I & D
|
252
257
|
def integral
|
253
|
-
|
258
|
+
@sum_error.clamp(@i_range.begin, @i_range.end)
|
254
259
|
end
|
255
260
|
|
256
261
|
def derivative
|
@@ -259,6 +264,8 @@ module DeviceControl
|
|
259
264
|
|
260
265
|
def to_s
|
261
266
|
[super,
|
267
|
+
format("Error: %+.3f\tLast: %+.3f\tSum: %+.3f",
|
268
|
+
@error, @last_error, @sum_error),
|
262
269
|
format(" Gain:\t%.3f\t%.3f\t%.3f",
|
263
270
|
@kp, @ki, @kd),
|
264
271
|
format(" PID:\t%+.3f\t%+.3f\t%+.3f\t= %.5f",
|
@@ -267,14 +274,40 @@ module DeviceControl
|
|
267
274
|
end
|
268
275
|
end
|
269
276
|
|
270
|
-
class
|
277
|
+
class MovingAverage
|
278
|
+
include Updateable
|
279
|
+
|
280
|
+
attr_reader :size, :idx, :storage
|
281
|
+
|
282
|
+
def initialize(size = 2)
|
283
|
+
@size = size
|
284
|
+
@idx = 0
|
285
|
+
@storage = Array.new(@size, 0)
|
286
|
+
end
|
287
|
+
|
288
|
+
# never grow @storage; just use modmath to track what to replace
|
289
|
+
def input=(val)
|
290
|
+
@storage[@idx % @size] = val
|
291
|
+
@idx += 1
|
292
|
+
end
|
293
|
+
|
294
|
+
def output
|
295
|
+
return 0 if @idx == 0
|
296
|
+
@storage.sum / (@idx > @size ? @size : @idx).to_f
|
297
|
+
end
|
298
|
+
end
|
299
|
+
|
300
|
+
class RateLimiter
|
271
301
|
include Updateable
|
272
302
|
|
273
|
-
|
303
|
+
attr_accessor :val
|
304
|
+
|
305
|
+
def initialize(max_step, val: 0)
|
274
306
|
@max_step = max_step
|
275
|
-
@val =
|
307
|
+
@val = val
|
276
308
|
end
|
277
309
|
|
310
|
+
# never allow @val to grow / shrink more than @max_step
|
278
311
|
def input=(val)
|
279
312
|
diff = val - @val
|
280
313
|
@val += diff.clamp(-1 * @max_step, @max_step)
|
data/test/device_control.rb
CHANGED
@@ -20,12 +20,12 @@ class Doubler
|
|
20
20
|
end
|
21
21
|
|
22
22
|
describe Updateable do
|
23
|
-
describe "a mixin that provides
|
23
|
+
describe "a mixin that provides an 'update' pattern" do
|
24
24
|
before do
|
25
25
|
@o = Doubler.new
|
26
26
|
end
|
27
27
|
|
28
|
-
it "has an
|
28
|
+
it "has an update method that accepts an input and returns output" do
|
29
29
|
expect(@o.input).must_equal 0.0
|
30
30
|
expect(@o.output).must_equal 0.0
|
31
31
|
|
@@ -34,7 +34,7 @@ describe Updateable do
|
|
34
34
|
expect(@o.output).must_equal output
|
35
35
|
end
|
36
36
|
|
37
|
-
it "requires an
|
37
|
+
it "requires an output method" do
|
38
38
|
k = Class.new(Object) do
|
39
39
|
include Updateable
|
40
40
|
end
|
@@ -49,7 +49,7 @@ describe Device do
|
|
49
49
|
@device = Device.new
|
50
50
|
end
|
51
51
|
|
52
|
-
it "has an
|
52
|
+
it "has an output" do
|
53
53
|
expect(@device.output).must_be_kind_of Float
|
54
54
|
end
|
55
55
|
|
@@ -57,7 +57,7 @@ describe Device do
|
|
57
57
|
expect(@device.to_s).must_be_kind_of String
|
58
58
|
end
|
59
59
|
|
60
|
-
it "has an
|
60
|
+
it "has an update method from Updateable" do
|
61
61
|
expect(@device.update(2.34)).must_be_kind_of Float
|
62
62
|
end
|
63
63
|
end
|
@@ -67,7 +67,7 @@ describe Heater do
|
|
67
67
|
@h = Heater.new(1000)
|
68
68
|
end
|
69
69
|
|
70
|
-
it "has an
|
70
|
+
it "has an output when knob is greater than zero" do
|
71
71
|
expect(@h.knob).must_equal 0
|
72
72
|
expect(@h.output).must_equal 0
|
73
73
|
@h.knob = 1
|
@@ -78,7 +78,7 @@ describe Heater do
|
|
78
78
|
expect(@h.to_s).must_be_kind_of String
|
79
79
|
end
|
80
80
|
|
81
|
-
it "has
|
81
|
+
it "has update from Updateable" do
|
82
82
|
expect(@h.knob).must_equal 0
|
83
83
|
expect(@h.output).must_equal 0
|
84
84
|
output = @h.update(1)
|
@@ -86,6 +86,17 @@ describe Heater do
|
|
86
86
|
expect(@h.knob).must_equal 1
|
87
87
|
expect(@h.output).must_equal output
|
88
88
|
end
|
89
|
+
|
90
|
+
describe Cooler do
|
91
|
+
it "is less efficient than a Heater" do
|
92
|
+
h = Heater.new(1000)
|
93
|
+
c = Cooler.new(1000)
|
94
|
+
h.knob = 1
|
95
|
+
c.knob = 1
|
96
|
+
expect(c.output).must_be :<, h.output
|
97
|
+
expect(c.output).must_be :<, h.output / 2.0
|
98
|
+
end
|
99
|
+
end
|
89
100
|
end
|
90
101
|
|
91
102
|
describe Controller do
|
@@ -94,7 +105,7 @@ describe Controller do
|
|
94
105
|
@c = Controller.new(@sp)
|
95
106
|
end
|
96
107
|
|
97
|
-
it "has an
|
108
|
+
it "has an output_, the difference between setpoint and measure" do
|
98
109
|
expect(@c.output).must_be_kind_of Float
|
99
110
|
expect(@c.output).must_equal @sp
|
100
111
|
end
|
@@ -103,7 +114,7 @@ describe Controller do
|
|
103
114
|
expect(@c.to_s).must_be_kind_of String
|
104
115
|
end
|
105
116
|
|
106
|
-
it "has an
|
117
|
+
it "has an update method from Updateable" do
|
107
118
|
expect(@c.update(499)).must_equal 1.0
|
108
119
|
end
|
109
120
|
end
|
@@ -122,38 +133,55 @@ describe Thermostat do
|
|
122
133
|
expect(@t.update 30).must_equal false
|
123
134
|
expect(@t.update 20).must_equal true
|
124
135
|
end
|
125
|
-
end
|
126
136
|
|
127
|
-
describe
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
137
|
+
describe Flexstat do
|
138
|
+
it "maps a hot_val to a suitable cold_val" do
|
139
|
+
expect(Flexstat.cold_val true).must_equal false
|
140
|
+
expect(Flexstat.cold_val false).must_equal true
|
141
|
+
expect(Flexstat.cold_val 0).must_equal 1
|
142
|
+
expect(Flexstat.cold_val 1).must_equal 0
|
143
|
+
expect(Flexstat.cold_val 2).must_equal 0
|
144
|
+
expect(Flexstat.cold_val 99.876).must_equal 0
|
145
|
+
expect(Flexstat.cold_val :on).must_equal :off
|
146
|
+
expect(Flexstat.cold_val :off).must_equal :on
|
147
|
+
|
148
|
+
expect { Flexstat.cold_val 'on' }.must_raise
|
149
|
+
expect { Flexstat.cold_val :anything_else }.must_raise
|
150
|
+
end
|
151
|
+
|
152
|
+
it "has configurable hot/cold values for its output" do
|
153
|
+
f = Flexstat.new(25, hot_val: :on, cold_val: :off)
|
154
|
+
expect(f).must_be_kind_of Flexstat
|
155
|
+
expect(f.hot_val).must_equal :on
|
156
|
+
expect(f.cold_val).must_equal :off
|
157
|
+
expect(f.update 20).must_equal :off
|
158
|
+
|
159
|
+
f = Flexstat.new(25, hot_val: :on)
|
160
|
+
expect(f).must_be_kind_of Flexstat
|
161
|
+
expect(f.hot_val).must_equal :on
|
162
|
+
expect(f.cold_val).must_equal :off
|
163
|
+
expect(f.update 30).must_equal :on
|
164
|
+
|
165
|
+
f = Flexstat.new(25)
|
166
|
+
expect(f).must_be_kind_of Flexstat
|
167
|
+
expect(f.hot_val).must_equal false # default, subject to change
|
168
|
+
expect(f.cold_val).must_equal true # likewise
|
169
|
+
expect(f.cold_val).must_equal Flexstat.cold_val(f.hot_val)
|
170
|
+
expect(f.update 30).must_equal f.hot_val
|
171
|
+
|
172
|
+
f = Flexstat.new(25, hot_val: 7, cold_val: 19)
|
173
|
+
expect(f).must_be_kind_of Flexstat
|
174
|
+
expect(f.hot_val).must_equal 7
|
175
|
+
expect(f.cold_val).must_equal 19
|
176
|
+
expect(f.update 20).must_equal 19
|
177
|
+
|
178
|
+
f = Flexstat.new(25, hot_val: 7)
|
179
|
+
expect(f).must_be_kind_of Flexstat
|
180
|
+
expect(f.hot_val).must_equal 7
|
181
|
+
expect(f.cold_val).must_equal Flexstat.cold_val(f.hot_val)
|
182
|
+
expect(f.cold_val).must_equal 0
|
183
|
+
expect(f.update 30).must_equal 7
|
184
|
+
end
|
157
185
|
end
|
158
186
|
end
|
159
187
|
|
@@ -182,13 +210,34 @@ describe PIDController do
|
|
182
210
|
expect(hsh[:td]).must_be :>, 0
|
183
211
|
end
|
184
212
|
|
185
|
-
it "has an optional
|
213
|
+
it "has an optional dt argument to initialize" do
|
186
214
|
pid = PIDController.new(1000, dt: 0.1)
|
187
215
|
expect(pid).must_be_kind_of PIDController
|
188
216
|
expect(pid.setpoint).must_equal 1000
|
189
217
|
expect(pid.dt).must_equal 0.1
|
190
218
|
end
|
191
219
|
|
220
|
+
it "tracks error, last_error, sum_error" do
|
221
|
+
pid = PIDController.new(100)
|
222
|
+
expect(pid.error).must_equal 0.0
|
223
|
+
expect(pid.last_error).must_equal 0.0
|
224
|
+
expect(pid.sum_error).must_equal 0.0
|
225
|
+
|
226
|
+
output = pid.update 50
|
227
|
+
expect(pid.output).must_equal output
|
228
|
+
expect(pid.measure).must_equal 50
|
229
|
+
expect(pid.error).must_be_within_epsilon 50.0
|
230
|
+
expect(pid.last_error).must_equal 0.0
|
231
|
+
expect(pid.sum_error).must_be_within_epsilon(50.0 * pid.dt)
|
232
|
+
|
233
|
+
output = pid.update 75
|
234
|
+
expect(pid.output).must_equal output
|
235
|
+
expect(pid.measure).must_equal 75
|
236
|
+
expect(pid.error).must_be_within_epsilon 25.0
|
237
|
+
expect(pid.last_error).must_be_within_epsilon 50.0
|
238
|
+
expect(pid.sum_error).must_be_within_epsilon(75.0 * pid.dt)
|
239
|
+
end
|
240
|
+
|
192
241
|
it "has PID gain settings" do
|
193
242
|
pid = PIDController.new(1000)
|
194
243
|
expect(pid.kp).must_be :>, 0
|
@@ -200,7 +249,7 @@ describe PIDController do
|
|
200
249
|
expect(pid.kd).must_equal 1000
|
201
250
|
end
|
202
251
|
|
203
|
-
it "clamps the
|
252
|
+
it "clamps the proportion term" do
|
204
253
|
pid = PIDController.new(1000)
|
205
254
|
pid.p_range = (0..1)
|
206
255
|
pid.update(500)
|
@@ -209,7 +258,7 @@ describe PIDController do
|
|
209
258
|
expect(pid.proportion).must_equal 0.0
|
210
259
|
end
|
211
260
|
|
212
|
-
it "clamps the
|
261
|
+
it "clamps the integral term" do
|
213
262
|
pid = PIDController.new(1000)
|
214
263
|
pid.i_range = (-1.0 .. 1.0)
|
215
264
|
pid.setpoint = 10_000
|
@@ -217,10 +266,11 @@ describe PIDController do
|
|
217
266
|
expect(pid.integral).must_equal 1.0
|
218
267
|
pid.update(10_001)
|
219
268
|
pid.update(20_000)
|
269
|
+
pid.update(30_000)
|
220
270
|
expect(pid.integral).must_equal(-1.0)
|
221
271
|
end
|
222
272
|
|
223
|
-
it "clamps the
|
273
|
+
it "clamps the derivative term" do
|
224
274
|
pid = PIDController.new(1000)
|
225
275
|
pid.d_range = (-1.0 .. 0.0)
|
226
276
|
pid.update(0)
|
@@ -233,7 +283,7 @@ describe PIDController do
|
|
233
283
|
expect(pid.derivative).must_equal(0.0)
|
234
284
|
end
|
235
285
|
|
236
|
-
it "clamps the
|
286
|
+
it "clamps the output" do
|
237
287
|
pid = PIDController.new(1000)
|
238
288
|
pid.o_range = (0.0 .. 1.0)
|
239
289
|
pid.update(0)
|
@@ -242,7 +292,20 @@ describe PIDController do
|
|
242
292
|
expect(pid.output).must_equal(0.0)
|
243
293
|
end
|
244
294
|
|
245
|
-
it "
|
295
|
+
it "clamps sum_error" do
|
296
|
+
pid = PIDController.new(1000)
|
297
|
+
pid.e_range = 999..1000
|
298
|
+
|
299
|
+
pid.update(500)
|
300
|
+
expect(pid.error).must_equal 500
|
301
|
+
expect(pid.sum_error).must_equal 999
|
302
|
+
pid.update(1000)
|
303
|
+
expect(pid.sum_error).must_equal 999
|
304
|
+
pid.update(-1000)
|
305
|
+
expect(pid.sum_error).must_equal 1000
|
306
|
+
end
|
307
|
+
|
308
|
+
it "calculates proportion based on current error" do
|
246
309
|
pid = PIDController.new(1000)
|
247
310
|
pid.kp = 1.0
|
248
311
|
pid.update(0)
|
@@ -253,33 +316,130 @@ describe PIDController do
|
|
253
316
|
expect(pid.proportion).must_equal(-1.0)
|
254
317
|
end
|
255
318
|
|
256
|
-
it "calculates
|
319
|
+
it "calculates integral based on accumulated error" do
|
257
320
|
pid = PIDController.new(1000)
|
258
321
|
pid.ki = 1.0
|
259
|
-
|
260
|
-
#
|
322
|
+
|
323
|
+
pid.update(0) # error is 1000; dt is 0.001
|
261
324
|
expect(pid.integral).must_equal(1.0)
|
262
|
-
|
325
|
+
|
326
|
+
pid.update(999) # error is 1, sum_error is 1.001
|
263
327
|
expect(pid.integral).must_be_within_epsilon(1.001)
|
264
|
-
|
265
|
-
|
328
|
+
|
329
|
+
pid.update(1100) # error is -100, sum_error is 0.901
|
330
|
+
expect(pid.integral).must_be_within_epsilon(0.901)
|
266
331
|
end
|
267
332
|
|
268
|
-
it "calculates
|
333
|
+
it "calculates derivative based on error slope" do
|
269
334
|
pid = PIDController.new(1000)
|
270
335
|
pid.kp = 1.0
|
271
336
|
pid.update(0)
|
272
|
-
|
273
|
-
expect(pid.
|
337
|
+
expect(pid.error).must_equal 1000
|
338
|
+
expect(pid.last_error).must_equal 0
|
339
|
+
expect(pid.derivative).must_be_within_epsilon(1000 / pid.dt)
|
340
|
+
|
274
341
|
pid.update(500)
|
275
|
-
expect(pid.
|
342
|
+
expect(pid.error).must_equal 500
|
343
|
+
expect(pid.last_error).must_equal 1000
|
344
|
+
expect(pid.derivative).must_be_within_epsilon(-500 / pid.dt)
|
345
|
+
|
346
|
+
pid.update(999)
|
347
|
+
expect(pid.error).must_equal 1
|
348
|
+
expect(pid.last_error).must_equal 500
|
349
|
+
expect(pid.derivative).must_be_within_epsilon(-499 / pid.dt)
|
350
|
+
|
351
|
+
pid.update(1001)
|
352
|
+
expect(pid.error).must_equal(-1)
|
353
|
+
expect(pid.last_error).must_equal(1)
|
354
|
+
expect(pid.derivative).must_be_within_epsilon(-2 / pid.dt)
|
355
|
+
|
356
|
+
pid.update(1100)
|
357
|
+
expect(pid.error).must_equal(-100)
|
358
|
+
expect(pid.last_error).must_equal(-1)
|
359
|
+
expect(pid.derivative).must_be_within_epsilon(-99 / pid.dt)
|
360
|
+
|
361
|
+
pid.update(900)
|
362
|
+
expect(pid.error).must_equal(100)
|
363
|
+
expect(pid.last_error).must_equal(-100)
|
364
|
+
expect(pid.derivative).must_be_within_epsilon(200 / pid.dt)
|
365
|
+
end
|
366
|
+
|
367
|
+
it "has an optional LPF on the derivative term at output" do
|
368
|
+
pid = PIDController.new(1000, low_pass_ticks: 2)
|
369
|
+
pid.kp = 1.0
|
370
|
+
|
371
|
+
pid.update(0) # error should be 1000; last_error 0 (1000)
|
372
|
+
expect(pid.error).must_be_within_epsilon(1000)
|
373
|
+
expect(pid.last_error).must_equal 0
|
374
|
+
expect(pid.derivative).must_be_within_epsilon(1000 / pid.dt)
|
375
|
+
|
376
|
+
pid.update(500) # error 500, last_error 1000 (-500)
|
377
|
+
expect(pid.error).must_equal 500
|
378
|
+
expect(pid.last_error).must_equal 1000
|
379
|
+
expect(pid.derivative).must_be_within_epsilon(-500 / pid.dt)
|
380
|
+
|
276
381
|
pid.update(999)
|
277
|
-
expect(pid.
|
382
|
+
expect(pid.error).must_equal 1
|
383
|
+
expect(pid.last_error).must_equal 500
|
384
|
+
expect(pid.derivative).must_be_within_epsilon(-499 / pid.dt)
|
385
|
+
|
278
386
|
pid.update(1001)
|
279
|
-
expect(pid.
|
387
|
+
expect(pid.error).must_equal(-1)
|
388
|
+
expect(pid.last_error).must_equal(1)
|
389
|
+
expect(pid.derivative).must_be_within_epsilon(-2 / pid.dt)
|
390
|
+
|
280
391
|
pid.update(1100)
|
281
|
-
expect(pid.
|
392
|
+
expect(pid.error).must_equal(-100)
|
393
|
+
expect(pid.last_error).must_equal(-1)
|
394
|
+
expect(pid.derivative).must_be_within_epsilon(-99 / pid.dt)
|
395
|
+
|
282
396
|
pid.update(900)
|
283
|
-
expect(pid.
|
397
|
+
expect(pid.error).must_equal(100)
|
398
|
+
expect(pid.last_error).must_equal(-100)
|
399
|
+
expect(pid.derivative).must_be_within_epsilon(200 / pid.dt)
|
400
|
+
end
|
401
|
+
|
402
|
+
describe MovingAverage do
|
403
|
+
it "has a configurable number of items to consider for the average" do
|
404
|
+
mavg = MovingAverage.new(5)
|
405
|
+
expect(mavg.output).must_equal 0
|
406
|
+
expect(mavg.update 10).must_equal 10
|
407
|
+
expect(mavg.update 20).must_equal 15
|
408
|
+
expect(mavg.update 30).must_equal 20
|
409
|
+
expect(mavg.update 20).must_equal 20
|
410
|
+
expect(mavg.update 20).must_equal 20
|
411
|
+
|
412
|
+
# 10 is about to drop; replace it to maintain 20
|
413
|
+
expect(mavg.update 10).must_equal 20
|
414
|
+
|
415
|
+
# 20 is about to drop; replace it
|
416
|
+
expect(mavg.update 20).must_equal 20
|
417
|
+
|
418
|
+
# 30 is about to drop; replace it
|
419
|
+
expect(mavg.update 30).must_equal 20
|
420
|
+
|
421
|
+
# 20 is about to drop; replace with 0
|
422
|
+
expect(mavg.update 0).must_equal 16
|
423
|
+
end
|
424
|
+
end
|
425
|
+
|
426
|
+
describe RateLimiter do
|
427
|
+
it "has a configurable step size per tick" do
|
428
|
+
rl = RateLimiter.new(10)
|
429
|
+
|
430
|
+
expect(rl.update 5).must_equal 5
|
431
|
+
expect(rl.update 15).must_equal 15
|
432
|
+
|
433
|
+
# now at 15, attempt 30; limited to 25
|
434
|
+
expect(rl.update 30).must_equal 25
|
435
|
+
|
436
|
+
# now at 25, attempt 0; limited to 15
|
437
|
+
expect(rl.update 0).must_equal 15
|
438
|
+
|
439
|
+
rl = RateLimiter.new(0.1)
|
440
|
+
expect(rl.output).must_equal 0
|
441
|
+
expect(rl.update 5).must_be_within_epsilon 0.1
|
442
|
+
expect(rl.update 15).must_be_within_epsilon 0.2
|
443
|
+
end
|
284
444
|
end
|
285
445
|
end
|