#!/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"
" f"