device_control 0.0.0.3
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 +7 -0
- data/README.md +251 -0
- data/Rakefile +22 -0
- data/VERSION +1 -0
- data/device_control.gemspec +17 -0
- data/lib/pid_controller.rb +267 -0
- data/test/pid_controller.rb +283 -0
- metadata +48 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: cf0616f0c1a9a604310b793eea313261cc6f70ecefe3a401ae3df83b973a4f45
|
4
|
+
data.tar.gz: 7ad33892bf3ad7420d4c72f3c94efe0aa9861889427d1028cba27a2fd1bc9fb1
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 1c71afb1964f420e16fa7941e7470cf33c19183218d3bb97f040f34047d653b3e52d1f86e2874ff96997caf5de38a9902f9d4e112f25a516bd1e316da40f79c3
|
7
|
+
data.tar.gz: a24a1a3e8d7dd72b3f30a4470f5cff0a5590c1c7ec00c76b711e5c91d9e799386182477a1400f57d3a2d701375ecfa417c11fcf36d8e3bbde9f09d75d50db802
|
data/README.md
ADDED
@@ -0,0 +1,251 @@
|
|
1
|
+
[](https://github.com/rickhull/device_control/actions/workflows/test.yaml)
|
2
|
+
|
3
|
+
# Rationale
|
4
|
+
|
5
|
+
At present, this library scratches the itch of implementing a simple PID
|
6
|
+
controller. It builds up to this with some simple abstractions that can
|
7
|
+
also be used to build other, more sophisticated controllers.
|
8
|
+
|
9
|
+
# Concepts
|
10
|
+
|
11
|
+
## Controller
|
12
|
+
|
13
|
+
A controller is a piece of equipment (or, more abstractly, perhaps even a
|
14
|
+
human operator) that is intended to achieve a certain measurement from the
|
15
|
+
environment. For example, a thermostat wants to maintain a temperature, or
|
16
|
+
the cruise control in your car wants to maintain a certain wheelspeed.
|
17
|
+
|
18
|
+
A thermostat on its own cannot (meaningfully) affect the environment; it is
|
19
|
+
just a controller, presumably for some other device, like a heater. The
|
20
|
+
thermostat, if hooked up to a heating device, can control when the heat
|
21
|
+
comes on, and this changes the measurement from the environment, ideally
|
22
|
+
towards the desired temperature.
|
23
|
+
|
24
|
+
## Device
|
25
|
+
|
26
|
+
I'm not sure about the actual nomenclature from a very rich field of study,
|
27
|
+
*control theory*, but I'm using the term "device" to describe that which the
|
28
|
+
controller is controlling. So a heater or a refrigerator may be the device,
|
29
|
+
or the throttle on an engine, or a broomstick balanced on a pencil eraser.
|
30
|
+
|
31
|
+
A controller requires a device, and a device must have some variable input,
|
32
|
+
like a control knob, which the controller can thus manipulate. The device
|
33
|
+
presumably reacts to the input with a new output, and this output presumably
|
34
|
+
affects the environment in some way that the controller can measure.
|
35
|
+
|
36
|
+
## Environment
|
37
|
+
|
38
|
+
The environment, in some way, connects the output of the device back to
|
39
|
+
the measurement on the controller. Often, in order to test a device or a
|
40
|
+
controller (or both), the environment must be modeled or simulated, often
|
41
|
+
crudely. Or perhaps the environment is already inherent to the problem, or
|
42
|
+
it has been modeled extensively as part of the problem.
|
43
|
+
|
44
|
+
This project will make little or no effort to model your environment. But
|
45
|
+
it's important to recognize that you have to "close the loop" for any of this
|
46
|
+
to make sense.
|
47
|
+
|
48
|
+
# Approach
|
49
|
+
|
50
|
+
## Control Loop
|
51
|
+
|
52
|
+
Our control loop is composed of the 3 concepts above:
|
53
|
+
|
54
|
+
```
|
55
|
+
CONTROLLER ==> DEVICE
|
56
|
+
^ |
|
57
|
+
| |
|
58
|
+
| V
|
59
|
+
ENVIRONMENT
|
60
|
+
```
|
61
|
+
|
62
|
+
### A Pattern
|
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.
|
68
|
+
|
69
|
+
```ruby
|
70
|
+
module Updateable
|
71
|
+
def update(val)
|
72
|
+
self.input = val
|
73
|
+
self.output
|
74
|
+
end
|
75
|
+
end
|
76
|
+
```
|
77
|
+
|
78
|
+
Notice, this is a module, not a class. This module is intended to be mixed in
|
79
|
+
to a class in order provide (and guarantee) the pattern of behavior. Any
|
80
|
+
class which wants to mix in `Updateable` should thus, at minimum, define:
|
81
|
+
|
82
|
+
* `initialize`
|
83
|
+
* `input=`
|
84
|
+
* `output`
|
85
|
+
|
86
|
+
Note that the class can use any ivars; there is no need to create or ever
|
87
|
+
touch `@input` if a different ivar name is preferred.
|
88
|
+
|
89
|
+
### Device
|
90
|
+
|
91
|
+
```ruby
|
92
|
+
class Device
|
93
|
+
include Updateable
|
94
|
+
|
95
|
+
attr_reader :knob
|
96
|
+
|
97
|
+
def initialize
|
98
|
+
@knob = 0.0
|
99
|
+
end
|
100
|
+
|
101
|
+
def input=(val)
|
102
|
+
@knob = val.to_f
|
103
|
+
end
|
104
|
+
alias_method :knob=, :input=
|
105
|
+
|
106
|
+
def output
|
107
|
+
@knob # do nothing by default
|
108
|
+
end
|
109
|
+
|
110
|
+
def to_s
|
111
|
+
format("Knob: %.3f\tOutput: %.3f", @knob, self.output)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
```
|
115
|
+
|
116
|
+
We've named our class `Device`, mixed in `Updateable`, and we've named our input
|
117
|
+
`knob`. In general, we will operate only on Floats for inputs and outputs,
|
118
|
+
though perhaps interesting things can be done outside this limitation.
|
119
|
+
|
120
|
+
`@knob` is initialized to zero, and `input=(val)` will update `@knob`. As
|
121
|
+
this is a generic device, we will just pass along the input as our output.
|
122
|
+
Let's also make a friendly string output.
|
123
|
+
|
124
|
+
#### Heater
|
125
|
+
|
126
|
+
```ruby
|
127
|
+
class Heater < Device
|
128
|
+
# convert electricity into thermal output
|
129
|
+
EFFICIENCY = 0.999
|
130
|
+
|
131
|
+
attr_reader :watts
|
132
|
+
|
133
|
+
def initialize(watts, threshold: 0)
|
134
|
+
super()
|
135
|
+
@watts = watts
|
136
|
+
@threshold = threshold
|
137
|
+
end
|
138
|
+
|
139
|
+
# output is all or none
|
140
|
+
def output
|
141
|
+
@knob > @threshold ? (@watts * self.class::EFFICIENCY) : 0
|
142
|
+
end
|
143
|
+
|
144
|
+
def to_s
|
145
|
+
format("Power: %d W\tKnob: %.1f\tThermal: %.1f W",
|
146
|
+
@watts, @knob, self.output)
|
147
|
+
end
|
148
|
+
end
|
149
|
+
```
|
150
|
+
|
151
|
+
Starting with a generic device, we'll add `@watts` for output, and we'll also
|
152
|
+
allow a configurable threshold for the knob -- *at what point does the knob
|
153
|
+
turn to on?* By default, anything above 0.
|
154
|
+
|
155
|
+
BTW, this is a crude model, as `@watts` sort of represents the input energy,
|
156
|
+
and we are representing its output as "the amout of heat that 1000 watts
|
157
|
+
(or whatever) puts out". Since electric devices waste power by shedding heat,
|
158
|
+
electric heaters are very efficient by definition. It's not difficult to dump
|
159
|
+
all your power into heat; just use a big resistor.
|
160
|
+
|
161
|
+
#### Cooler
|
162
|
+
|
163
|
+
```ruby
|
164
|
+
class Cooler < Heater
|
165
|
+
# not nearly as efficient as a heater at turning electrons into therms
|
166
|
+
EFFICIENCY = 0.35
|
167
|
+
end
|
168
|
+
```
|
169
|
+
|
170
|
+
A cooler is just a heater that puts out watts of cooling. You'd have to
|
171
|
+
model the inverse effect in your environment. You can of course create more
|
172
|
+
sophisticated Heater and Cooler models as well ;)
|
173
|
+
|
174
|
+
### Controller
|
175
|
+
|
176
|
+
```ruby
|
177
|
+
class Controller
|
178
|
+
include Updateable
|
179
|
+
|
180
|
+
attr_reader :measure
|
181
|
+
attr_accessor :setpoint
|
182
|
+
|
183
|
+
def initialize(setpoint)
|
184
|
+
@setpoint, @measure = setpoint, 0.0
|
185
|
+
end
|
186
|
+
|
187
|
+
def input=(val)
|
188
|
+
@measure = val.to_f
|
189
|
+
end
|
190
|
+
alias_method :measure=, :input=
|
191
|
+
|
192
|
+
# just output the error
|
193
|
+
def output
|
194
|
+
@setpoint - @measure
|
195
|
+
end
|
196
|
+
|
197
|
+
def to_s
|
198
|
+
format("Setpoint: %.3f\tMeasure: %.3f", @setpoint, @measure)
|
199
|
+
end
|
200
|
+
end
|
201
|
+
```
|
202
|
+
|
203
|
+
A Controller names its input `measure`, and it introduces a `setpoint`, and
|
204
|
+
the difference between setpoint and measure is the error.
|
205
|
+
|
206
|
+
#### Thermostat
|
207
|
+
|
208
|
+
```ruby
|
209
|
+
class Thermostat < Controller
|
210
|
+
# true or false; can drive a Heater or a Cooler
|
211
|
+
# true means input below setpoint; false otherwise
|
212
|
+
def output
|
213
|
+
@setpoint - @measure > 0
|
214
|
+
end
|
215
|
+
end
|
216
|
+
```
|
217
|
+
|
218
|
+
Now consider:
|
219
|
+
|
220
|
+
```ruby
|
221
|
+
|
222
|
+
h = Heater.new(1000)
|
223
|
+
ht = Thermostat.new(20)
|
224
|
+
c = Cooler.new(1000)
|
225
|
+
ct = Thermostat.new(25)
|
226
|
+
|
227
|
+
temp = 26.4
|
228
|
+
|
229
|
+
heat_knob = ht.update(temp) ? 1 : 0
|
230
|
+
heating_watts = h.update(heat_knob)
|
231
|
+
cool_knob = ct.update(temp) ? 0 : 1
|
232
|
+
cooling_watts = c.update(cool_knob)
|
233
|
+
|
234
|
+
temp = 24.9
|
235
|
+
|
236
|
+
# ...
|
237
|
+
|
238
|
+
```
|
239
|
+
|
240
|
+
Notice, the thermostat essentially answers the question: *is it hot enough?*
|
241
|
+
(or: *is it too cold?*). You can run it either or both ways, but notice that
|
242
|
+
you can simply pick one orientation and remain logically consistent. So the
|
243
|
+
**heat knob** goes to 1 when its thermostat goes *below* setpoint.
|
244
|
+
The **cool knob** goes to 1 when its thermostat goes *above* setpoint.
|
245
|
+
|
246
|
+
# Finale
|
247
|
+
|
248
|
+
If you've made it this far, congratulations! For further reading:
|
249
|
+
|
250
|
+
* [lib/pid_controller.rb](lib/pid_controller.rb#L155)
|
251
|
+
* [test/pid_controller.rb](test/pid_controller.rb)
|
data/Rakefile
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'rake/testtask'
|
2
|
+
|
3
|
+
Rake::TestTask.new :test do |t|
|
4
|
+
t.pattern = "test/*.rb"
|
5
|
+
t.warning = true
|
6
|
+
end
|
7
|
+
|
8
|
+
#
|
9
|
+
# GEM BUILD / PUBLISH
|
10
|
+
#
|
11
|
+
|
12
|
+
begin
|
13
|
+
require 'buildar'
|
14
|
+
|
15
|
+
Buildar.new do |b|
|
16
|
+
b.gemspec_file = 'device_control.gemspec'
|
17
|
+
b.version_file = 'VERSION'
|
18
|
+
b.use_git = true
|
19
|
+
end
|
20
|
+
rescue LoadError
|
21
|
+
warn "buildar tasks unavailable"
|
22
|
+
end
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.0.0.3
|
@@ -0,0 +1,17 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = 'device_control'
|
3
|
+
s.summary = "WIP"
|
4
|
+
s.description = "WIP"
|
5
|
+
s.authors = ["Rick Hull"]
|
6
|
+
s.homepage = "https://github.com/rickhull/device_control"
|
7
|
+
s.license = "LGPL-3.0"
|
8
|
+
|
9
|
+
s.required_ruby_version = "> 2"
|
10
|
+
|
11
|
+
s.version = File.read(File.join(__dir__, 'VERSION')).chomp
|
12
|
+
|
13
|
+
s.files = %w[device_control.gemspec VERSION README.md Rakefile]
|
14
|
+
s.files += Dir['lib/**/*.rb']
|
15
|
+
s.files += Dir['test/**/*.rb']
|
16
|
+
s.files += Dir['demo/**/*.rb']
|
17
|
+
end
|
@@ -0,0 +1,267 @@
|
|
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
|
@@ -0,0 +1,283 @@
|
|
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
|
metadata
ADDED
@@ -0,0 +1,48 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: device_control
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.0.3
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Rick Hull
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 1980-01-01 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: WIP
|
14
|
+
email:
|
15
|
+
executables: []
|
16
|
+
extensions: []
|
17
|
+
extra_rdoc_files: []
|
18
|
+
files:
|
19
|
+
- README.md
|
20
|
+
- Rakefile
|
21
|
+
- VERSION
|
22
|
+
- device_control.gemspec
|
23
|
+
- lib/pid_controller.rb
|
24
|
+
- test/pid_controller.rb
|
25
|
+
homepage: https://github.com/rickhull/device_control
|
26
|
+
licenses:
|
27
|
+
- LGPL-3.0
|
28
|
+
metadata: {}
|
29
|
+
post_install_message:
|
30
|
+
rdoc_options: []
|
31
|
+
require_paths:
|
32
|
+
- lib
|
33
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
34
|
+
requirements:
|
35
|
+
- - ">"
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '2'
|
38
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
39
|
+
requirements:
|
40
|
+
- - ">="
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
version: '0'
|
43
|
+
requirements: []
|
44
|
+
rubygems_version: 3.2.26
|
45
|
+
signing_key:
|
46
|
+
specification_version: 4
|
47
|
+
summary: WIP
|
48
|
+
test_files: []
|