device_control 0.1.0.1 → 0.2.0.2

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: bdcd9344b15b304432916abc67301349241f87f6001e1b8501ed391e810adccf
4
- data.tar.gz: 601ff3ef86d511d25ee7357b3798580f57f1afba3efccb9647d8b77ac7c165d4
3
+ metadata.gz: c076f6bf87773d4901c6e3d1f7e8455b130d9baacd3ac2310dcb114ef69430cc
4
+ data.tar.gz: f01f70616a8bf5da7038e595321ad443f71130ba221ff5a3a9bae9458a1a9f51
5
5
  SHA512:
6
- metadata.gz: 5ce49d473ee9c6c380f1e1f91b254b529db2a04bdc3652786d84a8e5c9c88b8a495530256ebbff5d4f60fe9e6323be6c965d85eda1161a378a048b329a8c017a
7
- data.tar.gz: 850d60e4173e84cbb2cd3aea55502a9b159e220c8dbed038f3b7391c6594ffe428c38dd6e0ed149f5ae0fd5d732f1ea74a47285f3975a8ce73c6986947efe36f
6
+ metadata.gz: 63b908be961a7ec43f8d1cd4df3baef1cb649e56356dc3f166e3cfc48ad814183f10cac65b44cee6bba91a7d5d8b0860f7ace50ce0c2bb41bc90f50d163ee453
7
+ data.tar.gz: e4bcd7948c9916692616bf7fd938bec2681f4d40326a626c2dda556c08eb3a7e6ccaaa62aa825e55a27fd61127c9be34fa7adad43326dabd5f9a26a8aa78ce8f
data/README.md CHANGED
@@ -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
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.0.1
1
+ 0.2.0.2
@@ -1,267 +1,287 @@
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
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
20
22
  end
21
- end
22
23
 
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
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
27
28
 
28
- attr_reader :knob
29
+ attr_reader :knob
29
30
 
30
- def initialize
31
- @knob = 0.0
32
- end
31
+ def initialize
32
+ @knob = 0.0
33
+ end
33
34
 
34
- def input=(val)
35
- @knob = val.to_f
36
- end
37
- alias_method :knob=, :input=
35
+ def input=(val)
36
+ @knob = val.to_f
37
+ end
38
+ alias_method :knob=, :input=
38
39
 
39
- def output
40
- @knob # do nothing by default
41
- end
40
+ def output
41
+ @knob # do nothing by default
42
+ end
42
43
 
43
- def to_s
44
- format("Knob: %.3f\tOutput: %.3f", @knob, self.output)
44
+ def to_s
45
+ format("Knob: %.3f\tOutput: %.3f", @knob, self.output)
46
+ end
45
47
  end
46
- end
47
48
 
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
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
54
55
 
55
- attr_reader :watts
56
+ attr_reader :watts
56
57
 
57
- def initialize(watts, threshold: 0)
58
- super()
59
- @watts = watts
60
- @threshold = threshold
61
- end
58
+ def initialize(watts, threshold: 0)
59
+ super()
60
+ @watts = watts
61
+ @threshold = threshold
62
+ end
62
63
 
63
- # output is all or none
64
- def output
65
- @knob > @threshold ? (@watts * self.class::EFFICIENCY) : 0
66
- end
64
+ # output is all or none
65
+ def output
66
+ @knob > @threshold ? (@watts * self.class::EFFICIENCY) : 0
67
+ end
67
68
 
68
- def to_s
69
- format("Power: %d W\tKnob: %.1f\tThermal: %.1f W",
70
- @watts, @knob, self.output)
69
+ def to_s
70
+ format("Power: %d W\tKnob: %.1f\tThermal: %.1f W",
71
+ @watts, @knob, self.output)
72
+ end
71
73
  end
72
- end
73
74
 
74
- class Cooler < Heater
75
- # not nearly as efficient as a heater at turning electrons into therms
76
- EFFICIENCY = 0.35
77
- end
75
+ class Cooler < Heater
76
+ # not nearly as efficient as a heater at turning electrons into therms
77
+ EFFICIENCY = 0.35
78
+ end
78
79
 
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
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
84
85
 
