Skip to content

Version 3: A PID Controller, Composition, and Logging

Version 2 left two things nagging: main was juggling every component and a pile of loose constants, and on/off control still chatters. This version fixes both. It bundles the hardware into a Plant (composition), keeps the controller cleanly separate (separation of concerns), and replaces bang-bang control with a PID controller — made swappable through the same interface trick you used for sensors.

It reuses Tank, Valve, and the Sensor interface unchanged from the earlier versions.


A plant: composition

A real system groups its physical kit together. A Plant has a tank and has a valve — it does not inherit from them; it contains them. That is composition:

class Plant {
    Tank tank_;
    Valve inlet_;
    double maxInflow_;
    double outflow_;
public:
    Plant(double initialLevel, double area, double maxInflow, double outflow)
        : tank_(initialLevel, area), maxInflow_(maxInflow), outflow_(outflow) {}

    // Apply a valve opening (0..1) and let one time step pass.
    void step(double valveOpening, double dt) {
        inlet_.setOpening(valveOpening);
        tank_.update(inlet_.flow(maxInflow_), outflow_, dt);
    }

    double level() const { return tank_.level(); }
};

Plant now exposes exactly two things — give it a valve opening and a time step (step) and what is the level (level). The tank and valve are sealed inside. The sensor reads the plant:

class LevelSensor : public Sensor {
    const Plant& plant_;
public:
    explicit LevelSensor(const Plant& plant) : plant_(plant) {}

    double read() const override { return plant_.level(); }
};

A controller interface

You met polymorphism for sensors; the same move makes controllers interchangeable. A Controller promises one thing — turn a measurement into a valve opening:

class Controller {
public:
    virtual ~Controller() = default;
    // Given the latest measurement and the time step, return a valve opening (0..1).
    virtual double compute(double measurement, double dt) = 0;
};

The on/off controller from Version 1 becomes one implementation (it simply ignores dt):

class OnOffController : public Controller {
    double setpoint_;
public:
    explicit OnOffController(double setpoint) : setpoint_(setpoint) {}

    double compute(double measurement, double /*dt*/) override {
        return (measurement < setpoint_) ? 1.0 : 0.0;
    }
};

The PID controller

A PID controller steers smoothly by combining three terms: the Proportional (how far off we are now), the Integral (how much error has built up over time), and the Derivative (how fast the error is changing):

class PIDController : public Controller {
    double kp_, ki_, kd_;
    double setpoint_;
    double integral_ = 0.0;
    double previousError_ = 0.0;
public:
    PIDController(double kp, double ki, double kd, double setpoint)
        : kp_(kp), ki_(ki), kd_(kd), setpoint_(setpoint) {}

    double compute(double measurement, double dt) override {
        double error = setpoint_ - measurement;
        double derivative = (error - previousError_) / dt;
        previousError_ = error;

        integral_ += error * dt;
        double output = kp_ * error + ki_ * integral_ + kd_ * derivative;

        // Anti-windup: if the valve is already hard against a limit, don't let
        // the integral keep piling up — undo this step's accumulation and clamp.
        if (output > 1.0) { integral_ -= error * dt; output = 1.0; }   // ...or more than fully open
        else if (output < 0.0) { integral_ -= error * dt; output = 0.0; }  // a valve cannot open less than shut
        return output;
    }
};

The gains kp_, ki_, kd_ are kept as private state, along with the running integral_ and the last error. (Tuning those gains well is an engineering field of its own; the values below are just sensible starting numbers.1)

Integral windup — and why the two anti-windup lines matter. While the level is far below the setpoint the valve is flat-out at 1.0, but the error is still positive, so a plain integral keeps growing step after step against a valve that cannot open any further. That stored-up surplus is integral windup. When the level finally reaches the setpoint the integral is now enormous, and it holds the valve wide open long past the point it should have eased off — the level sails well past target before the controller can claw it back. The fix, conditional integration (a form of anti-windup), is the two lines above: when the output is already saturated, we undo that step's contribution to integral_ so it never accumulates against a maxed-out valve. Every serious PID implementation you meet in industry has anti-windup of some kind; this is that feature in miniature.


Putting it together, with logging

#include <iostream>

int main() {
    Plant plant(2.0, 1.0, 0.10, 0.03);
    LevelSensor sensor(plant);

    PIDController pid(0.8, 0.05, 0.0, 5.0);   // Kp, Ki, Kd, setpoint = 5 m
    Controller& controller = pid;             // swap in OnOffController and nothing else changes

    const double dt = 1.0;

    std::cout << "time,level,setpoint\n";      // CSV header
    for (int step = 0; step < 80; ++step) {
        double measurement = sensor.read();                    // sense
        double opening     = controller.compute(measurement, dt);  // decide
        plant.step(opening, dt);                               // act + step

        std::cout << step << "," << measurement << ",5\n";     // log a row
    }
}

Run it and the level climbs from 2 m and settles near 5 m instead of chattering. For the first stretch the valve is flat-out and the level rises steadily; around step 27 the controller starts easing the valve off full, and the level reaches the setpoint at about step 50. It drifts a little past — a small overshoot to roughly 5.19 m near step 65 — then the controller pulls it gently back, and by the end of the run (step 80) it is sitting at about 5.12 m and still creeping down toward the setpoint. In steady state the valve settles toward the ~30% opening that exactly matches the outflow (0.03 / 0.10), with the integral term trimming away the last bit of offset. That is the difference between bang-bang and proportional control, on your screen.

The output is CSVtime,level,setpoint — so you can redirect it to a file (./tank_control > run.csv) and open it in a spreadsheet to plot the curve. To write the file from inside the program instead, swap std::cout for a std::ofstream; see IO & Streams.


What this version shows

  • CompositionPlant has a Tank and a Valve; it owns them and exposes a small interface. See Composition over inheritance.
  • Separation of concerns — the plant knows physics, the controller knows control, the sensor knows measurement. None reaches into another. See Separation of Concerns.
  • Polymorphism, again — the loop runs against a Controller&, so on/off and PID are drop-in swaps. See Polymorphism.
  • A real control law — PID is what actually runs in pumps, ovens, drones, and process plants.
  • Anti-windup — the two guarded lines in compute stop the integral running away while the valve is saturated; every industrial PID needs this.

What's still awkward → Version 4

Everything now lives in one steadily growing file. Real projects split into headers and source files, organised by component and built with CMake. Version 4 does exactly that — turning this example into a project laid out the way an industrial one would be.


  1. We leave the derivative term at 0 throughout this arc; using it well has pitfalls of its own (it amplifies sensor noise), so it stays out of the way here.