/* ───────────────────────────────────────────────────────────────────────────── NEM BESS SCADA & Energy Explorer — frontend logic ───────────────────────────────────────────────────────────────────────────── */ const API = ''; // same origin /* ── Global state ── */ let bessList = {}; let qualityFlags = {}; let cutoverDate = '2026-01-11'; // SCADA current/archive cutover (from /api/info) let scadaStartDate = '2025-04-29'; // from /api/info (NEMWEB archive starts here) let dispatchStartDate = '2025-04-01'; // from /api/info (NEMWEB archive starts here) let appEstimates = { current: { seconds: 120, sample_count: 0, is_default: true }, archive: { seconds: 480, sample_count: 0, is_default: true }, dispatch_current: { seconds: 60, sample_count: 0, is_default: true }, dispatch_archive: { seconds: 300, sample_count: 0, is_default: true }, }; /* ── DOM refs ── */ const selState = document.getElementById('sel-state'); const selBess = document.getElementById('sel-bess'); const inpDate = document.getElementById('inp-date'); const chkScada = document.getElementById('chk-scada'); const chkEnergy = document.getElementById('chk-energy'); const btnLoad = document.getElementById('btn-load'); const errorBox = document.getElementById('error-box'); const errorMsg = document.getElementById('error-msg'); const loadingBox = document.getElementById('loading-box'); const loadingMsg = document.getElementById('loading-msg'); const resultsBox = document.getElementById('results-box'); const scadaResults = document.getElementById('scada-results'); const energyResults = document.getElementById('energy-results'); // SCADA const statsGrid = document.getElementById('stats-grid'); const qualStrip = document.getElementById('quality-strip'); const btnCsv = document.getElementById('btn-csv'); const btnParquet = document.getElementById('btn-parquet'); // Energy const energyStatsGrid = document.getElementById('energy-stats-grid'); const btnEnergyCsv = document.getElementById('btn-energy-csv'); const btnEnergyParquet = document.getElementById('btn-energy-parquet'); /* ── Boot ── */ async function init() { // Default date = yesterday const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); const yd = yesterday.toISOString().slice(0, 10); inpDate.value = yd; inpDate.max = yd; // Fetch app info: min dates, cutover, timing estimates try { const info = await fetch(`${API}/api/info`).then(r => r.json()); if (info.data_start_date) scadaStartDate = info.data_start_date; if (info.dispatch_start_date) dispatchStartDate = info.dispatch_start_date; if (info.cutover_date) cutoverDate = info.cutover_date; if (info.estimates) appEstimates = { ...appEstimates, ...info.estimates }; inpDate.min = scadaStartDate; // default: SCADA start (most restrictive) } catch (_) {} // Load BESS list and quality flags in parallel let bessResp; try { [bessResp, qualityFlags] = await Promise.all([ fetch(`${API}/api/bess`).then(r => r.json()), fetch(`${API}/api/quality-flags`).then(r => r.json()), ]); } catch (e) { showError('Failed to load BESS list. Please refresh the page.'); return; } bessList = bessResp.states || {}; renderBessListBanner(bessResp); // Populate state selector Object.keys(bessList).sort().forEach(state => { const opt = document.createElement('option'); opt.value = state; opt.textContent = state; selState.appendChild(opt); }); selState.disabled = false; selState.addEventListener('change', onStateChange); selBess.addEventListener('change', onBessChange); chkScada.addEventListener('change', onCheckboxChange); chkEnergy.addEventListener('change', onCheckboxChange); btnLoad.addEventListener('click', onLoad); // ▶ About modal const aboutModal = document.getElementById('about-modal'); const modalTitle = document.getElementById('modal-title'); const modalBody = document.getElementById('modal-body'); const ABOUT = { scada: { title: '4-second SCADA · PUBLIC_NEXT_DAY_FPPMW', html: `

Source: PUBLIC_NEXT_DAY_FPPMW (FPP Daily)  ·  Available from 29 Apr 2025

