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 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 13:54:31 +02:00
co-authored by Claude Opus 5
parent e43ef83d47
commit b40feeea23
5 changed files with 494 additions and 0 deletions
+273
View File
@@ -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()