Files
wetter2oag/weather_report.py
T
admin 37127fada3 Fix: 31. des Monats fehlt – API-End auf Monatsersten (exklusiv) gesetzt
end = first_of_this (Juni 1 00:00) statt May 31 23:59:59, damit APIs
mit exklusivem End-Parameter alle Tage des letzten Monats liefern.
Zusätzlich ±12h Padding auf xlim der Linien-Charts (wie Balkendiagramm).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:59:50 +02:00

242 lines
8.4 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 # exklusives Ende: < Juni 1 = komplett Mai
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):
data = fetch("/weather/range", {
"start": start.isoformat(), "end": end.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):
vals = by_day[day_key]
daily = max(vals) - min(vals)
if daily < 0:
daily = max(vals)
dates.append(datetime.fromisoformat(day_key).replace(tzinfo=timezone.utc))
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()