Files
wetter2oag/weather_report.py
T
admin db8feb317e Erster Tag des Folgemonats wird im Chart mit angezeigt
Ermöglicht korrekte Regenberechnung für den letzten Monatstag:
der Abschlusswert des kumulativen Zählers liegt oft erst im
Mitternachtswert des 1. Folgemonats.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 21:30:13 +02:00

251 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Erstellt einen kombinierten Wetterbericht für den letzten Kalendermonat."""
import os
import smtplib
import tempfile
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import matplotlib.dates as mdates
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
import requests
from dotenv import load_dotenv
load_dotenv()
VERSION = os.getenv("APP_VERSION", "?")
API_BASE_URL = os.getenv("API_BASE_URL", "https://stwwetter.fuerst-stuttgart.de/api")
MAIL_SERVER = os.getenv("MAIL_SERVER")
MAIL_PORT = int(os.getenv("MAIL_PORT", "587"))
MAIL_USER = os.getenv("MAIL_USER")
MAIL_PASSWORD = os.getenv("MAIL_PASSWORD")
MAIL_FROM = os.getenv("MAIL_FROM")
MAIL_TO = [a.strip() for a in os.getenv("MAIL_TO", "").split(",") if a.strip()]
MAIL_SUBJECT = os.getenv("MAIL_SUBJECT", "Wetterbericht letzter Monat")
MONTHS_DE = [
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
]
plt.rcParams.update({
"figure.facecolor": "#f8f9fa",
"axes.facecolor": "#ffffff",
"axes.grid": True,
"grid.alpha": 0.4,
"axes.spines.top": False,
"axes.spines.right": False,
"font.size": 11,
})
def last_month_range() -> tuple[datetime, datetime, str]:
today = datetime.now(timezone.utc)
first_of_this = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
start = (first_of_this - timedelta(days=1)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end = first_of_this + timedelta(days=1) # inkl. 1. Folgemonat
label = f"{MONTHS_DE[start.month - 1]} {start.year}"
return start, end, label
def fetch(path: str, params: dict) -> list:
url = f"{API_BASE_URL}{path}"
resp = requests.get(url, params=params, timeout=30)
resp.raise_for_status()
return resp.json()
def parse_dt(s: str) -> datetime:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
def _data_temp_minmax(start: datetime, end: datetime):
data = fetch("/weather/daily-aggregated-range", {
"start": start.isoformat(), "end": end.isoformat(),
})
dates = [parse_dt(d["datetime"]) for d in data]
t_min = [d["min_temperature"] for d in data]
t_max = [d["max_temperature"] for d in data]
return dates, t_min, t_max
def _data_temp_hourly(start: datetime, end: datetime):
data = fetch("/weather/hourly-aggregated-range", {
"start": start.isoformat(), "end": end.isoformat(),
})
dates = [parse_dt(d["datetime"]) for d in data]
temps = [d["temperature"] for d in data]
return dates, temps
def _data_rain_daily(start: datetime, end: datetime):
# +1 Tag extra damit der Abschlusswert des letzten Tages (kum. Zähler) enthalten ist
data = fetch("/weather/range", {
"start": start.isoformat(),
"end": (end + timedelta(days=1)).isoformat(),
"limit": 50000,
})
by_day: dict[str, list[float]] = defaultdict(list)
for d in data:
if d.get("rain") is not None:
by_day[parse_dt(d["datetime"]).strftime("%Y-%m-%d")].append(d["rain"])
dates, rain = [], []
for day_key in sorted(by_day):
day_dt = datetime.fromisoformat(day_key).replace(tzinfo=timezone.utc)
if day_dt >= end:
break
vals = list(by_day[day_key])
next_key = (day_dt + timedelta(days=1)).strftime("%Y-%m-%d")
if next_key in by_day:
vals.append(by_day[next_key][0])
daily = max(vals) - min(vals)
if daily < 0:
daily = max(vals)
dates.append(day_dt)
rain.append(round(daily, 1))
return dates, rain
def create_combined_chart(start: datetime, end: datetime, label: str) -> bytes:
dates_mm, t_min, t_max = _data_temp_minmax(start, end)
dates_h, temps = _data_temp_hourly(start, end)
dates_r, rain = _data_rain_daily(start, end)
fig, axes = plt.subplots(3, 1, figsize=(13, 16))
fig.suptitle(f"Wetterbericht {label}", fontsize=15, fontweight="bold", y=0.985)
# --- Chart 1: Tages-Min / Tages-Max ---
ax = axes[0]
ax.plot(dates_mm, t_max, color="#e05c2a", linewidth=2, marker="o",
markersize=4, label="Tages-Maximum")
ax.plot(dates_mm, t_min, color="#2a7be0", linewidth=2, marker="o",
markersize=4, label="Tages-Minimum")
ax.fill_between(dates_mm, t_min, t_max, alpha=0.12, color="#888888")
ax.set_title("Temperaturverlauf (Tages-Min / Tages-Max)", fontweight="bold", pad=10)
ax.set_ylabel("Temperatur (°C)")
ax.set_xlim(start - timedelta(hours=12), end + timedelta(hours=12))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m."))
ax.xaxis.set_major_locator(mdates.DayLocator(interval=3))
plt.setp(ax.get_xticklabels(), rotation=0, ha="center")
ax.legend(framealpha=0.8)
# --- Chart 2: Stundenmittel ---
ax = axes[1]
ax.plot(dates_h, temps, color="#2a7be0", linewidth=1.5, label="Stundenmittel")
ax.set_title("Temperaturverlauf (Stundenmittel)", fontweight="bold", pad=10)
ax.set_ylabel("Temperatur (°C)")
ax.set_xlim(start - timedelta(hours=12), end + timedelta(hours=12))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m."))
ax.xaxis.set_major_locator(mdates.DayLocator(interval=3))
plt.setp(ax.get_xticklabels(), rotation=0, ha="center")
ax.legend(framealpha=0.8)
# --- Chart 3: Niederschlag ---
ax = axes[2]
colors = ["#2a7be0" if r > 0 else "#ccddee" for r in rain]
ax.bar(dates_r, rain, color=colors, width=0.7, label="Niederschlag")
total = sum(rain)
ax.set_title(f"Niederschlag pro Tag (Summe: {total:.1f} mm)",
fontweight="bold", pad=10)
ax.set_ylabel("Niederschlag (mm)")
ax.set_xlim(start - timedelta(hours=12), end + timedelta(hours=12))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m."))
ax.xaxis.set_major_locator(mdates.DayLocator(interval=3))
plt.setp(ax.get_xticklabels(), rotation=0, ha="center")
ax.legend(framealpha=0.8)
fig.tight_layout(rect=[0.015, 0.055, 0.985, 0.975])
# Rahmen um die gesamte Figure
border = mpatches.FancyBboxPatch(
(0.008, 0.008), 0.984, 0.984,
boxstyle="square,pad=0",
linewidth=2, edgecolor="#999999", facecolor="none",
transform=fig.transFigure, clip_on=False,
)
fig.add_artist(border)
# Footer: links E-Mail, Mitte Watermark, rechts Version + Datum
footer_y = 0.030
footer_kw = dict(va="center", fontsize=9, color="#999999",
transform=fig.transFigure)
build_date = datetime.now(timezone.utc).strftime("%d.%m.%Y")
fig.text(0.022, footer_y, "mailto:rexfue@gmail.com",
ha="left", **footer_kw)
fig.text(0.5, footer_y, "Wetterstation der Sternwarte Welzheim",
ha="center", **footer_kw)
fig.text(0.978, footer_y, f"V {VERSION} · {build_date}",
ha="right", **footer_kw)
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
fig.savefig(f.name, dpi=150, bbox_inches="tight")
path = f.name
plt.close(fig)
with open(path, "rb") as f:
data = f.read()
os.unlink(path)
return data
def send_email(png: bytes, label: str) -> None:
if not all([MAIL_SERVER, MAIL_USER, MAIL_PASSWORD, MAIL_FROM, MAIL_TO]):
print("E-Mail-Konfiguration unvollständig Mail wird nicht gesendet.")
return
# multipart/related: HTML + CID-Bild werden als Einheit behandelt
related = MIMEMultipart("related")
msg = MIMEMultipart("mixed")
msg["Subject"] = f"{MAIL_SUBJECT} {label}"
msg["From"] = MAIL_FROM
msg["To"] = ", ".join(MAIL_TO)
msg.attach(related)
body = MIMEText(
f"<html><body>"
f"<h2>Wetterbericht {label}</h2>"
f'<img src="cid:chart@weather" style="max-width:100%;"/>'
f"</body></html>",
"html",
)
related.attach(body)
img = MIMEImage(png, name="wetterbericht.png")
img.add_header("Content-ID", "<chart@weather>")
img.add_header("Content-Disposition", "inline", filename="wetterbericht.png")
related.attach(img)
with smtplib.SMTP(MAIL_SERVER, MAIL_PORT) as smtp:
smtp.ehlo()
smtp.starttls()
smtp.login(MAIL_USER, MAIL_PASSWORD)
smtp.send_message(msg)
print(f"Mail gesendet an: {', '.join(MAIL_TO)}")
def main() -> None:
start, end, label = last_month_range()
print(f"Hole Wetterdaten für {label} ({start.date()} {end.date()}) …")
png = create_combined_chart(start, end, label)
os.makedirs("images", exist_ok=True)
path = os.path.join("images", "wetterbericht.png")
with open(path, "wb") as f:
f.write(png)
print(f"Diagramm erstellt → {path}")
send_email(png, label)
if __name__ == "__main__":
main()