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 +4 -4
- data/README.md +8 -4
- data/VERSION +1 -1
- data/lib/device_control.rb +236 -216
- data/test/device_control.rb +2 -0
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c076f6bf87773d4901c6e3d1f7e8455b130d9baacd3ac2310dcb114ef69430cc
|
4
|
+
data.tar.gz: f01f70616a8bf5da7038e595321ad443f71130ba221ff5a3a9bae9458a1a9f51
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
66
|
-
an environmental output. The environment accepts the new output and
|
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.2.0.2
|
data/lib/device_control.rb
CHANGED
@@ -1,267 +1,287 @@
|
|
1
|
-
|
2
|
-
#
|
3
|
-
#
|
4
|
-
#
|
5
|
-
#
|
6
|
-
#
|
7
|
-
#
|
8
|
-
#
|
9
|
-
#
|
10
|
-
#
|
11
|
-
#
|
12
|
-
#
|
13
|
-
#
|
14
|
-
#
|
15
|
-
#
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
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
|
-
|
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
|
-
|
29
|
+
attr_reader :knob
|
29
30
|
|
30
|
-
|
31
|
-
|
32
|
-
|
31
|
+
def initialize
|
32
|
+
@knob = 0.0
|
33
|
+
end
|
33
34
|
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
35
|
+
def input=(val)
|
36
|
+
@knob = val.to_f
|
37
|
+
end
|
38
|
+
alias_method :knob=, :input=
|
38
39
|
|
39
|
-
|
40
|
-
|
41
|
-
|
40
|
+
def output
|
41
|
+
@knob # do nothing by default
|
42
|
+
end
|
42
43
|
|
43
|
-
|
44
|
-
|
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
|
-
|
53
|
-
|
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
|
-
|
56
|
+
attr_reader :watts
|
56
57
|
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
58
|
+
def initialize(watts, threshold: 0)
|
59
|
+
super()
|
60
|
+
@watts = watts
|
61
|
+
@threshold = threshold
|
62
|
+
end
|
62
63
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
64
|
+
# output is all or none
|
65
|
+
def output
|
66
|
+
@knob > @threshold ? (@watts * self.class::EFFICIENCY) : 0
|
67
|
+
end
|
67
68
|
|
68
|
-
|
69
|
-
|
70
|
-
|
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
|
-
|
76
|
-
|
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
|
-
|
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
|
-
|
86
|
-
|
86
|
+
attr_reader :measure
|
87
|
+
attr_accessor :setpoint
|
87
88
|
|
88
|
-
|
89
|
-
|
90
|
-
|
89
|
+
def initialize(setpoint)
|
90
|
+
@setpoint, @measure = setpoint, 0.0
|
91
|
+
end
|
91
92
|
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
93
|
+
def input=(val)
|
94
|
+
@measure = val.to_f
|
95
|
+
end
|
96
|
+
alias_method :measure=, :input=
|
96
97
|
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
98
|
+
# just output the error
|
99
|
+
def output
|
100
|
+
@setpoint - @measure
|
101
|
+
end
|
101
102
|
|
102
|
-
|
103
|
-
|
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
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
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
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
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
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
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
|
-
|
137
|
-
|
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
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
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
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
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
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
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
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
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
|
-
|
225
|
+
attr_accessor :kp, :ki, :kd, :p_range, :i_range, :d_range, :o_range
|
225
226
|
|
226
|
-
|
227
|
-
|
227
|
+
def initialize(setpoint, dt: TICK)
|
228
|
+
super
|
228
229
|
|
229
|
-
|
230
|
-
|
230
|
+
# gain / multipliers for PID; tunables
|
231
|
+
@kp, @ki, @kd = 1.0, 1.0, 1.0
|
231
232
|
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
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
|
-
|
239
|
-
|
239
|
+
yield self if block_given?
|
240
|
+
end
|
240
241
|
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
242
|
+
def output
|
243
|
+
(self.proportion +
|
244
|
+
self.integral +
|
245
|
+
self.derivative).clamp(@o_range.begin, @o_range.end)
|
246
|
+
end
|
246
247
|
|
247
|
-
|
248
|
-
|
249
|
-
|
248
|
+
def proportion
|
249
|
+
(@kp * @error).clamp(@p_range.begin, @p_range.end)
|
250
|
+
end
|
250
251
|
|
251
|
-
|
252
|
-
|
253
|
-
|
252
|
+
def integral
|
253
|
+
(@ki * @sum_error).clamp(@i_range.begin, @i_range.end)
|
254
|
+
end
|
254
255
|
|
255
|
-
|
256
|
-
|
257
|
-
|
256
|
+
def derivative
|
257
|
+
(@kd * (@error - @last_error) / @dt).clamp(@d_range.begin, @d_range.end)
|
258
|
+
end
|
258
259
|
|
259
|
-
|
260
|
-
|
261
|
-
|
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
|
data/test/device_control.rb
CHANGED