device_control 0.2.0.2 → 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: 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