device_control 0.0.0.3 → 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.
@@ -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