Below is a simulated cart with a pole balanced on top, governed by the real nonlinear equations of motion. Start it upright with a tiny tilt and watch what happens with the controller off versus on.
<p class="hint">A pole is hinged to a cart on a track. With the controller off it just falls. Turn it on and a feedback law reads the pole's tilt and speed (and the cart's position and speed) and pushes the cart to catch it.</p>
<div class="stage">
<canvas id="scene" width="520" height="260"></canvas>
</div>
<div class="status" id="status">Controller off — the pole will fall on its own.</div>
<div class="btns">
<button id="toggle" type="button">Start controller</button>
<button id="nudge" type="button">Nudge</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; }
.stage { background: #eef2f6; border: 1px solid #cdd9e3; border-radius: 10px; margin: .4rem 0; }
canvas { display: block; width: 100%; height: 260px; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.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; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Cart-pole dynamics (nonlinear, standard form): cart mass M, pole mass m,
// half-length l, gravity g. theta is measured from the upright position, so
// theta = 0 means the pole points straight up.
const g = 9.8, M = 1.0, m = 0.1, l = 0.5; // cart mass, pole mass, half-length, gravity
const MAX_FORCE = 40, DT = 0.016;
// State: x/v = cart position/velocity, theta/w = pole angle/angular velocity.
let state = { x: 0, v: 0, theta: 0.12, w: 0 };
let running = false;
let fell = false;
// Hand-tuned state-feedback gains K, verified numerically to stabilize small tilts.
const K = { x: 5, v: 5, theta: 40, w: 10 };
function derivative(s, F) {
const sinT = Math.sin(s.theta), cosT = Math.cos(s.theta);
const totalMass = M + m;
const temp = (F + m * l * s.w * s.w * sinT) / totalMass;
const thetaAcc = (g * sinT - cosT * temp) / (l * (4 / 3 - m * cosT * cosT / totalMass));
const xAcc = temp - m * l * thetaAcc * cosT / totalMass;
return { x: s.v, v: xAcc, theta: s.w, w: thetaAcc };
}
// Control law: force = Kx*x + Kv*v + Ktheta*theta + Kw*w, clamped to +/- MAX_FORCE.
function controlForce(s) {
const raw = K.x * s.x + K.v * s.v + K.theta * s.theta + K.w * s.w;
return Math.max(-MAX_FORCE, Math.min(MAX_FORCE, raw));
}
function step() {
const F = running ? controlForce(state) : 0;
const d = derivative(state, F);
state = {
x: state.x + d.x * DT,
v: state.v + d.v * DT,
theta: state.theta + d.theta * DT,
w: state.w + d.w * DT,
};
if (Math.abs(state.x) > 2.2) state.x = Math.sign(state.x) * 2.2;
if (!fell && Math.abs(state.theta) > Math.PI / 2) {
fell = true;
setStatus('✗ The pole fell. Reset and try again.', 'bad');
}
}
function draw() {
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
const originX = w / 2, trackY = h - 60;
const pxPerM = 90;
// draw the track the cart slides on
ctx.strokeStyle = '#adb1b8';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(40, trackY + 24);
ctx.lineTo(w - 40, trackY + 24);
ctx.stroke();
const cartX = originX + state.x * pxPerM;
const cartW = 70, cartH = 34;
// draw the cart body
ctx.fillStyle = '#1d3557';
ctx.fillRect(cartX - cartW / 2, trackY - cartH / 2, cartW, cartH);
// draw the cart's wheels
ctx.fillStyle = '#222';
ctx.beginPath(); ctx.arc(cartX - cartW / 3, trackY + cartH / 2, 6, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(cartX + cartW / 3, trackY + cartH / 2, 6, 0, Math.PI * 2); ctx.fill();
// draw the pole from the pivot to its tip
const poleLenPx = l * 2 * pxPerM;
const pivotY = trackY - cartH / 2;
const tipX = cartX + poleLenPx * Math.sin(state.theta);
const tipY = pivotY - poleLenPx * Math.cos(state.theta);
ctx.strokeStyle = fell ? '#c92f3c' : '#e63946';
ctx.lineWidth = 6;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(cartX, pivotY);
ctx.lineTo(tipX, tipY);
ctx.stroke();
// draw the weighted tip of the pole
ctx.fillStyle = fell ? '#c92f3c' : '#e63946';
ctx.beginPath(); ctx.arc(tipX, tipY, 8, 0, Math.PI * 2); ctx.fill();
// draw the hinge connecting pole and cart
ctx.fillStyle = '#457b9d';
ctx.beginPath(); ctx.arc(cartX, pivotY, 4, 0, Math.PI * 2); ctx.fill();
}
function setStatus(t, cls) {
const el = document.getElementById('status');
el.textContent = t;
el.className = 'status ' + cls;
}
function loop() {
if (!fell) step();
draw();
requestAnimationFrame(loop);
}
function reset() {
state = { x: 0, v: 0, theta: 0.12, w: 0 };
running = false;
fell = false;
document.getElementById('toggle').textContent = 'Start controller';
setStatus('Controller off — the pole will fall on its own.', '');
}
document.getElementById('toggle').addEventListener('click', () => {
if (fell) return;
running = !running;
document.getElementById('toggle').textContent = running ? 'Stop controller' : 'Start controller';
setStatus(running ? 'Controller on — balancing.' : 'Controller off — the pole will fall on its own.', running ? 'ok' : '');
});
document.getElementById('nudge').addEventListener('click', () => {
if (fell) return;
// give the pole a random angular kick to test the controller's recovery
state.w += (Math.random() < 0.5 ? -1 : 1) * 1.8;
setStatus(fmt('Nudged! Watch the controller react.', {}), running ? 'ok' : '');
});
document.getElementById('reset').addEventListener('click', reset);
draw();
requestAnimationFrame(loop);
<p class="hint">A pole is hinged to a cart on a track. With the controller off it just falls. Turn it on and a feedback law reads the pole's tilt and speed (and the cart's position and speed) and pushes the cart to catch it.</p>
<div class="stage">
<canvas id="scene" width="520" height="260"></canvas>
</div>
<div class="status" id="status">Controller off — the pole will fall on its own.</div>
<div class="btns">
<button id="toggle" type="button">Start controller</button>
<button id="nudge" type="button">Nudge</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; }
.stage { background: #eef2f6; border: 1px solid #cdd9e3; border-radius: 10px; margin: .4rem 0; }
canvas { display: block; width: 100%; height: 260px; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.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; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Cart-pole dynamics (nonlinear, standard form): cart mass M, pole mass m,
// half-length l, gravity g. theta is measured from the upright position, so
// theta = 0 means the pole points straight up.
const g = 9.8, M = 1.0, m = 0.1, l = 0.5; // cart mass, pole mass, half-length, gravity
const MAX_FORCE = 40, DT = 0.016;
// State: x/v = cart position/velocity, theta/w = pole angle/angular velocity.
let state = { x: 0, v: 0, theta: 0.12, w: 0 };
let running = false;
let fell = false;
// Hand-tuned state-feedback gains K, verified numerically to stabilize small tilts.
const K = { x: 5, v: 5, theta: 40, w: 10 };
function derivative(s, F) {
const sinT = Math.sin(s.theta), cosT = Math.cos(s.theta);
const totalMass = M + m;
const temp = (F + m * l * s.w * s.w * sinT) / totalMass;
const thetaAcc = (g * sinT - cosT * temp) / (l * (4 / 3 - m * cosT * cosT / totalMass));
const xAcc = temp - m * l * thetaAcc * cosT / totalMass;
return { x: s.v, v: xAcc, theta: s.w, w: thetaAcc };
}
// Control law: force = Kx*x + Kv*v + Ktheta*theta + Kw*w, clamped to +/- MAX_FORCE.
function controlForce(s) {
const raw = K.x * s.x + K.v * s.v + K.theta * s.theta + K.w * s.w;
return Math.max(-MAX_FORCE, Math.min(MAX_FORCE, raw));
}
function step() {
const F = running ? controlForce(state) : 0;
const d = derivative(state, F);
state = {
x: state.x + d.x * DT,
v: state.v + d.v * DT,
theta: state.theta + d.theta * DT,
w: state.w + d.w * DT,
};
if (Math.abs(state.x) > 2.2) state.x = Math.sign(state.x) * 2.2;
if (!fell && Math.abs(state.theta) > Math.PI / 2) {
fell = true;
setStatus('✗ The pole fell. Reset and try again.', 'bad');
}
}
function draw() {
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const w = canvas.width, h = canvas.height;
ctx.clearRect(0, 0, w, h);
const originX = w / 2, trackY = h - 60;
const pxPerM = 90;
// draw the track the cart slides on
ctx.strokeStyle = '#adb1b8';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(40, trackY + 24);
ctx.lineTo(w - 40, trackY + 24);
ctx.stroke();
const cartX = originX + state.x * pxPerM;
const cartW = 70, cartH = 34;
// draw the cart body
ctx.fillStyle = '#1d3557';
ctx.fillRect(cartX - cartW / 2, trackY - cartH / 2, cartW, cartH);
// draw the cart's wheels
ctx.fillStyle = '#222';
ctx.beginPath(); ctx.arc(cartX - cartW / 3, trackY + cartH / 2, 6, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(cartX + cartW / 3, trackY + cartH / 2, 6, 0, Math.PI * 2); ctx.fill();
// draw the pole from the pivot to its tip
const poleLenPx = l * 2 * pxPerM;
const pivotY = trackY - cartH / 2;
const tipX = cartX + poleLenPx * Math.sin(state.theta);
const tipY = pivotY - poleLenPx * Math.cos(state.theta);
ctx.strokeStyle = fell ? '#c92f3c' : '#e63946';
ctx.lineWidth = 6;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(cartX, pivotY);
ctx.lineTo(tipX, tipY);
ctx.stroke();
// draw the weighted tip of the pole
ctx.fillStyle = fell ? '#c92f3c' : '#e63946';
ctx.beginPath(); ctx.arc(tipX, tipY, 8, 0, Math.PI * 2); ctx.fill();
// draw the hinge connecting pole and cart
ctx.fillStyle = '#457b9d';
ctx.beginPath(); ctx.arc(cartX, pivotY, 4, 0, Math.PI * 2); ctx.fill();
}
function setStatus(t, cls) {
const el = document.getElementById('status');
el.textContent = t;
el.className = 'status ' + cls;
}
function loop() {
if (!fell) step();
draw();
requestAnimationFrame(loop);
}
function reset() {
state = { x: 0, v: 0, theta: 0.12, w: 0 };
running = false;
fell = false;
document.getElementById('toggle').textContent = 'Start controller';
setStatus('Controller off — the pole will fall on its own.', '');
}
document.getElementById('toggle').addEventListener('click', () => {
if (fell) return;
running = !running;
document.getElementById('toggle').textContent = running ? 'Stop controller' : 'Start controller';
setStatus(running ? 'Controller on — balancing.' : 'Controller off — the pole will fall on its own.', running ? 'ok' : '');
});
document.getElementById('nudge').addEventListener('click', () => {
if (fell) return;
// give the pole a random angular kick to test the controller's recovery
state.w += (Math.random() < 0.5 ? -1 : 1) * 1.8;
setStatus(fmt('Nudged! Watch the controller react.', {}), running ? 'ok' : '');
});
document.getElementById('reset').addEventListener('click', reset);
draw();
requestAnimationFrame(loop);
Comments
Loading comments...