Controlling an automotive throttle body with an arduino UNO

Hello all!

I have undertaken the project of creating a throttle body controller for a project car of mine, and have been experimenting with different code and motor drivers for around a month now.

I have been using PID control and a feedback loop from a throttle position sensor to calculate the error from the setpoint (which is a potentiometer as of now).

The issue I am having is in the response time/how well the throttle "follows" the pot input. I just can't quite seem to get it to follow the input accurately, without overshooting or oscillating rapidly.

I have tried to follow various tuning algorithms and they all seem to end up in too much overshoot for my liking.

As of now, I have a positive and negative coefficient for all 3 parts of the PID, this is because on the closing of the throttle the spring assists the action, so I figured less gain was needed in that direction of motion.

This is an image of what the response looks like. I have turned off both the Derivative and feed forward part of the tune, as I was just attempting to get a response with no oscillations.


I can get the throttle to follow the pedal quite well by upping the proportional gain, but that results in a bunch of overshoot, which is not good enough for me to use in an actual setting.

Below is the code I am running

/*
Program: TB controller program
Motor Driver: Cytron 13Amp 6V-30V DC Motor Driver
Date: 07/05/2025
Author: Kaden Van Domselaar
Wiring: Refer to pin definitions
*/

#include "CytronMotorDriver.h"

// Pin definitions
#define PEDAL_PIN A0  //currently POT input (full size signal)
#define PEDAL_PIN2 A1  //Half size signal pedal sensor
#define TPS_PIN A2 // TPS1 - Blue grey 
#define TPS_PIN2 A3 // TPS2 - Yellow white
#define PWM 3
#define DIR 4
#define Setsim 13 //simulate WOT
CytronMD motor(PWM_DIR, 3, 4); //define PWM and DIR pins to driver


struct {
  int Kp, Kpe, Ki, Kie, integralofe, Kd, Kde, derivativeofe, Kff, Kffe; //PID coefficents
  int KpNeg, KpPos, KiNeg, KiPos, KdNeg, KdPos, KffNeg, KffPos; //PID coefficent Limits
  double PreviousTime, deltaT, CurrentTime; //Used for Integral Action
  float PreviousSetpoint, DeltaSetpoint; //Used for Kff
  float PreviousError; //Used for Kd
} PID;

struct {
  int Pedal1Raw, Pedal2Raw, TPS1Raw, TPS2Raw; //Raw Readings from sensors
  int TruePedal, TrueTPS; //Outputs of Filters - Used to show filter action on Raw signal
  int Pedal1Min, Pedal1Max, Pedal2Min, Pedal2Max, TPS1Min, TPS1Max, TPS2Min, TPS2Max; //Mina and max analog values for all 4 sensors
}IN;


int dutycyclelow = -255, dutycyclehigh = 255;

int u = 0; //control signal
int e = 0; //error

void setup() {
  TCCR2B = TCCR2B & 0b11111000 | 0x01; //PWM to 32kHz
  pinMode(PEDAL_PIN, INPUT); //
  pinMode(PEDAL_PIN2, INPUT); //

  pinMode(TPS_PIN, INPUT); //
  pinMode(TPS_PIN2, INPUT); //

  pinMode(PWM, OUTPUT); // 0-255
  pinMode(DIR, OUTPUT); // High or Low
  Serial.begin(9600);

  // PID gains
  PID.KpPos = 300; // 100 = 1:1 ratio
  PID.KpNeg = 150; 

  PID.KiPos = 100;  // 100 = 1:1 ratio
  PID.KiNeg = 50; 

  PID.KdPos = 0; // 1000 = 1:1 ratio
  PID.KdNeg = 0;

  PID.KffPos = 0; // 1000 = 1:1 ratio NOT BEING USED
  PID.KffNeg = 0; 

  PID.PreviousTime = 0; 
  PID.deltaT = 0;
  PID.integralofe = 0;
  PID.derivativeofe = 0;
  PID.PreviousSetpoint = 0;
  PID.PreviousError = 0;
  PID.DeltaSetpoint = 0;
  PID.CurrentTime = 0;
  PID.Kpe = 0;
  PID.Kie = 0;
  PID.Kde = 0;
  PID.Kffe = 0;

  IN.TruePedal = 0;
  IN.TrueTPS = 0;

  IN.Pedal1Min = 148;
  IN.Pedal1Max = 534;

  IN.Pedal2Min = 0; //Not being used right now
  IN.Pedal2Max = 0;

  IN.TPS1Min = 790;
  IN.TPS1Max = 360; 

  IN.TPS2Min = 0; //not being used right now
  IN.TPS2Max = 0;
}


