(For what it's worth, I can see that this image indicates I have my motor set up backwards. Oops. I'll fix it tomorrow night. But the question I have is more of a theory question so I will soldier on.)
Note that currently I'm only using the P and I terms, and they have hardly been tuned. Basically I wanted to step back and make sure I know what I'm doing. Right now, the pseudocode for the behavior is
signal = pid.Update(dt, err);
motor.SetDutyCycle(signal);
What I've come to realize is that under a setup like this, the proportional term alone is never going to do much good. It will either undershoot, or if it tuned high enough to cause the motor to reach the desired speed, the error will be reduced to zero and therefore the motor's duty cycle.
That means that most of the heavy lifting in this job is inevitably going to be handled by the "i" term of the controller. That term will accumulate the error and figure out what the appropriate setpoint is. The d term would be to prevent overshoot, essentially.
My question is: is this the correct understanding of how the PID controller should be used? I could imagine another use:
signal += pid.Update(...);
motor.SetDuty(signal);
From everything I've read, I'm pretty sure this is not correct, but please let me know if I've misunderstood.
Besides PID control, is there a more preferred mechanism for controlling my motors' speed?
For informed help, please read and follow the instructions in the "How to get the best out of this forum" post, linked at the head of every forum category.
Note: the P term does the "heavy lifting" and in many practical circumstances is all that is required for satisfactory controller operation. In all cases, proper tuning is required before any informed judgement can be made.
Thank you for the reply. I am familiar with the forum guidelines. Can you share which of the guidelines I've failed to meet with my original post?
Note: the P term does the "heavy lifting" and in many practical circumstances is all that is required for satisfactory controller operation.
Yeah, see, this is what has me confused. In the case of controlling a motor's speed, I do not see how this can possibly be the case. Suppose we are controlling a motor's speed in degrees per second using the duty cycle of the motor as the process variable. Suppose we have a motor that will spin at 360 deg/sec unloaded when duty cycle is set to 100%, and to make the math easy let's suppose it will also scale exactly linearly in duty cycle, and suppose we desire that same speed.
Let's try a few different kP values and see what happens. We'll simulate each second of operation just to make things simple:
kP = 1/360
0: err = 360, p = 1, output = 1
During the following second, the motor will accelerate to 360 deg/s. The error is now zero.
1: err = 0, p = 0 * 1/360 = 0, output = 0
During the following second, the motor decelerates to zero deg/s
3: etc. Motor will converge somewhere between 90 and 135.
So, yeah, in this model, I do not understand how kP can be a dominant contributor to the overall output. Unless I've misunderstood what is theoretically supposed to be done with the output of the PID controller. That is the theoretical question I'm trying to understand in my original post.
No code, no equipment specifications, no wiring diagram, no useful numbers. In other words, nothing for forum members to cross check for you.
what is theoretically supposed to be done with the output of the PID controller
Directly control the process variable, in this case, motor speed. There are many tutorials on line, and since Control Theory is core component of mechanical engineering, many textbooks.
That all makes sense, because it's a theoretical question. There is not currently any particular problem with my code that I need help with. My motor works, my PID controller is tested and appears to do PID-like things, and I'm not stuck on anything else in particular. I needed a cross-check to make sure I wasn't fundamentally understanding how PID controllers are supposed to be used.
You mean duty cycle, right?
Yes, there are tutorials, but many of them are maddeningly vague or rely on LabView, which is irrelevant to me as an implementor on a microcontroller. For example, one tutorial I've looked at is here. If you look through it, a lot of what the PID controller actually does at a detailed/implementation level is implicit. Many of the tutorials online are for position control, where I can see much more clearly how P would be a dominant factor in the control loop.
If you are curious for the code despite all I've said so far, feel welcome to peruse it here. I would strongly discourage you spending time looking through this code for defects, because it's not something I need help with, and anyway I have some pending commits that significantly revise aspects of this already pending and just not synced yet.
#ifndef INTPID_INTPID_H
#define INTPID_INTPID_H
#include <FixedPointsCommon.h>
#include <cassert>
#include <cstdint>
#include <expected>
#include <limits>
#include <memory>
#include <string>
namespace intpid {
// Since this pid controller uses fixed point under the covers, it is important
// to keep the output within the range of the underlying type. In this case,
// that means your output should be centered somewhere near zero, and have an
// absolute value of 2^14-1 (which is 16383). On the other hand, be careful to
// stay away from outputs so small they require more than 13-14 bits of
// precision.
struct Config {
// Each update, we multiply the error by this number and add it to the output
// signal.
float kp;
// Each update, we multiply the accumulated error * time by this number and
// add it to the output signal. The error is accumulated only when the output
// is not saturated by the P and D terms.
//
// Hint: if this number is equal to kp, then if the error is constant for one
// tick, the I term will be equal to the P term. If it's 1/10 of kp, then
// it will take ten ticks before I = P.
float ki;
// Each update, we multiply the rate of change in the error by this number
// and add it to the output signal.
//
// Hint: if this number is equal to kp, then if the error is changing at rate
// X/tick, and the current error is also X, then the D term will equal the P
// term. If this is 1/10th of kp, then the change in error has to be 10X/tick
// before D = P.
float kd;
// The minimum and maximum outputs.
float output_min;
float output_max;
};
class Pid {
public:
Pid(Pid&&) = default;
Pid& operator=(Pid&&) = default;
static std::expected<Pid, std::string> Create(const Config& config);
// Adjusts the setpoint. This must be called once before the first call
// to Update.
void set_setpoint(SQ15x16 setpoint) { setpoint_ = setpoint; }
// Updates the PID controller with feedback and returns the new output value.
// dt is unitless -- it just needs to be consistent with the unit for
// integral_time and derivative_time.
SQ15x16 Update(SQ15x16 measurement, SQ15x16 dt);
private:
Pid(const Config& config)
: kp_(config.kp),
ki_(config.ki),
kd_(config.kd),
output_min_(config.output_min),
output_max_(config.output_max),
integrator_lower_cutoff_(output_min_ -
.25 * (output_max_ - output_min_)),
integrator_upper_cutoff_(output_max_ +
.25 * (output_max_ - output_min_)) {}
static SQ15x16 MaxI(SQ15x16 output_min, SQ15x16 output_max) {
const auto range = output_max - output_min;
const auto absmin = absFixed(output_min);
const auto absmax = absFixed(output_max);
// We only have an offset from zero if our output does not include zero in
// its range.
const bool has_offset = output_min > 0 == output_max != 0;
const auto offset = absmin > absmax ? absmin : absmax;
return 2 * (range + (has_offset ? offset : 0));
}
const SQ15x16 kp_, ki_, kd_;
const SQ15x16 integrator_clamp_;
const SQ15x16 output_min_, output_max_;
SQ15x16 setpoint_ = 0;
// Note: unlike other PID controller implementations I've seen, we multiply
// the error * time by ki before adding it to i_sum_. This means that it's
// easy to clamp the integrator to the desired range without the risk of
// overflow (that range being 2x the output range).
SQ15x16 i_sum_ = 0;
// If the raw output is outside of this range, the integrator term is reduced
// until the raw output would be at the edge of the range.
const SQ15x16 integrator_lower_cutoff_;
const SQ15x16 integrator_upper_cutoff_;
SQ15x16 prev_measurement_ = 0;
SQ15x16 derr_ = 0;
#if INTPID_SUPPRESS_LOGGING == 0
public:
// If logging is enabled, we record the values we saw for the
// setpoint, measurement, and the calculated P I and D terms.
// We also expose the state of the integrator and derr.
// These values are for testing only.
SQ15x16 setpoint() const { return setpoint_; }
SQ15x16 measurement() const { return measurement_; }
SQ15x16 p() const { return p_; }
SQ15x16 i() const { return i_; }
SQ15x16 d() const { return d_; }
SQ15x16 derr() const { return derr_; }
SQ15x16 sum() const { return sum_; }
private:
SQ15x16 measurement_ = 0;
SQ15x16 p_ = 0;
SQ15x16 i_ = 0;
SQ15x16 d_ = 0;
SQ15x16 sum_ = 0;
#endif
};
} // namespace intpid
#endif // INTPID_INTPID_H
#include "intpid.h"
#include <algorithm>
#include <cassert>
#include <cstdint>
#include <expected>
#include <format>
#include <limits>
namespace intpid {
std::expected<Pid, std::string> Pid::Create(const Config& config) {
Pid pid(config);
return pid;
}
SQ15x16 Pid::Update(SQ15x16 measurement, SQ15x16 dt) {
if (dt <= 0) {
prev_measurement_ = measurement;
return 0;
}
const SQ15x16 err = setpoint_ - measurement;
const SQ15x16 prev_err = setpoint_ - prev_measurement_;
derr_ = err - prev_err;
prev_measurement_ = measurement;
const SQ15x16 p = kp_ * err;
const SQ15x16 d = kd_ * derr_;
const SQ15x16 pd = p + d;
const SQ15x16 err_time = err * dt;
const SQ15x16 di = err_time * ki_;
i_sum_ += di;
SQ15x16 sum = pd + i_sum_;
if (sum > output_max_) {
if (sum > integrator_upper_cutoff_) {
// Anti-windup: prevent the integrator from going far higher than
// needed to saturate the output. This just undoes the increment we
// did earlier.
i_sum_ -= di;
}
sum = output_max_;
} else if (sum < output_min_) {
if (sum < integrator_lower_cutoff_) {
i_sum_ -= di;
}
sum = output_min_;
}
#if INTPID_SUPPRESS_LOGGING == 0
measurement_ = measurement;
p_ = p;
i_ = i_sum_;
d_ = d;
sum_ = sum;
#endif
return sum;
}
} // namespace intpid
No, the motor speed. Duty cycle is a commonly used approximation for motor speed control, but has highly nonlinear response and doesn't work at all, at very low speeds.
I have a motor and an encoder. I'm trying to control the motor's speed. It is not a variable I can just set, I need a feedback loop to determine what duty cycle to set the motor at to achieve a given speed. I mentioned this in the first sentence of my first post.
If you want a theoretical answer, then you control the motor speed.
If you are want a practical answer, then one option is to control duty cycle, which is NOT speed, but a reasonable approximation in some few limited cases. IF there is a linear relationship between PWM and speed, then the scale factor is included in the properly chosen K constants. If they are not properly chosen, then your code won't work.
Thanks. I take it that you are saying that PID controllers cannot or should not be used to control motor speed, or that you don't know how to use them for that purpose. Fine. I've read that they are typically used for this purpose, but perhaps I'm wrong.
If any one else knows about this topic and has helpful information to add, I'd welcome it. Otherwise, I'll muddle on on my own.
PID is very commonly used to control motor speed, but good understanding of the basic theory, some practical experience and experimentation is required for satisfactory results.
And, of course, the motor power and control electronics have to work properly.
It would be worth a google to understand what PID control is and how it works .
Output = proportional to error + integral of error over time .
Error being the error from the setpoint . So at the setpoint the proportional value is zero and the output is the integral term.
On its own proportional will get you get you some where towards the setpoint , but there will be an error from it , enough to drive the output “close” to the setpoint .
Derivative , which you aren’t using is about the rate you are approaching the setpoint, the faster , the more it pulls the output back .
Thanks. Yes, I did understand those basics, although I hadn't connected one fact that your post calls attention to. In any process where the driven variable is not zero at the setpoint (e.g. a motor seeking a given speed) the P term will be zero when you've reached the steady state. In fact, if you're in a steady state where the error is low, the I term is the only term that will have any value (because derr is zero or close to it, as is err).
I got the motor basically working last night. Regrettably, the noise in the speed sensor was quite high, probably because I'm polling it at a different interval than it is producing data. It has an output that can produce an interrupt each time it finishes collecting data, so I'm working on getting that fixed up now. However, there is some bug in my code, because everything grinds to a halt after a few interrupts, without even doing something like crashing. I'm working on debugging this now.
PID theory assumes that the sample time interval is constant, and that input and output are performed at the same time point. Deviations from those assumptions do impact the stability.
if you're in a steady state where the error is low, the I term is the only term that will have any value
The I term is often reset, and eventually goes to zero too. For that reason, when PWM is used for speed control, people usually modify the PID output term, such as forcing an upper limit and applying a zero offset.
For example, when steering a 2WD robot with differential drive, something like this works better than relying on the I term.
This doesn't match my understanding. In a system where average error is zero, the expected value of the I term would also be what I was initially set to in that system, or the value it was whenever the system achieved stability.
Yes. If you are operating at the setpoint with zero error at steady state, only the integral term can possibly contribute to the output. PID systems with bumpless initialization/transfer use that fact to initialize the integral term such that integratedError = Output/Ki. The arduino PID library author explains his implementation of bumpless transfer here:
If there is any error or process disturbances, the tuning of the terms and their interactions is important. If you aren't completely rigorous about tuning, many implementations often depend heavily on the P term and set the setpoint a little higher than the actual desired setpoint, and rely on output = Kp*error to do the work. Cruise controls on cars often do this--you can't set the CC to an explicit "65mph", you just get controls to bump the unseen setpoint up or down to visually make the actual speed as desired, and accept some slop around it as the load changes when you go uphill or downhill. Relying on the P term instead of the I term trades off some of some level of error for more robustness.
I've got some Wokwi simulations of PID systems in this thread:
Ah. This is great. I had recognized that problem in my own system and tried to counteract it by suppressing derr_ after setting a new setpoint, but I didn't think of a manual modification to the integrator. Thank you!
I managed to get my system working where it receives an interrupt when the encoder has new data. This has really smoothed out the speed signal, and the improvement in the quality of the motor drive is actually audible (the motor sounds a lot less grindy).
When stationary, this sensor is good to within half a degree. However, dt is about 10ms, so half a degree of angle noise from measurement to measurement would lead to 50 degrees/sec of noise in a speed signal. This is more or less what I see in my graphs.
(The motor is a no-name bdc motor from aliexpress.)
I suppose the first question I should ask, now that I feel like I have the code basically working (although the PID is not tuned yet), is whether I am simply barking up the wrong tree with this sensor. Is it incapable of doing what I intend to do? I had been under the impression that Lego motors use a similar sort of magnetometer to sense the absolute position of their motor output shafts but I may be mistaken. If there is some fundamental reason why this can't be used, please let me know.
According to the datasheet, the sensor can be configured in a number of different modes that lead to different conversion speeds and different amounts of noise. I'm wondering if it makes sense to configure the sensor for a lower noise but slower-to-update rate, or the opposite? Currently, I have it configured to update at slightly less than 100hz.
If I choose the higher frequency, higher noise approach, I imagine it would need some kind of filtering on the Arduino. I currently smooth the speed with an exponentially weighted moving average:
bool MLX90393Sensor::Update(uint64_t t) {
std::array<float, 2> data;
if (!sensor_->readMeasurement(MLX90393_X | MLX90393_Y, data)) {
return false;
}
// Note we only update sample time when we can successfully take a sample.
const SQ15x16 dt_ms = SQ15x16{SFixed<24, 4>{t - t_} / 1'000};
t_ = t;
const SQ15x16 newangle = VectorToAngleDegrees(data[0], data[1]);
const SQ15x16 delta = newangle - rawangle_;
rawangle_ = newangle;
SQ15x16 corrected_delta;
if (absFixed(delta) < 180) {
// If the delta was less than 180, we'll assume that it's a normal move --
// that is, the sensor did not wrap around.
corrected_delta = delta;
} else {
// If the move was more than 180 degrees, we assume that we've wrapped
// around. That means the *sign* of the move is the opposite delta, and the
// magnitude is 360 - abs(delta).
corrected_delta = (delta > 0 ? -360 + delta : 360 + delta);
}
// Since we want to accumulate the total angle, we add the adjusted delta.
angle_ += corrected_delta;
const SQ15x16 raw_speed = corrected_delta / (dt_ms / 1000);
// There is too much noise in the speed measurement to use it directly. We
// compute an exponentially weighted moving average of the speed to smooth out
// the signal.
speed_ = speed_ * 0.5 + raw_speed * 0.5;
return true;
}
Any advice on this with respect to the best approach for averaging the rate? It's currently EWMA w/ alpha = 0.5. I could also imagine keeping a ring of the last four values and averaging them. Any other suggestions?