Gridmind / dashboard /static /dashboard.js
ShreeshantXD's picture
Fix dashboard paths for /dashboard reverse proxy
832f069
/**
* GridMind-RL Dashboard — Premium Chart.js real-time visualization
* Polls /dashboard/api/state every 500ms and updates all charts + KPIs.
*/
'use strict';
// ── Config ──────────────────────────────────────────────────────────────────
const POLL_MS = 500;
const EPISODE_STEPS = 96; // 24h × 4 steps/h (15-min)
const HISTORY_LEN = EPISODE_STEPS;
const CURVE_POINTS = 24; // hourly downsample (EpisodeSteps/4)
const API_BASE = '/dashboard/api';
const TASK_NAMES = {
1: 'Task 1 — Cost Minimization (Easy)',
2: 'Task 2 — Temperature Management (Medium)',
3: 'Task 3 — Full Demand Response (Hard)',
};
let currentBuilding = 0;
let pollTimer = null;
let connected = false;
// ── Chart.js Premium Theme ──────────────────────────────────────────────────
Chart.defaults.color = '#5a6478';
Chart.defaults.borderColor = 'rgba(255,255,255,0.03)';
Chart.defaults.font.family = "'Inter', -apple-system, system-ui, sans-serif";
Chart.defaults.font.size = 11;
Chart.defaults.font.weight = 400;
Chart.defaults.plugins.legend.display = false;
Chart.defaults.animation.duration = 350;
Chart.defaults.animation.easing = 'easeOutQuart';
Chart.defaults.elements.line.borderCapStyle = 'round';
Chart.defaults.elements.line.borderJoinStyle = 'round';
const C = {
blue: '#5b9cf6',
green: '#4ade80',
amber: '#f5a623',
red: '#f06e6e',
purple: '#a78bfa',
cyan: '#34d4e4',
orange: '#fb923c',
teal: '#2dd4bf',
rose: '#fb7185',
grid: 'rgba(255,255,255,0.025)',
surface: '#1a1f2e',
};
function rgba(hex, alpha) {
const r = parseInt(hex.slice(1,3), 16);
const g = parseInt(hex.slice(3,5), 16);
const b = parseInt(hex.slice(5,7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
// ── Chart factory — refined styling ──────────────────────────────────────────
function makeLineChart(id, labels, datasets, opts = {}) {
const ctx = document.getElementById(id);
if (!ctx) return null;
return new Chart(ctx.getContext('2d'), {
type: 'line',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
layout: { padding: { top: 4, right: 4, bottom: 0, left: 4 } },
scales: {
x: {
grid: { color: C.grid, drawBorder: false },
ticks: {
maxTicksLimit: 8,
font: { size: 10, family: "'JetBrains Mono', monospace" },
color: '#3d4558',
padding: 4,
},
border: { display: false },
},
y: {
grid: { color: C.grid, drawBorder: false },
ticks: {
font: { size: 10, family: "'JetBrains Mono', monospace" },
color: '#3d4558',
padding: 8,
},
border: { display: false },
...opts.yAxis,
},
},
plugins: {
legend: {
display: opts.legend || false,
position: 'bottom',
labels: {
usePointStyle: true,
pointStyle: 'circle',
padding: 16,
font: { size: 10, weight: 500 },
color: '#8a94a8',
},
},
tooltip: {
backgroundColor: '#12151e',
titleColor: '#e8ecf4',
bodyColor: '#8a94a8',
borderColor: 'rgba(255,255,255,0.08)',
borderWidth: 1,
cornerRadius: 8,
padding: 10,
displayColors: true,
boxPadding: 4,
titleFont: { size: 11, weight: 600 },
bodyFont: { size: 11 },
},
},
...opts.extra,
},
});
}
function makeBarChart(id, labels, datasets) {
const ctx = document.getElementById(id);
if (!ctx) return null;
return new Chart(ctx.getContext('2d'), {
type: 'bar',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
layout: { padding: { top: 4, right: 4, bottom: 0, left: 4 } },
scales: {
x: {
stacked: true,
grid: { color: C.grid, drawBorder: false },
ticks: { maxTicksLimit: 8, font: { size: 10, family: "'JetBrains Mono', monospace" }, color: '#3d4558' },
border: { display: false },
},
y: {
stacked: true,
grid: { color: C.grid, drawBorder: false },
ticks: { font: { size: 10, family: "'JetBrains Mono', monospace" }, color: '#3d4558' },
border: { display: false },
},
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
usePointStyle: true,
pointStyle: 'circle',
padding: 16,
font: { size: 10, weight: 500 },
color: '#8a94a8',
},
},
tooltip: {
backgroundColor: '#12151e',
titleColor: '#e8ecf4',
bodyColor: '#8a94a8',
borderColor: 'rgba(255,255,255,0.08)',
borderWidth: 1,
cornerRadius: 8,
padding: 10,
},
},
},
});
}
// ── Gradient helper ──────────────────────────────────────────────────────────
function createGradient(ctx, hex, startAlpha, endAlpha) {
const gradient = ctx.createLinearGradient(0, 0, 0, ctx.canvas.clientHeight);
gradient.addColorStop(0, rgba(hex, startAlpha));
gradient.addColorStop(1, rgba(hex, endAlpha));
return gradient;
}
// ── Initialise all charts ─────────────────────────────────────────────────────
const emptyLabels = Array.from({ length: CURVE_POINTS }, (_, i) => `${i}h`);
const emptyData = Array(CURVE_POINTS).fill(null);
// 1. Price curve
const priceChart = makeLineChart('chart-price',
emptyLabels,
[
{
label: 'Price ($/kWh)',
data: [...emptyData],
borderColor: C.amber,
backgroundColor: rgba(C.amber, 0.08),
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
},
{
label: 'Current',
data: [...emptyData],
borderColor: C.red,
backgroundColor: 'transparent',
borderWidth: 0,
pointRadius: 5,
pointBackgroundColor: C.red,
pointBorderColor: rgba(C.red, 0.3),
pointBorderWidth: 6,
},
],
{ legend: true, yAxis: { title: { display: true, text: '$/kWh', color: '#3d4558', font: { size: 10 } } } }
);
// 2. Temperature
const tempChart = makeLineChart('chart-temp',
[],
[
{
label: 'Indoor Temp (°C)',
data: [],
borderColor: C.cyan,
backgroundColor: rgba(C.cyan, 0.06),
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
},
{
label: 'T_max (23°C)',
data: [],
borderColor: rgba(C.red, 0.35),
borderWidth: 1,
borderDash: [4, 4],
pointRadius: 0,
fill: false,
},
{
label: 'T_min (19°C)',
data: [],
borderColor: rgba(C.blue, 0.35),
borderWidth: 1,
borderDash: [4, 4],
pointRadius: 0,
fill: false,
},
],
{ legend: true, yAxis: { suggestedMin: 15, suggestedMax: 30, title: { display: true, text: '°C', color: '#3d4558', font: { size: 10 } } } }
);
// 3. Storage history
const storageChart = makeLineChart('chart-storage',
[],
[{
label: 'Storage Level',
data: [],
borderColor: C.teal,
backgroundColor: rgba(C.teal, 0.1),
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
}],
{ yAxis: { min: 0, max: 1 } }
);
// 4. HVAC + Load Shed
const hvacChart = makeBarChart('chart-hvac',
[],
[
{
label: 'HVAC Power',
data: [],
backgroundColor: rgba(C.blue, 0.6),
borderColor: C.blue,
borderWidth: 1,
borderRadius: 3,
},
{
label: 'Load Shed',
data: [],
backgroundColor: rgba(C.red, 0.6),
borderColor: C.red,
borderWidth: 1,
borderRadius: 3,
},
]
);
// 5. Cumulative cost vs baseline
const costChart = makeLineChart('chart-cost',
[],
[
{
label: 'Agent Cost ($)',
data: [],
borderColor: C.green,
backgroundColor: rgba(C.green, 0.06),
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
},
{
label: 'Baseline ($)',
data: [],
borderColor: rgba(C.amber, 0.6),
borderDash: [5, 3],
borderWidth: 2,
fill: false,
tension: 0.4,
pointRadius: 0,
},
],
{ legend: true, yAxis: { title: { display: true, text: '$', color: '#3d4558', font: { size: 10 } } } }
);
// 6. Grid stress history
const stressChart = makeLineChart('chart-stress',
[],
[{
label: 'Grid Stress',
data: [],
borderColor: C.red,
backgroundColor: rgba(C.red, 0.1),
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
}],
{ yAxis: { min: 0, max: 1 } }
);
// 7. Carbon curve
const carbonChart = makeLineChart('chart-carbon',
emptyLabels,
[{
label: 'Carbon Intensity (gCO₂/kWh)',
data: [...emptyData],
borderColor: C.orange,
backgroundColor: rgba(C.orange, 0.08),
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
}],
{ yAxis: { title: { display: true, text: 'gCO₂/kWh', color: '#3d4558', font: { size: 10 } } } }
);
// 8. Reward timeline
const rewardChart = makeLineChart('chart-reward',
[],
[{
label: 'Step Reward',
data: [],
borderColor: C.green,
backgroundColor: rgba(C.green, 0.06),
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 0,
}],
{ yAxis: { title: { display: true, text: 'Reward', color: '#3d4558', font: { size: 10 } } } }
);
// ── Stress meter bars ────────────────────────────────────────────────────────
function buildStressMeter() {
const el = document.getElementById('stress-meter');
if (!el) return;
el.innerHTML = '';
for (let i = 0; i < 20; i++) {
const bar = document.createElement('div');
bar.className = 'stress-bar';
bar.id = `sm-${i}`;
el.appendChild(bar);
}
}
buildStressMeter();
function updateStressMeter(stress) {
const bars = 20;
const active = Math.round(stress * bars);
for (let i = 0; i < bars; i++) {
const bar = document.getElementById(`sm-${i}`);
if (!bar) continue;
const pct = (i / bars) * 100;
bar.style.height = `${20 + pct * 0.8}%`;
if (i < active) {
const color = stress > 0.7 ? C.red : stress > 0.4 ? C.amber : C.green;
bar.style.background = color;
bar.style.boxShadow = `0 0 6px ${rgba(color === C.red ? C.red : color === C.amber ? C.amber : C.green, 0.3)}`;
} else {
bar.style.background = 'rgba(255,255,255,0.03)';
bar.style.boxShadow = 'none';
}
}
}
// ── Batch Gantt renderer ─────────────────────────────────────────────────────
function renderGantt(jobs, currentStep) {
const wrap = document.getElementById('gantt-wrap');
if (!wrap) return;
if (!jobs || jobs.length === 0) {
wrap.innerHTML = '<div class="gantt-empty">No batch jobs in this episode</div>';
return;
}
const totalSlots = EPISODE_STEPS;
wrap.innerHTML = '';
jobs.forEach(job => {
const row = document.createElement('div');
row.className = 'gantt-row';
const label = document.createElement('div');
label.className = 'gantt-label';
label.textContent = `J${job.id}`;
row.appendChild(label);
const track = document.createElement('div');
track.className = 'gantt-track';
// Deadline marker
const deadlinePct = (job.deadline_slot / totalSlots) * 100;
const deadline = document.createElement('div');
deadline.className = 'gantt-deadline';
deadline.style.left = `${deadlinePct}%`;
deadline.title = `Deadline: step ${job.deadline_slot}`;
track.appendChild(deadline);
// Job block
if (job.scheduled) {
const startPct = (job.scheduled_at / totalSlots) * 100;
const widthPct = (job.duration / totalSlots) * 100;
const block = document.createElement('div');
block.className = 'gantt-block ' + (job.completed ? 'completed' : job.missed_deadline ? 'missed' : 'scheduled');
block.style.left = `${startPct}%`;
block.style.width = `${Math.max(widthPct, 1)}%`;
track.appendChild(block);
}
// Current step marker
const curPct = (currentStep / totalSlots) * 100;
const curMarker = document.createElement('div');
curMarker.style.cssText = `position:absolute;top:2px;bottom:2px;width:2px;background:rgba(91,156,246,0.5);left:${curPct}%;border-radius:1px;box-shadow:0 0 4px rgba(91,156,246,0.3)`;
track.appendChild(curMarker);
row.appendChild(track);
// Status badge
const statusWrap = document.createElement('div');
statusWrap.className = 'gantt-status';
let badgeClass = 'pending', badgeText = 'pending';
if (job.completed) { badgeClass = 'ok'; badgeText = 'done'; }
else if (job.missed_deadline) { badgeClass = 'missed'; badgeText = 'missed'; }
else if (job.scheduled && !job.completed) { badgeClass = 'running'; badgeText = 'running'; }
statusWrap.innerHTML = `<span class="badge ${badgeClass}">${badgeText}</span>`;
row.appendChild(statusWrap);
wrap.appendChild(row);
});
}
// ── Reward breakdown rows ─────────────────────────────────────────────────────
function renderRewardRows(rc) {
if (!rc) return;
const container = document.getElementById('reward-rows');
if (!container) return;
const components = [
{ key: 'cost_savings', label: 'Cost Savings', color: C.green },
{ key: 'temp_constraint', label: 'Temp Constr.', color: C.cyan },
{ key: 'grid_response', label: 'Grid DR', color: C.blue },
{ key: 'efficiency_bonus', label: 'Efficiency', color: C.purple },
{ key: 'stability_penalty', label: 'Stability', color: C.amber },
{ key: 'deadline_penalty', label: 'Deadlines', color: C.red },
{ key: 'carbon_reward', label: 'Carbon', color: C.orange },
];
container.innerHTML = '';
components.forEach(c => {
const val = rc[c.key] || 0;
const absVal = Math.abs(val);
const pct = Math.min(100, absVal * 30);
container.innerHTML += `
<div class="reward-row">
<div class="reward-label">${c.label}</div>
<div class="reward-bar-track">
<div class="reward-bar" style="width:${pct}%;background:${c.color};opacity:0.7"></div>
</div>
<div class="reward-val" style="color:${val >= 0 ? C.green : C.red}">${val.toFixed(3)}</div>
</div>`;
});
}
// ── KPI color logic ──────────────────────────────────────────────────────────
function colorClass(val, good, bad) {
if (val <= good) return 'good';
if (val >= bad) return 'bad';
return 'warn';
}
// ── Main state update ─────────────────────────────────────────────────────────
let lastStep = -1;
async function fetchAndUpdate() {
try {
const res = await fetch(`${API_BASE}/state`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const state = await res.json();
connected = true;
document.getElementById('conn-banner').classList.remove('show');
document.getElementById('status-dot').style.background = 'var(--accent-green)';
document.getElementById('status-label').textContent = 'Live';
const b = state.buildings && state.buildings[currentBuilding];
if (!b) return;
const step = state.step;
// ── Header ──
document.getElementById('ep-step').textContent = `ep:${state.episode} step:${step}/${EPISODE_STEPS}`;
document.getElementById('task-badge').textContent = TASK_NAMES[state.task_id] || 'Task 1';
// ── KPIs ──
const priceEl = document.getElementById('kpi-price');
priceEl.textContent = `$${b.current_price.toFixed(4)}`;
priceEl.className = 'kpi-value ' + colorClass(b.current_price, 0.08, 0.16);
const tempEl = document.getElementById('kpi-temp');
tempEl.textContent = `${b.indoor_temperature.toFixed(1)}°C`;
const inBounds = b.indoor_temperature >= 19 && b.indoor_temperature <= 23;
tempEl.className = 'kpi-value ' + (inBounds ? 'good' : 'bad');
const stressEl = document.getElementById('kpi-stress');
stressEl.textContent = b.grid_stress_signal.toFixed(3);
stressEl.className = 'kpi-value ' + colorClass(b.grid_stress_signal, 0.4, 0.7);
const costEl = document.getElementById('kpi-cost');
const savings = b.baseline_cost - b.cumulative_cost;
costEl.textContent = `$${b.cumulative_cost.toFixed(2)}`;
costEl.className = 'kpi-value ' + (savings > 0 ? 'good' : 'warn');
document.getElementById('kpi-baseline').textContent = `$${b.baseline_cost.toFixed(2)}`;
document.getElementById('kpi-carbon').textContent = `${b.carbon_intensity.toFixed(0)}`;
document.getElementById('kpi-demand').textContent = `${b.process_demand.toFixed(1)}`;
document.getElementById('kpi-storage').textContent = `${(b.thermal_storage_level * 100).toFixed(1)}`;
// ── Price curve chart ──
if (state.price_curve_episode && state.price_curve_episode.length === CURVE_POINTS) {
const labels = Array.from({ length: CURVE_POINTS }, (_, i) => `${i}:00`);
priceChart.data.labels = labels;
priceChart.data.datasets[0].data = state.price_curve_episode;
const marker = Array(CURVE_POINTS).fill(null);
const markerIdx = Math.min(Math.floor(step / 4), CURVE_POINTS - 1);
marker[markerIdx] = state.price_curve_episode[markerIdx];
priceChart.data.datasets[1].data = marker;
priceChart.update('none');
}
// ── Carbon curve ──
if (state.carbon_curve_episode && state.carbon_curve_episode.length === CURVE_POINTS) {
carbonChart.data.labels = Array.from({ length: CURVE_POINTS }, (_, i) => `${i}:00`);
carbonChart.data.datasets[0].data = state.carbon_curve_episode;
carbonChart.update('none');
}
// ── Grid stress ──
const stressBig = document.getElementById('stress-big');
stressBig.textContent = b.grid_stress_signal.toFixed(3);
stressBig.className = 'stress-value ' +
(b.grid_stress_signal > 0.7 ? 'high' : b.grid_stress_signal > 0.4 ? 'mid' : 'low');
updateStressMeter(b.grid_stress_signal);
const cardStress = document.getElementById('card-stress');
if (b.grid_stress_signal > 0.7) {
cardStress.classList.add('alert-active');
} else {
cardStress.classList.remove('alert-active');
}
// ── Thermal storage bar ──
const storagePct = (b.thermal_storage_level * 100).toFixed(1);
document.getElementById('storage-pct').textContent = storagePct;
document.getElementById('storage-fill').style.width = `${storagePct}%`;
// ── History-based charts (only when step changes) ──
if (step !== lastStep) {
lastStep = step;
const stepLabels = Array.from({ length: b.temp_history.length }, (_, i) => i);
// Temperature chart
if (b.temp_history.length > 0) {
tempChart.data.labels = stepLabels;
tempChart.data.datasets[0].data = b.temp_history;
tempChart.data.datasets[1].data = b.temp_history.map(() => 23);
tempChart.data.datasets[2].data = b.temp_history.map(() => 19);
tempChart.update('none');
}
// Storage history
if (b.hvac_history && b.hvac_history.length > 0) {
storageChart.data.labels = stepLabels;
storageChart.data.datasets[0].data = Array.from({ length: b.hvac_history.length }, () =>
b.thermal_storage_level
);
storageChart.update('none');
}
// HVAC + load shed (bar)
if (b.hvac_history && b.load_shed_history) {
const n = Math.min(b.hvac_history.length, HISTORY_LEN);
hvacChart.data.labels = Array.from({ length: n }, (_, i) => i);
hvacChart.data.datasets[0].data = b.hvac_history.slice(0, n);
hvacChart.data.datasets[1].data = b.load_shed_history.slice(0, n);
hvacChart.update('none');
}
// Cost vs baseline
if (b.cost_history && b.cost_history.length > 0) {
const n = b.cost_history.length;
costChart.data.labels = Array.from({ length: n }, (_, i) => i);
costChart.data.datasets[0].data = b.cost_history;
const baselineStep = b.baseline_cost / Math.max(step, 1);
costChart.data.datasets[1].data = b.cost_history.map((_, i) => baselineStep * (i + 1));
costChart.update('none');
}
// Grid stress + reward history
if (b.reward_history && b.reward_history.length > 0) {
const n = b.reward_history.length;
stressChart.data.labels = Array.from({ length: n }, (_, i) => i);
stressChart.data.datasets[0].data = b.reward_history.map(r => Math.max(0, r.grid_response || 0));
stressChart.update('none');
rewardChart.data.labels = Array.from({ length: n }, (_, i) => i);
rewardChart.data.datasets[0].data = b.reward_history.map(r => r.total || 0);
rewardChart.update('none');
renderRewardRows(b.reward_history[b.reward_history.length - 1]);
}
// Batch Gantt
renderGantt(b.jobs || [], step);
}
} catch (err) {
connected = false;
document.getElementById('conn-banner').classList.add('show');
document.getElementById('status-dot').style.background = 'var(--accent-red)';
document.getElementById('status-label').textContent = 'Offline';
}
}
// ── Episode controls ─────────────────────────────────────────────────────────
async function doReset() {
const taskId = parseInt(document.getElementById('task-select').value, 10);
const btn = document.getElementById('btn-reset');
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="spin"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg> Resetting...';
btn.disabled = true;
btn.style.opacity = '0.6';
lastStep = -1;
try {
await fetch(`${API_BASE}/reset`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId, num_buildings: 1 }),
});
} catch (e) {
console.error(e);
}
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg> New Episode';
btn.disabled = false;
btn.style.opacity = '1';
document.getElementById('grade-result').textContent = '';
document.getElementById('grade-result').classList.remove('show');
}
let liveSimTimer = null;
let isLiveSimulating = false;
let lastLiveState = null;
// ── Smart Heuristic Agent ───────────────────────────────────────────────────
// Mirrors the Python _heuristic_action() from inference.py.
// Reads the latest fetched state and generates intelligent actions that
// exercise ALL reward components: cost, temperature, grid DR, efficiency,
// stability, deadlines, and carbon.
function heuristicAction(b) {
if (!b) {
return { hvac_power_level: 0.5, thermal_charge_rate: 0.0, batch_job_slot: 0, load_shed_fraction: 0.0, building_id: currentBuilding };
}
const price = b.current_price || 0.10;
const stress = b.grid_stress_signal || 0.0;
const temp = b.indoor_temperature || 21.0;
const storage = b.thermal_storage_level || 0.5;
const queue = b.batch_queue || [];
const carbon = b.carbon_intensity || 300;
const step = b.step || 0;
// ── HVAC: price-aware + temperature-reactive ──
let hvac = 0.5;
if (price < 0.07) hvac = 0.7; // cheap → run more
else if (price > 0.15) hvac = 0.3; // expensive → reduce
else hvac = 0.5;
// Temperature override: keep within 19–23°C
if (temp > 23.0) hvac = Math.max(hvac, 0.8);
else if (temp > 22.0) hvac = Math.max(hvac, 0.6);
else if (temp < 19.0) hvac = Math.min(hvac, 0.2);
else if (temp < 20.0) hvac = Math.min(hvac, 0.35);
// ── Thermal storage: arbitrage ──
let charge = 0.0;
if (price < 0.07 && storage < 0.8) {
charge = 0.6; // charge during cheap periods
} else if (price > 0.15 && storage > 0.3) {
charge = -0.5; // discharge during expensive periods
} else if (price < 0.10 && storage < 0.5) {
charge = 0.3; // moderate charge at mid-low prices
} else if (price > 0.12 && storage > 0.6) {
charge = -0.3; // moderate discharge at mid-high prices
}
// Carbon-aware: prefer charging when carbon is low
if (carbon < 250 && storage < 0.7) {
charge = Math.max(charge, 0.4);
}
// ── Load shedding: grid stress response ──
let shed = 0.0;
if (stress > 0.8) shed = 0.45;
else if (stress > 0.7) shed = 0.35;
else if (stress > 0.5) shed = 0.15;
else if (stress > 0.3) shed = 0.05;
// ── Batch scheduling: urgency-aware ──
let slot = 2; // default: moderate defer
if (queue.length > 0) {
const minDeadline = Math.min(...queue);
const stepsLeft = minDeadline - step;
if (stepsLeft < 4) slot = 0; // urgent: run now
else if (stepsLeft < 8) slot = 1; // soon: start soon
else if (stepsLeft < 16) slot = 2; // moderate
else if (price < 0.08) slot = 0; // cheap: might as well run now
else slot = 3; // defer
}
return {
hvac_power_level: Math.max(0, Math.min(1, hvac)),
thermal_charge_rate: Math.max(-1, Math.min(1, charge)),
batch_job_slot: Math.max(0, Math.min(4, slot)),
load_shed_fraction: Math.max(0, Math.min(0.5, shed)),
building_id: currentBuilding,
};
}
function toggleLiveSim() {
const btn = document.getElementById('btn-live');
if (isLiveSimulating) {
clearInterval(liveSimTimer);
isLiveSimulating = false;
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg> Start Live Simulation';
btn.classList.remove('active');
} else {
isLiveSimulating = true;
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg> Pause Simulation';
btn.classList.add('active');
liveSimTimer = setInterval(async () => {
try {
// Fetch current state to make informed actions
const stateRes = await fetch(`${API_BASE}/state`);
if (stateRes.ok) {
const state = await stateRes.json();
lastLiveState = state.buildings && state.buildings[currentBuilding];
}
// Use smart heuristic agent based on current state
const action = heuristicAction(lastLiveState);
await fetch(`${API_BASE}/step`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action),
});
} catch (e) {
console.error(e);
}
}, 400);
}
}
async function doGrade() {
try {
const res = await fetch(`${API_BASE}/grade`);
const grade = await res.json();
const score = (grade.score * 100).toFixed(2);
const el = document.getElementById('grade-result');
el.textContent = `Score: ${score}% ${grade.exploit_detected ? '⚠ exploit!' : ''}`;
el.style.color = grade.score > 0.6 ? 'var(--accent-green)' : grade.score > 0.3 ? 'var(--accent-amber)' : 'var(--accent-red)';
el.style.background = grade.score > 0.6 ? 'rgba(74,222,128,0.08)' : grade.score > 0.3 ? 'rgba(245,166,35,0.08)' : 'rgba(240,110,110,0.08)';
el.classList.add('show');
} catch (e) {
console.error(e);
}
}
function onTaskChange() {
[tempChart, storageChart, hvacChart, costChart, stressChart, rewardChart].forEach(c => {
if (!c) return;
c.data.labels = [];
c.data.datasets.forEach(d => d.data = []);
c.update('none');
});
}
function onBuildingChange() {
currentBuilding = parseInt(document.getElementById('building-select').value, 10);
lastStep = -1;
}
// ── Start polling ────────────────────────────────────────────────────────────
function startPolling() {
if (pollTimer) clearInterval(pollTimer);
fetchAndUpdate();
pollTimer = setInterval(fetchAndUpdate, POLL_MS);
}
startPolling();