void loop() {

  //Calculate Delta Time
  PID.CurrentTime = millis();
  PID.deltaT = (PID.CurrentTime-PID.PreviousTime)/1000;
  PID.PreviousTime = PID.CurrentTime;

  //Scan Pedal Signals
  //IN.Pedal1Raw = map(analogRead(PEDAL_PIN),IN.Pedal1Min,IN.Pedal1Max,0,100); //Pedal one
  IN.Pedal1Raw = map(analogRead(PEDAL_PIN),0,1020,1,100); //Reading Pot for testing
  IN.Pedal1Raw = min(IN.Pedal1Raw,100);
  IN.Pedal1Raw = max(IN.Pedal1Raw,0);

  IN.Pedal2Raw = map(analogRead(PEDAL_PIN2),235,500,0,100); //Read pedal two
  IN.Pedal2Raw = min(IN.Pedal2Raw,100);
  IN.Pedal2Raw = max(IN.Pedal2Raw,0);


  IN.TPS1Raw = map(analogRead(TPS_PIN), IN.TPS1Min, IN.TPS1Max, 0, 100); //read TPS one
  IN.TPS2Raw = map(analogRead(TPS_PIN2), 777, 987, 0, 100); //read TPS two


  //Filters not being used bc they slowed down the response time and were not needed, will remove extra variables in final version

  //Filtering Pedal signal
  IN.TruePedal = IN.Pedal1Raw;
  
  //Filtering TPS signal
  IN.TrueTPS = IN.TPS1Raw;
 


  if(digitalRead(13)) {
     IN.TruePedal = 80;  
  } //force WOT (for testing PID loop)



  //ERROR CALC
  e = IN.TruePedal - IN.TrueTPS;


  //Kp error calculaton
  if(e>0)
  {
    PID.Kp = PID.KpPos; //positive error gain
  } 
  else 
  {
    PID.Kp = PID.KpNeg; //negative error gain
  }

  PID.Kpe = PID.Kp * e / 100;


  //Ki error calculation
  if(abs(e) < 7)
  {
    PID.integralofe += e * PID.deltaT; //If we are within 7 percent error then start integrating the error
  }
  else
  {
    PID.integralofe = 0;
  }

  //add reset integralofe to 0 if error changes direction

  if(e>0) 
  {
    PID.Ki = PID.KiPos; //postive error gain
  } 
  else
  {
    PID.Ki = PID.KiNeg; //negative error gain
  }
  
  PID.Kie = PID.Ki * PID.integralofe / 100;
  


  //Kd error calculation
  if(e>0) 
  {
    PID.Kd = PID.KdPos; //postive error gain
  } 
  else 
  {
    PID.Kd = PID.KdNeg; //negative error gain
  }

  PID.derivativeofe = (e - PID.PreviousError)/PID.deltaT;
  PID.PreviousError = e;
  PID.Kde = PID.Kd * PID.derivativeofe / 1000;


  //Kf calculations NOT BEING USED
  /*
  if(e>0) {PID.Kff = PID.KffPos;} 
   else {PID.Kff = PID.KffNeg;}
  PID.DeltaSetpoint = (IN.TruePedal - PID.PreviousSetpoint);
  PID.PreviousSetpoint = IN.TruePedal;
  PID.Kffe = PID.DeltaSetpoint * PID.Kff / 1000;
  */

  //PID summing
  u = PID.Kpe + PID.Kie + PID.Kde + PID.Kffe;



  //Cap PWM signal
  if(u>dutycyclehigh)
  {
    u = dutycyclehigh;
  }
  if(u<dutycyclelow)
  {
    u = dutycyclelow;
  }

  //Drive Motor
  motor.setSpeed(u);


  //Tuning stuff for the graph
  Serial.print(PID.Kpe);
  Serial.print(" ");

  Serial.print(PID.Kie);
  Serial.print(" ");

  Serial.print(PID.Kde);
  Serial.print(" ");

  Serial.print(IN.TruePedal);
  Serial.print(" ");
  Serial.print(IN.TrueTPS);
  Serial.print(" ");
  Serial.println(u);

}

