device_control 0.2.0.2 → 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: c076f6bf87773d4901c6e3d1f7e8455b130d9baacd3ac2310dcb114ef69430cc
4
- data.tar.gz: f01f70616a8bf5da7038e595321ad443f71130ba221ff5a3a9bae9458a1a9f51
3
+ metadata.gz: af9b824c6799b18921c32a5aaaff2438f2552fff2b4d6bb3c72300da0867fb7e
4
+ data.tar.gz: cffbe03e20d0b3e9bc513d35eba821f8a11b6ea973cf5ea09b4e76eee9ad9b0e
5
5
  SHA512:
6
- metadata.gz: 63b908be961a7ec43f8d1cd4df3baef1cb649e56356dc3f166e3cfc48ad814183f10cac65b44cee6bba91a7d5d8b0860f7ace50ce0c2bb41bc90f50d163ee453
7
- data.tar.gz: e4bcd7948c9916692616bf7fd938bec2681f4d40326a626c2dda556c08eb3a7e6ccaaa62aa825e55a27fd61127c9be34fa7adad43326dabd5f9a26a8aa78ce8f
6
+ metadata.gz: cf33d6d28e0ae5d2ccfa955ca84305d526e127ba9b4d82c6573b203757f0e2a4996bbf057d8b070853f06b827a71970587d2574fa3d7c17c0f087a2fb9c2ca5f
7
+ data.tar.gz: 399e0470a633162355183d6a79c76ba368d7f236f4540d4e6c32c953b4f5abc4e7b85e371f2994abf4d58a08b4a8aba4db388cacae3fc141d174455d0488a4c6
data/README.md CHANGED
@@ -205,7 +205,8 @@ end
205
205
  ```
206
206
 
207
207
  A Controller names its input `measure`, and it introduces a `setpoint`, and
208
- 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.
209
210
 
210
211
  #### Thermostat
211
212
 
@@ -219,11 +220,10 @@ class Thermostat < Controller
219
220
  end
220
221
  ```
221
222
 
222
- Notice, the thermostat essentially answers the question: *is it hot enough?*
223
- (or: *is it too cold?*). You can run it either or both ways, but notice that
224
- you can simply pick one orientation and remain logically consistent.
225
-
226
- 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:
227
227
 
