In a recent Thread I had been hoping to get some guidance about setting the k values for PID control of a small DC motor for a model train. Subsequently I came across a video by @zhomeslice (link at the bottom of this Post) showing how to tune PID for a balancing toy and later he posted code for controlling a DC motor.
I think it will be easier to discuss this if I start a new Thread. The other Threads have a lot of interesting stuff but I don't think there is any need to refer to them to follow this discussion.
I was able to get my motor working well using that motor code and I have been doing some exploration to try to understand the system better.
I have found it very hard to get an clear understandung of the relationship between the k values, the input values and the output values. In @zhomeslice's program the input is RPM and the output is a PWM value 0 - 255. But it is just as valid to use the number of µsecs per revolution as the input. And in another project the input might be angles or temperatures.
It seems to me that life would be very much simpler if the PIDcompute() function always worked with the same range of values for inputs and outputs and it was the responsibility of the user to scale his/her inputs and outputs to match. This should mean that the range of k values will always be the same, and independent of the real-world values (RPM, µsecs or oC) that the project uses.
In the code posted below I have put the PIDcompute() function directly into my program so that everything is in a single file. I find that makes it very much easier to explore the code. The actual code in PIDcompute() is very close to that in @zhomeslice's motor program.
I have arbitrarily decided that PIDcompute should work with an input and output range of 4096. My program does not have negative input or output values but I see no reason why the range cannot be +/-4096. And it may be easier to use 0-8192 as a proxy for -4096 to +4096.
I have also arbitrarily decided the the k values should always be in the range 0 to 1. To achieve this it seemed necessary to add a scale factor for the derivative calcs. I think the fact that these calculations are based on the difference between subsequent input values has the effect of making the numbers too small to be useful without scaling up.
I wondered for a while what might be a practical way of dealing with the time factor and I concluded that the simplest way tp deal with this is probably for the user to input a minimum interval which is related to the expected shortest interval between measurements. For my tests I have used 1000µsecs which is about one fifth of the shortest duration for a revolution - 200rps = 12000rpm.
I had hoped that it would be possible to do the PID calculations using long rather than float variables to speed up the computation but my experiments suggested that it would actually be slower due to the need to use division so I have given up that line of investigation. Also note that on an Uno float and double are the same - 4 byte floats.
This program seems to work well with my small DC motor. It seems to hold the speed independent of the load on the motor so I can test the program with the unloaded motor and then install it in the loco without needing any code changes.
From a practical point of view it should be noted that the this simple code has no provision to recover from a stall - but that is not a PID issue. When the program is restarted code in setup() gives the motor a burst of power to get it going.
I am very interested to get feedback on any part of this. It would be especially interesting if other people could try the code for different problems - for example temperature control or balancing. Hopefully the end product will be a PID system and a description that makes it easy to understand and apply.
// python-build-start
// action, upload
// board, arduino:avr:uno
// port, /dev/ttyACM0
// ide, 1.6.3
// python-build-end
//Variables for PID calcs
float PIDinMin = 0;
float PIDinMax = 4096;
float PIDrange = PIDinMax - PIDinMin;
float PIDoutMin = 0;
float PIDoutMax = 4096;
float Setpoint;
float kdScaleFactor = 10.0;
//Variables for the project
#define PWMpin 5
float Target = 18000;
float Input;
float Output;
float inputMin = 4000.0;
float inputMax = 40000.0;
float inputRange = inputMax - inputMin;
float outMin = 0;
float outMax = 255;
float outRangeFactor = outMax / PIDoutMax;
float inRangeFactor = PIDrange / inputRange;
int dirn = -1;
float kp = 0.1;
float ki = 0.8;
float kd = 0.2;
float minInterval = 1000.0;
int PulsesPerRevolution = 1; // not used, should not be here
unsigned long sensorInterval;
unsigned long PIDstart, PIDend, PIDduration;
// variables used by the ISR
volatile unsigned long ISRinterval;
volatile bool newISR = false;
void setup() {
Serial.begin(115200);
Serial.println(F("Source File /mnt/sdb1/SGT-Docs/ModelRailways/RadioControl/Dubl00BPRC/PIDThoughts/myTacho/myTacho4bPublish.ino"));
Serial.println("aTest Tachometer");
delay(1000);
//Digital Pin 2 Set As An Interrupt for tacho.
pinMode(2, INPUT_PULLUP);
attachInterrupt(0, sensorInterrupt, RISING);
Setpoint = (Target - inputMin) * inRangeFactor;
pinMode(PWMpin, OUTPUT);
analogWrite(PWMpin, 150);
delay(200);
PIDcompute();
delay(100);
PIDcompute();
}
//===========
void loop() {
readRpm();
}
//===========
void sensorInterrupt() {
unsigned long ISRmicros;
static unsigned long prevISRmicros;
ISRmicros = micros();
ISRinterval = (micros() - prevISRmicros);
prevISRmicros = ISRmicros;
newISR = true;
}
//===========
void readRpm() {
if(! newISR) return;
cli ();
sensorInterval = ISRinterval;
newISR = false;
sei ();
// convert sensorInterval to correct range for PIDcompute()
Input = ((float) sensorInterval - inputMin) * inRangeFactor;
if (Input > PIDinMax) {
Input = PIDinMax;
}
if (Input < PIDinMin) {
Input = PIDinMin;
}
//~ PIDstart = micros();
PIDcompute();
//~ PIDend = micros();
//~ PIDduration = PIDend - PIDstart;
// convert result from PIDcompute() to required range (for PWM, in this case)
Output = Output * outRangeFactor;
analogWrite(PWMpin, Output);
}
//===========
void PIDcompute() {
static unsigned long prevInput;
static unsigned long prevPIDtime;
static float ITerm;
unsigned long PIDtime = micros();
unsigned long PIDinterval = PIDtime - prevPIDtime;
prevPIDtime = PIDtime;
float DTerm = 0;
float derivative = 0;
// Calculate error
float error = Setpoint - Input;
// Proportional term
float PTerm = kp * error * dirn;
// Integral term
float timeFraction = minInterval / (float) PIDinterval;
float ITadj = error * timeFraction * ki * dirn; // uses real delta T not a fixed delta T
ITerm += ITadj;
if((ITerm > PIDoutMax) || (ITerm < PIDoutMin) ) {
ITerm -= ITadj; // prevents windup
}
//~ if(ITerm > PIDoutMax) {
//~ ITerm = PIDoutMax;
//~ }
//~ if(ITerm < PIDoutMin) {
//~ ITerm = PIDoutMin;
//~ }
// Derivative term using Input change
derivative = (prevInput - Input) * timeFraction; // uses real delta T not a fixed delta T
prevInput = Input;
DTerm = kd * derivative * dirn * kdScaleFactor;
//Compute PID Output
float output = PTerm + ITerm + DTerm ;
if(output > PIDoutMax) {
output = PIDoutMax;
}
else if (output < PIDoutMin) {
output = PIDoutMin;
}
Output = output;
// Debugging
static unsigned long QTimer = millis();
if ( millis() - QTimer >= 100 ) { // one line Spam Delay at 100 miliseconds
QTimer = millis();
char S[10];
Serial.print("PIDInterval: "); Serial.print(PIDinterval );
Serial.print(F(" \tInput ")); Serial.print(dtostrf(Input,6,2,S));
Serial.print(F("\tSetpt ")); Serial.print(dtostrf(Setpoint,6,2,S));
Serial.print(F("\tKp ")); Serial.print(dtostrf(PTerm,6,2,S));
//~ Serial.print(F("\tITadj ")); Serial.print(ITadj);
Serial.print(F("\tKi ")); Serial.print(dtostrf(ITerm,6,2,S));
Serial.print(F("\tKd ")); Serial.print(dtostrf(DTerm,9,0,S));
Serial.print(F("\tOut ")); Serial.print((int)output);
//~ Serial.print(F("\tPIDduration ")); Serial.print(PIDduration);
Serial.println();
}
}
...R