85
- attr_reader :measure
86
- attr_accessor :setpoint
86
+ attr_reader :measure
87
+ attr_accessor :setpoint
87
88
 
88
- def initialize(setpoint)
89
- @setpoint, @measure = setpoint, 0.0
90
- end
89
+ def initialize(setpoint)
90
+ @setpoint, @measure = setpoint, 0.0
91
+ end
91
92
 
92
- def input=(val)
93
- @measure = val.to_f
94
- end
95
- alias_method :measure=, :input=
93
+ def input=(val)
94
+ @measure = val.to_f
95
+ end
96
+ alias_method :measure=, :input=
96
97
 
97
- # just output the error
98
- def output
99
- @setpoint - @measure
100
- end
98
+ # just output the error
99
+ def output
100
+ @setpoint - @measure
101
+ end
101
102
 
102
- def to_s
103
- format("Setpoint: %.3f\tMeasure: %.3f", @setpoint, @measure)
103
+ def to_s
104
+ format("Setpoint: %.3f\tMeasure: %.3f", @setpoint, @measure)
105
+ end
104
106
  end
105
- end
106
107
 
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
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
112
114
  end
113
- end
114
115
 
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"
116
+ class Flexstat < Thermostat
117
+ def self.cold_val(hot_val)
118
+ case hot_val
119
+ when true, false
120
+ !hot_val
121
+ when 0,1
122
+ hot_val == 0 ? 1 : 0
123
+ when Numeric
124
+ 0
125
+ when :on, :off
126
+ hot_val == :on ? :off : :on
127
+ else
128
+ raise "#{hot_val.inspect} not recognized"
129
+ end
128
130
  end
129
- end
130
131
 
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
132
+ def initalize(hot_val: false, cold_val: nil)
133
+ @hot_val = hot_val
134
+ @cold_val = cold_val.nil? ? self.class.cold_val(hot_val) : cold_val
135
+ end
135
136
 
136
- def output
137
- super ? @cold_val : @hot_val
137
+ def output
138
+ super ? @cold_val : @hot_val
139
+ end
138
140
  end
139
- end
140
141
 
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
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
155
+ #
156
+ class StatefulController < Controller
157
+ HZ = 1000
158
+ TICK = Rational(1) / HZ
159
+
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
167
168
 
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
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
177
179
  end
178
- end
179
180
 
180
- def to_s
181
- [super,
182
- format("Error: %+.3f\tLast: %+.3f\tSum: %+.3f",
183
- @error, @last_error, @sum_error),
184
- ].join("\n")
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
185
187
  end
186
- end
187
188
 
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
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
+ # Ziegler-Nichols method for tuning PID gain knobs
197
+ # https://en.wikipedia.org/wiki/Ziegler%E2%80%93Nichols_method
198
+ ZN = {
199
+ # Kp Ti Td Ki Kd
200
+ # Var: Ku Tu Tu Ku/Tu Ku*Tu
201
+ 'P' => [1/2r],
202
+ 'PI' => [9/20r, 4/5r, nil, 27/50r],
203
+ 'PD' => [ 4/5r, nil, 1/8r, nil, 1/10r],
204
+ 'PID' => [ 3/5r, 1/2r, 1/8r, 6/5r, 3/40r],
205
+ 'PIR' => [7/10r, 2/5r, 3/20r, 7/4r, 21/200r],
206
+ # less overshoot than standard PID
207
+ 'some' => [ 1/3r, 1/2r, 1/3r, 2/3r, 1/11r],
208
+ 'none' => [ 1/5r, 1/2r, 1/3r, 2/5r, 2/30r],
209
+ }
210
+
211
+ # _ku_ = ultimate gain, _tu_ = oscillation period
212
+ # output includes ti and td, which are not necessary
213
+ # typically kp, ki, and kd are used
214
+ def self.tune(type, ku, tu)
215
+ record = ZN[type.downcase] || ZN[type.upcase] || ZN.fetch(type)
216
+ kp, ti, td, ki, kd = *record
217
+ kp *= ku if kp
218
+ ti *= tu if ti
219
+ td *= tu if td
220
+ ki *= (ku / tu) if ki
221
+ kd *= (ku * tu) if kd
222
+ { kp: kp, ti: ti, td: td, ki: ki, kd: kd }
223
+ end
223
224
 