228
228
  ```ruby
229
229
 
@@ -248,9 +248,45 @@ temp = 24.9
248
248
  So the **heat knob** goes to 1 when its thermostat goes *below* setpoint.
249
249
  The **cool knob** goes to 1 when its thermostat goes *above* setpoint.
250
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
+
251
287
  # Finale
252
288
 
253
289
  If you've made it this far, congratulations! For further reading:
254
290
 
255
- * [lib/device_control.rb](lib/device_control.rb#L155)
291
+ * [lib/device_control.rb](lib/device_control.rb#L165)
256
292
  * [test/device_control.rb](test/device_control.rb)
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.0.2
1
+ 0.3.0.1
@@ -113,6 +113,18 @@ module DeviceControl
113
113
  end
114
114
  end
115
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
+
116
128
  class Flexstat < Thermostat
117
129
  def self.cold_val(hot_val)
118
130
  case hot_val
@@ -129,7 +141,11 @@ module DeviceControl
129
141
  end
130
142
  end
131
143
 
132
- def initalize(hot_val: false, cold_val: nil)
144
+ attr_reader :cold_val, :hot_val
145
+
146
+ def initialize(setpoint, hot_val: false, cold_val: nil)
147
+ super(setpoint)
148
+
133
149
  @hot_val = hot_val
134
150
  @cold_val = cold_val.nil? ? self.class.cold_val(hot_val) : cold_val
135
151
  end
@@ -139,60 +155,17 @@ module DeviceControl
139
155
  end
140
156
  end
141
157
 
142
- # now consider e.g.
143
- # h = Heater.new(1000)
144
- # ht = Thermostat.new(20)
145
- # c = Cooler.new(1000)
146
- # ct = Thermostat.new(25)
147
- # temp = 26.4
148
- # heat_knob = ht.update(temp) ? 1 : 0
149
- # heating_watts = h.update(heat_knob)
150
- # cool_knob = ct.update(temp) ? 0 : 1
151
- # cooling_watts = c.update(cool_knob)
152
- # etc
153
-
154
- # A StatefulController tracks its error over time: current, last, accumulated
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
155
164
  #
156
- class StatefulController < Controller
165
+ class PIDController < Controller
157
166
  HZ = 1000
158
167
  TICK = Rational(1) / HZ
159
168
 
160
- attr_accessor :dt
161
- attr_reader :error, :last_error, :sum_error
162
-
163
- def initialize(setpoint, dt: TICK)
164
- super(setpoint)
165
- @dt = dt
166
- @error, @last_error, @sum_error = 0.0, 0.0, 0.0
167
- end
168
-
169
- # update @error, @last_error, and @sum_error
170
- def input=(val)
171
- @measure = val
172
- @last_error = @error
173
- @error = @setpoint - @measure
174
- if @error * @last_error <= 0 # zero crossing; reset the accumulated error
175
- @sum_error = @error * @dt
176
- else
177
- @sum_error += @error * @dt
178
- end
179
- end
180
-
181
- def to_s
182
- [super,
183
- format("Error: %+.3f\tLast: %+.3f\tSum: %+.3f",
184
- @error, @last_error, @sum_error),
185
- ].join("\n")
186
- end
187
- end
188
-
189
- # A PIDController is a StatefulController that calculates
190
- # * Proportion (current error)
191
- # * Integral (accumulated error)
192
- # * Derivative (error slope, last_error)
193
- # The sum of these terms is the output
194
- #
195
- class PIDController < StatefulController
196
169
  # Ziegler-Nichols method for tuning PID gain knobs
197
170
  # https://en.wikipedia.org/wiki/Ziegler%E2%80%93Nichols_method
198
171
  ZN = {
@@ -222,10 +195,21 @@ module DeviceControl
222
195
  { kp: kp, ti: ti, td: td, ki: ki, kd: kd }
223
196
  end
224
197
 
225
- attr_accessor :kp, :ki, :kd, :p_range, :i_range, :d_range, :o_range
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
226
203
 
227
- def initialize(setpoint, dt: TICK)
228
- super
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
229
213
 
230
214
  # gain / multipliers for PID; tunables
231
215
  @kp, @ki, @kd = 1.0, 1.0, 1.0
@@ -235,22 +219,43 @@ module DeviceControl
235
219
  @i_range = (-Float::INFINITY..Float::INFINITY)
236
220
  @d_range = (-Float::INFINITY..Float::INFINITY)
237
221
  @o_range = (-Float::INFINITY..Float::INFINITY)
222
+ @e_range = (-Float::INFINITY..Float::INFINITY)
238
223
 
239
224
  yield self if block_given?
240
225
  end
241
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
242
  def output
243
+ drv = @mavg ? @mavg.output : self.derivative
243
244
  (self.proportion +
244
245
  self.integral +
245
- self.derivative).clamp(@o_range.begin, @o_range.end)
246
+ drv).clamp(@o_range.begin, @o_range.end)
246
247
  end
247
248
 
248
249
  def proportion
249
250
  (@kp * @error).clamp(@p_range.begin, @p_range.end)
250
251
  end
251
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
252
257
  def integral
253
- (@ki * @sum_error).clamp(@i_range.begin, @i_range.end)
258
+ @sum_error.clamp(@i_range.begin, @i_range.end)
254
259
  end
255
260
 
256
261
  def derivative
@@ -259,6 +264,8 @@ module DeviceControl
259
264
 
260
265
  def to_s
261
266
  [super,
267
+ format("Error: %+.3f\tLast: %+.3f\tSum: %+.3f",
268
+ @error, @last_error, @sum_error),
262
269
  format(" Gain:\t%.3f\t%.3f\t%.3f",
263
270
  @kp, @ki, @kd),
264
271
  format(" PID:\t%+.3f\t%+.3f\t%+.3f\t= %.5f",
@@ -267,14 +274,40 @@ module DeviceControl
267
274
  end
268
275
  end
269
276
 
270
- class Smoother
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
271
301
  include Updateable
272
302
 
273
- def initialize(max_step:)
303
+ attr_accessor :val
304
+
305
+ def initialize(max_step, val: 0)
274
306
  @max_step = max_step
275
- @val = 0.0
307
+ @val = val
276
308
  end
277
309
 
310
+ # never allow @val to grow / shrink more than @max_step
278
311
  def input=(val)
279
312
  diff = val - @val
280
313
  @val += diff.clamp(-1 * @max_step, @max_step)
@@ -20,12 +20,12 @@ class Doubler
20
20
  end
21
21
 
22
22
  describe Updateable do
23
- describe "a mixin that provides the _update_ pattern" do
23
+ describe "a mixin that provides an 'update' pattern" do
24
24
  before do
25
25
  @o = Doubler.new
26
26
  end
27
27
 
28
- it "has an _update_ method that accepts an _input_ and returns _output_" do
28
+ it "has an update method that accepts an input and returns output" do
29
29
  expect(@o.input).must_equal 0.0
30
30
  expect(@o.output).must_equal 0.0
31
31
 
@@ -34,7 +34,7 @@ describe Updateable do
34
34
  expect(@o.output).must_equal output
35
35
  end
36
36
 
37
- it "requires an _output_ method" do
37
+ it "requires an output method" do
38
38
  k = Class.new(Object) do
39
39
  include Updateable
40
40
  end
@@ -49,7 +49,7 @@ describe Device do
49
49
  @device = Device.new
50
50
  end
51
51
 
52
- it "has an _output_" do
52
+ it "has an output" do
53
53
  expect(@device.output).must_be_kind_of Float
54
54
  end
55
55
 
@@ -57,7 +57,7 @@ describe Device do
57
57
  expect(@device.to_s).must_be_kind_of String
58
58
  end
59
59
 
60
- it "has an _update_ method from Updateable" do
60
+ it "has an update method from Updateable" do
61
61
  expect(@device.update(2.34)).must_be_kind_of Float
62
62
  end
63
63
  end
@@ -67,7 +67,7 @@ describe Heater do
67
67
  @h = Heater.new(1000)
68
68
  end
69
69
 
70
- it "has an _output_ when _knob_ is greater than zero" do
70
+ it "has an output when knob is greater than zero" do
71
71
  expect(@h.knob).must_equal 0
72
72
  expect(@h.output).must_equal 0
73
73
  @h.knob = 1
@@ -78,7 +78,7 @@ describe Heater do
78
78
  expect(@h.to_s).must_be_kind_of String
79
79
  end
80
80
 
81
- it "has _update_ from Updateable" do
81
+ it "has update from Updateable" do
82
82
  expect(@h.knob).must_equal 0
83
83
  expect(@h.output).must_equal 0
84
84
  output = @h.update(1)
@@ -86,6 +86,17 @@ describe Heater do
86
86
  expect(@h.knob).must_equal 1
87
87
  expect(@h.output).must_equal output
88
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
89
100
  end
90
101
 
91
102
  describe Controller do
@@ -94,7 +105,7 @@ describe Controller do
94
105
  @c = Controller.new(@sp)
95
106
  end
96
107
 
97
- it "has an _output_, the difference between setpoint and measure" do
108
+ it "has an output_, the difference between setpoint and measure" do
98
109
  expect(@c.output).must_be_kind_of Float
99
110
  expect(@c.output).must_equal @sp
100
111
  end
@@ -103,7 +114,7 @@ describe Controller do
103
114
  expect(@c.to_s).must_be_kind_of String
104
115
  end
105
116
 
106
- it "has an _update_ method from Updateable" do
117
+ it "has an update method from Updateable" do
107
118
  expect(@c.update(499)).must_equal 1.0
108
119
  end
109
120
  end
@@ -122,38 +133,55 @@ describe Thermostat do
122
133
  expect(@t.update 30).must_equal false
123
134
  expect(@t.update 20).must_equal true
124
135
  end
125
- end
126
136
 
127
- describe StatefulController do
128
- it "tracks error, last_error, sum_error" do
129
- sc = StatefulController.new(100)
130
- expect(sc.error).must_equal 0.0
131
- expect(sc.last_error).must_equal 0.0
132
- expect(sc.sum_error).must_equal 0.0
133
-
134
- output = sc.update 50
135
- expect(sc.output).must_equal output
136
- expect(sc.measure).must_equal 50
137
- expect(sc.error).must_be_within_epsilon 50.0
138
- expect(sc.last_error).must_equal 0.0
139
- expect(sc.sum_error).must_be_within_epsilon(50.0 * sc.dt)
140
-
141
- output = sc.update 75
142
- expect(sc.output).must_equal output
143
- expect(sc.measure).must_equal 75
144
- expect(sc.error).must_be_within_epsilon 25.0
145
- expect(sc.last_error).must_be_within_epsilon 50.0
146
- expect(sc.sum_error).must_be_within_epsilon(75.0 * sc.dt)
147
- end
148
-
149
- it "resets sum_error after crossing setpoint" do
150
- sc = StatefulController.new(100)
151
- sc.update 50
152
- sc.update 75
153
- expect(sc.sum_error).must_be_within_epsilon(75.0 * sc.dt)
154
- sc.update 125
155
- expect(sc.error).must_equal(-25.0)
156
- expect(sc.sum_error).must_equal(sc.error * sc.dt)
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
157
185
  end
158
186
  end
159
187
 
@@ -182,13 +210,34 @@ describe PIDController do
182
210
  expect(hsh[:td]).must_be :>, 0
183
211
  end
184
212
 
185
- it "has an optional _dt_ argument to initialize" do
213
+ it "has an optional dt argument to initialize" do
186
214
  pid = PIDController.new(1000, dt: 0.1)
187
215
  expect(pid).must_be_kind_of PIDController
188
216
  expect(pid.setpoint).must_equal 1000
189
217
  expect(pid.dt).must_equal 0.1
190
218
  end
191
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
+
192
241
  it "has PID gain settings" do
193
242
  pid = PIDController.new(1000)
194
243
  expect(pid.kp).must_be :>, 0
@@ -200,7 +249,7 @@ describe PIDController do
200
249
  expect(pid.kd).must_equal 1000
201
250
  end
202
251
 
203
- it "clamps the _proportion_ term" do
252
+ it "clamps the proportion term" do
204
253
  pid = PIDController.new(1000)
205
254
  pid.p_range = (0..1)
206
255
  pid.update(500)
@@ -209,7 +258,7 @@ describe PIDController do
209
258
  expect(pid.proportion).must_equal 0.0
210
259
  end
211
260
 
212
- it "clamps the _integral_ term" do
261
+ it "clamps the integral term" do
213
262
  pid = PIDController.new(1000)
214
263
  pid.i_range = (-1.0 .. 1.0)
215
264
  pid.setpoint = 10_000
@@ -217,10 +266,11 @@ describe PIDController do
217
266
  expect(pid.integral).must_equal 1.0
218
267
  pid.update(10_001)
219
268
  pid.update(20_000)
269
+ pid.update(30_000)
220
270
  expect(pid.integral).must_equal(-1.0)
221
271
  end
222
272
 
223
- it "clamps the _derivative_ term" do
273
+ it "clamps the derivative term" do
224
274
  pid = PIDController.new(1000)
225
275
  pid.d_range = (-1.0 .. 0.0)
226
276
  pid.update(0)
@@ -233,7 +283,7 @@ describe PIDController do
233
283
  expect(pid.derivative).must_equal(0.0)
234
284
  end
235
285
 
236
- it "clamps the _output_" do
286
+ it "clamps the output" do
237
287
  pid = PIDController.new(1000)
238
288
  pid.o_range = (0.0 .. 1.0)
239
289
  pid.update(0)
@@ -242,7 +292,20 @@ describe PIDController do
242
292
  expect(pid.output).must_equal(0.0)
243
293
  end
244
294
 
245
- it "calculates _proportion_ based on current error" do
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
246
309
  pid = PIDController.new(1000)
247
310
  pid.kp = 1.0
248
311
  pid.update(0)
@@ -253,33 +316,130 @@ describe PIDController do
253
316
  expect(pid.proportion).must_equal(-1.0)
254
317
  end
255
318
 
256
- it "calculates _integral_ based on accumulated error" do
319
+ it "calculates integral based on accumulated error" do
257
320
  pid = PIDController.new(1000)
258
321
  pid.ki = 1.0
259
- pid.update(0)
260
- # sum error should be 1000; dt is 0.001
322
+
323
+ pid.update(0) # error is 1000; dt is 0.001
261
324
  expect(pid.integral).must_equal(1.0)
262
- pid.update(999)
325
+
326
+ pid.update(999) # error is 1, sum_error is 1.001
263
327
  expect(pid.integral).must_be_within_epsilon(1.001)
264
- pid.update(1100) # zero crossing
265
- expect(pid.integral).must_be_within_epsilon(-0.1)
328
+
329
+ pid.update(1100) # error is -100, sum_error is 0.901
330
+ expect(pid.integral).must_be_within_epsilon(0.901)
266
331
  end
267
332
 
268
- it "calculates _derivative_ based on error slope" do
333
+ it "calculates derivative based on error slope" do
269
334
  pid = PIDController.new(1000)
270
335
  pid.kp = 1.0
271
336
  pid.update(0)
272
- # error should be 1000; last_error 0
273
- expect(pid.derivative).must_equal(1_000_000)
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
+
274
341
  pid.update(500)
275
- expect(pid.derivative).must_equal(-500_000)
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
+
276
381
  pid.update(999)
277
- expect(pid.derivative).must_equal(-499_000)
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
+
278
386
  pid.update(1001)
279
- expect(pid.derivative).must_equal(-2000)
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
+
280
391
  pid.update(1100)
281
- expect(pid.derivative).must_equal(-99_000)
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
+
282
396
  pid.update(900)
283
- expect(pid.derivative).must_equal(200_000)
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
284
444
  end
285
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.2.0.2
4
+ version: 0.3.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rick Hull