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.
- checksums.yaml +4 -4
- data/README.md +54 -13
- data/VERSION +1 -1
- data/lib/device_control.rb +320 -0
- data/test/device_control.rb +445 -0
- metadata +3 -3
- data/lib/pid_controller.rb +0 -267
- data/test/pid_controller.rb +0 -283
@@ -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.
|
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/
|
24
|
-
- test/
|
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
|