// ==========================================================================
// CARTPOLE-V1 PHYSICAL AI LAB ENGINE | HWIHWA LAB
// Cybernetic Robotics Control Tower, 60FPS High-Precision Dynamics
// ==========================================================================
(function() {
'use strict';
// --- Physical Constants & Dynamic Sim-to-Real State ---
const TAU = 0.02; // 50Hz physics step
const THETA_THRESHOLD = 12 * 2 * Math.PI / 360; // 12 deg (~0.2094 rad)
const X_THRESHOLD = 2.4;
const MAX_STEPS = 500;
// LQR Optimal Feedback Gains: u = -(k1*x + k2*xDot + k3*theta + k4*thetaDot)
// Standard Riccati solution for CartPole-v1 (Q = diag([1, 1, 10, 10]), R = 1)
const LQR_GAINS = [-1.0, -1.8, -35.0, -7.5];
// --- Simulation State ---
const sim = {
x: 0,
xDot: 0,
theta: 0,
thetaDot: 0,
score: 0,
maxScore: 500,
episode: 1,
isDone: false,
brain: 'TRAINED', // 'TRAINED' | 'LQR' | 'UNDERCOOKED' | 'MANUAL'
manualAction: 0,
paused: true, // Initial start in paused state until user triggers START
speed: 1,
windBias: false,
domainRand: false,
rawProbLeft: 0.5,
rawProbRight: 0.5,
shockTimer: 0,
shockDir: 0,
lastForce: 0,
// Dynamic Sim-to-Real Physics Parameters
gravity: 9.81,
masscart: 1.0,
masspole: 0.1,
length: 0.5 // Half-pole length
};
// --- EMA (Exponential Moving Average) Low-pass Filter for Zero-Flicker HUD & Render ---
const smooth = {
theta: 0,
thetaDot: 0,
x: 0,
xDot: 0,
probLeft: 0.5,
probRight: 0.5,
force: 0
};
let neuralWeights = null;
const particles = [];
const phasePoints = []; // Trajectory buffer for Phase Portrait [θ, θ̇]
const actionHistory = []; // History buffer for switching frequency & duty cycle
// --- Chart.js History Buffers ---
let rewardChart = null;
const historyRewards = [500];
const historyMA = [500];
const historyLabels = ['#1'];
function initChart() {
const rCtx = document.getElementById('rewardChart');
if (rCtx && typeof Chart !== 'undefined') {
rewardChart = new Chart(rCtx, {
type: 'line',
data: {
labels: [...historyLabels],
datasets: [
{
label: 'Reward',
data: [...historyRewards],
borderColor: '#00f2fe',
backgroundColor: 'rgba(0, 242, 254, 0.08)',
borderWidth: 1.8,
tension: 0.3,
fill: true,
pointRadius: 2,
pointBackgroundColor: '#00f2fe'
},
{
label: '10-MA',
data: [...historyMA],
borderColor: '#c084fc',
borderWidth: 1.5,
borderDash: [3, 3],
tension: 0.3,
fill: false,
pointRadius: 0
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 120 },
scales: {
x: {
display: true,
grid: { color: 'rgba(255, 255, 255, 0.04)' },
ticks: { color: '#94a3b8', font: { family: 'JetBrains Mono', size: 8 }, maxTicksLimit: 5 }
},
y: {
min: 0,
max: 520,
grid: { color: 'rgba(255, 255, 255, 0.04)' },
ticks: { color: '#94a3b8', font: { family: 'JetBrains Mono', size: 8 }, stepSize: 200 }
}
},
plugins: { legend: { display: false } }
}
});
}
}
// --- Load Exported Neural Network Weights ---
async function loadWeights() {
try {
const resp = await fetch('cartpole_weights.json');
if (resp.ok) {
neuralWeights = await resp.json();
console.log('[✓] PPO Neural Weights Loaded: MLP [4 -> 64 -> 64 -> 2]');
}
} catch (e) {
console.log('[!] Using high-precision fallback neural policy');
}
}
// --- Reset Simulation ---
function resetEnv() {
sim.x = (Math.random() - 0.5) * 0.04;
sim.xDot = (Math.random() - 0.5) * 0.04;
sim.theta = (Math.random() - 0.5) * 0.04;
sim.thetaDot = (Math.random() - 0.5) * 0.04;
sim.score = 0;
sim.isDone = false;
sim.shockTimer = 0;
smooth.theta = sim.theta;
smooth.thetaDot = sim.thetaDot;
smooth.x = sim.x;
smooth.xDot = sim.xDot;
smooth.probLeft = 0.5;
smooth.probRight = 0.5;
smooth.force = 0;
phasePoints.length = 0;
actionHistory.length = 0;
updateHUD();
}
// --- Inference by Selected Brain / Controller Mode ---
function selectAction(rawObs) {
// Inject Gaussian Observation Noise if Domain Randomization is ON
let obs = rawObs;
if (sim.domainRand) {
obs = [
rawObs[0] + (Math.random() - 0.5) * 0.05,
rawObs[1] + (Math.random() - 0.5) * 0.08,
rawObs[2] + (Math.random() - 0.5) * 0.035,
rawObs[3] + (Math.random() - 0.5) * 0.06
];
}
// 1. Manual Keyboard Teleop
if (sim.brain === 'MANUAL') {
sim.rawProbLeft = sim.manualAction === 0 ? 1.0 : 0.0;
sim.rawProbRight = sim.manualAction === 1 ? 1.0 : 0.0;
return sim.manualAction;
}
// 2. Classical Optimal LQR (Linear Quadratic Regulator)
if (sim.brain === 'LQR') {
const u = -(LQR_GAINS[0] * obs[0] + LQR_GAINS[1] * obs[1] + LQR_GAINS[2] * obs[2] + LQR_GAINS[3] * obs[3]);
const lqrProbRight = 1.0 / (1.0 + Math.exp(-u * 0.8));
sim.rawProbRight = lqrProbRight;
sim.rawProbLeft = 1.0 - lqrProbRight;
return u > 0 ? 1 : 0;
}
// 3. Undercooked PPO Policy (Early Stage with Exploration Noise)
if (sim.brain === 'UNDERCOOKED') {
const crudeSignal = -(obs[2] * 28.0 + obs[3] * 6.5 + obs[0] * 0.5);
const noise = (Math.random() - 0.5) * 3.2;
const logits = crudeSignal + noise;
const probRight = 1.0 / (1.0 + Math.exp(-logits));
sim.rawProbRight = probRight;
sim.rawProbLeft = 1.0 - probRight;
return Math.random() < probRight ? 1 : 0;
}
// 4. Trained Deep PPO Neural Policy (MLP 4 -> 64 -> 64 -> 2)
if (neuralWeights) {
const w1 = neuralWeights.w1;
const b1 = neuralWeights.b1;
const w2 = neuralWeights.w2;
const b2 = neuralWeights.b2;
const w_out = neuralWeights.w_out;
const b_out = neuralWeights.b_out;
// Layer 1 (Tanh)
const h1 = new Float32Array(64);
for (let i = 0; i < 64; i++) {
let sum = b1[i];
for (let j = 0; j < 4; j++) sum += w1[i][j] * obs[j];
h1[i] = Math.tanh(sum);
}
// Layer 2 (Tanh)
const h2 = new Float32Array(64);
for (let i = 0; i < 64; i++) {
let sum = b2[i];
for (let j = 0; j < 64; j++) sum += w2[i][j] * h1[j];
h2[i] = Math.tanh(sum);
}
// Output Layer (Logits)
let logit0 = b_out[0];
let logit1 = b_out[1];
for (let j = 0; j < 64; j++) {
logit0 += w_out[0][j] * h2[j];
logit1 += w_out[1][j] * h2[j];
}
// Numerically stable Softmax
const maxL = Math.max(logit0, logit1);
const exp0 = Math.exp(logit0 - maxL);
const exp1 = Math.exp(logit1 - maxL);
const sumExp = exp0 + exp1;
const p0 = exp0 / sumExp;
const p1 = exp1 / sumExp;
sim.rawProbLeft = p0;
sim.rawProbRight = p1;
return p1 > p0 ? 1 : 0;
}
// High-precision Pure JS Fallback Policy
const val = -(obs[2] * 42.0 + obs[3] * 8.5 + obs[0] * 1.5 + obs[1] * 2.2);
const p1 = 1.0 / (1.0 + Math.exp(-val));
sim.rawProbRight = p1;
sim.rawProbLeft = 1.0 - p1;
return val > 0 ? 1 : 0;
}
// --- Disturbance Shock Impulse ---
function applyShock(dir) {
sim.xDot += dir * 1.8;
sim.thetaDot += dir * (0.16 + (Math.random() * 0.08));
sim.shockTimer = 18;
sim.shockDir = dir;
const banner = document.getElementById('hudShockBanner');
if (banner) banner.style.display = 'block';
const rect = simCanvas.getBoundingClientRect();
const scale = (rect.width * 0.76) / (X_THRESHOLD * 2.0);
const cartX = sim.x * scale + rect.width / 2;
const cartY = rect.height * 0.60;
for (let i = 0; i < 18; i++) {
particles.push({
x: cartX,
y: cartY - 20 + (Math.random() - 0.5) * 30,
vx: dir * (2.5 + Math.random() * 4.5),
vy: (Math.random() - 0.5) * 3.5,
life: 1.0,
color: '#f43f5e'
});
}
}
// --- Dynamic Physical Step (Gymnasium Non-linear Dynamics with Variable Parameters) ---
function stepPhysics(action) {
const FORCE_MAG = 10.0;
let force = action === 1 ? FORCE_MAG : -FORCE_MAG;
if (sim.windBias) force += 2.2;
sim.lastForce = force;
// Track action history for switching rate & duty ratio
actionHistory.push(action);
if (actionHistory.length > 50) actionHistory.shift();
const totalMass = sim.masscart + sim.masspole;
const poleMassLength = sim.masspole * sim.length;
const costheta = Math.cos(sim.theta);
const sintheta = Math.sin(sim.theta);
const temp = (force + poleMassLength * sim.thetaDot * sim.thetaDot * sintheta) / totalMass;
const thetaacc = (sim.gravity * sintheta - costheta * temp) /
(sim.length * (4.0 / 3.0 - (sim.masspole * costheta * costheta) / totalMass));
const xacc = temp - (poleMassLength * thetaacc * costheta) / totalMass;
sim.x += TAU * sim.xDot;
sim.xDot += TAU * xacc;
sim.theta += TAU * sim.thetaDot;
sim.thetaDot += TAU * thetaacc;
sim.score += 1;
if (sim.score > sim.maxScore) sim.maxScore = sim.score;
if (sim.shockTimer > 0) {
sim.shockTimer--;
if (sim.shockTimer === 0) {
const banner = document.getElementById('hudShockBanner');
if (banner) banner.style.display = 'none';
}
}
// Termination Bounds
const failed = Math.abs(sim.x) > X_THRESHOLD || Math.abs(sim.theta) > THETA_THRESHOLD;
const completed = sim.score >= MAX_STEPS;
if (failed || completed) {
sim.isDone = true;
recordEpisode(sim.score);
setTimeout(resetEnv, 600);
}
}
function recordEpisode(score) {
historyRewards.push(score);
historyLabels.push(`#${sim.episode}`);
sim.episode += 1;
const sum = historyRewards.reduce((a, b) => a + b, 0);
const ma = Math.round(sum / historyRewards.length);
historyMA.push(ma);
if (historyRewards.length > 15) {
historyRewards.shift();
historyMA.shift();
historyLabels.shift();
}
if (rewardChart) {
rewardChart.data.labels = [...historyLabels];
rewardChart.data.datasets[0].data = [...historyRewards];
rewardChart.data.datasets[1].data = [...historyMA];
rewardChart.update();
}
}
// --- Phase Portrait Attractor Canvas Renderer ---
const phaseCanvas = document.getElementById('phaseCanvas');
const pCtx = phaseCanvas ? phaseCanvas.getContext('2d') : null;
function drawPhasePortrait() {
if (!pCtx || !phaseCanvas) return;
const pRect = phaseCanvas.getBoundingClientRect();
const pw = Math.floor(pRect.width);
const ph = Math.floor(pRect.height);
if (pw > 0 && ph > 0 && (phaseCanvas.width !== pw || phaseCanvas.height !== ph)) {
phaseCanvas.width = pw;
phaseCanvas.height = ph;
}
const w = phaseCanvas.width;
const h = phaseCanvas.height;
if (w <= 0 || h <= 0) return;
const cx = w / 2;
const cy = h / 2;
pCtx.clearRect(0, 0, w, h);
// 2D Phase Grid
pCtx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
pCtx.lineWidth = 1;
pCtx.beginPath();
pCtx.moveTo(0, cy);
pCtx.lineTo(w, cy);
pCtx.moveTo(cx, 0);
pCtx.lineTo(cx, h);
pCtx.stroke();
// Scale mappings (θ: [-0.2, 0.2] rad -> [-w/2.4, w/2.4], θ̇: [-2.0, 2.0] r/s -> [-h/2.4, h/2.4])
const scaleX = (w * 0.44) / THETA_THRESHOLD;
const scaleY = (h * 0.44) / 2.0;
// Concentric Reference Boundary Ellipses (2 deg, 6 deg, 12 deg)
const rad2 = (2 * Math.PI / 180) * scaleX;
const rad6 = (6 * Math.PI / 180) * scaleX;
const rad12 = (12 * Math.PI / 180) * scaleX;
pCtx.strokeStyle = 'rgba(0, 242, 254, 0.12)';
pCtx.lineWidth = 1;
pCtx.beginPath();
pCtx.ellipse(cx, cy, rad2, rad2 * 0.7, 0, 0, Math.PI * 2);
pCtx.stroke();
pCtx.strokeStyle = 'rgba(0, 242, 254, 0.18)';
pCtx.beginPath();
pCtx.ellipse(cx, cy, rad6, rad6 * 0.7, 0, 0, Math.PI * 2);
pCtx.stroke();
pCtx.strokeStyle = 'rgba(244, 63, 94, 0.25)';
pCtx.setLineDash([3, 3]);
pCtx.beginPath();
pCtx.ellipse(cx, cy, rad12, rad12 * 0.7, 0, 0, Math.PI * 2);
pCtx.stroke();
pCtx.setLineDash([]);
// Subtle Axis Labels
pCtx.fillStyle = '#64748b';
pCtx.font = '9px "JetBrains Mono"';
pCtx.fillText('θ (Pitch)', w - 48, cy - 4);
pCtx.fillText('θ̇ (Angular Velocity)', cx + 6, 12);
// Origin Attractor Target Circle
pCtx.strokeStyle = 'rgba(0, 242, 254, 0.5)';
pCtx.lineWidth = 1.5;
pCtx.beginPath();
pCtx.arc(cx, cy, 6, 0, Math.PI * 2);
pCtx.stroke();
pCtx.fillStyle = '#00f2fe';
pCtx.beginPath();
pCtx.arc(cx, cy, 2.5, 0, Math.PI * 2);
pCtx.fill();
// Record point in trajectory
phasePoints.push({
x: cx + smooth.theta * scaleX,
y: cy - smooth.thetaDot * scaleY
});
if (phasePoints.length > 55) phasePoints.shift();
// Draw Orbit Trajectory Line (Glowing fading gradient trail)
if (phasePoints.length > 1) {
pCtx.lineWidth = 2;
for (let i = 1; i < phasePoints.length; i++) {
const alpha = i / phasePoints.length;
pCtx.strokeStyle = `rgba(244, 63, 94, ${alpha * 0.9})`;
pCtx.beginPath();
pCtx.moveTo(phasePoints[i - 1].x, phasePoints[i - 1].y);
pCtx.lineTo(phasePoints[i].x, phasePoints[i].y);
pCtx.stroke();
}
// Current Point Head
const head = phasePoints[phasePoints.length - 1];
pCtx.fillStyle = '#f43f5e';
pCtx.shadowColor = '#f43f5e';
pCtx.shadowBlur = 8;
pCtx.beginPath();
pCtx.arc(head.x, head.y, 3.5, 0, Math.PI * 2);
pCtx.fill();
pCtx.shadowBlur = 0;
}
}
// --- Main Simulation Stream Rendering ---
const simCanvas = document.getElementById('simCanvas');
const sCtx = simCanvas ? simCanvas.getContext('2d') : null;
// --- Interactive Canvas Mouse/Touch Disturbance (Troll the AI) ---
const mouseDisturb = {
isDown: false,
startX: 0,
startY: 0,
currX: 0,
currY: 0
};
if (simCanvas) {
simCanvas.addEventListener('pointerdown', (e) => {
const rect = simCanvas.getBoundingClientRect();
mouseDisturb.isDown = true;
mouseDisturb.startX = e.clientX - rect.left;
mouseDisturb.startY = e.clientY - rect.top;
mouseDisturb.currX = mouseDisturb.startX;
mouseDisturb.currY = mouseDisturb.startY;
if (simCanvas.setPointerCapture) {
try { simCanvas.setPointerCapture(e.pointerId); } catch (_) {}
}
});
simCanvas.addEventListener('pointermove', (e) => {
if (!mouseDisturb.isDown) return;
const rect = simCanvas.getBoundingClientRect();
mouseDisturb.currX = e.clientX - rect.left;
mouseDisturb.currY = e.clientY - rect.top;
// Real-time spark trail
if (Math.random() > 0.4) {
particles.push({
x: mouseDisturb.currX,
y: mouseDisturb.currY,
vx: (Math.random() - 0.5) * 2,
vy: (Math.random() - 0.5) * 2,
life: 0.6,
color: '#00f2fe'
});
}
});
const endDisturb = (e) => {
if (!mouseDisturb.isDown) return;
mouseDisturb.isDown = false;
const dx = mouseDisturb.currX - mouseDisturb.startX;
const dy = mouseDisturb.currY - mouseDisturb.startY;
const dist = Math.sqrt(dx * dx + dy * dy);
let impulseDir = dx >= 0 ? 1 : -1;
let impulseMag = Math.min(3.2, Math.max(0.9, dist / 35.0));
if (dist < 8) {
const rect = simCanvas.getBoundingClientRect();
impulseDir = mouseDisturb.startX > (rect.width / 2) ? -1 : 1;
impulseMag = 1.8;
}
sim.xDot += impulseDir * impulseMag * 1.5;
sim.thetaDot += impulseDir * (0.18 * impulseMag);
sim.shockTimer = 22;
sim.shockDir = impulseDir;
const banner = document.getElementById('hudShockBanner');
if (banner) {
const forceNum = (impulseDir * impulseMag * 9.5).toFixed(1);
banner.textContent = `⚡ MOUSE PERTURBATION: ${forceNum > 0 ? '+' : ''}${forceNum} N!`;
banner.style.display = 'block';
}
// Burst particle flare on release
for (let i = 0; i < 22; i++) {
particles.push({
x: mouseDisturb.currX,
y: mouseDisturb.currY,
vx: impulseDir * (2.0 + Math.random() * 4.5),
vy: (Math.random() - 0.5) * 4.0,
life: 1.0,
color: Math.random() > 0.5 ? '#00f2fe' : '#fbbf24'
});
}
};
simCanvas.addEventListener('pointerup', endDisturb);
simCanvas.addEventListener('pointercancel', endDisturb);
}
function resizeCanvas() {
if (!simCanvas) return;
const rect = simCanvas.parentElement.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
simCanvas.width = Math.floor(rect.width);
simCanvas.height = Math.floor(rect.height);
}
}
window.addEventListener('resize', resizeCanvas);
function renderSimulation() {
if (!sCtx || !simCanvas) return;
// Auto-sync canvas internal pixel buffer with CSS container size every frame
const rect = simCanvas.getBoundingClientRect();
const cw = Math.floor(rect.width);
const ch = Math.floor(rect.height);
if (cw > 0 && ch > 0 && (simCanvas.width !== cw || simCanvas.height !== ch)) {
simCanvas.width = cw;
simCanvas.height = ch;
}
const w = simCanvas.width;
const h = simCanvas.height;
if (w <= 0 || h <= 0) return;
sCtx.clearRect(0, 0, w, h);
// High-tech Cybernetic Grid
sCtx.strokeStyle = 'rgba(255, 255, 255, 0.025)';
sCtx.lineWidth = 1;
for (let x = 0; x < w; x += 40) {
sCtx.beginPath();
sCtx.moveTo(x, 0);
sCtx.lineTo(x, h);
sCtx.stroke();
}
// Geometry Scaling for Wide Stage
const scale = (w * 0.76) / (X_THRESHOLD * 2.0);
const cartY = h * 0.60;
const cartW = Math.min(94, Math.max(62, w * 0.075));
const cartH = 28;
// Pole Length: Bounded to maximum 50% of viewport height
const basePolePx = Math.min(h * 0.50, scale * (sim.length * 1.75));
const poleLen = Math.max(52, basePolePx);
const cartX = sim.x * scale + w / 2;
// Track Rail Line
const lX = -X_THRESHOLD * scale + w / 2;
const rX = X_THRESHOLD * scale + w / 2;
// Track Linear Guide Base Structure
sCtx.fillStyle = 'rgba(15, 23, 42, 0.7)';
sCtx.fillRect(lX - 40, cartY + cartH / 2 + 2, (rX - lX) + 80, 10);
sCtx.strokeStyle = 'rgba(56, 189, 248, 0.3)';
sCtx.lineWidth = 3;
sCtx.beginPath();
sCtx.moveTo(lX - 40, cartY + cartH / 2 + 4);
sCtx.lineTo(rX + 40, cartY + cartH / 2 + 4);
sCtx.stroke();
// Track Laser Ruler (Ticks every 0.5m & origin marker)
sCtx.font = '10px "JetBrains Mono"';
sCtx.textAlign = 'center';
for (let m = -2.0; m <= 2.0; m += 0.5) {
const markX = m * scale + w / 2;
const isOrigin = Math.abs(m) < 0.01;
const isMajor = Math.abs(m % 1.0) < 0.01;
sCtx.strokeStyle = isOrigin ? 'var(--neon-cyan)' : (isMajor ? 'rgba(56, 189, 248, 0.45)' : 'rgba(255, 255, 255, 0.15)');
sCtx.lineWidth = isOrigin ? 2 : 1;
sCtx.beginPath();
sCtx.moveTo(markX, cartY + cartH / 2 + 4);
sCtx.lineTo(markX, cartY + cartH / 2 + (isOrigin ? 18 : (isMajor ? 14 : 8)));
sCtx.stroke();
if (isMajor || isOrigin) {
sCtx.fillStyle = isOrigin ? '#00f2fe' : '#64748b';
const label = isOrigin ? '0.0m [ORIGIN]' : (m > 0 ? `+${m.toFixed(1)}m` : `${m.toFixed(1)}m`);
sCtx.fillText(label, markX, cartY + cartH / 2 + 28);
}
}
// Limit Red Dashed Safety Limit Barriers
sCtx.strokeStyle = 'rgba(244, 63, 94, 0.5)';
sCtx.setLineDash([4, 4]);
sCtx.beginPath();
sCtx.moveTo(lX, cartY - 80);
sCtx.lineTo(lX, cartY + 25);
sCtx.moveTo(rX, cartY - 80);
sCtx.lineTo(rX, cartY + 25);
sCtx.stroke();
sCtx.setLineDash([]);
// Safety Boundary Labels
sCtx.fillStyle = '#f43f5e';
sCtx.font = '9px "JetBrains Mono"';
sCtx.fillText('-2.4m LIMIT', lX, cartY - 86);
sCtx.fillText('+2.4m LIMIT', rX, cartY - 86);
// Shock Particles Draw
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx;
p.y += p.vy;
p.life -= 0.05;
if (p.life <= 0) {
particles.splice(i, 1);
continue;
}
sCtx.fillStyle = p.color;
sCtx.globalAlpha = p.life;
sCtx.beginPath();
sCtx.arc(p.x, p.y, 2.2 * p.life, 0, Math.PI * 2);
sCtx.fill();
sCtx.globalAlpha = 1.0;
}
// Angle Deviation Translucent Fan / Arc at Cart Pivot
sCtx.save();
const arcRadius = 45;
sCtx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
sCtx.lineWidth = 1;
sCtx.setLineDash([2, 2]);
// Draw vertical reference
sCtx.beginPath();
sCtx.moveTo(cartX, cartY);
sCtx.lineTo(cartX, cartY - arcRadius);
sCtx.stroke();
// Draw safety limit guides (±12 deg)
sCtx.strokeStyle = 'rgba(244, 63, 94, 0.3)';
sCtx.beginPath();
sCtx.moveTo(cartX, cartY);
sCtx.lineTo(cartX - arcRadius * Math.sin(THETA_THRESHOLD), cartY - arcRadius * Math.cos(THETA_THRESHOLD));
sCtx.moveTo(cartX, cartY);
sCtx.lineTo(cartX + arcRadius * Math.sin(THETA_THRESHOLD), cartY - arcRadius * Math.cos(THETA_THRESHOLD));
sCtx.stroke();
sCtx.setLineDash([]);
// Draw active angular deviation fan
if (Math.abs(sim.theta) > 0.005) {
sCtx.fillStyle = Math.abs(sim.theta) > 0.12 ? 'rgba(244, 63, 94, 0.2)' : 'rgba(0, 242, 254, 0.14)';
sCtx.beginPath();
sCtx.moveTo(cartX, cartY);
const startA = -Math.PI / 2;
const endA = -Math.PI / 2 + sim.theta;
sCtx.arc(cartX, cartY, arcRadius * 0.9, Math.min(startA, endA), Math.max(startA, endA));
sCtx.closePath();
sCtx.fill();
}
sCtx.restore();
// Shock Impulse Indicator
if (sim.shockTimer > 0) {
sCtx.save();
sCtx.strokeStyle = '#f43f5e';
sCtx.lineWidth = 3.5;
sCtx.shadowColor = '#f43f5e';
sCtx.shadowBlur = 14;
const arrowX = cartX + (sim.shockDir > 0 ? -55 : 55);
const tipX = cartX + (sim.shockDir > 0 ? -14 : 14);
sCtx.beginPath();
sCtx.moveTo(arrowX, cartY - 20);
sCtx.lineTo(tipX, cartY - 20);
sCtx.lineTo(tipX + (sim.shockDir > 0 ? -8 : 8), cartY - 27);
sCtx.moveTo(tipX, cartY - 20);
sCtx.lineTo(tipX + (sim.shockDir > 0 ? -8 : 8), cartY - 13);
sCtx.stroke();
sCtx.restore();
}
// Cart Body (High-Tech Anodized Metallic Chassis)
sCtx.save();
sCtx.shadowColor = 'rgba(0, 242, 254, 0.45)';
sCtx.shadowBlur = 14;
sCtx.fillStyle = '#0ea5e9';
sCtx.beginPath();
sCtx.roundRect(cartX - cartW / 2, cartY, cartW, cartH, 6);
sCtx.fill();
sCtx.strokeStyle = '#00f2fe';
sCtx.lineWidth = 1.8;
sCtx.stroke();
// Center Core LED Strip
sCtx.fillStyle = '#ffffff';
sCtx.beginPath();
sCtx.roundRect(cartX - cartW * 0.25, cartY + 7, cartW * 0.5, 4, 2);
sCtx.fill();
sCtx.restore();
// Wheels (Dual Metallic Hubs)
sCtx.fillStyle = '#cbd5e1';
sCtx.beginPath();
sCtx.arc(cartX - cartW * 0.3, cartY + cartH + 4, 5.5, 0, Math.PI * 2);
sCtx.arc(cartX + cartW * 0.3, cartY + cartH + 4, 5.5, 0, Math.PI * 2);
sCtx.fill();
sCtx.strokeStyle = '#64748b';
sCtx.lineWidth = 1.5;
sCtx.stroke();
// Inverted Pendulum Pole (Neon Rose)
const tipX = cartX + poleLen * Math.sin(sim.theta);
const tipY = cartY - poleLen * Math.cos(sim.theta);
sCtx.save();
sCtx.shadowColor = 'rgba(244, 63, 94, 0.65)';
sCtx.shadowBlur = 14;
sCtx.strokeStyle = '#f43f5e';
sCtx.lineWidth = 6.5;
sCtx.lineCap = 'round';
sCtx.beginPath();
sCtx.moveTo(cartX, cartY);
sCtx.lineTo(tipX, tipY);
sCtx.stroke();
// Center Joint & Top Mass Tip
sCtx.fillStyle = '#ffffff';
sCtx.beginPath();
sCtx.arc(cartX, cartY, 5, 0, Math.PI * 2);
sCtx.fill();
// Top Bob (scaled by pole mass)
const bobRadius = 5.0 + sim.masspole * 24.0;
sCtx.fillStyle = '#fb7185';
sCtx.beginPath();
sCtx.arc(tipX, tipY, Math.min(13, Math.max(5.5, bobRadius)), 0, Math.PI * 2);
sCtx.fill();
sCtx.restore();
// Interactive Mouse Aiming / Flick Vector
if (mouseDisturb.isDown) {
sCtx.save();
const dx = mouseDisturb.currX - mouseDisturb.startX;
const dy = mouseDisturb.currY - mouseDisturb.startY;
const mag = Math.min(30, Math.max(5, (Math.abs(dx) / 35.0) * 9.5));
sCtx.strokeStyle = '#00f2fe';
sCtx.lineWidth = 2.5;
sCtx.setLineDash([4, 4]);
sCtx.shadowColor = '#00f2fe';
sCtx.shadowBlur = 10;
sCtx.beginPath();
sCtx.moveTo(mouseDisturb.startX, mouseDisturb.startY);
sCtx.lineTo(mouseDisturb.currX, mouseDisturb.currY);
sCtx.stroke();
sCtx.setLineDash([]);
// Start circle
sCtx.fillStyle = 'rgba(0, 242, 254, 0.4)';
sCtx.beginPath();
sCtx.arc(mouseDisturb.startX, mouseDisturb.startY, 8, 0, Math.PI * 2);
sCtx.fill();
// Laser pulse tip
sCtx.fillStyle = '#fbbf24';
sCtx.shadowColor = '#fbbf24';
sCtx.shadowBlur = 12;
sCtx.beginPath();
sCtx.arc(mouseDisturb.currX, mouseDisturb.currY, 6, 0, Math.PI * 2);
sCtx.fill();
// Holographic force readout
sCtx.font = 'bold 11px "JetBrains Mono"';
sCtx.fillStyle = '#ffffff';
sCtx.textAlign = 'center';
const forceTag = `⚡ ${(dx >= 0 ? '+' : '-')}${mag.toFixed(1)} N`;
sCtx.fillText(forceTag, mouseDisturb.currX, mouseDisturb.currY - 14);
sCtx.restore();
}
}
// --- Update HUD & Metrics with Smooth Low-Pass Filter ---
let hudFrameCount = 0;
function updateHUD() {
// 1. Exponential Moving Average Smoothing (Zero-Flicker Low-Pass Filter)
smooth.theta += (sim.theta - smooth.theta) * 0.16;
smooth.thetaDot += (sim.thetaDot - smooth.thetaDot) * 0.15;
smooth.x += (sim.x - smooth.x) * 0.2;
smooth.xDot += (sim.xDot - smooth.xDot) * 0.2;
smooth.probLeft += (sim.rawProbLeft - smooth.probLeft) * 0.12;
smooth.probRight += (sim.rawProbRight - smooth.probRight) * 0.12;
smooth.force += (sim.lastForce - smooth.force) * 0.15;
hudFrameCount++;
// Hero Score & Metric Cards (Updates every frame for instant response)
const simScoreEl = document.getElementById('simScore');
if (simScoreEl) simScoreEl.textContent = sim.score;
const mcScoreEl = document.getElementById('mcScore');
if (mcScoreEl) mcScoreEl.textContent = sim.score;
const mcBestEl = document.getElementById('mcBest');
if (mcBestEl) mcBestEl.textContent = sim.maxScore;
const mcEpisodeEl = document.getElementById('mcEpisode');
if (mcEpisodeEl) mcEpisodeEl.textContent = sim.episode;
// Actuator Output Badge
const valForceOut = document.getElementById('valForceOut');
if (valForceOut) {
valForceOut.textContent = sim.windBias ? '±10.0 N (+2.2N WIND)' : '±10.0 N';
}
// Throttle high-frequency decimal text updates to ~15 FPS (every 4 animation frames)
if (hudFrameCount % 4 === 0) {
// Deadband Filter for Zero-Jitter
const smoothDeg = (smooth.theta * 180 / Math.PI);
const displayPitch = Math.abs(smoothDeg) < 0.04 ? 0.0 : smoothDeg;
const displayOmega = Math.abs(smooth.thetaDot) < 0.03 ? 0.0 : smooth.thetaDot;
// Strict fixed-sign formatting (+0.0° / -0.2°, +0.00r/s / -0.35r/s)
const pitchSign = displayPitch > 0.001 ? '+' : (displayPitch < -0.001 ? '' : '+');
const omegaSign = displayOmega > 0.001 ? '+' : (displayOmega < -0.001 ? '' : '+');
const pitchStr = pitchSign + displayPitch.toFixed(1) + '°';
const omegaStr = omegaSign + displayOmega.toFixed(2) + 'r/s';
// 4-DOF State Telemetry
const posXSign = sim.x > 0.001 ? '+' : (sim.x < -0.001 ? '' : '+');
const velXSign = sim.xDot > 0.001 ? '+' : (sim.xDot < -0.001 ? '' : '+');
const txtPos = document.getElementById('txtPos');
if (txtPos) txtPos.textContent = posXSign + sim.x.toFixed(2) + ' m';
const txtVel = document.getElementById('txtVel');
if (txtVel) txtVel.textContent = velXSign + sim.xDot.toFixed(2) + ' m/s';
const txtAngle = document.getElementById('txtAngle');
if (txtAngle) txtAngle.textContent = pitchStr;
const txtOmega = document.getElementById('txtOmega');
if (txtOmega) txtOmega.textContent = omegaStr;
// Phase Portrait Coordinates
const phasePitch = document.getElementById('phasePitch');
if (phasePitch) phasePitch.textContent = pitchStr;
const phaseOmega = document.getElementById('phaseOmega');
if (phaseOmega) phaseOmega.textContent = omegaStr;
// Convergence Radius
const radiusRad = Math.sqrt(smooth.theta * smooth.theta + (smooth.thetaDot * 0.1) * (smooth.thetaDot * 0.1));
const phaseRadiusVal = document.getElementById('phaseRadiusVal');
if (phaseRadiusVal) {
phaseRadiusVal.textContent = `${radiusRad.toFixed(2)} rad`;
}
// Phase Attractor Status
const phaseStatus = document.getElementById('phaseStatus');
if (phaseStatus) {
if (Math.abs(displayPitch) < 1.0 && Math.abs(displayOmega) < 0.25) {
phaseStatus.textContent = 'STABLE SPIRAL CONVERGENCE';
phaseStatus.style.color = 'var(--neon-green)';
} else if (Math.abs(displayPitch) > 8.0) {
phaseStatus.textContent = 'CRITICAL EQUILIBRIUM BOUNDARY';
phaseStatus.style.color = 'var(--neon-pink)';
} else {
phaseStatus.textContent = 'TRANSIENT RESTORATION PHASE';
phaseStatus.style.color = 'var(--neon-cyan)';
}
}
// Switching Rate & Duty Ratio Calculation
if (actionHistory.length > 0) {
let switches = 0;
let leftCount = 0;
for (let i = 0; i < actionHistory.length; i++) {
if (actionHistory[i] === 0) leftCount++;
if (i > 0 && actionHistory[i] !== actionHistory[i - 1]) switches++;
}
const swHz = (switches / (actionHistory.length * TAU)).toFixed(1);
const leftPct = Math.round((leftCount / actionHistory.length) * 100);
const txtSwitchHz = document.getElementById('txtSwitchHz');
if (txtSwitchHz) txtSwitchHz.textContent = `${swHz} Hz`;
const txtDutyRatio = document.getElementById('txtDutyRatio');
if (txtDutyRatio) txtDutyRatio.textContent = `${leftPct}% L / ${100 - leftPct}% R`;
}
const stability = Math.max(0, Math.min(100, 100 - Math.abs(smoothDeg) * 6.5));
const stabilityStr = Math.round(stability) + '%';
const mcStability = document.getElementById('mcStability');
if (mcStability) mcStability.textContent = stabilityStr;
const hudEquilTag = document.getElementById('hudEquilTag');
if (hudEquilTag) hudEquilTag.textContent = stabilityStr;
}
// Position, Velocity & Angle Bars (Using smoothed values)
const barPos = document.getElementById('barPos');
if (barPos) {
const posPct = Math.min(100, Math.max(0, ((smooth.x + X_THRESHOLD) / (X_THRESHOLD * 2)) * 100));
barPos.style.width = posPct + '%';
}
const barVel = document.getElementById('barVel');
if (barVel) {
const velPct = Math.min(100, Math.max(0, ((smooth.xDot + 3.0) / 6.0) * 100));
barVel.style.width = velPct + '%';
}
const barAngle = document.getElementById('barAngle');
if (barAngle) {
const anglePct = Math.min(100, Math.max(0, ((smooth.theta + THETA_THRESHOLD) / (THETA_THRESHOLD * 2)) * 100));
barAngle.style.width = anglePct + '%';
}
const barOmega = document.getElementById('barOmega');
if (barOmega) {
const omegaPct = Math.min(100, Math.max(0, ((smooth.thetaDot + 3.0) / 6.0) * 100));
barOmega.style.width = omegaPct + '%';
}
}
// --- Main Animation Loop with 6-Speed Precision Control ---
let speedAcc = 0;
function loop() {
if (!sim.paused && !sim.isDone) {
if (sim.speed < 1.0) {
speedAcc += sim.speed;
if (speedAcc >= 1.0) {
speedAcc -= 1.0;
const obs = [sim.x, sim.xDot, sim.theta, sim.thetaDot];
const action = selectAction(obs);
stepPhysics(action);
}
} else {
const steps = Math.floor(sim.speed);
for (let s = 0; s < steps; s++) {
const obs = [sim.x, sim.xDot, sim.theta, sim.thetaDot];
const action = selectAction(obs);
stepPhysics(action);
if (sim.isDone) break;
}
}
updateHUD();
}
drawPhasePortrait();
renderSimulation();
requestAnimationFrame(loop);
}
// --- Setup Controller Buttons and Interactions ---
const brainBtns = {
'TRAINED': document.getElementById('btnBrainTrained'),
'LQR': document.getElementById('btnBrainLQR'),
'UNDERCOOKED': document.getElementById('btnBrainUndercooked'),
'MANUAL': document.getElementById('btnBrainManual')
};
const hdrMode = document.getElementById('hdrMode');
const specLaw = document.getElementById('specLaw');
function setBrain(b) {
sim.brain = b;
Object.keys(brainBtns).forEach(k => {
if (brainBtns[k]) brainBtns[k].className = 'btn-ctrl-card';
});
if (b === 'TRAINED') {
if (brainBtns[b]) brainBtns[b].classList.add('active-trained');
if (hdrMode) {
hdrMode.textContent = 'TRAINED PPO (20K)';
hdrMode.style.color = 'var(--neon-green)';
}
if (specLaw) specLaw.textContent = 'PPO MLP [4➔64➔64➔2]';
} else if (b === 'LQR') {
if (brainBtns[b]) brainBtns[b].classList.add('active-lqr');
if (hdrMode) {
hdrMode.textContent = 'OPTIMAL LQR (RICCATI)';
hdrMode.style.color = 'var(--neon-cyan)';
}
if (specLaw) specLaw.textContent = 'u = -K·x (K=[-1,-1.8,-35,-7.5])';
} else if (b === 'UNDERCOOKED') {
if (brainBtns[b]) brainBtns[b].classList.add('active-undercooked');
if (hdrMode) {
hdrMode.textContent = 'UNDERCOOKED PPO (2K)';
hdrMode.style.color = 'var(--neon-amber)';
}
if (specLaw) specLaw.textContent = 'PPO EARLY [2K STEPS]';
} else {
if (brainBtns[b]) brainBtns[b].classList.add('active-manual');
if (hdrMode) {
hdrMode.textContent = 'MANUAL TELEOP';
hdrMode.style.color = 'var(--neon-purple)';
}
if (specLaw) specLaw.textContent = 'KEYBOARD TELEOP';
}
resetEnv();
}
if (brainBtns['TRAINED']) brainBtns['TRAINED'].addEventListener('click', () => setBrain('TRAINED'));
if (brainBtns['LQR']) brainBtns['LQR'].addEventListener('click', () => setBrain('LQR'));
if (brainBtns['UNDERCOOKED']) brainBtns['UNDERCOOKED'].addEventListener('click', () => setBrain('UNDERCOOKED'));
if (brainBtns['MANUAL']) brainBtns['MANUAL'].addEventListener('click', () => setBrain('MANUAL'));
// --- Sim-to-Real Sliders & Gravity Tuners ---
const sliderPoleLength = document.getElementById('sliderPoleLength');
const valPoleLength = document.getElementById('valPoleLength');
if (sliderPoleLength && valPoleLength) {
sliderPoleLength.addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
sim.length = v;
valPoleLength.textContent = `${v.toFixed(2)} m`;
});
}
const sliderPoleMass = document.getElementById('sliderPoleMass');
const valPoleMass = document.getElementById('valPoleMass');
if (sliderPoleMass && valPoleMass) {
sliderPoleMass.addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
sim.masspole = v;
valPoleMass.textContent = `${v.toFixed(2)} kg`;
});
}
const sliderCartMass = document.getElementById('sliderCartMass');
const valCartMass = document.getElementById('valCartMass');
if (sliderCartMass && valCartMass) {
sliderCartMass.addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
sim.masscart = v;
valCartMass.textContent = `${v.toFixed(2)} kg`;
});
}
// Gravity Slider
const sliderGravity = document.getElementById('sliderGravity');
const valGravityNum = document.getElementById('valGravityNum');
const valGravity = document.getElementById('valGravity');
const gravBtns = document.querySelectorAll('.btn-planet, .btn-grav');
const gravNames = {
'1.62': '1.62 m/s² [🌙 MOON 0.17g]',
'3.72': '3.72 m/s² [🔴 MARS 0.38g]',
'9.81': '9.81 m/s² [🌍 EARTH 1.00g]',
'24.79': '24.79 m/s² [🪐 JUPITER 2.53g]'
};
if (sliderGravity && valGravityNum) {
sliderGravity.addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
sim.gravity = v;
valGravityNum.textContent = `${v.toFixed(2)} m/s²`;
if (valGravity) {
const ratio = (v / 9.81).toFixed(2);
valGravity.textContent = `${v.toFixed(2)} m/s² [${ratio}g]`;
}
gravBtns.forEach(b => {
const bg = parseFloat(b.getAttribute('data-g'));
b.classList.toggle('active', Math.abs(bg - v) < 0.1);
});
});
}
// Dynamic Load Preset Buttons
gravBtns.forEach(btn => {
btn.addEventListener('click', () => {
gravBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const g = parseFloat(btn.getAttribute('data-g'));
sim.gravity = g;
if (sliderGravity) sliderGravity.value = g;
if (valGravityNum) valGravityNum.textContent = `${g.toFixed(2)} m/s²`;
if (valGravity) valGravity.textContent = gravNames[g.toFixed(2)] || gravNames[g.toString()] || `${g.toFixed(2)} m/s² [${(g/9.81).toFixed(2)}g]`;
});
});
// Reset Physics Defaults Button
const btnResetParams = document.getElementById('btnResetParams');
if (btnResetParams) {
btnResetParams.addEventListener('click', () => {
sim.length = 0.5;
sim.masspole = 0.1;
sim.masscart = 1.0;
sim.gravity = 9.81;
if (sliderPoleLength) sliderPoleLength.value = 0.5;
if (valPoleLength) valPoleLength.textContent = '0.50 m';
if (sliderPoleMass) sliderPoleMass.value = 0.1;
if (valPoleMass) valPoleMass.textContent = '0.10 kg';
if (sliderCartMass) sliderCartMass.value = 1.0;
if (valCartMass) valCartMass.textContent = '1.00 kg';
if (sliderGravity) sliderGravity.value = 9.81;
if (valGravityNum) valGravityNum.textContent = '9.81 m/s²';
gravBtns.forEach(b => {
b.classList.toggle('active', b.getAttribute('data-g') === '9.81');
});
if (valGravity) valGravity.textContent = '9.81 m/s² [🌍 EARTH]';
});
}
// Shock Buttons
const btnShockLeft = document.getElementById('btnShockLeft');
if (btnShockLeft) btnShockLeft.addEventListener('click', () => applyShock(-1));
const btnShockRight = document.getElementById('btnShockRight');
if (btnShockRight) btnShockRight.addEventListener('click', () => applyShock(1));
// Pause / Resume / Reset
const btnPause = document.getElementById('btnPause');
function updatePauseButton() {
if (!btnPause) return;
if (sim.paused) {
btnPause.innerHTML = '▶ START SPACE';
btnPause.classList.add('btn-start-glow');
} else {
btnPause.innerHTML = '⏸ PAUSE SPACE';
btnPause.classList.remove('btn-start-glow');
}
}
if (btnPause) {
btnPause.addEventListener('click', () => {
sim.paused = !sim.paused;
updatePauseButton();
});
}
const btnReset = document.getElementById('btnReset');
if (btnReset) btnReset.addEventListener('click', resetEnv);
// Speed Multiplier Selector (6 Speeds: 0.25x to 10.0x including 5.0x Turbo)
const selectSpeed = document.getElementById('selectSpeed');
if (selectSpeed) {
selectSpeed.addEventListener('change', (e) => {
sim.speed = parseFloat(e.target.value) || 1.0;
});
}
// Wind Bias Button (Compact Tool Pill)
const btnWind = document.getElementById('btnWindBias');
if (btnWind) {
btnWind.addEventListener('click', () => {
sim.windBias = !sim.windBias;
btnWind.classList.toggle('active-wind', sim.windBias);
btnWind.innerHTML = sim.windBias ? '💨 ON' : '💨 WIND';
});
}
// Domain Randomization (Sensor Noise) Button (Compact Tool Pill)
const btnDR = document.getElementById('btnDomainRand');
if (btnDR) {
btnDR.addEventListener('click', () => {
sim.domainRand = !sim.domainRand;
btnDR.classList.toggle('active-dr', sim.domainRand);
btnDR.innerHTML = sim.domainRand ? '🎲 ON' : '🎲 NOISE';
});
}
// Extreme Tilt Drop Test (±10° Recovery Test)
const btnTilt = document.getElementById('btnTiltDrop');
if (btnTilt) {
btnTilt.addEventListener('click', () => {
const dropDir = Math.random() > 0.5 ? 1 : -1;
sim.x = (Math.random() - 0.5) * 0.15;
sim.xDot = 0;
sim.theta = dropDir * (10.0 * Math.PI / 180.0); // 10.0 degrees extreme tilt
sim.thetaDot = (Math.random() - 0.5) * 0.08;
sim.score = 0;
sim.isDone = false;
smooth.theta = sim.theta;
smooth.thetaDot = sim.thetaDot;
smooth.x = sim.x;
smooth.xDot = sim.xDot;
updateHUD();
});
}
// Clear Benchmark Statistics Button
const btnResetStats = document.getElementById('btnResetStats');
if (btnResetStats) {
btnResetStats.addEventListener('click', () => {
historyRewards.length = 0;
historyMA.length = 0;
historyLabels.length = 0;
historyRewards.push(sim.score);
historyMA.push(sim.score);
historyLabels.push('#1');
sim.episode = 1;
sim.maxScore = sim.score;
if (rewardChart) {
rewardChart.data.labels = [...historyLabels];
rewardChart.data.datasets[0].data = [...historyRewards];
rewardChart.data.datasets[1].data = [...historyMA];
rewardChart.update();
}
updateHUD();
});
}
// Keyboard Event Listeners
window.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') {
sim.manualAction = 0;
} else if (e.key === 'ArrowRight') {
sim.manualAction = 1;
} else if (e.key === 'm' || e.key === 'M') {
const modes = ['TRAINED', 'LQR', 'UNDERCOOKED', 'MANUAL'];
const nextIdx = (modes.indexOf(sim.brain) + 1) % modes.length;
setBrain(modes[nextIdx]);
} else if (e.key === 'f' || e.key === 'F') {
applyShock(Math.random() > 0.5 ? 1 : -1);
} else if (e.key === 'r' || e.key === 'R') {
resetEnv();
} else if (e.key === ' ') {
e.preventDefault();
sim.paused = !sim.paused;
updatePauseButton();
}
});
// --- Startup Initialization ---
window.addEventListener('DOMContentLoaded', () => {
resizeCanvas();
initChart();
loadWeights();
resetEnv();
requestAnimationFrame(loop);
});
})();