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