Files
EOS/docs/akkudoktoreos/nodered_pv_measurement_function.js
T
Andreas f976335122 feat(pvforecast): local pvlib provider with measurement calibration
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.
2026-09-06 18:21:20 +02:00

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;