mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-09 07:56:40 +00:00
Add PVForecastAkkudoktorLocal, which runs the modelling chain inside EOS on raw Open-Meteo irradiance instead of calling a forecast service: solar position, horizon shading, plane transposition, incidence-angle modifier, cell temperature, PVWatts DC and inverter AC. It needs no API key and serves up to 16 days at 15-minute resolution from a single hourly request, which is what keeps `optimization.tail_horizon_hours` fed - services wrapping Open-Meteo cut the horizon much shorter. Several Open-Meteo models can be listed in `weather_models` and are averaged per variable at no extra request cost. With `calibration_enabled` the provider fits itself against `measurement.pv_production_emr_keys` over the past `calibration_days`: a global scale factor plus optional per-solar-azimuth factors, each weighted by modelled energy, shrunk toward the global factor by `calibration_prior_kwh` and clamped to `[calibration_min_factor, calibration_max_factor]`. The comparison runs on past intervals, where Open-Meteo serves analysed rather than forecast weather, so it corrects the error of the PV model and not that of the weather forecast. Calibration is a scale factor on the output and never touches `userhorizon`, `surface_tilt`, `surface_azimuth` or `peakpower`. The docs say so, and say why a short window and a plant fault inside it are the two ways to end up with a misleading factor. Also add `Measurement.pv_production_total_kwh()` alongside the existing load total, and `scripts/pvforecast_backtest.py`, which scores configuration variants against the stored meter readings without waiting for new forecasts to come true.
69 lines
2.7 KiB
JavaScript
69 lines
2.7 KiB
JavaScript
// Node-RED function: cumulative PV production meter readings -> EOS measurement API.
|
|
//
|
|
// Strang:
|
|
// inject (repeat 3600 s, msg.topic = die SQL aus dem Tutorial)
|
|
// -> mysql "MariaDB" (DB `sensor`)
|
|
// -> DIESE function
|
|
// -> http request (Method: "- set by msg.method -")
|
|
//
|
|
// EOS speichert unter `pv_production_emr_keys` Zaehlerstaende (EMR) in kWh und
|
|
// bildet die Differenzen selbst. Aus den Momentanleistungen in `data.solarallpower`
|
|
// muss also erst ein monoton steigender Zaehler werden - das macht die SQL.
|
|
//
|
|
// Wichtig: das Startdatum in der SQL bleibt FEST. Ein rollendes
|
|
// `NOW() - INTERVAL n DAY` verschiebt den Nullpunkt der kumulativen Summe bei
|
|
// jedem Lauf, und EOS liest den Sprung als Produktion.
|
|
|
|
const BASE_URL = "http://192.168.1.151:8503";
|
|
const KEY = "pv_produktion_emr";
|
|
const TZ = "Europe/Berlin";
|
|
|
|
function toIsoWithOffset(value) {
|
|
// DATE_FORMAT() kommt als String in lokaler Zeit zurueck. Den Offset aus dem
|
|
// Datum selbst bilden, damit CEST und CET beide stimmen.
|
|
const date = value instanceof Date ? value : new Date(String(value).replace(" ", "T"));
|
|
const pad = n => String(Math.trunc(Math.abs(n))).padStart(2, "0");
|
|
const offsetMin = -date.getTimezoneOffset();
|
|
const sign = offsetMin >= 0 ? "+" : "-";
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
|
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` +
|
|
`${sign}${pad(offsetMin / 60)}:${pad(offsetMin % 60)}`;
|
|
}
|
|
|
|
const rows = Array.isArray(msg.payload) ? msg.payload : [];
|
|
const data = {};
|
|
let last = null;
|
|
let skipped = 0;
|
|
for (const row of rows) {
|
|
const value = Number(row.emr);
|
|
if (!Number.isFinite(value)) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
// Ein Zaehler laeuft nur vorwaerts. Ein Rueckschritt bedeutet eine Luecke oder
|
|
// einen kaputten Messwert - EOS wuerde daraus eine negative Produktionsstunde
|
|
// machen.
|
|
if (last !== null && value < last) {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
last = value;
|
|
data[toIsoWithOffset(row.ts)] = Number(value.toFixed(6));
|
|
}
|
|
|
|
const count = Object.keys(data).length;
|
|
if (count === 0) {
|
|
node.warn("Keine PV-Messwerte gefunden - topic und Zeitfenster in der SQL pruefen.");
|
|
return null;
|
|
}
|
|
if (skipped > 0) {
|
|
node.warn(`${skipped} Zeilen uebersprungen (nicht-numerisch oder Zaehler rueckwaerts).`);
|
|
}
|
|
node.status({ text: `${count} EMR-Werte, letzter ${last.toFixed(1)} kWh` });
|
|
|
|
msg.method = "PUT";
|
|
msg.url = `${BASE_URL}/v1/measurement/series?key=${encodeURIComponent(KEY)}`;
|
|
msg.headers = { "Content-Type": "application/json" };
|
|
msg.payload = { data: data, dtype: "float64", tz: TZ };
|
|
return msg;
|