Built-in project · Arduino Uno R3

Heated bed PID on an Arduino Uno

A 100k thermistor, a MOSFET heater and a PID loop holding a modeled bed at its target, with heat-up watch and thermal runaway protection that cut the heater when the reading stops following. The whole build, an Arduino Uno and 9 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.

Teaching? Assign it to your class in one click.

Advanced Runs in your browser. Free, and no account needed.

Heated bed PID · Arduino Uno R3live0.000 s 0.00x
Press Run, type M140 S60, then slide the switch to drop the thermistor
The circuit itself, running here on the simulator. Press what can be pressed; click anything else to open it in the editor.

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · Arduino Uno R3
// Heated bed PID with thermal runaway protection: a thermistor, a MOSFET and a model bed.
//
//   A0      100k NTC thermistor (beta 3950) to GND, with a 4.7k pull-up to 5 V
//   pin 9   PWM to the MOSFET's gate; the MOSFET switches the heater
//   pin 7   slide switch: thermistor clamped to the bed, or fallen off
//   pin 12  red fault lamp
//
// Type M140 S60 to set the bed to 60 C, M140 S0 to switch it off, M105 for a report.
// The commands borrow Marlin's names; this sketch is not Marlin, and its numbers
// are chosen to teach, not copied from any printer.
//
// WHAT IS MODELED, honestly. Mokxi simulates the thermistor, the divider, the ADC,
// the PWM and the MOSFET, and a 12 V lamp glows with the heater's power. It does
// NOT simulate heat flowing from the heater into the thermistor. So this sketch
// carries a small model of the bed (see MODEL below): the heater's power warms
// it, the room cools it, and a thermistor clamped to it reads it a little late.
// The thermistor part's slider is the room temperature. The model's clock runs
// about ten times faster than a real bed heats, so you are not waiting minutes.
//
// SAFETY, for the real thing: a bed or hotend heater can start a fire. Thermal
// runaway protection is the firmware noticing that the heater is on but the
// temperature is not following, usually because the thermistor has come loose,
// and switching everything off until a reset. Never disable it. Mains-powered
// beds need proper wiring, a thermal fuse and an earth; leave that to the bench.
//
// Try it: set M140 S60, let it settle, then slide the switch to "fallen off".
// The heater keeps pushing, the reading does not follow, and the protection trips.

const int THERM_PIN = A0;
const int HEATER_PIN = 9;
const int ATTACHED_PIN = 7;
const int FAULT_LAMP = 12;

// The thermistor: 100k at 25 C, beta 3950, under a 4.7k pull-up.
const float R_PULLUP = 4700.0;
const float R25 = 100000.0;
const float BETA = 3950.0;

// PID, in heater counts (0 to 255) per degree.
const float KP = 40.0, KI = 1.2, KD = 120.0;

// Protection, in the model's fast seconds.
const float MIN_C = 0.0, MAX_C = 120.0;       // outside this the sensor is broken
const float WATCH_SECONDS = 15.0;             // heating must show progress in this time
const float WATCH_RISE = 2.0;                 // ...by at least this many degrees
const float RUNAWAY_SECONDS = 20.0;           // at temperature, a drop below target
const float RUNAWAY_HYSTERESIS = 4.0;         // ...of this much, for this long, trips

float target = 0;
float integral = 0;
float lastMeasured = 0;
int power = 0;
bool halted = false;

// MODEL: the bed's true temperature and what a clamped thermistor would read.
float bedC = 0;       // the bed itself
float sensedC = 0;    // the thermistor on it, lagging a little behind
const float HEATER_WATTS = 100.0, HEAT_CAPACITY = 50.0, LOSS_W_PER_C = 1.0;
const float SENSOR_LAG_S = 2.0;

float watchStart = 0;         // when the watch began, in model seconds
float watchFrom = 0;          // the reading it began at
bool watching = false;
float belowSince = -1;        // when the reading fell out of the band
bool reached = false;
float modelSeconds = 0;
unsigned long lastTick = 0, lastReport = 0;

// The real part of the sketch: the thermistor through the divider and the beta equation.
float readThermistor() {
  int raw = analogRead(THERM_PIN);
  if (raw <= 0 || raw >= 1023) return -100;  // open or shorted: nothing a heater could make
  float r = R_PULLUP * raw / (1023.0 - raw);
  float kelvin = 1.0 / (1.0 / 298.15 + log(r / R25) / BETA);
  return kelvin - 273.15;
}

void halt(const char *why) {
  halted = true;
  power = 0;
  analogWrite(HEATER_PIN, 0);
  digitalWrite(FAULT_LAMP, HIGH);
  Serial.print("HALTED: ");
  Serial.print(why);
  Serial.println(". Heater off until reset.");
}

void report(float measured) {
  Serial.print("B:");
  Serial.print(measured, 1);
  Serial.print(" /");
  Serial.print(target, 1);
  Serial.print(" heater ");
  Serial.print(power * 100 / 255);
  Serial.println("%");
}