I saw in someone else code in a similar project that they had limited the Integral to only working within 7 percent of the setpoint, so I tried to compliment that into my code as well.

I would eventually like my setup to be as accurate as the one in this video:

This person though has developed their own microcontroller and has integrated it with an ECU tuning software, with CANBUS capability, fancy stuff. In the video you can see how well the TPS follows the pedal, unlike mine. When I try and replicate the coefficients he uses, it results in a ton of overshoot, which makes me think my code is poorly written.

On top of that he is able to get his TPS to read within 5 percent or less of the setpoint with just proportional gain, something that seems to be impossible for me to do.

Right now, I am a bit stumped as to what I should do to get this setup to work better. I was hoping by posting this to the forum you all could give me suggestions on how to make it better.

This is my first Arduino/electronics project, so any input helps, Thanks!

Just from first principles, it doesn't make sense to turn off derivative control if your goal is to reduce/remove oscillations. Typically, the higher you make the proportional gain (to improve tracking or response time), the higher you must make the derivative gain to stabilize the response.

Sorry yes, I had just done that temporarily to get a good image for the post.

The problem is that when I turn up the derivative, it does dampen the overshoot, but before the overshoot is fully negated, it starts causing oscillations as well.

Overall my problem definitely seems to be in my tuning or programming, but I just can't seem to get it to react quick, while also not overshooting the setpoint.

I just can't seem to get mine to at all act like other peoples do.

If derivative gain is too high, it can amplify high-frequency noise in your system.

The overshoot may in part be due to the fact that the input you are using for testing is a step change (i.e., it starts immediately at 80 instead of ramping up from 0). You may get better results using velocity feedback control instead of derivative control. This means that your derivative control action computation would be replaced by something like this:

  PID.derivativeofTPS = (PreviousTPS - IN.TrueTPS)/PID.deltaT;
  PreviousTPS = IN.TrueTP;
  PID.Kde = -  PID.Kd * PID.derivativeofTPS / 1000; // Note the negative sign

You might also get some additional insight if you plot the separate control actions PID.Kpe, PID.Kie, and PID.Kde along with the input and output.

I’m speaking through my bum here, but to me, it seems counterproductive to use a spring in conjunction with a PID controlled servo.

I see what you mean, but I think all throttle bodies come with spring return. Its also not a servo I don't think, its just a DC motor that uses PWM to vary the position, using the throttle position sensor as feedback of said position. I think the spring is just a failsafe, so if power gets cut the throttle shuts on its own.

Ive got that plotted now, thanks for the idea.

I never thought about doing that, that makes good sense ill set that up and give it a shot :smiley:

Thanks for the quick replies everyone I really appreciate it!!

I have quickly played around with the gains and have gotten a response that looks like this:

Getting closer, but still too much overshoot.

I will try to up the derivative term tomorrow, but in the meantime I'm wondering what would be best for the integral term? Is the way I have it set up okay? Or is the "only integrate when error is less than x" a bad idea?

Again thanks for the help. :+1:

