Introduction

A PID controller has three knobs — proportional, integral, derivative — and no obvious way to know where to set them. Turn them by feel and you either get a sluggish system that crawls to its target, or a jumpy one that overshoots and rings.

In 1942, engineers John Ziegler and Nathaniel Nichols published a shortcut that needed no model of the plant at all. Instead of computing anything, you provoke the system: turn up the proportional gain, and only the proportional gain, until the loop starts to oscillate on its own and keeps oscillating with constant amplitude — neither growing nor dying out. Read off two numbers from that one experiment, plug them into a small table, and out come working PID gains.

It is a strange kind of answer to a hard question: instead of solving the system's equations, you let the system show you where its instability lives, then back away from the edge by a fixed, well-tested amount.

Try It

Below is a simulated control loop with only a proportional gain KpK_p active. Drag the slider up and watch the response. Somewhere past a critical value the output stops settling and rings forever at a steady amplitude — that critical value is the ultimate gain KuK_u, and the time between peaks is the ultimate period TuT_u.

<p class="hint">This simulated loop has only a proportional gain active. Raise <b>K<sub>p</sub></b> and press Run — below a critical gain the response settles, above it the wobble grows without bound. Right at the edge it oscillates forever at constant amplitude: that is the ultimate gain and period.</p>
<div class="row">
  <label for="kp">K_p</label>
  <input type="range" id="kp" min="1" max="9" step="0.05" value="4">
  <span class="kpval" id="kpval">4.00</span>
</div>
<canvas id="plot" width="640" height="220"></canvas>
<div class="readout" id="readout">K_u = ?  T_u = ?</div>
<div class="status" id="status">Pick a gain and press Run.</div>
<div class="btns">
  <button id="run" type="button">Run</button>
  <button id="apply" type="button" disabled>Apply Ziegler-Nichols PID</button>
  <button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.row { display: flex; align-items: center; gap: .6rem; margin: 0 0 .5rem; flex-wrap: wrap; }
