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;
integral_ += error * dt;
double derivative = (error - previousError_) / dt;
previousError_ = error;
double output = kp_ * error + ki_ * integral_ + kd_ * derivative;
if (output < 0.0) { output = 0.0; } // a valve cannot open less than shut
if (output > 1.0) { output = 1.0; } // ...or more than fully open
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.)
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 rises and settles at 5 m instead of chattering: as it nears the setpoint the PID eases the valve toward the ~30% opening that exactly matches the outflow, and the integral term trims away the last bit of offset. That is the difference between bang-bang and proportional control, on your screen.
The output is CSV — time,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¶
- Composition —
Planthas aTankand aValve; 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.
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.