Generally, using this strategy is going to reduce the overshoot.

Also, I see no evidence in your response that the integral control action is having any effect. Its role is to close the gap between the actual response and the desired value, so if it is working, you should see the TPS output at least start to creep towards the required position after the initial transients have settled.

Are the orange and green lines at the bottom of the plot supposed to be you control actions PID.Kpe , PID.Kie , and PID.Kde? If so, it seems that only one of them is having any effect (presumably PID.Kpe). Also, you may want to increase your baud rate so that the plots are not so choppy.

I don't think this is a good idea, and it will make your tuning more difficult. When approaching the desired setpoint (whether from above or below), the spring will mostly affect the proportional control action (because this type of control acts like a virtual spring that returns the throttle body to the commanded setpoint); the interaction between the real spring and the virtual spring is like that of two parallel springs, so the proportional gain and the stiffness combine into a single effective stiffness (or a single effective gain). Thus, if there is no good reason to have separate positive/negative gains in the absence of a physical spring, then, IMO, there is also no good reason to have separate positive/negative gains in the presence of a physical spring.

Instead, what I would suggest to try is the following. Assuming the spring is pre-tensioned, so that some minimum torque Tmin is required to cause the throttle body to open at all, then make the following adjustment to the calculation of the control action u:

u = Tmin + PID.Kpe + PID.Kie + PID.Kde + PID.Kffe; // Add offset Tmin to overcome spring pretension

You'd have to experiment to find an appropriate value of Tmin (the easiest way would probably be to set IN.TruePedal = 0 and find the highest value of Tmin at which the TPS consistently measures 0 at a wide range of gain values for PID.Kp).

As of that image the green line is PID.Kde, the orange is PID.Kie, and the blue (not turned on in the image) is PID.Kpe.

For whatever reason the integral term was not working there, Ill have to look into that. Ill also increase the baud rate too, thanks for the suggestions.

As for this idea, I will implement that as well. In my research I found a few other people using the "Tmin" idea, they called it a sort of offset to the control signal. I didn't quite understand its purpose until your explanation there, so thanks for that.

So essentially, with the Tmin, I am trying to find the control signal that provides the torque that will effectively "remove" the spring from the system, as the springs torque will always be cancelled out by the Tmin?

You'll need to look at all three, if you're troubleshooting the tuning.

Maybe because your response never satisfied the requirement abs(e) < 7?

 

Not quite. You are just removing the initial tension, so the result will be as if the spring is at its rest length when the TPS reads zero. I am not familiar with the detailed mechanics of throttle springs, but I assume that when the spring has closed the throttle valve, the spring is still under some tension? If so, there will be no movement of the valve until you get to a torque that can overcome the initial spring tension. This may lead you to dial up the gains too high, just so that u will be able to exceed the initial tension and produce any movement.

The Tmin term is intended to make the system behave as though the there is no initial tension in the throttle spring, so that even a small control torque will cause the valve to start opening. Therefore, even small to moderate gain values (which are less likely to produce overshoot) will be able to make the valve open.

Yeah not sure why I had that turned off... dumb move.

Ill try and up the error that it kicks in at, see if that makes a difference.

Im pretty sure that is how it works, seems to be the way mine works at least.

Ahhh okay I understand now, thanks for explaining again.

Going to try and put all of this into practice and see if I can get a good response out of it.

Thanks again :+1:

I have been playing around with the ideas you gave me and ive gotten it pretty good, only issue being that there seems to be a ton of instability at the lower percentages


This is what the graph looks like when I have the setpoint at 5 percent
PID.Kpe = blue, PID.Kde = green, yellow is setpoint and pink is TPS
(integral not in effect for now)

And this is what it looks like at 69 percent setpoint.


totally stable and smooth, Same legend as before.

I'm really not sure what to do at this point, I have tried to turn down my PID.Kp but then I lose a ton of response time, which is not ideal.

