device_control 0.0.0.3

Sign up to get free protection for your applications and to get access to all the features.
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
+ [![Test Status](https://github.com/rickhull/device_control/actions/workflows/test.yaml/badge.svg)](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: []