396587554a
pyproject.toml wird nicht ins Docker-Image kopiert und war zur Laufzeit nicht vorhanden. deploy.sh übergibt VERSION bereits als Build-Arg, das jetzt als ENV APP_VERSION im Image gesetzt wird. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
242 lines
8.3 KiB
Python
242 lines
8.3 KiB
Python
#!/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)
|
||
end = first_of_this - timedelta(seconds=1)
|
||
start = end.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||
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, end)
|
||
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, end)
|
||
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()
|