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,446 @@
from __future__ import annotations
import json
from dataclasses import asdict
from datetime import datetime
from gibil.classes.oracle_builder import EnergyOracleBuilder
from gibil.classes.forecast_config import EnergyForecastConfig
from astrape_ems.classes.store import SigenStore
from astrape_oracle.classes.weather.store import WeatherStore
from gibil.classes.models import (
NetPowerForecastPoint,
PowerForecastPoint,
PowerForecastRun,
)
from astrape_oracle.classes.oracle_store import OracleStore
class OracleDisplay:
"""Renders energy oracle curves for the Astrape web UI."""
def render(self) -> str:
return """
<section class="panel oracle-panel" data-module="oracle-display">
<div class="panel-heading">
<div>
<h2>Energy Oracle</h2>
<p>Solar, usage, and net power projection curves</p>
</div>
<div class="control-row">
<div id="oracle-legend" class="legend-control"></div>
<label>
Curve
<select id="oracle-variable">
<option value="net">Net power</option>
<option value="history">Past net predictions</option>
<option value="solar">Solar production</option>
<option value="load">Consumption</option>
</select>
</label>
</div>
</div>
<div class="chart-shell">
<canvas id="oracle-chart" width="1100" height="420"></canvas>
</div>
</section>
<script>
window.astrapeModules = window.astrapeModules || {};
window.astrapeModules.oracleDisplay = (() => {
const colors = {
actual: "#34d399",
historical: "#a78bfa",
p10: "#60a5fa",
p50: "#f8fafc",
p90: "#fbbf24",
safe: "#fb7185"
};
function init() {
document.getElementById("oracle-variable").addEventListener("change", render);
refresh();
setInterval(refresh, 5000);
}
async function refresh() {
const response = await fetch("/api/oracle", { cache: "no-store" });
window.astrapeOracleData = await response.json();
render();
}
function render() {
const payload = window.astrapeOracleData || {};
const variable = document.getElementById("oracle-variable").value;
const series = buildSeries(payload, variable);
renderLegend(series);
drawChart(series, payload);
}
function renderLegend(series) {
const legend = document.getElementById("oracle-legend");
legend.innerHTML = "";
series.forEach((item) => {
const entry = document.createElement("div");
entry.className = "horizon-option";
entry.innerHTML = `
<span class="legend-swatch" style="${legendSwatchStyle(item)}"></span>
<span>${item.label}</span>
`;
legend.appendChild(entry);
});
}
function legendSwatchStyle(item) {
if (item.dash) {
return `background: repeating-linear-gradient(90deg, ${item.color} 0 8px, transparent 8px 13px); border: 1px solid ${item.color};`;
}
return `background: ${item.color}`;
}
function buildSeries(payload, variable) {
if (variable === "solar") {
return [
{ label: "Observed solar", color: colors.actual, width: 3, markers: true, points: actualPoints(payload.actual_points, "solar_power_w", payload.now) },
{ label: "Current solar low", color: colors.p10, width: 2, dash: [6, 5], points: powerPoints(payload.solar_points, "p10_power_w") },
{ label: "Current solar expected", color: colors.p50, width: 3, points: powerPoints(payload.solar_points, "p50_power_w") },
{ label: "Current solar high", color: colors.p90, width: 2, dash: [6, 5], points: powerPoints(payload.solar_points, "p90_power_w") },
...historicalPowerSeries(payload.historical_solar_runs || [], "Solar forecast"),
];
}
if (variable === "load") {
return [
{ label: "Observed load", color: colors.actual, width: 3, markers: true, points: actualPoints(payload.actual_points, "load_power_w", payload.now) },
{ label: "Current load low", color: colors.p10, width: 2, dash: [6, 5], points: powerPoints(payload.load_points, "p10_power_w") },
{ label: "Current load expected", color: colors.p50, width: 3, points: powerPoints(payload.load_points, "p50_power_w") },
{ label: "Current load high", color: colors.p90, width: 2, dash: [6, 5], points: powerPoints(payload.load_points, "p90_power_w") },
...historicalPowerSeries(payload.historical_load_runs || [], "Load forecast"),
];
}
if (variable === "history") {
return [
{ label: "Observed net", color: colors.actual, width: 3, markers: true, points: actualPoints(payload.actual_points, "net_power_w", payload.now) },
...historicalNetSeries(payload.historical_net_runs || []),
];
}
return [
{ label: "Observed net", color: colors.actual, width: 3, markers: true, points: actualPoints(payload.actual_points, "net_power_w", payload.now) },
{ label: "Current net low", color: colors.p10, width: 2, dash: [6, 5], points: netPoints(payload.net_points, "p10_net_power_w") },
{ label: "Current net expected", color: colors.p50, width: 3, points: netPoints(payload.net_points, "p50_net_power_w") },
{ label: "Current net high", color: colors.p90, width: 2, dash: [6, 5], points: netPoints(payload.net_points, "p90_net_power_w") },
...historicalNetSeries(payload.historical_net_runs || []),
];
}
function historicalNetSeries(runs) {
const palette = ["#a78bfa", "#c084fc", "#818cf8", "#38bdf8", "#f472b6", "#f59e0b"];
return runs.map((run, index) => ({
label: `Net forecast ${formatLag(run)}`,
color: palette[index % palette.length],
width: 2,
dash: [3, 5],
points: (run.points || []).map((point) => ({
target_at: point.target_at,
value: point.p50_net_power_w ?? point.expected_net_power_w
})).filter((point) => new Date(point.target_at).getTime() >= new Date(run.issued_at).getTime())
}));
}
function historicalPowerSeries(runs, labelPrefix) {
const palette = ["#a78bfa", "#c084fc", "#818cf8", "#38bdf8", "#f472b6", "#f59e0b"];
return runs.map((run, index) => ({
label: `${labelPrefix} ${formatLag(run)}`,
color: palette[index % palette.length],
width: 2,
dash: [3, 5],
points: (run.points || []).map((point) => ({
target_at: point.target_at,
value: point.p50_power_w ?? point.expected_power_w
})).filter((point) => new Date(point.target_at).getTime() >= new Date(run.issued_at).getTime())
}));
}
function formatLag(run) {
if (run.lag_hours) return `${run.lag_hours}h ago`;
return `issued ${formatIssuedAge(run.issued_at)}`;
}
function formatIssuedAge(issuedAt) {
const ageMs = Math.max(0, new Date(window.astrapeOracleData.now).getTime() - new Date(issuedAt).getTime());
const minutes = Math.round(ageMs / 60000);
if (minutes < 60) return `${minutes}m ago`;
return `${Math.round(minutes / 60)}h ago`;
}
function actualPoints(points, key, nowIso) {
const parsedNow = new Date(nowIso).getTime();
const now = Number.isFinite(parsedNow) ? parsedNow : Date.now();
return (points || [])
.filter((point) => new Date(point.target_at).getTime() <= now)
.map((point) => ({ target_at: point.target_at, value: point[key] }));
}
function powerPoints(points, key) {
return (points || []).map((point) => ({ target_at: point.target_at, value: point[key] }));
}
function netPoints(points, key) {
return (points || []).map((point) => ({ target_at: point.target_at, value: point[key] }));
}
function drawChart(series, payload) {
const canvas = document.getElementById("oracle-chart");
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
const allPoints = series.flatMap((item) => item.points).filter((point) => point.value !== null);
if (!allPoints.length) return;
const ys = allPoints.map((point) => point.value);
ys.push(0);
const windowBounds = oracleAlignedBounds(payload.now);
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);
drawZeroLine(ctx, canvas, bounds);
drawNowMarker(ctx, canvas, bounds, windowBounds.nowX);
series.forEach((item) => drawSeries(ctx, canvas, bounds, item));
}
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 = "#94a3b8";
ctx.font = "12px system-ui";
ctx.fillText(`${Math.round(bounds.maxY)} W`, 10, margin.top + 4);
ctx.fillText(`${Math.round(bounds.minY)} W`, 10, canvas.height - margin.bottom);
}
function drawZeroLine(ctx, canvas, bounds) {
if (bounds.minY > 0 || bounds.maxY < 0) return;
const margin = chartMargin();
const y = scale(0, bounds.minY, bounds.maxY, canvas.height - margin.bottom, margin.top);
ctx.save();
ctx.strokeStyle = "#475569";
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(margin.left, y);
ctx.lineTo(canvas.width - margin.right, y);
ctx.stroke();
ctx.restore();
}
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 drawSeries(ctx, canvas, bounds, series) {
const points = series.points.filter((point) => point.value !== null);
if (!points.length) return;
const margin = chartMargin();
ctx.strokeStyle = series.color;
ctx.lineWidth = series.width;
ctx.setLineDash(series.dash || []);
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();
ctx.setLineDash([]);
if (series.markers || points.length < 12) {
ctx.fillStyle = series.color;
points.forEach((point) => {
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 (x < margin.left || x > canvas.width - margin.right) return;
ctx.beginPath();
ctx.arc(x, y, 3.5, 0, Math.PI * 2);
ctx.fill();
});
}
}
function scale(value, inMin, inMax, outMin, outMax) {
if (inMin === inMax) return (outMin + outMax) / 2;
return outMin + ((value - inMin) / (inMax - inMin)) * (outMax - outMin);
}
function chartMargin() {
return { top: 24, right: 28, bottom: 34, left: 64 };
}
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
};
}
return { init };
})();
window.astrapeModules.oracleDisplay.init();
</script>
"""
def data_payload(self) -> str:
plant_store = SigenStore.from_env()
builder = EnergyOracleBuilder(
weather_history=WeatherStore.from_env(),
plant_history=plant_store,
config=EnergyForecastConfig.from_env(),
)
oracle_store = OracleStore.from_env()
stored_runs = oracle_store.load_latest_runs()
if stored_runs is None:
solar_run, load_run, net_run = builder.build()
else:
solar_run, load_run, net_run = stored_runs
actual_points = plant_store.load_recent_actual_points()
try:
historical_net_runs = oracle_store.load_lagged_net_runs()
historical_solar_runs = oracle_store.load_lagged_power_runs("solar")
historical_load_runs = oracle_store.load_lagged_power_runs("load")
except Exception:
historical_net_runs = []
historical_solar_runs = []
historical_load_runs = []
return json.dumps(
{
"issued_at": self._iso(net_run.issued_at),
"now": self._iso(net_run.issued_at),
"solar_model": solar_run.model_version,
"load_model": load_run.model_version,
"solar_points": [
self._power_point(point) for point in solar_run.points
],
"load_points": [
self._power_point(point) for point in load_run.points
],
"net_points": [self._net_point(point) for point in net_run.points],
"actual_points": [
self._actual_point(point) for point in actual_points
],
"historical_net_runs": [
self._historical_net_run(run) for run in historical_net_runs
],
"historical_solar_runs": [
self._historical_power_run(run) for run in historical_solar_runs
],
"historical_load_runs": [
self._historical_power_run(run) for run in historical_load_runs
],
}
)
def _power_point(self, point: PowerForecastPoint) -> dict[str, object]:
return {
"target_at": self._iso(point.target_at),
"horizon_minutes": point.horizon_minutes,
"expected_power_w": point.expected_power_w,
"p10_power_w": point.p10_power_w,
"p50_power_w": point.p50_power_w,
"p90_power_w": point.p90_power_w,
"confidence": point.confidence,
"source": point.source,
"model_version": point.model_version,
"metadata": point.metadata,
}
def _net_point(self, point: NetPowerForecastPoint) -> dict[str, object]:
return asdict(point) | {"target_at": self._iso(point.target_at)}
def _actual_point(self, point: dict[str, object]) -> dict[str, object]:
return {
"target_at": self._iso(point["target_at"]),
"solar_power_w": point["solar_power_w"],
"load_power_w": point["load_power_w"],
"net_power_w": point["net_power_w"],
"grid_import_w": point["grid_import_w"],
"grid_export_w": point["grid_export_w"],
"sample_count": point["sample_count"],
}
def _historical_net_run(self, run: dict[str, object]) -> dict[str, object]:
return {
"lag_hours": run.get("lag_hours"),
"issued_at": self._iso(run["issued_at"]),
"points": [
{
"target_at": self._iso(point["target_at"]),
"horizon_minutes": point["horizon_minutes"],
"expected_net_power_w": point["expected_net_power_w"],
"safe_net_power_w": point["safe_net_power_w"],
"p10_net_power_w": point.get("p10_net_power_w"),
"p50_net_power_w": point.get("p50_net_power_w"),
"p90_net_power_w": point.get("p90_net_power_w"),
"solar_p50_power_w": point["solar_p50_power_w"],
"load_p50_power_w": point["load_p50_power_w"],
"solar_p10_power_w": point["solar_p10_power_w"],
"solar_p90_power_w": point.get("solar_p90_power_w"),
"load_p10_power_w": point.get("load_p10_power_w"),
"load_p90_power_w": point["load_p90_power_w"],
}
for point in run["points"]
],
}
def _historical_power_run(self, run: dict[str, object]) -> dict[str, object]:
return {
"lag_hours": run.get("lag_hours"),
"issued_at": self._iso(run["issued_at"]),
"kind": run["kind"],
"source": run["source"],
"model_version": run["model_version"],
"points": [
{
"target_at": self._iso(point["target_at"]),
"horizon_minutes": point["horizon_minutes"],
"expected_power_w": point["expected_power_w"],
"p10_power_w": point["p10_power_w"],
"p50_power_w": point["p50_power_w"],
"p90_power_w": point["p90_power_w"],
"confidence": point["confidence"],
}
for point in run["points"]
],
}
def _iso(self, value: datetime) -> str:
return value.isoformat()
@@ -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)
@@ -0,0 +1,273 @@
"""Renders local weather station history for the web UI."""
from __future__ import annotations
import json
from datetime import datetime
from gibil.classes.models import WeatherStationObservation
from astrape_oracle.classes.datasets import WeatherStationDisplayDataset
class WeatherStationDisplay:
"""Renders local weather station history for the Astrape web UI."""
def render(self) -> str:
return """
<section class="panel weather-station-panel" data-module="weather-station-display">
<div class="panel-heading">
<div>
<h2>Weather Station</h2>
<p>Local Ecowitt history</p>
</div>
<div class="control-row">
<label>
Range
<select id="station-range">
<option value="24">24 hours</option>
<option value="72">3 days</option>
<option value="168" selected>7 days</option>
<option value="720">30 days</option>
</select>
</label>
<label>
Variable
<select id="station-variable">
<option value="solar_radiation_w_m2">Sun</option>
<option value="rain_rate_mm_h">Rain rate</option>
<option value="rain_daily_mm">Daily rain</option>
<option value="wind_speed_m_s">Wind speed</option>
<option value="wind_gust_m_s">Wind gust</option>
<option value="temperature_c">Temperature</option>
<option value="relative_humidity_pct">Humidity</option>
<option value="relative_pressure_hpa">Pressure</option>
<option value="uv_index">UV index</option>
</select>
</label>
</div>
</div>
<div id="station-stats" class="stat-grid"></div>
<div class="chart-shell">
<canvas id="station-chart" width="1100" height="420"></canvas>
</div>
</section>
<script>
window.astrapeModules = window.astrapeModules || {};
window.astrapeModules.weatherStationDisplay = (() => {
const variables = {
solar_radiation_w_m2: { label: "Sun", unit: "W/m2", color: "#fbbf24", decimals: 0 },
rain_rate_mm_h: { label: "Rain rate", unit: "mm/h", color: "#60a5fa", decimals: 1 },
rain_daily_mm: { label: "Daily rain", unit: "mm", color: "#38bdf8", decimals: 1 },
wind_speed_m_s: { label: "Wind speed", unit: "m/s", color: "#34d399", decimals: 1 },
wind_gust_m_s: { label: "Wind gust", unit: "m/s", color: "#22d3ee", decimals: 1 },
temperature_c: { label: "Temperature", unit: "C", color: "#fb7185", decimals: 1 },
relative_humidity_pct: { label: "Humidity", unit: "%", color: "#a78bfa", decimals: 0 },
relative_pressure_hpa: { label: "Pressure", unit: "hPa", color: "#f8fafc", decimals: 1 },
uv_index: { label: "UV index", unit: "", color: "#f59e0b", decimals: 1 },
};
function init() {
document.getElementById("station-range").addEventListener("change", refresh);
document.getElementById("station-variable").addEventListener("change", render);
refresh();
setInterval(refresh, 30000);
}
async function refresh() {
const hours = document.getElementById("station-range").value;
const response = await fetch(`/api/weather-station?lookback_hours=${hours}`, { cache: "no-store" });
window.astrapeWeatherStationData = await response.json();
render();
}
function render() {
const payload = window.astrapeWeatherStationData || { observations: [], stats: {} };
renderStats(payload);
drawChart(payload);
}
function renderStats(payload) {
const stats = payload.stats || {};
const items = [
{ label: "Sun peak", value: stats.max_solar_radiation_w_m2, unit: "W/m2", decimals: 0 },
{ label: "Rain today", value: stats.latest_rain_daily_mm, unit: "mm", decimals: 1 },
{ label: "Rain rate peak", value: stats.max_rain_rate_mm_h, unit: "mm/h", decimals: 1 },
{ label: "Wind peak", value: stats.max_wind_gust_m_s, unit: "m/s", decimals: 1 },
{ label: "Outdoor temp", value: stats.latest_temperature_c, unit: "C", decimals: 1 },
{ label: "Humidity", value: stats.latest_relative_humidity_pct, unit: "%", decimals: 0 },
];
document.getElementById("station-stats").innerHTML = items.map((item) => `
<div class="stat-card">
<div class="stat-label">${item.label}</div>
<div class="stat-value">${formatValue(item.value, item.decimals)}<span>${item.unit}</span></div>
</div>
`).join("");
}
function drawChart(payload) {
const canvas = document.getElementById("station-chart");
const ctx = canvas.getContext("2d");
const variable = document.getElementById("station-variable").value;
const config = variables[variable];
const points = (payload.observations || [])
.map((point) => ({ observed_at: point.observed_at, value: point[variable] }))
.filter((point) => point.value !== null && Number.isFinite(Number(point.value)));
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!points.length) {
drawEmpty(ctx, canvas);
return;
}
const xs = points.map((point) => new Date(point.observed_at).getTime());
const ys = points.map((point) => Number(point.value));
const bounds = {
minX: Math.min(...xs),
maxX: Math.max(...xs),
minY: Math.min(...ys),
maxY: Math.max(...ys),
};
if (bounds.minY === bounds.maxY) {
bounds.minY -= 1;
bounds.maxY += 1;
}
drawAxes(ctx, canvas, bounds, config);
drawSeries(ctx, canvas, bounds, points, config.color);
}
function drawAxes(ctx, canvas, bounds, config) {
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 = "#94a3b8";
ctx.font = "12px system-ui";
ctx.fillText(`${config.label} (${config.unit})`, margin.left, 16);
ctx.fillText(formatValue(bounds.maxY, config.decimals), 10, margin.top + 4);
ctx.fillText(formatValue(bounds.minY, config.decimals), 10, canvas.height - margin.bottom);
}
function drawSeries(ctx, canvas, bounds, points, color) {
const margin = chartMargin();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
points.forEach((point, index) => {
const x = scale(new Date(point.observed_at).getTime(), bounds.minX, bounds.maxX, margin.left, canvas.width - margin.right);
const y = scale(Number(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 drawEmpty(ctx, canvas) {
ctx.fillStyle = "#9aa8ba";
ctx.font = "14px system-ui";
ctx.fillText("No station observations in this range yet.", 28, 42);
}
function scale(value, inMin, inMax, outMin, outMax) {
if (inMin === inMax) return (outMin + outMax) / 2;
return outMin + ((value - inMin) / (inMax - inMin)) * (outMax - outMin);
}
function chartMargin() {
return { top: 28, right: 28, bottom: 34, left: 58 };
}
function formatValue(value, decimals) {
if (value === null || value === undefined || !Number.isFinite(Number(value))) return "--";
return Number(value).toFixed(decimals);
}
return { init };
})();
window.astrapeModules.weatherStationDisplay.init();
</script>
"""
def data_payload(
self,
dataset: WeatherStationDisplayDataset | None = None,
) -> str:
if dataset is None:
dataset = WeatherStationDisplayDataset(observations=[])
observations = [
self._observation(observation) for observation in dataset.observations
]
return json.dumps(
{
"now": datetime.now().astimezone().isoformat(),
"observations": observations,
"stats": self._stats(observations),
}
)
def _observation(
self,
observation: WeatherStationObservation,
) -> dict[str, object]:
return {
"observed_at": self._iso(observation.observed_at),
"received_at": self._iso(observation.received_at),
"source": observation.source,
"temperature_c": observation.temperature_c,
"relative_humidity_pct": observation.relative_humidity_pct,
"dew_point_c": observation.dew_point_c,
"pressure_hpa": observation.pressure_hpa,
"wind_speed_m_s": observation.wind_speed_m_s,
"wind_gust_m_s": observation.wind_gust_m_s,
"wind_max_m_s": observation.wind_max_m_s,
"wind_direction_deg": observation.wind_direction_deg,
"solar_radiation_w_m2": observation.solar_radiation_w_m2,
"uv_index": observation.uv_index,
"rain_rate_mm_h": observation.rain_rate_mm_h,
"rain_event_mm": observation.rain_event_mm,
"rain_hourly_mm": observation.rain_hourly_mm,
"rain_daily_mm": observation.rain_daily_mm,
"rain_weekly_mm": observation.rain_weekly_mm,
"rain_monthly_mm": observation.rain_monthly_mm,
"rain_yearly_mm": observation.rain_yearly_mm,
"indoor_temperature_c": observation.indoor_temperature_c,
"indoor_humidity_pct": observation.indoor_humidity_pct,
"absolute_pressure_hpa": observation.absolute_pressure_hpa,
"relative_pressure_hpa": observation.relative_pressure_hpa,
}
def _stats(self, observations: list[dict[str, object]]) -> dict[str, object]:
latest = observations[-1] if observations else {}
return {
"latest_temperature_c": latest.get("temperature_c"),
"latest_relative_humidity_pct": latest.get("relative_humidity_pct"),
"latest_rain_daily_mm": latest.get("rain_daily_mm"),
"max_solar_radiation_w_m2": self._max_value(
observations, "solar_radiation_w_m2"
),
"max_rain_rate_mm_h": self._max_value(observations, "rain_rate_mm_h"),
"max_wind_gust_m_s": self._max_value(observations, "wind_gust_m_s"),
}
def _max_value(
self,
observations: list[dict[str, object]],
key: str,
) -> float | None:
values = [
value
for observation in observations
if isinstance((value := observation.get(key)), (int, float))
]
if not values:
return None
return float(max(values))
def _iso(self, value: datetime) -> str:
return value.isoformat()
@@ -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()