Flatten to a single package directory
The checkout is now astrape/astrape-dashboard, not astrape/astrape-dashboard/<package>. The package contents move to the repository root and pyproject maps the package name to '.' via package-dir, with the subpackages listed explicitly. The systemd units move out to /srv/astrape/deploy/systemd, outside every repository, because they describe this deployment rather than any one component. Verified both install modes: editable, which is how the deployment runs, and a plain wheel install, which is what the container build does. 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