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
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
@@ -52,7 +52,7 @@ to make sense.
|
|
52
52
|
Our control loop is composed of the 3 concepts above:
|
53
53
|
|
54
54
|
```
|
55
|
-
CONTROLLER
|
55
|
+
CONTROLLER ----> DEVICE
|
56
56
|
^ |
|
57
57
|
| |
|
58
58
|
| V
|
@@ -61,10 +61,14 @@ CONTROLLER ==> DEVICE
|
|
61
61
|
|
62
62
|
### A Pattern
|
63
63
|
|
64
|
-
Each component accepts an input and yields an output. Controllers accept
|
65
|
-
a measure and yield a control value. Devices accept a control value and
|
66
|
-
an environmental output. The environment accepts the new output and
|
67
|
-
a new measure for the controller.
|
64
|
+
Each component accepts an input and yields an output. **Controllers** accept
|
65
|
+
a measure and yield a control value. **Devices** accept a control value and
|
66
|
+
yield an environmental output. The **environment** accepts the new output and
|
67
|
+
produces a new measure for the controller.
|
68
|
+
|
69
|
+
It's worth noting that a control loop may include multiple controllers feeding
|
70
|
+
one another as well as multiple devices. And the environment may affect
|
71
|
+
different stages of the control loop in different ways.
|
68
72
|
|
69
73
|
```ruby
|
70
74
|
module Updateable
|
@@ -201,7 +205,8 @@ end
|
|
201
205
|
```
|
202
206
|
|
203
207
|
A Controller names its input `measure`, and it introduces a `setpoint`, and
|
204
|
-
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.
|
205
210
|
|
206
211
|
#### Thermostat
|
207
212
|
|
@@ -215,7 +220,10 @@ class Thermostat < Controller
|
|
215
220
|
end
|
216
221
|
```
|
217
222
|
|
218
|
-
|
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:
|
219
227
|
|
220
228
|
```ruby
|
221
229
|
|
@@ -237,15 +245,48 @@ temp = 24.9
|
|
237
245
|
|
238
246
|
```
|
239
247
|
|
240
|
-
|
241
|
-
(or: *is it too cold?*). You can run it either or both ways, but notice that
|
242
|
-
you can simply pick one orientation and remain logically consistent. So the
|
243
|
-
**heat knob** goes to 1 when its thermostat goes *below* setpoint.
|
248
|
+
So the **heat knob** goes to 1 when its thermostat goes *below* setpoint.
|
244
249
|
The **cool knob** goes to 1 when its thermostat goes *above* setpoint.
|
245
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
|
+
|
246
287
|
# Finale
|
247
288
|
|
248
289
|
If you've made it this far, congratulations! For further reading:
|
249
290
|
|
250
|
-
* [lib/
|
251
|
-
* [test/
|
291
|
+
* [lib/device_control.rb](lib/device_control.rb#L165)
|
292
|
+
* [test/device_control.rb](test/device_control.rb)
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.
|
1
|
+
0.3.0.1
|
@@ -0,0 +1,320 @@
|
|
1
|
+
module DeviceControl
|
2
|
+
# There is a pattern for how both Controllers (e.g. thermostat) and Devices
|
3
|
+
# (e.g. heater) operate.
|
4
|
+
# They each have an _input_ varying over time which determines the _output_.
|
5
|
+
# A thermostat (Controller) listens for temperature and tells the heater how
|
6
|
+
# high to turn it up (or just on / off). A heater (Device) listens to its
|
7
|
+
# control knob and yields heat as an output.
|
8
|
+
#
|
9
|
+
# We capture this pattern with a single method: _update_. It accepts the
|
10
|
+
# latest input and provides an _output_ based on the input. When the input
|
11
|
+
# is read in, perhaps some internal state is changed on
|
12
|
+
# the processor which will affect the _output_.
|
13
|
+
#
|
14
|
+
# Any class which mixes in Updateable can define its own _input=_ method,
|
15
|
+
# which may update any ivars. Any such class must define an _output_ method.
|
16
|
+
#
|
17
|
+
module Updateable
|
18
|
+
def update(val)
|
19
|
+
self.input = val
|
20
|
+
self.output
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# A Device is like a heater. It has a control knob, maybe on/off or perhaps
|
25
|
+
# a variable control. Its output (maybe on/off) depends on the control knob.
|
26
|
+
class Device
|
27
|
+
include Updateable
|
28
|
+
|
29
|
+
attr_reader :knob
|
30
|
+
|
31
|
+
def initialize
|
32
|
+
@knob = 0.0
|
33
|
+
end
|
34
|
+
|
35
|
+
def input=(val)
|
36
|
+
@knob = val.to_f
|
37
|
+
end
|
38
|
+
alias_method :knob=, :input=
|
39
|
+
|
40
|
+
def output
|
41
|
+
@knob # do nothing by default
|
42
|
+
end
|
43
|
+
|
44
|
+
def to_s
|
45
|
+
format("Knob: %.3f\tOutput: %.3f", @knob, self.output)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Alright, fine, let's make a Heater
|
50
|
+
# Input is the control knob (turned far enough to on, else off)
|
51
|
+
# Output is watts
|
52
|
+
class Heater < Device
|
53
|
+
# convert electricity into thermal output
|
54
|
+
EFFICIENCY = 0.999
|
55
|
+
|
56
|
+
attr_reader :watts
|
57
|
+
|
58
|
+
def initialize(watts, threshold: 0)
|
59
|
+
super()
|
60
|
+
@watts = watts
|
61
|
+
@threshold = threshold
|
62
|
+
end
|
63
|
+
|
64
|
+
# output is all or none
|
65
|
+
def output
|
66
|
+
@knob > @threshold ? (@watts * self.class::EFFICIENCY) : 0
|
67
|
+
end
|
68
|
+
|
69
|
+
def to_s
|
70
|
+
format("Power: %d W\tKnob: %.1f\tThermal: %.1f W",
|
71
|
+
@watts, @knob, self.output)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
class Cooler < Heater
|
76
|
+
# not nearly as efficient as a heater at turning electrons into therms
|
77
|
+
EFFICIENCY = 0.35
|
78
|
+
end
|
79
|
+
|
80
|
+
# A Controller is like a thermostat. It has a setpoint, and it reads a
|
81
|
+
# measurement from the environment, and it adjusts its output to try to make
|
82
|
+
# the measurement match the setpoint.
|
83
|
+
class Controller
|
84
|
+
include Updateable
|
85
|
+
|
86
|
+
attr_reader :measure
|
87
|
+
attr_accessor :setpoint
|
88
|
+
|
89
|
+
def initialize(setpoint)
|
90
|
+
@setpoint, @measure = setpoint, 0.0
|
91
|
+
end
|
92
|
+
|
93
|
+
def input=(val)
|
94
|
+
@measure = val.to_f
|
95
|
+
end
|
96
|
+
alias_method :measure=, :input=
|
97
|
+
|
98
|
+
# just output the error
|
99
|
+
def output
|
100
|
+
@setpoint - @measure
|
101
|
+
end
|
102
|
+
|
103
|
+
def to_s
|
104
|
+
format("Setpoint: %.3f\tMeasure: %.3f", @setpoint, @measure)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
class Thermostat < Controller
|
109
|
+
# true or false; can drive a Heater or a Cooler
|
110
|
+
# true means input below setpoint; false otherwise
|
111
|
+
def output
|
112
|
+
@setpoint - @measure > 0
|
113
|
+
end
|
114
|
+
end
|
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
|
+
|
128
|
+
class Flexstat < Thermostat
|
129
|
+
def self.cold_val(hot_val)
|
130
|
+
case hot_val
|
131
|
+
when true, false
|
132
|
+
!hot_val
|
133
|
+
when 0,1
|
134
|
+
hot_val == 0 ? 1 : 0
|
135
|
+
when Numeric
|
136
|
+
0
|
137
|
+
when :on, :off
|
138
|
+
hot_val == :on ? :off : :on
|
139
|
+
else
|
140
|
+
raise "#{hot_val.inspect} not recognized"
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
attr_reader :cold_val, :hot_val
|
145
|
+
|
146
|
+
def initialize(setpoint, hot_val: false, cold_val: nil)
|
147
|
+
super(setpoint)
|
148
|
+
|
149
|
+
@hot_val = hot_val
|
150
|
+
@cold_val = cold_val.nil? ? self.class.cold_val(hot_val) : cold_val
|
151
|
+
end
|
152
|
+
|
153
|
+
def output
|
154
|
+
super ? @cold_val : @hot_val
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
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
|
164
|
+
#
|
165
|
+
class PIDController < Controller
|
166
|
+
HZ = 1000
|
167
|
+
TICK = Rational(1) / HZ
|
168
|
+
|
169
|
+
# Ziegler-Nichols method for tuning PID gain knobs
|
170
|
+
# https://en.wikipedia.org/wiki/Ziegler%E2%80%93Nichols_method
|
171
|
+
ZN = {
|
172
|
+
# Kp Ti Td Ki Kd
|
173
|
+
# Var: Ku Tu Tu Ku/Tu Ku*Tu
|
174
|
+
'P' => [1/2r],
|
175
|
+
'PI' => [9/20r, 4/5r, nil, 27/50r],
|
176
|
+
'PD' => [ 4/5r, nil, 1/8r, nil, 1/10r],
|
177
|
+
'PID' => [ 3/5r, 1/2r, 1/8r, 6/5r, 3/40r],
|
178
|
+
'PIR' => [7/10r, 2/5r, 3/20r, 7/4r, 21/200r],
|
179
|
+
# less overshoot than standard PID
|
180
|
+
'some' => [ 1/3r, 1/2r, 1/3r, 2/3r, 1/11r],
|
181
|
+
'none' => [ 1/5r, 1/2r, 1/3r, 2/5r, 2/30r],
|
182
|
+
}
|
183
|
+
|
184
|
+
# _ku_ = ultimate gain, _tu_ = oscillation period
|
185
|
+
# output includes ti and td, which are not necessary
|
186
|
+
# typically kp, ki, and kd are used
|
187
|
+
def self.tune(type, ku, tu)
|
188
|
+
record = ZN[type.downcase] || ZN[type.upcase] || ZN.fetch(type)
|
189
|
+
kp, ti, td, ki, kd = *record
|
190
|
+
kp *= ku if kp
|
191
|
+
ti *= tu if ti
|
192
|
+
td *= tu if td
|
193
|
+
ki *= (ku / tu) if ki
|
194
|
+
kd *= (ku * tu) if kd
|
195
|
+
{ kp: kp, ti: ti, td: td, ki: ki, kd: kd }
|
196
|
+
end
|
197
|
+
|
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
|
203
|
+
|
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
|
213
|
+
|
214
|
+
# gain / multipliers for PID; tunables
|
215
|
+
@kp, @ki, @kd = 1.0, 1.0, 1.0
|
216
|
+
|
217
|
+
# optional clamps for PID terms and output
|
218
|
+
@p_range = (-Float::INFINITY..Float::INFINITY)
|
219
|
+
@i_range = (-Float::INFINITY..Float::INFINITY)
|
220
|
+
@d_range = (-Float::INFINITY..Float::INFINITY)
|
221
|
+
@o_range = (-Float::INFINITY..Float::INFINITY)
|
222
|
+
@e_range = (-Float::INFINITY..Float::INFINITY)
|
223
|
+
|
224
|
+
yield self if block_given?
|
225
|
+
end
|
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
|
+
def output
|
243
|
+
drv = @mavg ? @mavg.output : self.derivative
|
244
|
+
(self.proportion +
|
245
|
+
self.integral +
|
246
|
+
drv).clamp(@o_range.begin, @o_range.end)
|
247
|
+
end
|
248
|
+
|
249
|
+
def proportion
|
250
|
+
(@kp * @error).clamp(@p_range.begin, @p_range.end)
|
251
|
+
end
|
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
|
257
|
+
def integral
|
258
|
+
@sum_error.clamp(@i_range.begin, @i_range.end)
|
259
|
+
end
|
260
|
+
|
261
|
+
def derivative
|
262
|
+
(@kd * (@error - @last_error) / @dt).clamp(@d_range.begin, @d_range.end)
|
263
|
+
end
|
264
|
+
|
265
|
+
def to_s
|
266
|
+
[super,
|
267
|
+
format("Error: %+.3f\tLast: %+.3f\tSum: %+.3f",
|
268
|
+
@error, @last_error, @sum_error),
|
269
|
+
format(" Gain:\t%.3f\t%.3f\t%.3f",
|
270
|
+
@kp, @ki, @kd),
|
271
|
+
format(" PID:\t%+.3f\t%+.3f\t%+.3f\t= %.5f",
|
272
|
+
self.proportion, self.integral, self.derivative, self.output),
|
273
|
+
].join("\n")
|
274
|
+
end
|
275
|
+
end
|
276
|
+
|
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
|
301
|
+
include Updateable
|
302
|
+
|
303
|
+
attr_accessor :val
|
304
|
+
|
305
|
+
def initialize(max_step, val: 0)
|
306
|
+
@max_step = max_step
|
307
|
+
@val = val
|
308
|
+
end
|
309
|
+
|
310
|
+
# never allow @val to grow / shrink more than @max_step
|
311
|
+
def input=(val)
|
312
|
+
diff = val - @val
|
313
|
+
@val += diff.clamp(-1 * @max_step, @max_step)
|
314
|
+
end
|
315
|
+
|
316
|
+
def output
|
317
|
+
@val
|
318
|
+
end
|
319
|
+
end
|
320
|
+
end
|