224
- attr_accessor :kp, :ki, :kd, :p_range, :i_range, :d_range, :o_range
225
+ attr_accessor :kp, :ki, :kd, :p_range, :i_range, :d_range, :o_range
225
226
 
226
- def initialize(setpoint, dt: TICK)
227
- super
227
+ def initialize(setpoint, dt: TICK)
228
+ super
228
229
 
229
- # gain / multipliers for PID; tunables
230
- @kp, @ki, @kd = 1.0, 1.0, 1.0
230
+ # gain / multipliers for PID; tunables
231
+ @kp, @ki, @kd = 1.0, 1.0, 1.0
231
232
 
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)
233
+ # optional clamps for PID terms and output
234
+ @p_range = (-Float::INFINITY..Float::INFINITY)
235
+ @i_range = (-Float::INFINITY..Float::INFINITY)
236
+ @d_range = (-Float::INFINITY..Float::INFINITY)
237
+ @o_range = (-Float::INFINITY..Float::INFINITY)
237
238
 
238
- yield self if block_given?
239
- end
239
+ yield self if block_given?
240
+ end
240
241
 
241
- def output
242
- (self.proportion +
243
- self.integral +
244
- self.derivative).clamp(@o_range.begin, @o_range.end)
245
- end
242
+ def output
243
+ (self.proportion +
244
+ self.integral +
245
+ self.derivative).clamp(@o_range.begin, @o_range.end)
246
+ end
246
247
 
247
- def proportion
248
- (@kp * @error).clamp(@p_range.begin, @p_range.end)
249
- end
248
+ def proportion
249
+ (@kp * @error).clamp(@p_range.begin, @p_range.end)
250
+ end
250
251
 
251
- def integral
252
- (@ki * @sum_error).clamp(@i_range.begin, @i_range.end)
253
- end
252
+ def integral
253
+ (@ki * @sum_error).clamp(@i_range.begin, @i_range.end)
254
+ end
254
255
 
255
- def derivative
256
- (@kd * (@error - @last_error) / @dt).clamp(@d_range.begin, @d_range.end)
257
- end
256
+ def derivative
257
+ (@kd * (@error - @last_error) / @dt).clamp(@d_range.begin, @d_range.end)
258
+ end
258
259
 
259
- def to_s
260
- super +
261
- [format(" Gain:\t%.3f\t%.3f\t%.3f",
260
+ def to_s
261
+ [super,
262
+ format(" Gain:\t%.3f\t%.3f\t%.3f",
262
263
  @kp, @ki, @kd),
263
264
  format(" PID:\t%+.3f\t%+.3f\t%+.3f\t= %.5f",
264
265
  self.proportion, self.integral, self.derivative, self.output),
265
266
  ].join("\n")
267
+ end
268
+ end
269
+
270
+ class Smoother
271
+ include Updateable
272
+
273
+ def initialize(max_step:)
274
+ @max_step = max_step
275
+ @val = 0.0
276
+ end
277
+
278
+ def input=(val)
279
+ diff = val - @val
280
+ @val += diff.clamp(-1 * @max_step, @max_step)
281
+ end
282
+
283
+ def output
284
+ @val
285
+ end
266
286
  end
267
287
  end
@@ -1,6 +1,8 @@
1
1
  require 'device_control'
2
2
  require 'minitest/autorun'
3
3
 
4
+ include DeviceControl
5
+
4
6
  # create a basic class that includes Updateable as a Mixin
5
7
  # the class should define #initialize, #input= and #output at minimum
6
8
  class Doubler
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.1.0.1
4
+ version: 0.2.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rick Hull