Initial commit: astrape-dashboard split out of the combined Astrape tree

The web frontend: the UI itself, the oracle, weather, station and
quality renderers, and the serving daemon.

The weather and station display modules were each split in two. The
dataclasses went to astrape-oracle with the stores that return them;
only the rendering stayed here, so the dependency runs dashboard ->
oracle -> ems -> gibil with no cycle.

Extracted from a single repository where the forecasting engine, the
database plumbing, the hardware client, the ingest daemons and the web
UI all imported each other freely. Verified that every module imports
with gibil resolved from its own installed checkout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
rpotter6298
2026-08-30 14:19:09 +02:00
commit 665c941802
16 changed files with 1912 additions and 0 deletions
@@ -0,0 +1,152 @@
from __future__ import annotations
import json
from datetime import timedelta
from astrape_oracle.classes.oracle_store import OracleStore
class OracleQualityDisplay:
"""Renders oracle prediction quality tables."""
def render(self) -> str:
return """
<section class="panel oracle-quality-panel" data-module="oracle-quality-display">
<div class="panel-heading">
<div>
<h2>Oracle Quality</h2>
<p>Prediction error by model and horizon</p>
</div>
<div class="control-row">
<label>
Window
<select id="quality-lookback">
<option value="24">24 hours</option>
<option value="168" selected>7 days</option>
<option value="720">30 days</option>
</select>
</label>
</div>
</div>
<div class="table-shell">
<table>
<thead>
<tr>
<th>Kind</th>
<th>Model</th>
<th>Horizon</th>
<th>Samples</th>
<th>Bias</th>
<th>MAE</th>
<th>Median AE</th>
<th>MAPE</th>
<th>Coverage</th>
</tr>
</thead>
<tbody id="quality-rows"></tbody>
</table>
</div>
</section>
<script>
window.astrapeModules = window.astrapeModules || {};
window.astrapeModules.oracleQualityDisplay = (() => {
function init() {
document.getElementById("quality-lookback").addEventListener("change", refresh);
refresh();
setInterval(refresh, 10000);
}
async function refresh() {
const lookback = document.getElementById("quality-lookback").value;
const response = await fetch(`/api/oracle-quality?lookback_hours=${lookback}`, { cache: "no-store" });
const payload = await response.json();
render(payload.rows || []);
}
function render(rows) {
const tbody = document.getElementById("quality-rows");
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="9">No evaluated oracle predictions yet.</td></tr>`;
return;
}
tbody.innerHTML = rows.map((row) => `
<tr>
<td>${escapeHtml(row.kind)}</td>
<td>${escapeHtml(row.model_version)}</td>
<td>${formatHorizon(row)}</td>
<td>${row.evaluated_count}</td>
<td class="${biasClass(row.mean_error_w)}">${formatW(row.mean_error_w)}</td>
<td>${formatW(row.mean_absolute_error_w)}</td>
<td>${formatW(row.median_absolute_error_w)}</td>
<td>${formatPct(row.mean_absolute_pct_error)}</td>
<td>${formatPct(row.interval_coverage)}</td>
</tr>
`).join("");
}
function formatHorizon(row) {
if (row.horizon_label) return row.horizon_label;
return `${row.min_horizon_minutes}-${row.max_horizon_minutes}m`;
}
function formatW(value) {
if (value === null || value === undefined) return "n/a";
return `${Math.round(Number(value))} W`;
}
function formatPct(value) {
if (value === null || value === undefined) return "n/a";
return `${(Number(value) * 100).toFixed(1)}%`;
}
function biasClass(value) {
if (value === null || value === undefined) return "";
const absolute = Math.abs(Number(value));
if (absolute < 250) return "metric-good";
if (absolute < 1000) return "metric-warn";
return "metric-bad";
}
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
return { init };
})();
window.astrapeModules.oracleQualityDisplay.init();
</script>
"""
def data_payload(self, lookback_hours: float = 168) -> str:
try:
rows = OracleStore.from_env().load_evaluation_summary(
lookback=timedelta(hours=lookback_hours)
)
except Exception:
rows = []
return json.dumps(
{
"lookback_hours": lookback_hours,
"rows": [self._row(row) for row in rows],
}
)
def _row(self, row: dict[str, object]) -> dict[str, object]:
return {
key: self._json_value(value)
for key, value in row.items()
}
def _json_value(self, value: object) -> object:
if value is None or isinstance(value, (str, int, float, bool)):
return value
try:
return float(value)
except (TypeError, ValueError):
return str(value)