.row label { font-size: .85rem; font-weight: 600; color: #1d3557; }
.row input[type="range"] { flex: 1; min-width: 140px; }
.kpval { font: 700 14px ui-monospace, monospace; color: #1d3557; min-width: 3.2em; }
canvas { width: 100%; max-width: 640px; height: 220px; background: #f4f6f8; border: 1px solid #cdd9e3;
         border-radius: 8px; display: block; }
.readout { font-size: .85rem; color: #333; margin: .5rem 0 0; min-height: 1.3em; font-family: ui-monospace, monospace; }
.status { font-size: 1rem; font-weight: 600; margin: .35rem 0 .6rem; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.warn { color: #c98a00; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
         background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .5; cursor: not-allowed; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));

// Plant: three cascaded first-order lags (a classic textbook plant) driven by a
// proportional-only controller, unity feedback, setpoint = 1.
const DT = 0.05;
const TAU = 1.0;
const SETPOINT = 1;
const MAX_STEPS = 3000; // cap so the animation and the loop both terminate

const kpSlider = document.getElementById('kp');
const kpVal = document.getElementById('kpval');
const canvas = document.getElementById('plot');
const ctx = canvas.getContext('2d');
const readout = document.getElementById('readout');
const statusEl = document.getElementById('status');
const runBtn = document.getElementById('run');
const applyBtn = document.getElementById('apply');
const resetBtn = document.getElementById('reset');

let mode = 'p'; // 'p' while hunting for Ku/Tu, 'pid' once tuned gains are applied
let history = [];
let running = false;
let timer = null;
let Ku = null, Tu = null;
let pidGains = null;

function setStatus(t, cls) { statusEl.textContent = t; statusEl.className = 'status ' + (cls || ''); }

// For a pure-P controller this plant settles at Kp/(Kp+1) of the setpoint if it does not oscillate.
function steadyOffset(Kp) { return (Kp / (Kp + 1)) * SETPOINT; }

// All turning points (peaks and troughs) of the response, used to detect real oscillation.
function findExtrema(h, center) {
  const ext = [];
  const eps = 1e-6;
  for (let i = 1; i < h.length - 1; i++) {
    const d0 = h[i] - h[i - 1];
    const d1 = h[i + 1] - h[i];
    if ((d0 > eps && d1 < -eps) || (d0 < -eps && d1 > eps)) ext.push(i);
  }
  return ext;
}

// Growing, decaying, or roughly steady amplitude around the offset marks the ultimate-gain boundary.
function classifyOscillation(h, Kp) {
  const center = steadyOffset(Kp);
  const ext = findExtrema(h, center);
  if (ext.length < 6) return { kind: 'settled' };
  // Real oscillation must swing above and below the offset in alternation, not drift on one side.
  const signs = ext.map((i) => Math.sign(h[i] - center));
  for (let i = 1; i < signs.length; i++) {
    if (signs[i] === signs[i - 1]) return { kind: 'settled' };
  }
  const amp = (i) => Math.abs(h[ext[i]] - center);
  const a0 = amp(2);
  const a1 = amp(ext.length - 1);
  if (a0 < 1e-3) return { kind: 'settled' };
  const fullPeriods = [];
  for (let i = 2; i + 2 < ext.length; i += 2) fullPeriods.push((ext[i + 2] - ext[i]) * DT);
  const avgPeriod = fullPeriods.length ? fullPeriods.reduce((a, b) => a + b, 0) / fullPeriods.length : null;
  const ratio = a1 / a0;
  if (ratio > 1.35) return { kind: 'growing', period: avgPeriod };
  if (ratio < 0.55) return { kind: 'decaying', period: avgPeriod };
  return { kind: 'sustained', period: avgPeriod };
}

function stepP(state, Kp) {
  const err = SETPOINT - state.y;
  const u = Kp * err;
  return advance(state, u);
}

function stepPID(state, gains) {
  const err = SETPOINT - state.y;
  state.integral += err * DT;
  const deriv = (err - state.prevErr) / DT;
  state.prevErr = err;
  const u = gains.kp * err + gains.ki * state.integral + gains.kd * deriv;
  return advance(state, u);
}

// One Euler integration step of the three-lag plant driven by control signal u.
function advance(state, u) {
  const x = state.x;
  const dx0 = (u - x[0]) / TAU;
  const dx1 = (x[0] - x[1]) / TAU;
  const dx2 = (x[1] - x[2]) / TAU;
  x[0] += dx0 * DT;
  x[1] += dx1 * DT;
  x[2] += dx2 * DT;
  state.y = x[2];
  return state.y;
}

function drawPlot() {
  const w = canvas.width, h = canvas.height;
  ctx.clearRect(0, 0, w, h);
  ctx.strokeStyle = '#cdd9e3';
  ctx.lineWidth = 1;
  ctx.beginPath();
  const midY = h * 0.55;
  ctx.moveTo(0, midY);
  ctx.lineTo(w, midY);
  ctx.stroke();

  if (history.length < 2) return;
  const n = history.length;
  const maxAbs = Math.max(1.5, ...history.map((v) => Math.abs(v - SETPOINT))) * 1.15;
  const scaleY = (h * 0.42) / maxAbs;
  const stepX = w / Math.max(1, Math.min(n, MAX_STEPS) - 1);
  const start = Math.max(0, n - MAX_STEPS);

  ctx.strokeStyle = '#1d3557';
  ctx.lineWidth = 2;
  ctx.beginPath();
  for (let i = start; i < n; i++) {
    const px = (i - start) * stepX;
    const py = midY - (history[i] - SETPOINT) * scaleY;
    if (i === start) ctx.moveTo(px, py);
    else ctx.lineTo(px, py);
  }
  ctx.stroke();
}

function resetSim() {
  running = false;
  if (timer) clearInterval(timer);
  history = [];
  drawPlot();
  setStatus('Pick a gain and press Run.', '');
  readout.textContent = 'K_u = ?  T_u = ?';
}

function runP() {
  mode = 'p';
  applyBtn.disabled = true;
  Ku = null;
  Tu = null;
  const Kp = parseFloat(kpSlider.value);
  const state = { x: [0, 0, 0], y: 0 };
  history = [0];
  running = true;
  setStatus('Running...', '');
  let steps = 0;
  timer = setInterval(() => {
    for (let i = 0; i < 4 && steps < MAX_STEPS; i++, steps++) {
      stepP(state, Kp);
      history.push(state.y);
    }
    drawPlot();
    if (steps < 1200) return; // wait for a few oscillation cycles before judging growth or decay
    const result = classifyOscillation(history, Kp);
    if (result.kind === 'sustained') {
      Ku = Kp;
      Tu = result.period;
      setStatus(i18nFmt('Sustained oscillation found: K_u = %ku%, T_u = %tu%.', { ku: Ku.toFixed(2), tu: Tu.toFixed(2) }), 'ok');
      readout.textContent = i18nFmt('K_u = %ku%   T_u = %tu% s', { ku: Ku.toFixed(2), tu: Tu.toFixed(2) });
      applyBtn.disabled = false;
      clearInterval(timer);
      running = false;
    } else if (result.kind === 'growing') {
      setStatus('Growing — the gain is too high, lower it.', 'bad');
    } else if (result.kind === 'decaying') {
      setStatus('Settling — try a higher gain to find the edge.', 'warn');
    }
    if (steps >= MAX_STEPS) {
      clearInterval(timer);
      running = false;
      if (!Ku) setStatus('Inconclusive after this run — nudge the gain and try again.', 'warn');
    }
  }, 16);
}

function applyPID() {
  if (!Ku || !Tu) return;
  mode = 'pid';
  pidGains = { kp: 0.6 * Ku, ki: (1.2 * Ku) / Tu, kd: 0.075 * Ku * Tu };
  readout.textContent = i18nFmt('PID gains -> K_p = %kp%, K_i = %ki%, K_d = %kd%', {
    kp: pidGains.kp.toFixed(3),
    ki: pidGains.ki.toFixed(3),
    kd: pidGains.kd.toFixed(3),
  });
  const state = { x: [0, 0, 0], y: 0, integral: 0, prevErr: 0 };
  history = [0];
  running = true;
  setStatus('Running the tuned PID controller...', '');
  let steps = 0;
  timer = setInterval(() => {
    for (let i = 0; i < 4 && steps < MAX_STEPS; i++, steps++) {
      stepPID(state, pidGains);
      history.push(state.y);
    }
    drawPlot();
    if (steps >= MAX_STEPS) {
      clearInterval(timer);
      running = false;
      const settledNear = Math.abs(state.y - SETPOINT) < 0.05;
      setStatus(settledNear ? 'PID controller settled near the setpoint — no oscillation needed.' : 'Still converging — real loops often need a longer run or hand-refinement.', settledNear ? 'ok' : 'warn');
    }
  }, 16);
}

kpSlider.addEventListener('input', () => {
  kpVal.textContent = parseFloat(kpSlider.value).toFixed(2);
});
runBtn.addEventListener('click', () => {
  if (running) return;
  runP();
});
applyBtn.addEventListener('click', () => {
  if (running) return;
  applyPID();
});
resetBtn.addEventListener('click', resetSim);

drawPlot();
Result
Console

Once you've bracketed KuK_u and measured TuT_u, press Apply Ziegler-Nichols PID to load the classic table's values — Kp=0.6KuK_p = 0.6K_u, Ki=1.2Ku/TuK_i = 1.2K_u/T_u, Kd=0.075KuTuK_d = 0.075K_uT_u — and watch a proper PID controller settle the same loop cleanly, no oscillation required.

The Real Complexity

Ziegler-Nichols is not an open problem — it is a solved, published recipe (Ziegler & Nichols, 1942), and it works because of a specific fact about feedback loops.

  • What the experiment measures. As you raise the proportional gain KpK_p, the closed loop's dominant poles move toward the imaginary axis. At the exact gain KuK_u where a pair of poles crosses onto that axis, the system's phase lag around the loop has reached 180°180° at the frequency where the loop gain is 11 — the classic marginal-stability condition from Nyquist and Bode analysis. That crossover frequency is 2π/Tu2\pi / T_u.
  • Why one experiment is enough. KuK_u and TuT_u together summarize, in just two numbers, how much gain and phase lag the unknown plant contributes near its most dangerous frequency — exactly the information a controller needs to respect. You never write down the plant's transfer function; the plant reveals its own critical point by ringing.
  • Where the table comes from. Ziegler and Nichols tried their rule against many real industrial processes and picked ratios — Kp=0.6KuK_p = 0.6K_u, Ki=1.2Ku/TuK_i = 1.2K_u / T_u, Kd=0.075KuTuK_d = 0.075 K_u T_u — that reliably gave a fast response with roughly a quarter-amplitude decay (each overshoot about a quarter the size of the last). It is empirical curve-fitting, not a derivation from first principles, which is exactly why it is a starting point: it favors quick settling over gentleness, and aggressive plants can still ring or saturate an actuator, a failure mode tamed separately by anti-windup.
  • A cousin, not a rival. Methods like LQR instead demand a full model of the plant and optimize a cost function exactly — more accurate when you have the model, useless when you don't. Ziegler-Nichols trades optimality for the ability to tune any stable loop you can physically oscillate.

Where It Matters

"Get a working controller without modeling the plant" is a need that never went away, and Ziegler-Nichols is still the fastest honest answer:

  • Process industries: temperature, pressure, flow and level loops in chemical plants and refineries are routinely first-tuned this way, then hand-refined.
  • Commissioning new equipment: whenever a motor, oven or pump is installed and nobody has (or wants to derive) its transfer function, oscillating it once is faster than modeling it.
  • Autotuning features: many commercial PID controllers and PLCs run an automated version of the same closed-loop test at the press of a button.
  • Teaching feedback control: because it needs no calculus beyond reading a stopwatch and a gain dial, Ziegler-Nichols is often the first real tuning method students perform on a physical system.

Learn why one oscillation is enough and you've met the deeper idea behind PID control: a feedback loop's stability boundary carries almost all the information you need to tame it.

Conclusion

Ziegler-Nichols tuning turns a hard modeling problem into a simple, physical one: don't compute the plant's behavior, provoke it. The ultimate gain and ultimate period compress everything dangerous about a loop into two numbers, and a table trained on decades of real processes turns those two numbers into a controller that just works.

It won't be optimal, and a twitchy plant can still punish you near that edge — but as a fast, universal starting point for PID control, nothing else gets you from zero knowledge to a running loop quite as directly.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/ziegler-nichols-tuning/Content licensed under CC BY-NC 4.0.