device_control 0.0.0.3 → 0.3.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,445 @@
1
+ require 'device_control'
2
+ require 'minitest/autorun'
3
+
4
+ include DeviceControl
5
+
6
+ # create a basic class that includes Updateable as a Mixin
7
+ # the class should define #initialize, #input= and #output at minimum
8
+ class Doubler
9
+ include Updateable
10
+
11
+ attr_accessor :input
12
+
13
+ def initialize
14
+ @input = 0.0
15
+ end
16
+
17
+ def output
18
+ @input * 2
19
+ end
20
+ end
21
+
22
+ describe Updateable do
23
+ describe "a mixin that provides an 'update' pattern" do
24
+ before do
25
+ @o = Doubler.new
26
+ end
27
+
28
+ it "has an update method that accepts an input and returns output" do
29
+ expect(@o.input).must_equal 0.0
30
+ expect(@o.output).must_equal 0.0
31
+
32
+ output = @o.update(45)
33
+ expect(@o.input).must_equal 45
34
+ expect(@o.output).must_equal output
35
+ end
36
+
37
+ it "requires an output method" do
38
+ k = Class.new(Object) do
39
+ include Updateable
40
+ end
41
+ o = k.new
42
+ expect { o.update(45) }.must_raise NoMethodError
43
+ end
44
+ end
45
+ end
46
+
47
+ describe Device do
48
+ before do
49
+ @device = Device.new
50
+ end
51
+
52
+ it "has an output" do
53
+ expect(@device.output).must_be_kind_of Float
54
+ end
55
+
56
+ it "has a string representation" do
57
+ expect(@device.to_s).must_be_kind_of String
58
+ end
59
+
60
+ it "has an update method from Updateable" do
61
+ expect(@device.update(2.34)).must_be_kind_of Float
62
+ end
63
+ end
64
+
65
+ describe Heater do
66
+ before do
67
+ @h = Heater.new(1000)
68
+ end
69
+
70
+ it "has an output when knob is greater than zero" do
71
+ expect(@h.knob).must_equal 0
72
+ expect(@h.output).must_equal 0
73
+ @h.knob = 1
74
+ expect(@h.output).must_be :>, 0
75
+ end
76
+
77
+ it "has a string representation" do
78
+ expect(@h.to_s).must_be_kind_of String
79
+ end
80
+
81
+ it "has update from Updateable" do
82
+ expect(@h.knob).must_equal 0
83
+ expect(@h.output).must_equal 0
84
+ output = @h.update(1)
85
+ expect(output).must_be :>, 0
86
+ expect(@h.knob).must_equal 1
87
+ expect(@h.output).must_equal output
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
100
+ end
101
+
102
+ describe Controller do
103
+ before do
104
+ @sp = 500
105
+ @c = Controller.new(@sp)
106
+ end
107
+
108
+ it "has an output_, the difference between setpoint and measure" do
109
+ expect(@c.output).must_be_kind_of Float
110
+ expect(@c.output).must_equal @sp
111
+ end
112
+
113
+ it "has a string representation" do
114
+ expect(@c.to_s).must_be_kind_of String
115
+ end
116
+
117
+ it "has an update method from Updateable" do
118
+ expect(@c.update(499)).must_equal 1.0
119
+ end
120
+ end
121
+
122
+ describe Thermostat do
123
+ before do
124
+ @t = Thermostat.new 25
125
+ end
126
+
127
+ it "outputs true when it's too cold; when measure < setpoint" do
128
+ expect(@t.update 20).must_equal true
129
+ expect(@t.update 30).must_equal false
130
+ end
131
+
132
+ it "outputs false when it's too hot; when measure > setpoint" do
133
+ expect(@t.update 30).must_equal false
134
+ expect(@t.update 20).must_equal true
135
+ end
136
+
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
185
+ end
186
+ end
187
+
188
+ describe PIDController do
189
+ it "informs Ziegler-Nichols tuning" do
190
+ # P only, not PID
191
+ hsh = PIDController.tune('P', 5, 0.01)
192
+ expect(hsh[:kp]).must_be :>, 0
193
+ expect(hsh[:ki]).must_be_nil
194
+ expect(hsh[:kd]).must_be_nil
195
+ expect(hsh[:ti]).must_be_nil
196
+ expect(hsh[:td]).must_be_nil
197
+
198
+ hsh = PIDController.tune('PI', 5, 0.01)
199
+ expect(hsh[:kp]).must_be :>, 0
200
+ expect(hsh[:ki]).must_be :>, 0
201
+ expect(hsh[:kd]).must_be_nil
202
+ expect(hsh[:ti]).must_be :>, 0
203
+ expect(hsh[:td]).must_be_nil
204
+
205
+ hsh = PIDController.tune('PID', 5, 0.01)
206
+ expect(hsh[:kp]).must_be :>, 0
207
+ expect(hsh[:ki]).must_be :>, 0
208
+ expect(hsh[:kd]).must_be :>, 0
209
+ expect(hsh[:ti]).must_be :>, 0
210
+ expect(hsh[:td]).must_be :>, 0
211
+ end
212
+
213
+ it "has an optional dt argument to initialize" do
214
+ pid = PIDController.new(1000, dt: 0.1)
215
+ expect(pid).must_be_kind_of PIDController
216
+ expect(pid.setpoint).must_equal 1000
217
+ expect(pid.dt).must_equal 0.1
218
+ end
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
+
241
+ it "has PID gain settings" do
242
+ pid = PIDController.new(1000)
243
+ expect(pid.kp).must_be :>, 0
244
+ pid.kp = 1000
245
+ expect(pid.kp).must_equal 1000
246
+ pid.ki = 1000
247
+ expect(pid.ki).must_equal 1000
248
+ pid.kd = 1000
249
+ expect(pid.kd).must_equal 1000
250
+ end
251
+
252
+ it "clamps the proportion term" do
253
+ pid = PIDController.new(1000)
254
+ pid.p_range = (0..1)
255
+ pid.update(500)
256
+ expect(pid.proportion).must_equal 1.0
257
+ pid.update(1500)
258
+ expect(pid.proportion).must_equal 0.0
259
+ end
260
+
261
+ it "clamps the integral term" do
262
+ pid = PIDController.new(1000)
263
+ pid.i_range = (-1.0 .. 1.0)
264
+ pid.setpoint = 10_000
265
+ pid.update(500)
266
+ expect(pid.integral).must_equal 1.0
267
+ pid.update(10_001)
268
+ pid.update(20_000)
269
+ pid.update(30_000)
270
+ expect(pid.integral).must_equal(-1.0)
271
+ end
272
+
273
+ it "clamps the derivative term" do
274
+ pid = PIDController.new(1000)
275
+ pid.d_range = (-1.0 .. 0.0)
276
+ pid.update(0)
277
+ pid.update(10)
278
+ expect(pid.derivative).must_equal(-1.0)
279
+ pid.update(990)
280
+ expect(pid.derivative).must_equal(-1.0)
281
+ pid.update(1000)
282
+ pid.update(990)
283
+ expect(pid.derivative).must_equal(0.0)
284
+ end
285
+
286
+ it "clamps the output" do
287
+ pid = PIDController.new(1000)
288
+ pid.o_range = (0.0 .. 1.0)
289
+ pid.update(0)
290
+ expect(pid.output).must_equal(1.0)
291
+ pid.update(2000)
292
+ expect(pid.output).must_equal(0.0)
293
+ end
294
+
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
309
+ pid = PIDController.new(1000)
310
+ pid.kp = 1.0
311
+ pid.update(0)
312
+ expect(pid.proportion).must_equal 1000.0
313
+ pid.update(1)
314
+ expect(pid.proportion).must_equal 999.0
315
+ pid.update(1001)
316
+ expect(pid.proportion).must_equal(-1.0)
317
+ end
318
+
319
+ it "calculates integral based on accumulated error" do
320
+ pid = PIDController.new(1000)
321
+ pid.ki = 1.0
322
+
323
+ pid.update(0) # error is 1000; dt is 0.001
324
+ expect(pid.integral).must_equal(1.0)
325
+
326
+ pid.update(999) # error is 1, sum_error is 1.001
327
+ expect(pid.integral).must_be_within_epsilon(1.001)
328
+
329
+ pid.update(1100) # error is -100, sum_error is 0.901
330
+ expect(pid.integral).must_be_within_epsilon(0.901)
331
+ end
332
+
333
+ it "calculates derivative based on error slope" do
334
+ pid = PIDController.new(1000)
335
+ pid.kp = 1.0
336
+ pid.update(0)
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
+
341
+ pid.update(500)
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
+
381
+ pid.update(999)
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
+
386
+ pid.update(1001)
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
+
391
+ pid.update(1100)
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
+
396
+ pid.update(900)
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
444
+ end
445
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: device_control
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0.3
4
+ version: 0.3.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rick Hull
@@ -20,8 +20,8 @@ files:
20
20
  - Rakefile
21
21
  - VERSION
22
22
  - device_control.gemspec
23
- - lib/pid_controller.rb
24
- - test/pid_controller.rb
23
+ - lib/device_control.rb
24
+ - test/device_control.rb
25
25
  homepage: https://github.com/rickhull/device_control
26
26
  licenses:
27
27
  - LGPL-3.0