c8e3016fd6
- 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.
435 lines
19 KiB
Python
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()
|