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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cf0616f0c1a9a604310b793eea313261cc6f70ecefe3a401ae3df83b973a4f45
4
- data.tar.gz: 7ad33892bf3ad7420d4c72f3c94efe0aa9861889427d1028cba27a2fd1bc9fb1
3
+ metadata.gz: af9b824c6799b18921c32a5aaaff2438f2552fff2b4d6bb3c72300da0867fb7e
4
+ data.tar.gz: cffbe03e20d0b3e9bc513d35eba821f8a11b6ea973cf5ea09b4e76eee9ad9b0e
5
5
  SHA512:
6
- metadata.gz: 1c71afb1964f420e16fa7941e7470cf33c19183218d3bb97f040f34047d653b3e52d1f86e2874ff96997caf5de38a9902f9d4e112f25a516bd1e316da40f79c3
7
- data.tar.gz: a24a1a3e8d7dd72b3f30a4470f5cff0a5590c1c7ec00c76b711e5c91d9e799386182477a1400f57d3a2d701375ecfa417c11fcf36d8e3bbde9f09d75d50db802
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 ==> DEVICE
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 yield
66
- an environmental output. The environment accepts the new output and produces
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
- 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:
219
227
 
220
228
  ```ruby
221
229
 
@@ -237,15 +245,48 @@ temp = 24.9
237
245
 
238
246
  ```
239
247
 
240
- Notice, the thermostat essentially answers the question: *is it hot enough?*
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/pid_controller.rb](lib/pid_controller.rb#L155)
251
- * [test/pid_controller.rb](test/pid_controller.rb)
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.0.0.3
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