311 lines
12 KiB
Python
311 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
|
|
from gibil.classes.models import WeatherForecastPoint, WeatherResolvedTruth
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WeatherDisplayDataset:
|
|
forecast_points: list[WeatherForecastPoint]
|
|
resolved_truth: list[WeatherResolvedTruth]
|
|
|
|
|
|
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>
|
|
</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 now = Date.now();
|
|
const xs = allPoints.map((point) => new Date(point.target_at).getTime());
|
|
xs.push(now);
|
|
const ys = allPoints.map((point) => point.value).filter((value) => value !== null);
|
|
if (!xs.length || !ys.length) return;
|
|
|
|
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);
|
|
drawNowMarker(ctx, canvas, bounds);
|
|
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) {
|
|
const now = Date.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 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(
|
|
{
|
|
"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,
|
|
}
|
|
|
|
def _iso(self, value: datetime) -> str:
|
|
return value.isoformat()
|