I added a capacitor between the TPS input and ground, that has cleared some of the noise out, but it still remains at low percentages.

One would think if it was a PID coefficient issue the instability would exist at higher percentages as well?

Anyways, here is my current code for reference:

/*
Program: TB controller program
Motor Driver: Cytron 13Amp 6V-30V DC Motor Driver
Date: 07/05/2025
Author: Kaden Van Domselaar
Wiring: Refer to pin definitions
*/

#include "CytronMotorDriver.h"

// Pin definitions
#define PEDAL_PIN A0  //currently POT input (full size signal)
#define PEDAL_PIN2 A1  //Half size signal pedal sensor
#define TPS_PIN A2 // TPS1 - Blue grey 
#define TPS_PIN2 A3 // TPS2 - Yellow white
#define PWM 3
#define DIR 4
#define Setsim 13 //simulate WOT
CytronMD motor(PWM_DIR, 3, 4); //define PWM and DIR pins to driver


struct {
  float Kp, Ki, Kd, Kff; //PID coefficents
  float integralofe, derivativeofTPS, derivativeofe; //Integral and derivative action stuff
  double PreviousTime, deltaT, CurrentTime; //Used for Integral Action
  float PreviousSetpoint, DeltaSetpoint; //Used for Kff
  float PreviousTPS, Previouserror; //Used for Kd
  float Tmin;
  float Kpe, Kie, Kde, Kffe; //PID control signal parts
} PID;

struct {
  int Pedal1Raw, Pedal2Raw, TPS1Raw, TPS2Raw; //Raw Readings from sensors
  int TruePedal, TrueTPS; //Outputs of Filters - Used to show filter action on Raw signal
  int Pedal1Min, Pedal1Max, Pedal2Min, Pedal2Max, TPS1Min, TPS1Max, TPS2Min, TPS2Max; //Mina and max analog values for all 4 sensors
}IN;


int dutycyclelow = -255, dutycyclehigh = 255;

float u = 0; //control signal
float e = 0; //error

void setup() {
  TCCR2B = TCCR2B & 0b11111000 | 0x01; //PWM to 32kHz
  pinMode(PEDAL_PIN, INPUT); //
  pinMode(PEDAL_PIN2, INPUT); //

  pinMode(TPS_PIN, INPUT); //
  pinMode(TPS_PIN2, INPUT); //

  pinMode(PWM, OUTPUT); // 0-255
  pinMode(DIR, OUTPUT); // High or Low
  Serial.begin(38400);

  // PID gains
  PID.Kp = 9; // 1:1 ratio (1 percent error = 1 unit of signal) //10

  PID.Ki = 0; // 1:1 ratio (1 percent error = 1 unit of signal)

  PID.Kd = 90; // 1000 = 1:1 ratio

  PID.Kff = 0; // 1000 = 1:1 ratio NOT BEING USED
  
  PID.Tmin = 35; //20

  PID.PreviousTime = 0; 
  PID.deltaT = 0;
  PID.integralofe = 0;
  PID.derivativeofTPS = 0;
  PID.derivativeofe = 0;
  PID.Previouserror = 0;
  PID.PreviousSetpoint = 0;
  PID.PreviousTPS = 0;
  PID.DeltaSetpoint = 0;
  PID.CurrentTime = 0;
  PID.Kpe = 0;
  PID.Kie = 0;
  PID.Kde = 0;
  PID.Kffe = 0;
 

  IN.TruePedal = 0;
  IN.TrueTPS = 0;

  IN.Pedal1Min = 148;
  IN.Pedal1Max = 534;

  IN.Pedal2Min = 0; //Not being used right now
  IN.Pedal2Max = 0;

  IN.TPS1Min = 790;
  IN.TPS1Max = 360; 

  IN.TPS2Min = 0; //not being used right now
  IN.TPS2Max = 0;
}


