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