device_control 0.1.0.1 → 0.2.0.2

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: 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