void loop() {

  //Calculate Delta Time
  PID.CurrentTime = millis();
  PID.deltaT = max((PID.CurrentTime-PID.PreviousTime)/1000,0.0001); //Make sure to not divide by 0, thats why the max function is there
  PID.PreviousTime = PID.CurrentTime;

  //Scan Pedal Signals
  //IN.Pedal1Raw = map(analogRead(PEDAL_PIN),IN.Pedal1Min,IN.Pedal1Max,0,100); //Pedal one
  IN.Pedal1Raw = map(analogRead(PEDAL_PIN),0,1020,5,100); //Reading Pot for testing
  IN.Pedal1Raw = min(IN.Pedal1Raw,100);
  IN.Pedal1Raw = max(IN.Pedal1Raw,0);

  IN.Pedal2Raw = map(analogRead(PEDAL_PIN2),235,500,0,100); //Read pedal two
  IN.Pedal2Raw = min(IN.Pedal2Raw,100);
  IN.Pedal2Raw = max(IN.Pedal2Raw,0);


  IN.TPS1Raw = map(analogRead(TPS_PIN), IN.TPS1Min, IN.TPS1Max, 0, 100); //read TPS one
  IN.TPS2Raw = map(analogRead(TPS_PIN2), 777, 987, 0, 100); //read TPS two


  //Filters not being used bc they slowed down the response time and were not needed, will remove extra variables in final version

  //Filtering Pedal signal
  IN.TruePedal = IN.Pedal1Raw;
  
  //Filtering TPS signal
  IN.TrueTPS = IN.TPS1Raw;
 


  if(digitalRead(13)) {
     IN.TruePedal = 90;  
  } //force WOT (for testing PID loop)



  //ERROR CALC
  e = IN.TruePedal - IN.TrueTPS;


  //Kp error calculaton
  PID.Kpe = PID.Kp * e;


  //Ki error calculation
  if(abs(e) < 5)
  {
    PID.integralofe += e * PID.deltaT; //If we are within 10 percent error then start integrating the error
  }
  else
  {
    PID.integralofe = 0;
  }
  //add reset integralofe to 0 if error changes direction

  PID.Kie = PID.Ki * PID.integralofe;
  

  //Kd error calculation
  //Slope of error derivative
  
  PID.derivativeofe = (e - PID.Previouserror)/PID.deltaT;
  PID.Previouserror = e;
  PID.Kde = PID.Kd * PID.derivativeofe / 1000;
  
  
  //Slope of TPS derivative
  /*
  PID.derivativeofTPS = (PID.PreviousTPS - IN.TrueTPS)/PID.deltaT;
  PID.PreviousTPS = IN.TrueTPS;
  PID.Kde =- PID.Kd * PID.derivativeofTPS / 1000;
  */

  //Kf calculations NOT BEING USED
  /*
  if(e>0) {PID.Kff = PID.KffPos;} 
   else {PID.Kff = PID.KffNeg;}
  PID.DeltaSetpoint = (IN.TruePedal - PID.PreviousSetpoint);
  PID.PreviousSetpoint = IN.TruePedal;
  PID.Kffe = PID.DeltaSetpoint * PID.Kff / 1000;
  */

  //PID summing
  u = PID.Kpe + PID.Kie + PID.Kde + PID.Kffe + PID.Tmin;



  //Cap PWM signal
  u = constrain(u, dutycyclelow, dutycyclehigh);

  //Drive Motor
  motor.setSpeed(u);


  //Tuning stuff for the graph
  Serial.print(PID.Kpe);
  Serial.print(" ");

  Serial.print(PID.Kie);
  Serial.print(" ");

  Serial.print(PID.Kde);
  Serial.print(" ");

  Serial.print(IN.TruePedal);
  Serial.print(" ");
  Serial.print(IN.TrueTPS);
  Serial.print(" ");
  Serial.print(PID.deltaT);
  Serial.print(" ");
  Serial.println(u);

}

Thanks again for the help, its looking way better then yesterday that's for sure :laughing: