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,315 @@
"""Renders Open-Meteo forecast history and resolved truth for the web UI."""
from __future__ import annotations
import json
from datetime import datetime
from gibil.classes.models import WeatherForecastPoint, WeatherResolvedTruth
from astrape_oracle.classes.datasets import WeatherDisplayDataset
class WeatherDisplay:
"""Renders weather source data for the Astrape web UI."""
def render(self) -> str:
return """
<section class="panel weather-panel" data-module="weather-display">
<div class="panel-heading">
<div>
<h2>Weather</h2>
<p>External forecast history</p>
</div>
<div class="control-row">
<label>
Variable
<select id="weather-variable">
<option value="temperature_c">Temperature</option>
<option value="shortwave_radiation_w_m2">Solar radiation</option>
<option value="cloud_cover_pct">Cloud cover</option>
</select>
</label>
<div class="legend-control">
<div class="legend-title">Horizons</div>
<div id="weather-horizons" class="horizon-options"></div>
<div class="horizon-option">
<span class="legend-swatch truth-swatch"></span>
<span>Resolved truth</span>
</div>
</div>
</div>
</div>
<div class="chart-shell">
<canvas id="weather-chart" width="1100" height="420"></canvas>
</div>
</section>
<script>
window.astrapeModules = window.astrapeModules || {};
window.astrapeModules.weatherDisplay = (() => {
const palette = ["#60a5fa", "#34d399", "#fbbf24", "#a78bfa", "#fb7185", "#22d3ee"];
const defaultSlots = [
{ enabled: true, horizon: 4 },
{ enabled: true, horizon: 8 },
{ enabled: true, horizon: 12 },
{ enabled: false, horizon: 16 },
{ enabled: false, horizon: 24 },
{ enabled: false, horizon: 36 },
];
function init() {
const variable = document.getElementById("weather-variable");
variable.addEventListener("change", render);
buildHorizonControls();
refresh();
setInterval(refresh, 5000);
}
async function refresh() {
const response = await fetch("/api/weather", { cache: "no-store" });
const payload = await response.json();
window.astrapeWeatherData = payload;
render();
}
function buildHorizonControls() {
const horizons = document.getElementById("weather-horizons");
horizons.innerHTML = "";
const slots = loadSlots();
slots.forEach((slot, index) => {
const option = document.createElement("div");
option.className = "horizon-option";
option.innerHTML = `
<input class="horizon-enabled" type="checkbox" ${slot.enabled ? "checked" : ""}>
<span class="legend-swatch" style="background: ${palette[index]}"></span>
<input class="horizon-value" type="number" min="1" max="47" step="1" value="${slot.horizon}">
<span>h</span>
`;
const checkbox = option.querySelector(".horizon-enabled");
const value = option.querySelector(".horizon-value");
checkbox.addEventListener("change", render);
value.addEventListener("input", render);
horizons.appendChild(option);
});
}
function render() {
const payload = window.astrapeWeatherData || { forecast_points: [], resolved_truth: [] };
const variable = document.getElementById("weather-variable").value;
const selectedHorizons = selectedSlots();
saveSlots();
drawChart(payload, variable, selectedHorizons);
}
function drawChart(payload, variable, selectedHorizons) {
const canvas = document.getElementById("weather-chart");
const ctx = canvas.getContext("2d");
const series = buildSeries(payload, variable, selectedHorizons);
ctx.clearRect(0, 0, canvas.width, canvas.height);
const allPoints = series.flatMap((item) => item.points);
const windowBounds = oracleAlignedBounds(payload.now);
const ys = allPoints.map((point) => point.value).filter((value) => value !== null);
if (!ys.length) return;
const bounds = {
minX: windowBounds.minX,
maxX: windowBounds.maxX,
minY: Math.min(...ys),
maxY: Math.max(...ys),
};
if (bounds.minY === bounds.maxY) {
bounds.minY -= 1;
bounds.maxY += 1;
}
drawAxes(ctx, canvas, bounds);
drawNowMarker(ctx, canvas, bounds, windowBounds.nowX);
series.forEach((item) => {
drawSeries(ctx, canvas, bounds, item.points, item.color, item.width);
});
}
function buildSeries(payload, variable, selectedHorizons) {
const series = [];
for (const slot of selectedHorizons) {
const points = (payload.forecast_points || [])
.filter((point) => point.horizon_hours === slot.horizon)
.map((point) => ({ target_at: point.target_at, value: point[variable] }))
.filter((point) => point.value !== null);
series.push({ label: `${slot.horizon}h forecast`, points, color: slot.color, width: 2 });
}
const truth = (payload.resolved_truth || [])
.map((point) => ({ target_at: point.resolved_at, value: point[variable] }))
.filter((point) => point.value !== null);
series.push({ label: "resolved truth", points: truth, color: "#f8fafc", width: 3 });
return series;
}
function drawAxes(ctx, canvas, bounds) {
const margin = chartMargin();
ctx.strokeStyle = "#94a3b8";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(margin.left, margin.top);
ctx.lineTo(margin.left, canvas.height - margin.bottom);
ctx.lineTo(canvas.width - margin.right, canvas.height - margin.bottom);
ctx.stroke();
ctx.fillStyle = "#475569";
ctx.font = "12px system-ui";
ctx.fillText(bounds.maxY.toFixed(1), 10, margin.top + 4);
ctx.fillText(bounds.minY.toFixed(1), 10, canvas.height - margin.bottom);
}
function drawSeries(ctx, canvas, bounds, points, color, width) {
if (!points.length) return;
const margin = chartMargin();
if (points.length === 1) {
const point = points[0];
const x = scale(new Date(point.target_at).getTime(), bounds.minX, bounds.maxX, margin.left, canvas.width - margin.right);
const y = scale(point.value, bounds.minY, bounds.maxY, canvas.height - margin.bottom, margin.top);
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, width + 3, 0, Math.PI * 2);
ctx.fill();
return;
}
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.beginPath();
points.forEach((point, index) => {
const x = scale(new Date(point.target_at).getTime(), bounds.minX, bounds.maxX, margin.left, canvas.width - margin.right);
const y = scale(point.value, bounds.minY, bounds.maxY, canvas.height - margin.bottom, margin.top);
if (index === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
}
function drawNowMarker(ctx, canvas, bounds, now) {
if (now < bounds.minX || now > bounds.maxX) return;
const margin = chartMargin();
const x = scale(now, bounds.minX, bounds.maxX, margin.left, canvas.width - margin.right);
ctx.save();
ctx.strokeStyle = "#f8fafc";
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(x, margin.top);
ctx.lineTo(x, canvas.height - margin.bottom);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = "#f8fafc";
ctx.font = "12px system-ui";
ctx.fillText("now", Math.min(x + 8, canvas.width - margin.right - 28), margin.top + 14);
ctx.restore();
}
function selectedSlots() {
return [...document.querySelectorAll("#weather-horizons .horizon-option")]
.map((item, index) => {
const enabled = item.querySelector(".horizon-enabled").checked;
const input = item.querySelector(".horizon-value");
const horizon = clamp(Number(input.value), 1, 47);
input.value = horizon;
return enabled ? { horizon, color: palette[index] } : null;
})
.filter(Boolean);
}
function saveSlots() {
const slots = [...document.querySelectorAll("#weather-horizons .horizon-option")]
.map((item) => ({
enabled: item.querySelector(".horizon-enabled").checked,
horizon: Number(item.querySelector(".horizon-value").value),
}));
localStorage.setItem("astrapeWeatherHorizonSlots", JSON.stringify(slots));
}
function loadSlots() {
try {
const parsed = JSON.parse(localStorage.getItem("astrapeWeatherHorizonSlots"));
if (Array.isArray(parsed) && parsed.length === 6) return parsed;
} catch (error) {
return defaultSlots;
}
return defaultSlots;
}
function clamp(value, min, max) {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, Math.round(value)));
}
function chartMargin() {
return { top: 24, right: 28, bottom: 34, left: 52 };
}
function oracleAlignedBounds(nowIso) {
const parsedNow = new Date(nowIso).getTime();
const now = Number.isFinite(parsedNow) ? parsedNow : Date.now();
return {
minX: now - 24 * 60 * 60 * 1000,
maxX: now + 48 * 60 * 60 * 1000,
nowX: now
};
}
function scale(value, inMin, inMax, outMin, outMax) {
if (inMin === inMax) return (outMin + outMax) / 2;
return outMin + ((value - inMin) / (inMax - inMin)) * (outMax - outMin);
}
return { init };
})();
window.astrapeModules.weatherDisplay.init();
</script>
"""
def data_payload(self, dataset: WeatherDisplayDataset | None = None) -> str:
if dataset is None:
dataset = WeatherDisplayDataset(forecast_points=[], resolved_truth=[])
forecast_points = [self._forecast_point(point) for point in dataset.forecast_points]
resolved_truth = [self._truth_point(point) for point in dataset.resolved_truth]
horizons = sorted({point["horizon_hours"] for point in forecast_points})
return json.dumps(
{
"now": datetime.now().astimezone().isoformat(),
"forecast_points": forecast_points,
"resolved_truth": resolved_truth,
"horizons": horizons,
"min_horizon": 1,
"max_horizon": 47,
}
)
def _forecast_point(self, point: WeatherForecastPoint) -> dict[str, object]:
return {
"issued_at": self._iso(point.issued_at),
"target_at": self._iso(point.target_at),
"horizon_hours": point.horizon_hours,
"source": point.source,
"temperature_c": point.temperature_c,
"shortwave_radiation_w_m2": point.shortwave_radiation_w_m2,
"cloud_cover_pct": point.cloud_cover_pct,
}
def _truth_point(self, point: WeatherResolvedTruth) -> dict[str, object]:
return {
"resolved_at": self._iso(point.resolved_at),
"source": point.source,
"temperature_c": point.temperature_c,
"shortwave_radiation_w_m2": point.shortwave_radiation_w_m2,
"cloud_cover_pct": point.cloud_cover_pct,
}
def _iso(self, value: datetime) -> str:
return value.isoformat()