void setTarget(float t) {
  target = t;
  integral = 0;
  reached = false;
  belowSince = -1;
  watching = t > lastMeasured + WATCH_RISE;
  watchStart = modelSeconds;
  watchFrom = lastMeasured;
  Serial.print("bed target ");
  Serial.println(t, 0);
}

// Reads one line like "M140 S60" or "M105" from the Serial Monitor.
void readCommand() {
  static char line[24];
  static int length = 0;
  while (Serial.available() > 0) {
    char c = Serial.read();
    if (c != '\n' && c != '\r') {
      if (length < 23) line[length++] = c;
      continue;
    }
    line[length] = 0;
    length = 0;
    if (strncmp(line, "M140", 4) == 0 || strncmp(line, "m140", 4) == 0) {
      char *s = strchr(line, 'S');
      if (!s) s = strchr(line, 's');
      if (s && !halted) setTarget(constrain(atoi(s + 1), 0, 110));
    } else if (strncmp(line, "M105", 4) == 0 || strncmp(line, "m105", 4) == 0) {
      report(lastMeasured);
    }
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(ATTACHED_PIN, INPUT_PULLUP);
  pinMode(FAULT_LAMP, OUTPUT);
  analogWrite(HEATER_PIN, 0);
  float room = readThermistor();
  bedC = sensedC = lastMeasured = room;
  Serial.print("Bed ready at ");
  Serial.print(room, 1);
  Serial.println(" C. Type M140 S60 to heat, M105 to report.");
}

void loop() {
  readCommand();
  unsigned long now = millis();
  if (now - lastTick < 100) return;
  float dt = (now - lastTick) / 1000.0 * 10.0;  // model seconds: ten times real time
  lastTick = now;
  modelSeconds += dt;

  float room = readThermistor();
  bool attached = digitalRead(ATTACHED_PIN) == LOW;

  // MODEL: heater in, losses to the room out, and a sensor that lags.
  bedC += (HEATER_WATTS * power / 255.0 - LOSS_W_PER_C * (bedC - room)) / HEAT_CAPACITY * dt;
  sensedC += (bedC - sensedC) * dt / SENSOR_LAG_S;
  // A thermistor that has fallen off reads the room, not the bed. A broken wire
  // reads nonsense, which the range check below catches either way.
  float measured = (room < -50) ? room : (attached ? sensedC : room);

  if (!halted) {
    if (measured < MIN_C) halt("thermistor reads too cold, it may be disconnected");
    else if (measured > MAX_C) halt("bed too hot");
  }

  if (!halted && target > 0) {
    // PID on the error, with the derivative on the measurement so a new target
    // does not kick the output, and the integral clamped so it cannot wind up.
    float error = target - measured;
    integral = constrain(integral + error * dt, -50, 150);
    float derivative = (measured - lastMeasured) / dt;
    power = constrain((int)(KP * error + KI * integral - KD * derivative), 0, 255);

    // Watch: after a new, higher target, the reading has to climb in time.
    if (watching) {
      if (measured >= watchFrom + WATCH_RISE) watching = false;
      else if (modelSeconds - watchStart > WATCH_SECONDS) halt("heating failed, the reading is not rising");
    }
    // Runaway: once at temperature, a sustained drop below the band trips.
    if (!reached && measured >= target - 1) reached = true;
    if (reached && !halted) {
      if (measured < target - RUNAWAY_HYSTERESIS) {
        if (belowSince < 0) belowSince = modelSeconds;
        else if (modelSeconds - belowSince > RUNAWAY_SECONDS) halt("thermal runaway");
      } else {
        belowSince = -1;
      }
    }
  } else {
    power = 0;
  }
  analogWrite(HEATER_PIN, power);
  lastMeasured = measured;

  if (now - lastReport >= 1000) {
    lastReport = now;
    if (target > 0 || halted) report(measured);
  }
}

Parts list

12 parts, plus the jumper wires. Every one is in the editor's parts bin.

How it is wired

10 connections, pin by pin, read from the circuit itself. Each line is one set of pins joined together, by a jumper wire or a breadboard strip.

  • Arduino Uno R3 pin 12; LED, red pin A
  • Arduino Uno R3 pin 9; Resistor, 220 Ω (1) pin 2
  • Arduino Uno R3 pin 7; Slide switch pin 2
  • Arduino Uno R3 pin 5V; Resistor, 4.7k Ω pin 1
  • Ground: Arduino Uno R3 pin GND; Thermistor, 10k NTC pin 2; MOSFET pin S; Resistor, 10k Ω pin 2; Slide switch pin 1; Resistor, 220 Ω (2) pin 2
  • Arduino Uno R3 pin A0; Resistor, 4.7k Ω pin 2; Thermistor, 10k NTC pin 1
  • MOSFET pin D; Filament lamp pin 1
  • MOSFET pin G; Resistor, 220 Ω (1) pin 1; Resistor, 10k Ω pin 1
  • 12 V: Filament lamp pin 2
  • LED, red pin C; Resistor, 220 Ω (2) pin 1

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.