Reinforcement Learning
stable-baselines3
PyTorch
English
Korean
deep-reinforcement-learning
ppo
proximal-policy-optimization
cartpole-v1
gymnasium
openai-gym
classic-control
robotics
physical-ai
sim-to-real
zero-shot-transfer
domain-randomization
inverted-pendulum
optimal-control
lqr-control
control-theory
interactive-demo
Eval Results (legacy)
Instructions to use hwihwalab/cartpole-v1-ppo with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- stable-baselines3
How to use hwihwalab/cartpole-v1-ppo with stable-baselines3:
from huggingface_sb3 import load_from_hub checkpoint = load_from_hub( repo_id="hwihwalab/cartpole-v1-ppo", filename="{MODEL FILENAME}.zip", ) - Notebooks
- Google Colab
- Kaggle
Download cartpole_sim.js from hwihwalab/cartpole-v1-ppo: direct link, hf CLI and curl.
- Browser
- Download file 43.5 kB
-
https://huggingface.co/hwihwalab/cartpole-v1-ppo/resolve/main/cartpole_sim.js
- Command line
-
hf download hf://hwihwalab/cartpole-v1-ppo/cartpole_sim.js
-
curl -L -o cartpole_sim.js https://huggingface.co/hwihwalab/cartpole-v1-ppo/resolve/main/cartpole_sim.js
43.5 kB
| // ========================================================================== | |
| // 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 = '<span>▶</span> START <span class="hotkey-pill">SPACE</span>'; | |
| btnPause.classList.add('btn-start-glow'); | |
| } else { | |
| btnPause.innerHTML = '<span>⏸</span> PAUSE <span class="hotkey-pill">SPACE</span>'; | |
| 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 ? '<span>💨</span> ON' : '<span>💨</span> 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 ? '<span>🎲</span> ON' : '<span>🎲</span> 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); | |
| }); | |
| })(); | |