INTERVAL_DATETIME Target 4-second grid slot (YYYY/MM/DD HH:MM:SS). Each measurement is snapped to the nearest standard interval.
MEASUREMENT_DATETIME Actual timestamp the SCADA reading was recorded. May differ slightly from INTERVAL_DATETIME due to communication latency.
MEASURED_MW Instantaneous power (MW). Positive = discharging (generation), negative = charging (load).
MW_QUALITY_FLAG Data health code — 0 Good · 1 Substituted · 2 Bad · 3 Manual Override.
`, }, energy: { title: '5-minute Dispatch · DISPATCH_UNIT_SOLUTION', html: `

Source: DISPATCH_UNIT_SOLUTION via Next_Day_Dispatch  ·  Available from 1 Apr 2025

INITIALMW Actual power output (MW) at the exact start of the 5-minute dispatch interval. Used as the baseline for the next dispatch instruction.
INITIAL_ENERGY_STORAGE State of Energy (SoE) at interval start (MWh) — energy physically available in the battery before dispatch began.
ENERGY_STORAGE Target energy level at interval end (MWh) — accounts for scheduled charging or discharging during the 5-minute block.
`, }, }; function openAboutModal(key) { const data = ABOUT[key]; modalTitle.textContent = data.title; modalBody.innerHTML = data.html; aboutModal.classList.remove('hidden'); document.body.style.overflow = 'hidden'; } function closeAboutModal() { aboutModal.classList.add('hidden'); document.body.style.overflow = ''; } document.querySelectorAll('.about-toggle').forEach(btn => { btn.addEventListener('click', () => openAboutModal(btn.dataset.about)); }); document.getElementById('modal-close').addEventListener('click', closeAboutModal); aboutModal.addEventListener('click', e => { if (e.target === aboutModal) closeAboutModal(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && !aboutModal.classList.contains('hidden')) closeAboutModal(); }); } function onStateChange() { const state = selState.value; selBess.innerHTML = ''; selBess.disabled = !state; inpDate.disabled = true; updateLoadButton(); if (!state || !bessList[state]) return; bessList[state].forEach(b => { const mw = b.capacity_mw != null ? `${b.capacity_mw} MW` : '? MW'; const mwh = b.capacity_mwh != null ? `${b.capacity_mwh} MWh` : '? MWh'; const opt = document.createElement('option'); opt.value = b.duid; opt.textContent = `${b.duid} (${b.name}) (${mw} / ${mwh})`; selBess.appendChild(opt); }); } function onBessChange() { inpDate.disabled = !selBess.value; updateDateMin(); updateLoadButton(); } function onCheckboxChange() { updateDateMin(); updateLoadButton(); } function updateDateMin() { // If SCADA is selected (alone or combined) use SCADA start date (more restrictive). // If only energy is selected, allow the earlier dispatch start date. inpDate.min = chkScada.checked ? scadaStartDate : dispatchStartDate; } function updateLoadButton() { const ready = !!selBess.value && !!inpDate.value; const anyChecked = chkScada.checked || chkEnergy.checked; btnLoad.disabled = !ready || !anyChecked; } /* ── Timing estimate helpers ── */ /** * Return the combined wait-time estimate for the selected data types. * When both are selected the requests run in parallel, so we use max(). */ function getLoadEstimate(doScada, doEnergy, dateStr) { const scadaSrc = dateStr >= cutoverDate ? 'current' : 'archive'; const scadaEst = appEstimates[scadaSrc] || appEstimates.current; const energyEst = appEstimates.dispatch_current || { seconds: 60, is_default: true, sample_count: 0 }; if (doScada && doEnergy) { const maxSec = Math.max(scadaEst.seconds, energyEst.seconds); // is_default only if both are defaults; sample_count = min of the two return { seconds: maxSec, is_default: scadaEst.is_default && energyEst.is_default, sample_count: Math.min(scadaEst.sample_count, energyEst.sample_count), }; } return doScada ? scadaEst : energyEst; } function formatEstimate(est) { const sec = est.seconds || 60; const suffix = est.is_default ? '' : ` (based on ${est.sample_count} recent request${est.sample_count === 1 ? '' : 's'})`; let duration; if (sec < 60) duration = 'less than a minute'; else if (sec < 100) duration = 'about 1 minute'; else if (sec < 160) duration = 'about 2 minutes'; else if (sec < 220) duration = 'about 3 minutes'; else if (sec < 310) duration = 'about 4–5 minutes'; else if (sec < 420) duration = 'about 6–7 minutes'; else if (sec < 570) duration = 'about 8–10 minutes'; else duration = '10 minutes or more'; return `this typically takes ${duration}${suffix}`; } /* ── Load data ── */ function setFormLocked(locked) { selState.disabled = locked; selBess.disabled = locked || !selState.value; inpDate.disabled = locked || !selBess.value; chkScada.disabled = locked; chkEnergy.disabled = locked; btnLoad.disabled = locked; } async function onLoad() { const duid = selBess.value; const date = inpDate.value; const doScada = chkScada.checked; const doEnergy = chkEnergy.checked; if (!duid || !date || (!doScada && !doEnergy)) return; hideError(); hideResults(); // Build loading message with combined timing estimate const est = getLoadEstimate(doScada, doEnergy, date); let dataLabel; if (doScada && doEnergy) dataLabel = '4-second SCADA and 5-minute energy data'; else if (doScada) dataLabel = '4-second SCADA data'; else dataLabel = '5-minute dispatch energy data'; loadingMsg.textContent = `Fetching ${dataLabel} from AEMO NEMWEB\u2026 ${formatEstimate(est)}. Please wait.`; showLoading(true); setFormLocked(true); // Fire selected requests in parallel const [scadaResult, energyResult] = await Promise.allSettled([ doScada ? fetchScada(duid, date) : Promise.resolve(null), doEnergy ? fetchEnergy(duid, date) : Promise.resolve(null), ]); showLoading(false); setFormLocked(false); // Collect error messages const errors = []; if (doScada && scadaResult.status === 'rejected') errors.push(`SCADA: ${scadaResult.reason?.message || scadaResult.reason}`); if (doEnergy && energyResult.status === 'rejected') errors.push(`Energy: ${energyResult.reason?.message || energyResult.reason}`); if (errors.length) showError(errors.join(' | ')); // Render whatever succeeded const scadaPayload = scadaResult.status === 'fulfilled' ? scadaResult.value : null; const energyPayload = energyResult.status === 'fulfilled' ? energyResult.value : null; if (scadaPayload || energyPayload) { const base = `${API}/api/download`; const params = `duid=${encodeURIComponent(duid)}&date=${date}`; // Unhide containers BEFORE calling Plotly so it can measure the real // width of each chart container. Rendering into a display:none element // causes Plotly to record zero width and never fill the available space. resultsBox.classList.remove('hidden'); if (scadaPayload) { scadaResults.classList.remove('hidden'); renderScadaResults(scadaPayload); btnCsv.href = `${base}/csv?${params}`; btnParquet.href = `${base}/parquet?${params}`; } if (energyPayload) { energyResults.classList.remove('hidden'); renderEnergyResults(energyPayload); btnEnergyCsv.href = `${base}/energy-csv?${params}`; btnEnergyParquet.href = `${base}/energy-parquet?${params}`; } } } /* ── Fetch helpers ── */ async function fetchScada(duid, date) { const resp = await fetch( `${API}/api/data?duid=${encodeURIComponent(duid)}&date=${date}` ); if (!resp.ok) { const err = await resp.json().catch(() => ({ detail: `Server error ${resp.status}` })); throw new Error(err.detail || `Server error ${resp.status}`); } return resp.json(); } async function fetchEnergy(duid, date) { const resp = await fetch( `${API}/api/energy-data?duid=${encodeURIComponent(duid)}&date=${date}` ); if (!resp.ok) { const err = await resp.json().catch(() => ({ detail: `Server error ${resp.status}` })); throw new Error(err.detail || `Server error ${resp.status}`); } return resp.json(); } /* ── BESS-list staleness banner ── */ function renderBessListBanner(resp) { const box = document.getElementById('bess-list-banner'); if (!box) return; const warnings = (resp && resp.warnings) || []; if (!warnings.length) { box.classList.add('hidden'); box.innerHTML = ''; return; } // One line per warning, prefixed with a warning icon. box.innerHTML = warnings.map(w => `
⚠️ ${escapeHtml(w)}
` ).join(''); box.classList.remove('hidden'); } function escapeHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } /* ── Warnings render ── */ function renderWarnings(warnings) { let box = document.getElementById('warnings-box'); if (!box) { box = document.createElement('div'); box.id = 'warnings-box'; resultsBox.prepend(box); } if (!warnings.length) { box.classList.add('hidden'); box.innerHTML = ''; return; } box.classList.remove('hidden'); box.innerHTML = warnings.map(w => `
\u26a0\ufe0f ${w}
` ).join(''); } /* ── SCADA render ── */ function renderScadaResults(payload) { const { summary, data, warnings } = payload; renderWarnings(warnings || []); renderStats(summary, payload.total_rows); renderQualityStrip(summary.flag_breakdown); renderScadaChart(data); } function renderStats(summary, totalRows) { statsGrid.innerHTML = ''; const stats = [ { label: 'Total Readings', value: totalRows.toLocaleString() }, { label: 'Min MW', value: summary.min_mw != null ? summary.min_mw.toFixed(2) : '—' }, { label: 'Max MW', value: summary.max_mw != null ? summary.max_mw.toFixed(2) : '—' }, { label: 'Mean MW', value: summary.mean_mw != null ? summary.mean_mw.toFixed(2) : '—' }, { label: 'Std Dev', value: summary.std_mw != null ? summary.std_mw.toFixed(2) : '—' }, ]; stats.forEach(s => { statsGrid.insertAdjacentHTML('beforeend', `
${s.value}
${s.label}
`); }); } function renderQualityStrip(breakdown) { qualStrip.innerHTML = ''; ['0', '1', '2', '-1'].forEach(flag => { const info = qualityFlags[flag] || { label: `Flag ${flag}`, description: '', color: '#94a3b8' }; const entry = breakdown[flag]; if (!entry) return; qualStrip.insertAdjacentHTML('beforeend', `
${info.label}: ${entry.count.toLocaleString()} readings (${entry.pct}%)
`); }); } function renderScadaChart(data) { if (!data || data.length === 0) return; const flagGroups = {}; data.forEach(row => { const flag = String(row.MW_QUALITY_FLAG ?? '-1'); if (!flagGroups[flag]) flagGroups[flag] = { x: [], y: [] }; flagGroups[flag].x.push(row.MEASUREMENT_DATETIME); flagGroups[flag].y.push(row.MEASURED_MW); }); const traces = Object.entries(flagGroups).map(([flag, pts]) => { const info = qualityFlags[flag] || { label: `Flag ${flag}`, color: '#94a3b8' }; return { x: pts.x, y: pts.y, mode: 'lines+markers', marker: { size: 2, color: info.color }, line: { width: 1, color: info.color }, name: info.label, type: 'scattergl', }; }); const layout = _chartLayout('Time', 'Power (MW)'); Plotly.react('scada-chart-container', traces, layout, _chartConfig()); } /* ── Energy render ── */ function renderEnergyResults(payload) { const { summary, data } = payload; renderEnergyStats(summary, payload.total_rows); renderEnergyChart(data); } function renderEnergyStats(summary, totalRows) { energyStatsGrid.innerHTML = ''; const stats = [ { label: 'Total Intervals', value: totalRows.toLocaleString() }, { label: 'Min Dispatch MW', value: summary.min_mw != null ? summary.min_mw.toFixed(1) : '—' }, { label: 'Max Dispatch MW', value: summary.max_mw != null ? summary.max_mw.toFixed(1) : '—' }, { label: 'Min Energy (MWh)', value: summary.min_init_mwh != null ? summary.min_init_mwh.toFixed(1) : '—' }, { label: 'Max Energy (MWh)', value: summary.max_init_mwh != null ? summary.max_init_mwh.toFixed(1) : '—' }, { label: 'Mean Energy (MWh)',value: summary.mean_init_mwh != null ? summary.mean_init_mwh.toFixed(1) : '—' }, ]; stats.forEach(s => { energyStatsGrid.insertAdjacentHTML('beforeend', `
${s.value}
${s.label}
`); }); } function renderEnergyChart(data) { if (!data || data.length === 0) return; const times = data.map(r => r.SETTLEMENTDATE); const traces = []; // Initial Energy Storage (left y-axis, MWh) — primary line if (data.some(r => r.INITIAL_ENERGY_STORAGE != null)) { traces.push({ x: times, y: data.map(r => r.INITIAL_ENERGY_STORAGE), name: 'Initial Energy (MWh)', type: 'scatter', mode: 'lines+markers', yaxis: 'y1', line: { width: 2, color: '#22c55e' }, marker: { size: 4, color: '#22c55e' }, }); } // End-of-interval Energy Storage (left y-axis, MWh) — dashed if (data.some(r => r.ENERGY_STORAGE != null)) { traces.push({ x: times, y: data.map(r => r.ENERGY_STORAGE), name: 'End Energy (MWh)', type: 'scatter', mode: 'lines+markers', yaxis: 'y1', line: { width: 1.5, color: '#38bdf8', dash: 'dash' }, marker: { size: 3, color: '#38bdf8' }, }); } // Initial MW dispatch (right y-axis, MW) if (data.some(r => r.INITIALMW != null)) { traces.push({ x: times, y: data.map(r => r.INITIALMW), name: 'Dispatch MW', type: 'scatter', mode: 'lines+markers', yaxis: 'y2', line: { width: 1.5, color: '#f97316' }, marker: { size: 3, color: '#f97316' }, }); } const layout = { ..._chartLayout('Time', 'Energy (MWh)'), yaxis2: { title: 'Dispatch (MW)', overlaying: 'y', side: 'right', gridcolor: '#334155', linecolor: '#334155', tickfont: { color: '#f97316' }, titlefont: { color: '#f97316' }, zeroline: true, zerolinecolor: '#475569', }, legend: { orientation: 'h', y: -0.2 }, margin: { t: 20, r: 70, b: 60, l: 60 }, // wider right margin for y2 label }; Plotly.react('energy-chart-container', traces, layout, _chartConfig()); } /* ── Shared chart helpers ── */ function _chartLayout(xTitle, yTitle) { return { autosize: true, paper_bgcolor: 'transparent', plot_bgcolor: 'transparent', font: { color: '#e2e8f0', size: 11 }, xaxis: { title: xTitle, gridcolor: '#334155', linecolor: '#334155', tickfont: { color: '#94a3b8' }, }, yaxis: { title: yTitle, gridcolor: '#334155', linecolor: '#334155', tickfont: { color: '#94a3b8' }, zeroline: true, zerolinecolor: '#475569', }, legend: { orientation: 'h', y: -0.2 }, margin: { t: 20, r: 20, b: 60, l: 60 }, hovermode: 'x unified', }; } function _chartConfig() { return { responsive: true, displayModeBar: true, modeBarButtonsToRemove: ['select2d', 'lasso2d'], }; } /* ── UI helpers ── */ function showError(msg) { errorMsg.textContent = msg; errorBox.classList.remove('hidden'); } function hideError() { errorBox.classList.add('hidden'); } function showLoading(show) { loadingBox.classList.toggle('hidden', !show); } function hideResults() { resultsBox.classList.add('hidden'); scadaResults.classList.add('hidden'); energyResults.classList.add('hidden'); } /* ── Start ── */ init();