diff --git a/config.ini.example b/config.ini.example index f719fe6..ae4beae 100644 --- a/config.ini.example +++ b/config.ini.example @@ -54,3 +54,29 @@ max_alter_stunden = 3 ; Adresse der BMU. Ab Werk 192.168.16.254; der Abschnitt darf fehlen. [byd] host = 192.168.16.254 + +; =========================================================================== +; Wettervorhersage (gatherForecastData.py) +; =========================================================================== +; Grundlage des Meteogramms. Geschrieben wird in die Datenbank, nicht nach +; MQTT - die Zugangsdaten stehen in [database]. +[vorhersage] +lat = 47.5780 +lon = 10.2630 +timezone = Europe/Berlin +; Was Open-Meteo hergibt. past_days traegt Bewoelkung, Strahlung und +; Niederschlag links vom Jetzt-Strich - beides misst die eigene Station nicht. +forecast_days = 7 +past_days = 2 +; Open-Meteo rechnet stuendlich neu. Zwanzig Minuten heisst: die laufende +; Stunde ist nie aelter als zwanzig Minuten, und 72 Abrufe am Tag liegen weit +; unter dem freien Kontingent. +intervall_minuten = 20 +; Einstrahlung in Modulebene, je Flaeche "kuerzel:neigung:azimut". +; Neigung in Grad gegen die Waagerechte, Azimut in Grad mit 0 = Sued, +; negativ = Ost, positiv = West (so verlangt es Open-Meteo). +; Leer lassen, solange die Winkel nicht ausgemessen sind - dann wird die +; geneigte Einstrahlung uebersprungen und das Meteogramm merkt nichts davon. +; Nachtragen geht jederzeit, die Archiv-API liefert sie auch rueckwirkend. +; flaechen = dach:30:10, veranda:90:10, carport:15:10 +flaechen = diff --git a/gatherForecastData.py b/gatherForecastData.py new file mode 100644 index 0000000..5a4817d --- /dev/null +++ b/gatherForecastData.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""Wettervorhersage von Open-Meteo in die Datenbank. + +Grundlage des Meteogramms auf der Solar-Seite (js/solar/meteogramm.js im +Web-Repository, bedient von ajax/meteogramm.php). Geschrieben werden die +Tabellen weatherHours, weatherDays, weatherTilted und weatherForecastLog in +solarLog - angelegt von solarLog_weather.sql im Web-Repository, dort steht +auch, warum sie so aussehen, wie sie aussehen. + +Warum ein eigenes Skript und keine Erweiterung von gatherRainData.py, das +dieselbe Schnittstelle benutzt: dessen Fehlerregel ist sicherheitsrelevant. +Es leert bei einer Stoerung bewusst seine Werte, damit der AutoAction-Runner +nicht auf eine alte "0,0 mm" hin giesst. Hier gilt das Gegenteil - eine +Vorhersage von vor zwei Stunden ist immer noch eine brauchbare Vorhersage, +sie wird nur als alt beschriftet. Zwei gegenlaeufige Fehlerregeln in einer +Schleife waeren eine Falle fuer den naechsten, der etwas aendert. + +Es wird nichts geloescht. Die Tabellen sind zugleich ein Archiv: aus der +Einstrahlung der Vergangenheit und EnergyFlow_hourly.pv_kwh soll spaeter eine +eigene Ertragsprognose gerechnet werden. 8.760 Zeilen im Jahr kosten rund +1,5 MB - ein einziger Tag EnergyFlow ist groesser. + +Drei Dinge, die den Aufbau erklaeren: + + Eine Stunde wird mehrfach geschrieben und dabei genauer. Erst ist sie + Vorhersage fuer uebermorgen, zuletzt Analyse des vergangenen Tages. + Deshalb REPLACE INTO auf dem Zeitstempel und kein Anhaengen. Damit man der + Zeile ansieht, woher sie stammt, wird "ist_vorhersage" mitgeschrieben: 1, + solange die Stunde beim Schreiben noch in der Zukunft lag. + + Was dabei verlorenginge, faengt weatherForecastLog auf - dort landet je + Stunde die *erste* Aussage und wird nie wieder angefasst (INSERT IGNORE). + Nur so laesst sich spaeter beantworten, wie gut eine Vorhersage von vor drei + Tagen war. + + Die Einstrahlung in Modulebene haengt an Neigung und Azimut und braucht + einen eigenen Abruf je Flaeche. Sie landet in weatherTilted und ist + freiwillig: ohne Eintrag in der config.ini wird sie uebersprungen, und das + Meteogramm merkt nichts davon. + +Gestartet von startSolarServer.sh, Standort und Takt stehen in config.ini +(siehe config.ini.example, Abschnitt [vorhersage]). +""" + +import logging +import time +from datetime import datetime, timedelta + +import mysql.connector as mc +import requests + +import konfig + +OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" + +# Was je Stunde geholt wird. Die Reihenfolge ist gleichgueltig, die Namen +# nicht - sie sind zugleich die Schluessel in der Antwort. +STUNDENFELDER = [ + "temperature_2m", "apparent_temperature", "relativehumidity_2m", + "dewpoint_2m", "pressure_msl", "precipitation", "precipitation_probability", + "snowfall", "weathercode", "cloudcover_low", "cloudcover_mid", + "cloudcover_high", "windspeed_10m", "windgusts_10m", "winddirection_10m", + "shortwave_radiation", "direct_radiation", "diffuse_radiation", "is_day", +] + +TAGESFELDER = [ + "weathercode", "temperature_2m_max", "temperature_2m_min", "sunrise", + "sunset", "precipitation_sum", "precipitation_probability_max", + "windspeed_10m_max", "windgusts_10m_max", "shortwave_radiation_sum", +] + +# pressure_msl und nicht surface_pressure: auf 729 m Hoehe meldet der +# Stationsdruck rund 940 hPa, die eigene Wetterstation meldet qff (auf +# Meereshoehe gerechnet) rund 1035. Nebeneinander waere das kein Vergleich, +# sondern eine Verwechslung. + +logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s") +# mysql.connector meldet bei jedem Verbindungsaufbau drei Zeilen ueber sein +# Authentifizierungsmodul. Bei einem Abruf alle zwanzig Minuten waeren das +# neunzig Zeilen am Tag, die niemandem etwas sagen. +logging.getLogger("mysql.connector").setLevel(logging.WARNING) +logger = logging.getLogger("gatherForecastData") + + +def flaechen_lesen(): + """ + Die Flaechen fuer die Einstrahlung in Modulebene. + + Format in der config.ini, eine je Zeile oder durch Komma getrennt: + + flaechen = dach:30:10, carport:15:10 + + also Kuerzel, Neigung in Grad, Azimut in Grad (0 = Sued, negativ = Ost, + positiv = West - so verlangt es Open-Meteo). Ein leerer Eintrag ist + erlaubt und heisst: keine geneigte Einstrahlung holen. + """ + roh = konfig.wert("vorhersage", "flaechen", "") + aus = [] + for teil in roh.replace("\n", ",").split(","): + teil = teil.strip() + if not teil: + continue + try: + name, neigung, azimut = teil.split(":") + aus.append((name.strip(), float(neigung), float(azimut))) + except ValueError: + logger.warning("[vorhersage] flaechen: '%s' verstehe ich nicht, " + "erwartet wird kuerzel:neigung:azimut", teil) + return aus + + +def abrufen(zusatz=None, felder=None): + """Eine Antwort von Open-Meteo holen. Wirft bei Fehlern.""" + p = { + "latitude": konfig.wert("vorhersage", "lat"), + "longitude": konfig.wert("vorhersage", "lon"), + "timezone": konfig.wert("vorhersage", "timezone", "Europe/Berlin"), + "forecast_days": konfig.wert("vorhersage", "forecast_days", "7"), + "past_days": konfig.wert("vorhersage", "past_days", "2"), + "hourly": ",".join(felder if felder else STUNDENFELDER), + } + if felder is None: + p["daily"] = ",".join(TAGESFELDER) + if zusatz: + p.update(zusatz) + antwort = requests.get(OPEN_METEO_URL, params=p, timeout=20) + antwort.raise_for_status() + return antwort.json() + + +def _zeit(text): + """'2026-09-21T14:00' -> datetime. Open-Meteo liefert immer Minuten mit.""" + return datetime.strptime(text, "%Y-%m-%dT%H:%M") + + +def _zahl(wert): + """None bleibt None - eine fehlende Zahl ist nicht dasselbe wie eine 0.""" + return None if wert is None else wert + + +def stundenzeilen(daten, jetzt): + """Die Antwort in Zeilen fuer weatherHours uebersetzen.""" + h = daten["hourly"] + zeilen = [] + for i, t in enumerate(h["time"]): + zeit = _zeit(t) + h_ = lambda name: _zahl(h[name][i]) + zeilen.append(( + zeit, + h_("temperature_2m"), h_("apparent_temperature"), + h_("relativehumidity_2m"), h_("dewpoint_2m"), + None if h_("pressure_msl") is None else round(h_("pressure_msl")), + h_("precipitation"), h_("precipitation_probability"), h_("snowfall"), + h_("weathercode"), + h_("cloudcover_low"), h_("cloudcover_mid"), h_("cloudcover_high"), + h_("windspeed_10m"), h_("windgusts_10m"), h_("winddirection_10m"), + h_("shortwave_radiation"), h_("direct_radiation"), h_("diffuse_radiation"), + 1 if h_("is_day") else 0, + 1 if zeit > jetzt else 0, # ist_vorhersage + jetzt, # abgerufen + )) + return zeilen + + +def tageszeilen(daten, jetzt): + """Die Antwort in Zeilen fuer weatherDays uebersetzen.""" + d = daten["daily"] + zeilen = [] + for i, t in enumerate(d["time"]): + tag = datetime.strptime(t, "%Y-%m-%d").date() + d_ = lambda name: _zahl(d[name][i]) + # sunrise/sunset kommen als voller Zeitstempel, gespeichert wird die + # Uhrzeit - das Datum steht schon im Schluessel. + auf = _zeit(d["sunrise"][i]).time() if d["sunrise"][i] else None + unter = _zeit(d["sunset"][i]).time() if d["sunset"][i] else None + zeilen.append(( + tag, d_("weathercode"), d_("temperature_2m_min"), d_("temperature_2m_max"), + auf, unter, + d_("precipitation_sum"), d_("precipitation_probability_max"), + d_("windspeed_10m_max"), d_("windgusts_10m_max"), + d_("shortwave_radiation_sum"), + 1 if tag > jetzt.date() else 0, + jetzt, + )) + return zeilen + + +def schreiben(zug, stunden, tage, geneigt): + """Alles in einer Transaktion. Entweder der ganze Stand oder keiner.""" + with mc.connect(**zug) as verbindung: + with verbindung.cursor() as cursor: + cursor.executemany( + "REPLACE INTO weatherHours (datetime, temp, gefuehlt, feuchte, " + "taupunkt, druck, regen, regen_wkt, schnee, wettercode, " + "wolken_tief, wolken_mittel, wolken_hoch, wind, boe, richtung, " + "strahlung, direkt, diffus, tag, ist_vorhersage, abgerufen) " + "VALUES (" + ",".join(["%s"] * 22) + ");", stunden) + cursor.executemany( + "REPLACE INTO weatherDays (date, wettercode, temp_min, temp_max, " + "sonnenauf, sonnenunter, regen_summe, regen_wkt, wind_max, " + "boe_max, strahlung_summe, ist_vorhersage, abgerufen) " + "VALUES (" + ",".join(["%s"] * 13) + ");", tage) + # Nur die erste Aussage je Stunde, danach nie wieder angefasst. + cursor.executemany( + "INSERT IGNORE INTO weatherForecastLog " + "(datetime, abgerufen, temp, strahlung, regen) " + "VALUES (%s,%s,%s,%s,%s);", + [(z[0], z[21], z[1], z[16], z[6]) for z in stunden if z[20]]) + if geneigt: + cursor.executemany( + "REPLACE INTO weatherTilted (datetime, flaeche, strahlung) " + "VALUES (%s,%s,%s);", geneigt) + verbindung.commit() + + +def geneigte_einstrahlung(flaechen): + """ + Je Flaeche ein zusaetzlicher Abruf, weil tilt/azimut global gelten. + + Faellt einer aus, fehlt nur diese Flaeche - die uebrigen Werte sind + deswegen nicht schlechter. + """ + aus = [] + for name, neigung, azimut in flaechen: + try: + daten = abrufen({"tilt": neigung, "azimuth": azimut}, + felder=["global_tilted_irradiance"]) + h = daten["hourly"] + for i, t in enumerate(h["time"]): + aus.append((_zeit(t), name, _zahl(h["global_tilted_irradiance"][i]))) + except Exception as fehler: + logger.warning("Flaeche %s: %r", name, fehler) + return aus + + +def main(): + takt = int(konfig.wert("vorhersage", "intervall_minuten", "20")) * 60 + flaechen = flaechen_lesen() + if flaechen: + logger.info("Einstrahlung in Modulebene fuer: %s", + ", ".join(f[0] for f in flaechen)) + else: + logger.info("Keine Flaechen eingetragen - ohne geneigte Einstrahlung") + + letzter_erfolg = time.time() + gewarnt = False + while True: + try: + jetzt = datetime.now().replace(microsecond=0) + daten = abrufen() + stunden = stundenzeilen(daten, jetzt) + tage = tageszeilen(daten, jetzt) + geneigt = geneigte_einstrahlung(flaechen) + schreiben(konfig.datenbank(), stunden, tage, geneigt) + logger.info("%d Stunden, %d Tage%s geschrieben", len(stunden), len(tage), + ", %d Flaechenwerte" % len(geneigt) if geneigt else "") + letzter_erfolg = time.time() + gewarnt = False + except Exception as fehler: + # Die Tabelle bleibt stehen. Eine alte Vorhersage ist brauchbar, + # sie wird in der Anzeige nur als alt beschriftet - dafuer reist + # "abgerufen" mit. Erst nach drei Stunden ohne jeden Erfolg lohnt + # eine Meldung, vorher ist es ein Schluckauf der Leitung. + logger.debug("Abruf fehlgeschlagen: %r", fehler) + if not gewarnt and time.time() - letzter_erfolg > 3 * 3600: + logger.error("Seit drei Stunden keine Vorhersage: %r", fehler) + gewarnt = True + time.sleep(takt) + + +if __name__ == "__main__": + main() diff --git a/logrotate.conf b/logrotate.conf index ae67bf9..2114652 100644 --- a/logrotate.conf +++ b/logrotate.conf @@ -20,6 +20,7 @@ /volume1/homes/wagner/SolarManager/solarOutput.log /volume1/homes/wagner/SolarManager/autoActions.log /volume1/homes/wagner/SolarManager/rainOutput.log +/volume1/homes/wagner/SolarManager/forecastOutput.log /volume1/homes/wagner/SolarManager/wattpilotshell.log /volume1/homes/wagner/SolarManager/wsMQTTbridge.log { diff --git a/startSolarServer.sh b/startSolarServer.sh index bdafa4c..37aa3c7 100755 --- a/startSolarServer.sh +++ b/startSolarServer.sh @@ -62,3 +62,4 @@ starte() { starte "solarManager.py" "solarOutput.log" starte "autoActions/autoaction_runner.py" "autoActions.log" starte "gatherRainData.py" "rainOutput.log" +starte "gatherForecastData.py" "forecastOutput.log" diff --git a/wetterarchiv_nachtragen.py b/wetterarchiv_nachtragen.py new file mode 100644 index 0000000..95a1498 --- /dev/null +++ b/wetterarchiv_nachtragen.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""Das Wetterarchiv rueckwirkend fuellen - einmalig, kein Dauerlaeufer. + + python3 wetterarchiv_nachtragen.py [--von 2022-04-02] [--bis 2026-09-20] + +gatherForecastData.py sammelt ab heute. Warten muss man deswegen nicht: +Open-Meteo hat neben der Vorhersage eine Archiv-Schnittstelle +(archive-api.open-meteo.com, aus den ERA5-Reanalysen), die dieselben +Stundenwerte rueckwirkend liefert - Temperatur, Wind, Niederschlag, +Bewoelkung und vor allem die Einstrahlung. + +Wozu: EnergyFlow_hourly.pv_kwh haelt den gemessenen Ertrag je Stunde seit dem +02.04.2022. Das ist die eine Haelfte eines Datenpaares; die andere ist die +Einstrahlung derselben Stunde. Ist sie nachgetragen, laesst sich die +Korrelation sofort rechnen statt erst in einem Jahr - ueber alle +Jahreszeiten, mit Schnee, Dunst und Hochsommer. + +Geschrieben wird in dieselbe Tabelle wie im laufenden Betrieb, mit +ist_vorhersage = 0: eine Analyse ist keine Vorhersage. Bereits vorhandene +Stunden werden ueberschrieben - die Analyse ist genauer als die Vorhersage, +die dort vielleicht noch steht. weatherForecastLog bleibt unberuehrt: dort +gehoert hinein, was *vorher* gesagt wurde, und das laesst sich nicht +nachtraegen. + +Die Archiv-Schnittstelle hinkt dem heutigen Tag rund fuenf Tage hinterher. +Ohne --bis endet der Lauf deshalb eine Woche vor heute; was danach kommt, +hat der Sammler ohnehin schon. + +Abgerufen wird jahresweise. Ein einzelner Aufruf ueber vier Jahre waere +mehrere Megabyte in einer Antwort, und faellt er in der Mitte aus, faengt +alles von vorn an. +""" + +import argparse +import logging +import time +from datetime import date, datetime, timedelta + +import mysql.connector as mc +import requests + +import konfig +import gatherForecastData as vorhersage + +ARCHIV_URL = "https://archive-api.open-meteo.com/v1/archive" + +# Dieselben Felder wie im laufenden Betrieb, abzueglich derer, die das Archiv +# nicht kennt: precipitation_probability ist eine Eigenschaft der Vorhersage - +# im Nachhinein hat es geregnet oder nicht. +ARCHIVFELDER = [f for f in vorhersage.STUNDENFELDER + if f != "precipitation_probability"] + +logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s") +logging.getLogger("mysql.connector").setLevel(logging.WARNING) +logger = logging.getLogger("wetterarchiv") + + +def archiv_holen(von, bis, felder=None, zusatz=None): + p = { + "latitude": konfig.wert("vorhersage", "lat"), + "longitude": konfig.wert("vorhersage", "lon"), + "timezone": konfig.wert("vorhersage", "timezone", "Europe/Berlin"), + "start_date": von.isoformat(), + "end_date": bis.isoformat(), + "hourly": ",".join(felder if felder else ARCHIVFELDER), + } + if felder is None: + p["daily"] = ",".join(vorhersage.TAGESFELDER) + if zusatz: + p.update(zusatz) + antwort = requests.get(ARCHIV_URL, params=p, timeout=120) + antwort.raise_for_status() + return antwort.json() + + +def zeilen(daten, gelaufen): + """ + Wie vorhersage.stundenzeilen(), nur ohne Regenwahrscheinlichkeit und + immer als Analyse. + + Absichtlich eine eigene Funktion und kein Schalter in der anderen: der + Sammler soll nicht bei jedem Takt eine Bedingung auswerten, die nur + dieses Skript braucht. + """ + h = daten["hourly"] + aus = [] + for i, t in enumerate(h["time"]): + w = lambda name: h[name][i] if name in h else None + druck = w("pressure_msl") + aus.append(( + vorhersage._zeit(t), + w("temperature_2m"), w("apparent_temperature"), + w("relativehumidity_2m"), w("dewpoint_2m"), + None if druck is None else round(druck), + w("precipitation"), None, w("snowfall"), + w("weathercode"), + w("cloudcover_low"), w("cloudcover_mid"), w("cloudcover_high"), + w("windspeed_10m"), w("windgusts_10m"), w("winddirection_10m"), + w("shortwave_radiation"), w("direct_radiation"), w("diffuse_radiation"), + 1 if w("is_day") else 0, + 0, # ist_vorhersage: eine Analyse ist keine + gelaufen, + )) + return aus + + +def schreiben(zug, stunden, tage, geneigt): + """Wie im Sammler, aber ohne weatherForecastLog.""" + with mc.connect(**zug) as verbindung: + with verbindung.cursor() as cursor: + cursor.executemany( + "REPLACE INTO weatherHours (datetime, temp, gefuehlt, feuchte, " + "taupunkt, druck, regen, regen_wkt, schnee, wettercode, " + "wolken_tief, wolken_mittel, wolken_hoch, wind, boe, richtung, " + "strahlung, direkt, diffus, tag, ist_vorhersage, abgerufen) " + "VALUES (" + ",".join(["%s"] * 22) + ");", stunden) + if tage: + cursor.executemany( + "REPLACE INTO weatherDays (date, wettercode, temp_min, " + "temp_max, sonnenauf, sonnenunter, regen_summe, regen_wkt, " + "wind_max, boe_max, strahlung_summe, ist_vorhersage, abgerufen) " + "VALUES (" + ",".join(["%s"] * 13) + ");", tage) + if geneigt: + cursor.executemany( + "REPLACE INTO weatherTilted (datetime, flaeche, strahlung) " + "VALUES (%s,%s,%s);", geneigt) + verbindung.commit() + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--von", default="2022-04-02", + help="erster Tag (Vorgabe: Beginn von EnergyFlow_hourly)") + p.add_argument("--bis", default=None, + help="letzter Tag (Vorgabe: heute minus sieben Tage)") + a = p.parse_args() + + von = date.fromisoformat(a.von) + bis = date.fromisoformat(a.bis) if a.bis else date.today() - timedelta(days=7) + if von > bis: + raise SystemExit("--von liegt hinter --bis") + + flaechen = vorhersage.flaechen_lesen() + gelaufen = datetime.now().replace(microsecond=0) + zug = konfig.datenbank() + gesamt = 0 + + jahr_von = von + while jahr_von <= bis: + jahr_bis = min(date(jahr_von.year, 12, 31), bis) + logger.info("hole %s bis %s", jahr_von, jahr_bis) + daten = archiv_holen(jahr_von, jahr_bis) + stunden = zeilen(daten, gelaufen) + + tage = vorhersage.tageszeilen(daten, gelaufen) if "daily" in daten else [] + # Zwei Korrekturen gegenueber dem laufenden Betrieb: + # + # ist_vorhersage ist hier immer 0 - tageszeilen() entscheidet das + # anhand von "heute", und im Archiv ist jeder Tag Vergangenheit. + # + # Sonnenauf- und -untergang werden verworfen. Die Archiv-Schnittstelle + # rechnet alle Zeiten mit dem *heute* gueltigen Zeitzonenversatz um - + # die Antwort sagt selbst "utc_offset_seconds: 7200", auch fuer einen + # Dezembertag. Sonnenaufgang am 21.12.2025 kaeme so als 09:04 statt + # 08:04. Bei den Stundenwerten faellt das nicht ins Gewicht (sie sind + # ohnehin an ihrem Zeitstempel aufgehaengt und werden nur als Reihe + # gelesen), bei einer Uhrzeit schon. Gebraucht werden die Sonnenzeiten + # nur fuer die sichtbaren Tage, und die schreibt der Sammler richtig. + tage = [t[:4] + (None, None) + t[6:-2] + (0, gelaufen) for t in tage] + + geneigt = [] + for name, neigung, azimut in flaechen: + g = archiv_holen(jahr_von, jahr_bis, + felder=["global_tilted_irradiance"], + zusatz={"tilt": neigung, "azimuth": azimut}) + h = g["hourly"] + geneigt += [(vorhersage._zeit(t), name, + h["global_tilted_irradiance"][i]) + for i, t in enumerate(h["time"])] + + schreiben(zug, stunden, tage, geneigt) + gesamt += len(stunden) + logger.info(" %d Stunden, %d Tage, %d Flaechenwerte geschrieben", + len(stunden), len(tage), len(geneigt)) + jahr_von = date(jahr_von.year + 1, 1, 1) + time.sleep(2) # nicht in einem Zug durch die fremde Schnittstelle + + logger.info("fertig: %d Stunden nachgetragen", gesamt) + + +if __name__ == "__main__": + main()