Skip to content

Detailed Calculation Flow

Alex edited this page Aug 18, 2026 · 1 revision

Detailed calc() Flow

1. Read Current Time

const uint32_t now = millis();

The current Arduino system time is used as the basis for the controller timing.


2. Calculate Elapsed Time

const uint32_t elapsedMs =
    static_cast<uint32_t>(now - mLastComputeMs);

Using unsigned subtraction makes the calculation safe across the millis() rollover.

On typical Arduino platforms using a 32-bit millisecond counter, millis() wraps after roughly 49.7 days.

The subtraction remains valid as long as the elapsed interval is within the normal unsigned arithmetic range.


3. Check Sample Time

if (mInitialized &&
    elapsedMs < mSampleTimeMs)
{
    return mOut;
}

If not enough time has passed since the previous calculation, the previous output is returned.

No P, I, or D calculation is performed during this call.


4. Initialize on the First Call

if (!mInitialized)
{
    mPrevActual = actual;
    mDerivativeFiltered = 0.0;

    mLastComputeMs = now;
    mInitialized = true;

    return mOut;
}

The derivative term requires a previous process measurement.

During the first call, no valid previous value exists.

The controller therefore stores the current measurement and waits until the next calculation before evaluating the derivative.

This prevents a large artificial derivative spike during startup.


5. Convert Elapsed Time to Seconds

const double dt =
    static_cast<double>(elapsedMs) / 1000.0;

Example:

elapsedMs = 100
dt        = 0.1 s

Using seconds is important because the integral and derivative gains are then defined consistently with time.


6. Determine Controller Direction

const double direction =
    (mDirection == Direction::DIRECT)
    ? 1.0
    : -1.0;

The raw process error is calculated as:

const double rawError =
    setpoint - actual;

The direction-adjusted control error is:

const double error =
    direction * rawError;

This allows the same internal PID equations to support both direct and reverse control action.


7. Calculate the Proportional Term

const double p =
    mKp * error;

The proportional term reacts directly to the current error:

$P = K_p e$

Example:

Kp    = 3
Error = 20

P = 60

As the actual value approaches the setpoint, the proportional term naturally decreases.


8. Calculate Derivative-on-Measurement

const double derivativeActual =
    (actual - mPrevActual) / dt;

This calculates the rate of change of the measured process value.

The derivative contribution is inverted according to controller direction:

const double rawDerivative =
    -direction * derivativeActual;

For a direct-acting controller:

Actual value rises quickly
        ->
Derivative becomes negative
        ->
Controller output is reduced

This creates a braking effect before the setpoint is reached.

That behavior is particularly useful for reducing overshoot.

Using derivative-on-measurement also avoids a derivative kick when the setpoint changes abruptly.


9. Apply the Derivative Low-Pass Filter

If filtering is enabled:

const double alpha =
    dt /
    (mDerivativeFilterTau + dt);

mDerivativeFiltered +=
    alpha *
    (rawDerivative - mDerivativeFiltered);

This is a first-order low-pass filter.

A larger filter time constant produces a smoother but slower derivative response.

If filtering is disabled:

mDerivativeFiltered = rawDerivative;

The final D term is:

const double d =
    mKd * mDerivativeFiltered;

10. Store the Current Process Value

mPrevActual = actual;

The current measurement becomes the previous measurement for the next PID calculation.


11. Calculate the Absolute Tolerance

const double toleranceAbsolute =
    fabs(setpoint) * mTolerance;

Example:

Setpoint  = 100
Tolerance = 0.02

Absolute tolerance = 2

12. Check Whether the Error Is Outside the Tolerance Band

const bool outsideTolerance =
    fabs(rawError) > toleranceAbsolute;

If the error is inside the tolerance band, the integral term is frozen.

P and D remain active.


13. Check the Integral Activation Range

bool insideIntegralRange = true;

if (mIntegralActivationRange > 0.0)
{
    insideIntegralRange =
        fabs(rawError)
        <= mIntegralActivationRange;
}

If the integral activation range is configured, integration is only permitted near the setpoint.

Example:

Setpoint = 100
Integral activation range = 10

Then:

Error 20 -> integral disabled
Error 15 -> integral disabled
Error 10 -> integral enabled
Error  5 -> integral enabled

14. Determine Whether Integration Is Allowed

const bool integrationAllowed =
    (mKi > 0.0) &&
    outsideTolerance &&
    insideIntegralRange;

Integration is allowed only if all conditions are true:

  1. Ki is greater than zero
  2. the error is outside the tolerance band
  3. the error is inside the integral activation range

This results in the following behavior:

Far from setpoint     Near setpoint       Inside tolerance
       |                    |                    |
       v                    v                    v
    I disabled           I enabled            I frozen

    P active             P active             P active
    D active             D active             D active

15. Calculate an Integral Candidate

double integralCandidate =
    mIntegral;

Instead of modifying the real integral state immediately, a candidate value is calculated first:

if (integrationAllowed)
{
    integralCandidate +=
        error * dt;
}

The discrete integral is therefore:

$I_{state,new} = I_{state,old} + e \cdot dt$

Example:

Previous integral state = 20
Error                   = 5
dt                      = 0.1

Candidate = 20 + 5 * 0.1
          = 20.5

16. Predict the Output With the Candidate Integral

const double iCandidate =
    mKi * integralCandidate;

const double candidateOutput =
    p +
    iCandidate +
    d;

This allows the controller to determine whether accepting the new integral value would drive the output further into saturation.


17. Conditional Integration / Anti-Windup

bool integralDrivesIntoSaturation = false;

Upper saturation:

if (candidateOutput > mMax &&
    error > 0.0)
{
    integralDrivesIntoSaturation = true;
}

Lower saturation:

if (candidateOutput < mMin &&
    error < 0.0)
{
    integralDrivesIntoSaturation = true;
}

If the integral would make an already saturated output even more saturated, the new integral value is rejected.

This prevents integral windup.


18. Commit the Integral State

if (integrationAllowed &&
    !integralDrivesIntoSaturation)
{
    mIntegral =
        integralCandidate;
}

The candidate is accepted only when integration is permitted and it does not worsen saturation.

The final integral contribution is:

const double i =
    mKi * mIntegral;

19. Calculate the Raw PID Output

double output =
    p +
    i +
    d;

This is the basic controller equation:

$u = P + I + D$


20. Apply Hard Output Limits

output =
    clamp(
        output,
        mMin,
        mMax);

Regardless of the internal PID result, the output cannot exceed the configured controller limits.


21. Apply the Slew-Rate Limiter

If enabled:

const double maxChange =
    mOutputSlewRate * dt;

The desired output change is:

const double change =
    output - mOut;

If the requested change is too large:

if (change > maxChange)
{
    output =
        mOut + maxChange;
}
else if (change < -maxChange)
{
    output =
        mOut - maxChange;
}

This limits both rising and falling output changes.


22. Store and Return the Final Output

mOut =
    clamp(
        output,
        mMin,
        mMax);

return mOut;

The final output is stored for the next cycle and returned to the caller.


Clone this wiki locally