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.
@@ -1,267 +0,0 @@
1
- # There is a pattern for how both Controllers (e.g. thermostat) and Devices
2
- # (e.g. heater) operate.
3
- # They each have an _input_ varying over time which determines the _output_.
4
- # A thermostat (Controller) listens for temperature and tells the heater how
5
- # high to turn it up (or just on / off). A heater (Device) listens to its
6
- # control knob and yields heat as an output.
7
- #
8
- # We capture this pattern with a single method: _update_. It accepts the
9
- # latest input and provides an _output_ based on the input. When the input
10
- # is read in, perhaps some internal state is changed on
11
- # the processor which will affect the _output_.
12
- #
13
- # Any class which mixes in Updateable can define its own _input=_ method,
14
- # which may update any ivars. Any such class must define an _output_ method.
15
- #
16
- module Updateable
17
- def update(val)
18
- self.input = val
19
- self.output
20
- end
21
- end
22
-
23
- # A Device is like a heater. It has a control knob, maybe on/off or perhaps
24
- # a variable control. Its output (maybe on/off) depends on the control knob.
25
- class Device
26
- include Updateable
27
-
28
- attr_reader :knob
29
-
30
- def initialize
31
- @knob = 0.0
32
- end
33
-
34
- def input=(val)
35
- @knob = val.to_f
36
- end
37
- alias_method :knob=, :input=
38
-
39
- def output
40
- @knob # do nothing by default
41
- end
42
-
43
- def to_s
44
- format("Knob: %.3f\tOutput: %.3f", @knob, self.output)
45
- end
46
- end
47
-
48
- # Alright, fine, let's make a Heater
49
- # Input is the control knob (turned far enough to on, else off)
50
- # Output is watts
51
- class Heater < Device
52
- # convert electricity into thermal output
53
- EFFICIENCY = 0.999
54
-
55
- attr_reader :watts
56
-
57
- def initialize(watts, threshold: 0)
58
- super()
59
- @watts = watts
60
- @threshold = threshold
61
- end
62
-
63
- # output is all or none
64
- def output
65
- @knob > @threshold ? (@watts * self.class::EFFICIENCY) : 0
66
- end
67
-
68
- def to_s
69
- format("Power: %d W\tKnob: %.1f\tThermal: %.1f W",
70
- @watts, @knob, self.output)
71
- end
72
- end
73
-
74
- class Cooler < Heater
75
- # not nearly as efficient as a heater at turning electrons into therms
76
- EFFICIENCY = 0.35
77
- end
78
-
79
- # A Controller is like a thermostat. It has a setpoint, and it reads a
80
- # measurement from the environment, and it adjusts its output to try to make
81
- # the measurement match the setpoint.
82
- class Controller
83
- include Updateable
84
-
85
- attr_reader :measure
86
- attr_accessor :setpoint
87
-
88
- def initialize(setpoint)
89
- @setpoint, @measure = setpoint, 0.0
90
- end
91
-
92
- def input=(val)
93
- @measure = val.to_f
94
- end
95
- alias_method :measure=, :input=
96
-
97
- # just output the error
98
- def output
99
- @setpoint - @measure
100
- end
101
-
102
- def to_s
103
- format("Setpoint: %.3f\tMeasure: %.3f", @setpoint, @measure)
104
- end
105
- end
106
-
107
- class Thermostat < Controller
108
- # true or false; can drive a Heater or a Cooler
109
- # true means input below setpoint; false otherwise
110
- def output
111
- @setpoint - @measure > 0
112
- end
113
- end
114
-
115
- class Flexstat < Thermostat
116
- def self.cold_val(hot_val)
117
- case hot_val
118
- when true, false
119
- !hot_val
120
- when 0,1
121
- hot_val == 0 ? 1 : 0
122
- when Numeric
123
- 0
124
- when :on, :off
125
- hot_val == :on ? :off : :on
126
- else
127
- raise "#{hot_val.inspect} not recognized"
128
- end
129
- end
130
-
131
- def initalize(hot_val: false, cold_val: nil)
132
- @hot_val = hot_val
133
- @cold_val = cold_val.nil? ? self.class.cold_val(hot_val) : cold_val
134
- end
135
-
136
- def output
137
- super ? @cold_val : @hot_val
138
- end
139
- end
140
-
141
- # now consider e.g.
142
- # h = Heater.new(1000)
143
- # ht = Thermostat.new(20)
144
- # c = Cooler.new(1000)
145
- # ct = Thermostat.new(25)
146
- # temp = 26.4
147
- # heat_knob = ht.update(temp) ? 1 : 0
148
- # heating_watts = h.update(heat_knob)
149
- # cool_knob = ct.update(temp) ? 0 : 1
150
- # cooling_watts = c.update(cool_knob)
151
- # etc
152
-
153
- # A StatefulController tracks its error over time: current, last, accumulated
154
- #
155
- class StatefulController < Controller
156
- HZ = 1000
157
- TICK = Rational(1) / HZ
158
-
159
- attr_accessor :dt
160
- attr_reader :error, :last_error, :sum_error
161
-
162
- def initialize(setpoint, dt: TICK)
163
- super(setpoint)
164
- @dt = dt
165
- @error, @last_error, @sum_error = 0.0, 0.0, 0.0
166
- end
167
-
168
- # update @error, @last_error, and @sum_error
169
- def input=(val)
170
- @measure = val
171
- @last_error = @error
172
- @error = @setpoint - @measure
173
- if @error * @last_error <= 0 # zero crossing; reset the accumulated error
174
- @sum_error = @error * @dt
175
- else
176
- @sum_error += @error * @dt
177
- end
178
- end
179
-
180
- def to_s
181
- [super,
182
- format("Error: %+.3f\tLast: %+.3f\tSum: %+.3f",
183
- @error, @last_error, @sum_error),
184
- ].join("\n")
185
- end
186
- end
187
-
188
- # A PIDController is a StatefulController that calculates
189
- # * Proportion (current error)
190
- # * Integral (accumulated error)
191
- # * Derivative (error slope, last_error)
192
- # The sum of these terms is the output
193
- #
194
- class PIDController < StatefulController
195
- # Ziegler-Nichols method for tuning PID gain knobs
196
- # https://en.wikipedia.org/wiki/Ziegler%E2%80%93Nichols_method
197
- ZN = {
198
- # Kp Ti Td Ki Kd
199
- # Var: Ku Tu Tu Ku/Tu Ku*Tu
200
- 'P' => [1/2r],
201
- 'PI' => [9/20r, 4/5r, nil, 27/50r],
202
- 'PD' => [ 4/5r, nil, 1/8r, nil, 1/10r],
203
- 'PID' => [ 3/5r, 1/2r, 1/8r, 6/5r, 3/40r],
204
- 'PIR' => [7/10r, 2/5r, 3/20r, 7/4r, 21/200r],
205
- # less overshoot than standard PID
206
- 'some' => [ 1/3r, 1/2r, 1/3r, 2/3r, 1/11r],
207
- 'none' => [ 1/5r, 1/2r, 1/3r, 2/5r, 2/30r],
208
- }
209
-
210
- # _ku_ = ultimate gain, _tu_ = oscillation period
211
- # output includes ti and td, which are not necessary
212
- # typically kp, ki, and kd are used
213
- def self.tune(type, ku, tu)
214
- record = ZN[type.downcase] || ZN[type.upcase] || ZN.fetch(type)
215
- kp, ti, td, ki, kd = *record
216
- kp *= ku if kp
217
- ti *= tu if ti
218
- td *= tu if td
219
- ki *= (ku / tu) if ki
220
- kd *= (ku * tu) if kd
221
- { kp: kp, ti: ti, td: td, ki: ki, kd: kd }
222
- end
223
-
224
- attr_accessor :kp, :ki, :kd, :p_range, :i_range, :d_range, :o_range
225
-
226
- def initialize(setpoint, dt: TICK)
227
- super
228
-
229
- # gain / multipliers for PID; tunables
230
- @kp, @ki, @kd = 1.0, 1.0, 1.0
231
-
232
- # optional clamps for PID terms and output
233
- @p_range = (-Float::INFINITY..Float::INFINITY)
234
- @i_range = (-Float::INFINITY..Float::INFINITY)
235
- @d_range = (-Float::INFINITY..Float::INFINITY)
236
- @o_range = (-Float::INFINITY..Float::INFINITY)
237
-
238
- yield self if block_given?
239
- end
240
-
241
- def output
242
- (self.proportion +
243
- self.integral +
244
- self.derivative).clamp(@o_range.begin, @o_range.end)
245
- end
246
-
247
- def proportion
248
- (@kp * @error).clamp(@p_range.begin, @p_range.end)
249
- end
250
-
251
- def integral
252
- (@ki * @sum_error).clamp(@i_range.begin, @i_range.end)
253
- end
254
-
255
- def derivative
256
- (@kd * (@error - @last_error) / @dt).clamp(@d_range.begin, @d_range.end)
257
- end
258
-
259
- def to_s
260
- super +
261
- [format(" Gain:\t%.3f\t%.3f\t%.3f",
262
- @kp, @ki, @kd),
263
- format(" PID:\t%+.3f\t%+.3f\t%+.3f\t= %.5f",
264
- self.proportion, self.integral, self.derivative, self.output),
265
- ].join("\n")
266
- end
267
- end
@@ -1,283 +0,0 @@
1
- require 'pid_controller'
2
- require 'minitest/autorun'
3
-
4
- # create a basic class that includes Updateable as a Mixin
5
- # the class should define #initialize, #input= and #output at minimum
6
- class Doubler
7
- include Updateable
8
-
9
- attr_accessor :input
10
-
11
- def initialize
12
- @input = 0.0
13
- end
14
-
15
- def output
16
- @input * 2
17
- end
18
- end
19
-
20
- describe Updateable do
21
- describe "a mixin that provides the _update_ pattern" do
22
- before do
23
- @o = Doubler.new
24
- end
25
-
26
- it "has an _update_ method that accepts an _input_ and returns _output_" do
27
- expect(@o.input).must_equal 0.0
28
- expect(@o.output).must_equal 0.0
29
-
30
- output = @o.update(45)
31
- expect(@o.input).must_equal 45
32
- expect(@o.output).must_equal output
33
- end
34
-
35
- it "requires an _output_ method" do
36
- k = Class.new(Object) do
37
- include Updateable
38
- end
39
- o = k.new
40
- expect { o.update(45) }.must_raise NoMethodError
41
- end
42
- end
43
- end
44
-
45
- describe Device do
46
- before do
47
- @device = Device.new
48
- end
49
-
50
- it "has an _output_" do
51
- expect(@device.output).must_be_kind_of Float
52
- end
53
-
54
- it "has a string representation" do
55
- expect(@device.to_s).must_be_kind_of String
56
- end
57
-
58
- it "has an _update_ method from Updateable" do
59
- expect(@device.update(2.34)).must_be_kind_of Float
60
- end
61
- end
62
-
63
- describe Heater do
64
- before do
65
- @h = Heater.new(1000)
66
- end
67
-
68
- it "has an _output_ when _knob_ is greater than zero" do
69
- expect(@h.knob).must_equal 0
70
- expect(@h.output).must_equal 0
71
- @h.knob = 1
72
- expect(@h.output).must_be :>, 0
73
- end
74
-
75
- it "has a string representation" do
76
- expect(@h.to_s).must_be_kind_of String
77
- end
78
-
79
- it "has _update_ from Updateable" do
80
- expect(@h.knob).must_equal 0
81
- expect(@h.output).must_equal 0
82
- output = @h.update(1)
83
- expect(output).must_be :>, 0
84
- expect(@h.knob).must_equal 1
85
- expect(@h.output).must_equal output
86
- end
87
- end
88
-
89
- describe Controller do
90
- before do
91
- @sp = 500
92
- @c = Controller.new(@sp)
93
- end
94
-
95
- it "has an _output_, the difference between setpoint and measure" do
96
- expect(@c.output).must_be_kind_of Float
97
- expect(@c.output).must_equal @sp
98
- end
99
-
100
- it "has a string representation" do
101
- expect(@c.to_s).must_be_kind_of String
102
- end
103
-
104
- it "has an _update_ method from Updateable" do
105
- expect(@c.update(499)).must_equal 1.0
106
- end
107
- end
108
-
109
- describe Thermostat do
110
- before do
111
- @t = Thermostat.new 25
112
- end
113
-
114
- it "outputs true when it's too cold; when measure < setpoint" do
115
- expect(@t.update 20).must_equal true
116
- expect(@t.update 30).must_equal false
117
- end
118
-
119
- it "outputs false when it's too hot; when measure > setpoint" do
120
- expect(@t.update 30).must_equal false
121
- expect(@t.update 20).must_equal true
122
- end
123
- end
124
-
125
- describe StatefulController do
126
- it "tracks error, last_error, sum_error" do
127
- sc = StatefulController.new(100)
128
- expect(sc.error).must_equal 0.0
129
- expect(sc.last_error).must_equal 0.0
130
- expect(sc.sum_error).must_equal 0.0
131
-
132
- output = sc.update 50
133
- expect(sc.output).must_equal output
134
- expect(sc.measure).must_equal 50
135
- expect(sc.error).must_be_within_epsilon 50.0
136
- expect(sc.last_error).must_equal 0.0
137
- expect(sc.sum_error).must_be_within_epsilon(50.0 * sc.dt)
138
-
139
- output = sc.update 75
140
- expect(sc.output).must_equal output
141
- expect(sc.measure).must_equal 75
142
- expect(sc.error).must_be_within_epsilon 25.0
143
- expect(sc.last_error).must_be_within_epsilon 50.0
144
- expect(sc.sum_error).must_be_within_epsilon(75.0 * sc.dt)
145
- end
146
-
147
- it "resets sum_error after crossing setpoint" do
148
- sc = StatefulController.new(100)
149
- sc.update 50
150
- sc.update 75
151
- expect(sc.sum_error).must_be_within_epsilon(75.0 * sc.dt)
152
- sc.update 125
153
- expect(sc.error).must_equal(-25.0)
154
- expect(sc.sum_error).must_equal(sc.error * sc.dt)
155
- end
156
- end
157
-
158
- describe PIDController do
159
- it "informs Ziegler-Nichols tuning" do
160
- # P only, not PID
161
- hsh = PIDController.tune('P', 5, 0.01)
162
- expect(hsh[:kp]).must_be :>, 0
163
- expect(hsh[:ki]).must_be_nil
164
- expect(hsh[:kd]).must_be_nil
165
- expect(hsh[:ti]).must_be_nil
166
- expect(hsh[:td]).must_be_nil
167
-
168
- hsh = PIDController.tune('PI', 5, 0.01)
169
- expect(hsh[:kp]).must_be :>, 0
170
- expect(hsh[:ki]).must_be :>, 0
171
- expect(hsh[:kd]).must_be_nil
172
- expect(hsh[:ti]).must_be :>, 0
173
- expect(hsh[:td]).must_be_nil
174
-
175
- hsh = PIDController.tune('PID', 5, 0.01)
176
- expect(hsh[:kp]).must_be :>, 0
177
- expect(hsh[:ki]).must_be :>, 0
178
- expect(hsh[:kd]).must_be :>, 0
179
- expect(hsh[:ti]).must_be :>, 0
180
- expect(hsh[:td]).must_be :>, 0
181
- end
182
-
183
- it "has an optional _dt_ argument to initialize" do
184
- pid = PIDController.new(1000, dt: 0.1)
185
- expect(pid).must_be_kind_of PIDController
186
- expect(pid.setpoint).must_equal 1000
187
- expect(pid.dt).must_equal 0.1
188
- end
189
-
190
- it "has PID gain settings" do
191
- pid = PIDController.new(1000)
192
- expect(pid.kp).must_be :>, 0
193
- pid.kp = 1000
194
- expect(pid.kp).must_equal 1000
195
- pid.ki = 1000
196
- expect(pid.ki).must_equal 1000
197
- pid.kd = 1000
198
- expect(pid.kd).must_equal 1000
199
- end
200
-
201
- it "clamps the _proportion_ term" do
202
- pid = PIDController.new(1000)
203
- pid.p_range = (0..1)
204
- pid.update(500)
205
- expect(pid.proportion).must_equal 1.0
206
- pid.update(1500)
207
- expect(pid.proportion).must_equal 0.0
208
- end
209
-
210
- it "clamps the _integral_ term" do
211
- pid = PIDController.new(1000)
212
- pid.i_range = (-1.0 .. 1.0)
213
- pid.setpoint = 10_000
214
- pid.update(500)
215
- expect(pid.integral).must_equal 1.0
216
- pid.update(10_001)
217
- pid.update(20_000)
218
- expect(pid.integral).must_equal(-1.0)
219
- end
220
-
221
- it "clamps the _derivative_ term" do
222
- pid = PIDController.new(1000)
223
- pid.d_range = (-1.0 .. 0.0)
224
- pid.update(0)
225
- pid.update(10)
226
- expect(pid.derivative).must_equal(-1.0)
227
- pid.update(990)
228
- expect(pid.derivative).must_equal(-1.0)
229
- pid.update(1000)
230
- pid.update(990)
231
- expect(pid.derivative).must_equal(0.0)
232
- end
233
-
234
- it "clamps the _output_" do
235
- pid = PIDController.new(1000)
236
- pid.o_range = (0.0 .. 1.0)
237
- pid.update(0)
238
- expect(pid.output).must_equal(1.0)
239
- pid.update(2000)
240
- expect(pid.output).must_equal(0.0)
241
- end
242
-
243
- it "calculates _proportion_ based on current error" do
244
- pid = PIDController.new(1000)
245
- pid.kp = 1.0
246
- pid.update(0)
247
- expect(pid.proportion).must_equal 1000.0
248
- pid.update(1)
249
- expect(pid.proportion).must_equal 999.0
250
- pid.update(1001)
251
- expect(pid.proportion).must_equal(-1.0)
252
- end
253
-
254
- it "calculates _integral_ based on accumulated error" do
255
- pid = PIDController.new(1000)
256
- pid.ki = 1.0
257
- pid.update(0)
258
- # sum error should be 1000; dt is 0.001
259
- expect(pid.integral).must_equal(1.0)
260
- pid.update(999)
261
- expect(pid.integral).must_be_within_epsilon(1.001)
262
- pid.update(1100) # zero crossing
263
- expect(pid.integral).must_be_within_epsilon(-0.1)
264
- end
265
-
266
- it "calculates _derivative_ based on error slope" do
267
- pid = PIDController.new(1000)
268
- pid.kp = 1.0
269
- pid.update(0)
270
- # error should be 1000; last_error 0
271
- expect(pid.derivative).must_equal(1_000_000)
272
- pid.update(500)
273
- expect(pid.derivative).must_equal(-500_000)
274
- pid.update(999)
275
- expect(pid.derivative).must_equal(-499_000)
276
- pid.update(1001)
277
- expect(pid.derivative).must_equal(-2000)
278
- pid.update(1100)
279
- expect(pid.derivative).must_equal(-99_000)
280
- pid.update(900)
281
- expect(pid.derivative).must_equal(200_000)
282
- end
283
- end