Files
Astrape/gibil/classes/oracle/display.py
T
rpotter6298 c8e3016fd6 Add new daemons and debug scripts for Sigenergy and Oracle functionalities
- Implement `sigen_daemon.py` to poll Sigenergy plant metrics and store snapshots.
- Create `web_daemon.py` for serving a web interface with various endpoints.
- Add debug scripts:
  - `debug_duplicates.py` to find duplicate target times in forecast data.
  - `debug_energy_forecast.py` to print baseline energy forecast curves.
  - `debug_oracle_evaluations.py` to run the oracle evaluator.
  - `debug_sigen.py` to inspect stored Sigenergy plant snapshots.
  - `debug_weather.py` to trace resolved truth data.
  - `modbus_test.py` for exploring Sigenergy plants or inverters over Modbus TCP.
- Introduce `oracle_evaluator.py` for evaluating stored oracle predictions against actuals.
- Add TCN training scripts in `tcn` directory for training usage sequence models.
2026-04-28 08:14:00 +02:00

435 lines
19 KiB
Python

from __future__ import annotations
import json
from dataclasses import asdict
from datetime import datetime
from gibil.classes.oracle.builder import EnergyOracleBuilder
from gibil.classes.models import (
NetPowerForecastPoint,
PowerForecastPoint,
PowerForecastRun,
)
from gibil.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:
builder = EnergyOracleBuilder.from_env()
solar_run, load_run, net_run = builder.build()
actual_points = builder.sigen_store.load_recent_actual_points()
try:
oracle_store = OracleStore.from_env()
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()