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:
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import pi, sin
|
||||
|
||||
from gibil.classes.models import WeatherForecastPoint, WeatherResolvedTruth
|
||||
from astrape_oracle.classes.datasets import WeatherDisplayDataset
|
||||
|
||||
|
||||
class WeatherSampleData:
|
||||
"""Builds temporary display-only weather data for UI tuning."""
|
||||
|
||||
def build(self, hours: int = 72) -> WeatherDisplayDataset:
|
||||
now = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
|
||||
start = now - timedelta(hours=hours)
|
||||
horizons = [2, 4, 8, 12, 24]
|
||||
|
||||
forecast_points: list[WeatherForecastPoint] = []
|
||||
resolved_truth: list[WeatherResolvedTruth] = []
|
||||
|
||||
for offset in range(hours + 1):
|
||||
target_at = start + timedelta(hours=offset)
|
||||
truth_temperature = self._temperature(target_at, offset)
|
||||
truth_solar = self._solar(target_at, offset)
|
||||
|
||||
resolved_truth.append(
|
||||
WeatherResolvedTruth(
|
||||
resolved_at=target_at,
|
||||
source="sample",
|
||||
temperature_c=truth_temperature,
|
||||
shortwave_radiation_w_m2=truth_solar,
|
||||
)
|
||||
)
|
||||
|
||||
for horizon in horizons:
|
||||
forecast_points.append(
|
||||
WeatherForecastPoint(
|
||||
issued_at=target_at - timedelta(hours=horizon),
|
||||
target_at=target_at,
|
||||
horizon_hours=horizon,
|
||||
temperature_c=truth_temperature
|
||||
+ self._temperature_error(offset, horizon),
|
||||
shortwave_radiation_w_m2=max(
|
||||
0,
|
||||
truth_solar + self._solar_error(offset, horizon),
|
||||
),
|
||||
cloud_cover_pct=max(
|
||||
0,
|
||||
min(100, 45 + 30 * sin((offset + horizon) / 9)),
|
||||
),
|
||||
source="sample",
|
||||
)
|
||||
)
|
||||
|
||||
return WeatherDisplayDataset(
|
||||
forecast_points=forecast_points,
|
||||
resolved_truth=resolved_truth,
|
||||
)
|
||||
|
||||
def _temperature(self, target_at: datetime, offset: int) -> float:
|
||||
daily = sin(((target_at.hour - 7) / 24) * 2 * pi)
|
||||
slow = sin(offset / 18)
|
||||
return round(6.5 + daily * 5.5 + slow * 1.3, 1)
|
||||
|
||||
def _solar(self, target_at: datetime, offset: int) -> float:
|
||||
daylight = max(0, sin(((target_at.hour - 5) / 15) * pi))
|
||||
cloud_effect = 0.75 + 0.25 * sin(offset / 7)
|
||||
return round(780 * daylight * cloud_effect, 1)
|
||||
|
||||
def _temperature_error(self, offset: int, horizon: int) -> float:
|
||||
return round((horizon / 8) * sin((offset + horizon) / 5), 1)
|
||||
|
||||
def _solar_error(self, offset: int, horizon: int) -> float:
|
||||
return round((horizon * 9) * sin((offset + horizon) / 4), 1)
|
||||
@@ -0,0 +1,437 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from os import environ
|
||||
|
||||
from astrape_ems.classes.env import EnvLoader
|
||||
from astrape_dashboard.classes.sample_data import WeatherSampleData
|
||||
from astrape_oracle.classes.weather.store import WeatherStore, WeatherStoreConfigurationError
|
||||
from astrape_dashboard.classes.displays.weather import WeatherDisplay
|
||||
from astrape_dashboard.classes.displays.station import WeatherStationDisplay
|
||||
from astrape_dashboard.classes.displays.oracle import OracleDisplay
|
||||
from astrape_dashboard.classes.displays.oracle_quality import OracleQualityDisplay
|
||||
|
||||
|
||||
class WebUI:
|
||||
"""Composes Astrape web modules into a small control panel."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.weather_display = WeatherDisplay()
|
||||
self.weather_station_display = WeatherStationDisplay()
|
||||
self.oracle_display = OracleDisplay()
|
||||
self.oracle_quality_display = OracleQualityDisplay()
|
||||
|
||||
def render_page(self, page: str = "oracle") -> str:
|
||||
current_page = (
|
||||
page
|
||||
if page in {"oracle", "weather", "weather_station", "quality"}
|
||||
else "oracle"
|
||||
)
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Astrape</title>
|
||||
<style>
|
||||
:root {{
|
||||
color-scheme: light;
|
||||
--bg: #11151c;
|
||||
--surface: #181e27;
|
||||
--panel: #1f2733;
|
||||
--ink: #edf2f7;
|
||||
--muted: #9aa8ba;
|
||||
--line: #344052;
|
||||
--field: #121821;
|
||||
--active: #38bdf8;
|
||||
}}
|
||||
|
||||
* {{
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
|
||||
body {{
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
line-height: 1.4;
|
||||
}}
|
||||
|
||||
header {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 22px 28px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}}
|
||||
|
||||
.brand {{
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}}
|
||||
|
||||
nav {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}}
|
||||
|
||||
nav a {{
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}}
|
||||
|
||||
nav a:hover {{
|
||||
color: var(--ink);
|
||||
border-color: var(--line);
|
||||
}}
|
||||
|
||||
nav a.active {{
|
||||
color: var(--ink);
|
||||
border-color: var(--active);
|
||||
background: #102334;
|
||||
}}
|
||||
|
||||
h1, h2, p {{
|
||||
margin: 0;
|
||||
}}
|
||||
|
||||
h1 {{
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}}
|
||||
|
||||
h2 {{
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}}
|
||||
|
||||
p {{
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}}
|
||||
|
||||
main {{
|
||||
width: min(1280px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}}
|
||||
|
||||
.panel {{
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 18px;
|
||||
}}
|
||||
|
||||
.panel + .panel {{
|
||||
margin-top: 18px;
|
||||
}}
|
||||
|
||||
.panel-heading {{
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, auto) 1fr;
|
||||
gap: 24px;
|
||||
margin-bottom: 18px;
|
||||
}}
|
||||
|
||||
.control-row {{
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
}}
|
||||
|
||||
label {{
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}}
|
||||
|
||||
select {{
|
||||
min-width: 150px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--field);
|
||||
color: var(--ink);
|
||||
padding: 8px;
|
||||
font: inherit;
|
||||
}}
|
||||
|
||||
.legend-control {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 12px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}}
|
||||
|
||||
.legend-title {{
|
||||
line-height: 1;
|
||||
}}
|
||||
|
||||
.horizon-options {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}}
|
||||
|
||||
.horizon-option {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}}
|
||||
|
||||
.horizon-option input {{
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
}}
|
||||
|
||||
.horizon-option .horizon-value {{
|
||||
width: 54px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--field);
|
||||
color: var(--ink);
|
||||
padding: 4px 6px;
|
||||
font: inherit;
|
||||
}}
|
||||
|
||||
.legend-swatch {{
|
||||
width: 22px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}}
|
||||
|
||||
.truth-swatch {{
|
||||
background: #f8fafc;
|
||||
}}
|
||||
|
||||
.chart-shell {{
|
||||
position: relative;
|
||||
min-height: 420px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--panel);
|
||||
}}
|
||||
|
||||
canvas {{
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 420px;
|
||||
}}
|
||||
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}}
|
||||
|
||||
th, td {{
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}}
|
||||
|
||||
th:first-child, td:first-child,
|
||||
th:nth-child(2), td:nth-child(2) {{
|
||||
text-align: left;
|
||||
}}
|
||||
|
||||
th {{
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}}
|
||||
|
||||
.table-shell {{
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
}}
|
||||
|
||||
.stat-grid {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}}
|
||||
|
||||
.stat-card {{
|
||||
min-height: 76px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
padding: 12px;
|
||||
}}
|
||||
|
||||
.stat-label {{
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}}
|
||||
|
||||
.stat-value {{
|
||||
margin-top: 6px;
|
||||
font-size: 24px;
|
||||
font-weight: 750;
|
||||
line-height: 1.1;
|
||||
}}
|
||||
|
||||
.stat-value span {{
|
||||
margin-left: 5px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}}
|
||||
|
||||
.metric-good {{
|
||||
color: #34d399;
|
||||
}}
|
||||
|
||||
.metric-warn {{
|
||||
color: #fbbf24;
|
||||
}}
|
||||
|
||||
.metric-bad {{
|
||||
color: #fb7185;
|
||||
}}
|
||||
|
||||
@media (max-width: 760px) {{
|
||||
header, .panel-heading, .control-row {{
|
||||
display: grid;
|
||||
}}
|
||||
|
||||
.legend-control, .horizon-options {{
|
||||
justify-content: flex-start;
|
||||
}}
|
||||
|
||||
main {{
|
||||
padding: 14px;
|
||||
}}
|
||||
|
||||
select {{
|
||||
width: 100%;
|
||||
}}
|
||||
|
||||
.stat-grid {{
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand">
|
||||
<h1>Astrape</h1>
|
||||
<p>Gibil control panel</p>
|
||||
</div>
|
||||
{self._nav(current_page)}
|
||||
</header>
|
||||
<main>
|
||||
{self._page_body(current_page)}
|
||||
</main>
|
||||
<script>
|
||||
let astrapeUiVersion = null;
|
||||
async function watchUiVersion() {{
|
||||
const response = await fetch("/api/ui-version", {{ cache: "no-store" }});
|
||||
const payload = await response.json();
|
||||
if (astrapeUiVersion === null) {{
|
||||
astrapeUiVersion = payload.version;
|
||||
return;
|
||||
}}
|
||||
if (astrapeUiVersion !== payload.version) {{
|
||||
window.location.reload();
|
||||
}}
|
||||
}}
|
||||
setInterval(watchUiVersion, 1000);
|
||||
watchUiVersion();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
def _nav(self, current_page: str) -> str:
|
||||
pages = [
|
||||
("oracle", "/oracle", "Oracle"),
|
||||
("weather", "/weather", "Weather"),
|
||||
("weather_station", "/weather-station", "Station"),
|
||||
("quality", "/quality", "Quality"),
|
||||
]
|
||||
links = [
|
||||
f'<a class="{"active" if key == current_page else ""}" href="{href}">{label}</a>'
|
||||
for key, href, label in pages
|
||||
]
|
||||
return f"<nav>{''.join(links)}</nav>"
|
||||
|
||||
def _page_body(self, page: str) -> str:
|
||||
if page == "weather":
|
||||
return self.weather_display.render()
|
||||
if page == "weather_station":
|
||||
return self.weather_station_display.render()
|
||||
if page == "quality":
|
||||
return self.oracle_quality_display.render()
|
||||
return self.oracle_display.render()
|
||||
|
||||
def weather_payload(self) -> str:
|
||||
EnvLoader().load()
|
||||
if environ.get("ASTRAPE_WEB_SAMPLE_DATA") == "1":
|
||||
return self.weather_display.data_payload(WeatherSampleData().build())
|
||||
|
||||
try:
|
||||
dataset = WeatherStore.from_env().load_display_dataset()
|
||||
except WeatherStoreConfigurationError:
|
||||
dataset = None
|
||||
|
||||
return self.weather_display.data_payload(dataset)
|
||||
|
||||
def weather_station_payload(self, lookback_hours: float = 168) -> str:
|
||||
EnvLoader().load()
|
||||
try:
|
||||
hours = min(2160, max(1, float(lookback_hours)))
|
||||
except (TypeError, ValueError):
|
||||
hours = 168
|
||||
|
||||
try:
|
||||
now = datetime.now(timezone.utc)
|
||||
dataset = WeatherStore.from_env().load_station_display_dataset(
|
||||
start_at=now - timedelta(hours=hours),
|
||||
end_at=now,
|
||||
)
|
||||
except WeatherStoreConfigurationError:
|
||||
dataset = None
|
||||
|
||||
return self.weather_station_display.data_payload(dataset)
|
||||
|
||||
def oracle_payload(self) -> str:
|
||||
EnvLoader().load()
|
||||
return self.oracle_display.data_payload()
|
||||
|
||||
def oracle_quality_payload(self, lookback_hours: float = 168) -> str:
|
||||
EnvLoader().load()
|
||||
return self.oracle_quality_display.data_payload(lookback_hours=lookback_hours)
|
||||
Reference in New Issue
Block a user