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 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