From b40feeea238337e29b3f5e84b974e8ba416b4343 Mon Sep 17 00:00:00 2001 From: "m0@nas" Date: Mon, 21 Sep 2026 13:54:31 +0200 Subject: [PATCH] Wettervorhersage sammeln und archivieren (Open-Meteo) gatherForecastData.py holt alle zwanzig Minuten Stunden- und Tageswerte und schreibt sie nach solarLog (weatherHours, weatherDays, weatherForecastLog, weatherTilted). Grundlage des neuen Meteogramms im Web-Repository. Kein Anbau an gatherRainData.py, obwohl es dieselbe Schnittstelle benutzt: dessen Fehlerregel ist sicherheitsrelevant - es leert bei einer Stoerung bewusst seine Werte, damit der Runner nicht auf eine alte "0,0 mm" hin giesst. Hier gilt das Gegenteil, eine Vorhersage von vor zwei Stunden ist brauchbar und wird nur als alt beschriftet. Zwei gegenlaeufige Fehlerregeln in einer Schleife waeren eine Falle. Es wird nichts geloescht. Die Tabellen sind zugleich ein Archiv: aus der Einstrahlung und EnergyFlow_hourly.pv_kwh soll spaeter eine eigene Ertragsprognose gerechnet werden. 8.760 Zeilen im Jahr kosten 1,5 MB. wetterarchiv_nachtragen.py fuellt das Archiv rueckwirkend aus der ERA5-Schnittstelle von Open-Meteo - 39.264 Stunden ab dem 02.04.2022, dem Beginn von EnergyFlow_hourly. Damit ist die Korrelation sofort rechenbar statt erst in einem Jahr. Sonnenzeiten schreibt es dabei ausdruecklich nicht: die Archiv-Schnittstelle rechnet alle Zeiten mit dem heute gueltigen Zeitzonenversatz um und meldet das auch selbst ("utc_offset_seconds: 7200" fuer einen Dezembertag). Co-Authored-By: Claude Opus 5 --- config.ini.example | 26 ++++ gatherForecastData.py | 273 +++++++++++++++++++++++++++++++++++++ logrotate.conf | 1 + startSolarServer.sh | 1 + wetterarchiv_nachtragen.py | 193 ++++++++++++++++++++++++++ 5 files changed, 494 insertions(+) create mode 100644 gatherForecastData.py create mode 100644 wetterarchiv_nachtragen.py 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()