Developer Guide — Architecture, Theory & Extension Points
The lab is a single-file web application (lab.html) using
Plotly.js for interactive plots and vanilla JavaScript for the control
systems math engine. No backend, no build step — runs entirely in the browser.
Event-Driven Update Loop
Every slider, button, and input selector registers an event listener to the update() function. When any parameter
changes, the entire visualization recomputes:
Slider/Button → update() → Build G(s) & C(s) → closedLoop() → simulate() via RK4 → Plotly.react()
| Function | Purpose |
|---|---|
update() |
Main loop — reads sliders, builds TFs, runs simulation, updates both plots and metrics |
simulate(num, den, tEnd, nPts, inputFn) |
RK4 ODE integration using controllable canonical state-space form. Accepts arbitrary input function |
closedLoop(numG, denG, numC, denC) |
Computes T(s) = C·G / (1 + C·G) via polynomial arithmetic |
polyMul(a, b) / polyAdd(a, b) |
Polynomial multiplication and addition (coefficient arrays, highest power first) |
polyRoots(coeffs) |
Finds complex roots using quadratic/cubic formulas or Durand-Kerner iteration (degree 4+) |
openLoopMetrics(z, wn) |
Analytical Mₚ, tₚ, tₛ for pure second-order systems |
responseMetrics(t, y) |
Numerical metrics from actual simulation output (for closed-loop or non-step inputs) |
| Library | Role |
|---|---|
Plotly.js 2.27 |
Interactive plotting with Plotly.react() for live updates |
| Vanilla JavaScript | Polynomial math, ODE solver (RK4), root finding, metrics |
| Vanilla CSS | Catppuccin Mocha theme, responsive grid layout |
G(s) = ωₙ² / (s² + 2ζωₙs + ωₙ²)
Implemented as polynomial arrays:
const numG = [wn * wn]; // numerator coefficients
const denG = [1, 2 * z * wn, wn * wn]; // denominator coefficients
C(s) = Kp + Ki/s + Kd·s = (Kd·s² + Kp·s + Ki) / s
const numC = [kd, kp, ki]; // Kd·s² + Kp·s + Ki
const denC = [1, 0]; // s
T(s) = C(s)·G(s) / (1 + C(s)·G(s))
// JavaScript implementation:
const numCG = polyMul(numC, numG);
const denCG = polyMul(denC, denG);
const clNum = numCG;
const clDen = polyAdd(denCG, numCG);
The transfer function is converted to controllable canonical state-space form,
then integrated via 4th-order Runge-Kutta. The input function inputFn(t, dt) is
evaluated at each timestep, supporting step, impulse, ramp, and sinusoidal inputs.
// State equation: ẋ = Ax + Bu, y = Cx + Du
// A = companion matrix from denominator coefficients
// C = output vector from numerator coefficients (reversed indexing)
// The output uses: C[i] = numPad[n-i] - numPad[0] * denN[n-i]
| Metric | Formula | Valid When |
|---|---|---|
| Max Overshoot (Mₚ) | e^(-πζ/√(1-ζ²)) × 100% |
0 < ζ < 1 |
| Peak Time (tₚ) | π / (ωₙ√(1-ζ²)) |
0 < ζ < 1 |
| Settling Time (tₛ) | 4 / (ζωₙ) |
All ζ > 0 (2% criterion) |
For closed-loop metrics, the analytical formulas don't apply (the system is no longer a pure second-order). Instead, the lab computes them numerically from the simulation output: peak value, peak time, and the last time the signal exits the 2% settling band.
Add a new <button> inside the presets panel in the HTML:
<button class="preset-btn" data-z="0.45" data-wn="8"
data-cat="Underdamped" onclick="applyPreset(this)">
New Scenario <span class="cat">· Underdamped</span>
</button>
Add an entry to the inputFunctions object and a button:
// In the JS section:
inputFunctions.sawtooth = (t) => t % 1;
inputLabels.sawtooth = 'Sawtooth';
// In the HTML:
<button class="input-btn" data-input="sawtooth"
onclick="setInput(this)">Sawtooth</button>
To use a non-standard plant (e.g., the hydraulic transmission G(s) = 29s² / s(s² + 11s + 39)):
// Replace numG and denG in update():
const numG = [29, 0, 0]; // 29s²
const denG = [1, 11, 39, 0]; // s³ + 11s² + 39s
# Web version (no install needed):
https://control.gamely.dev/lab.html
# Python version (local, requires dependencies):
cd ~/Control && source .venv/bin/activate && python control_lab.py
The web version runs entirely in the browser — no Python, no packages, no setup. Just open the URL.