Merge branch 'all_in_one' into main

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rxf
2026-05-26 11:34:16 +02:00
2 changed files with 108 additions and 119 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[project] [project]
name = "weather2oag" name = "weather2oag"
version = "1.0.0" version = "1.1.0"
description = "Monatlicher Wetterbericht der Sternwarte Welzheim" description = "Monatlicher Wetterbericht der Sternwarte Welzheim"
requires-python = ">=3.12" requires-python = ">=3.12"
+105 -116
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Erstellt Wetterdiagramme für den letzten Kalendermonat und verschickt sie per Mail.""" """Erstellt einen kombinierten Wetterbericht für den letzten Kalendermonat."""
import os import os
import pathlib
import re
import smtplib import smtplib
import tempfile import tempfile
from collections import defaultdict from collections import defaultdict
@@ -11,12 +13,17 @@ from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
import matplotlib.dates as mdates import matplotlib.dates as mdates
import matplotlib.patches as mpatches
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import requests import requests
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
_v = re.search(r'^version\s*=\s*"([^"]+)"',
pathlib.Path("pyproject.toml").read_text(), re.MULTILINE)
VERSION = _v.group(1) if _v else "?"
API_BASE_URL = os.getenv("API_BASE_URL", "https://stwwetter.fuerst-stuttgart.de/api") API_BASE_URL = os.getenv("API_BASE_URL", "https://stwwetter.fuerst-stuttgart.de/api")
MAIL_SERVER = os.getenv("MAIL_SERVER") MAIL_SERVER = os.getenv("MAIL_SERVER")
MAIL_PORT = int(os.getenv("MAIL_PORT", "587")) MAIL_PORT = int(os.getenv("MAIL_PORT", "587"))
@@ -43,10 +50,9 @@ plt.rcParams.update({
def last_month_range() -> tuple[datetime, datetime, str]: def last_month_range() -> tuple[datetime, datetime, str]:
"""Gibt (start, end, Monatsname) für den letzten Kalendermonat zurück."""
today = datetime.now(timezone.utc) today = datetime.now(timezone.utc)
first_of_this = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0) first_of_this = today.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end = first_of_this - timedelta(seconds=1) # letzter Moment des Vormonats end = first_of_this - timedelta(seconds=1)
start = end.replace(day=1, hour=0, minute=0, second=0, microsecond=0) start = end.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
label = f"{MONTHS_DE[start.month - 1]} {start.year}" label = f"{MONTHS_DE[start.month - 1]} {start.year}"
return start, end, label return start, end, label
@@ -63,109 +69,116 @@ def parse_dt(s: str) -> datetime:
return datetime.fromisoformat(s.replace("Z", "+00:00")) return datetime.fromisoformat(s.replace("Z", "+00:00"))
def _data_temp_minmax(start: datetime, end: datetime):
def chart_temp_minmax(start: datetime, end: datetime, label: str) -> bytes:
data = fetch("/weather/daily-aggregated-range", { data = fetch("/weather/daily-aggregated-range", {
"start": start.isoformat(), "start": start.isoformat(), "end": end.isoformat(),
"end": end.isoformat(),
}) })
dates = [parse_dt(d["datetime"]) for d in data] dates = [parse_dt(d["datetime"]) for d in data]
t_min = [d["min_temperature"] for d in data] t_min = [d["min_temperature"] for d in data]
t_max = [d["max_temperature"] for d in data] t_max = [d["max_temperature"] for d in data]
return dates, t_min, t_max
fig, ax = plt.subplots(figsize=(12, 5))
ax.plot(dates, t_max, color="#e05c2a", linewidth=2, marker="o", markersize=4,
label="Tages-Maximum")
ax.plot(dates, t_min, color="#2a7be0", linewidth=2, marker="o", markersize=4,
label="Tages-Minimum")
ax.fill_between(dates, t_min, t_max, alpha=0.12, color="#888888")
ax.set_title(f"Temperaturverlauf (Tages-Min / Tages-Max) {label}",
fontweight="bold", pad=14)
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))
fig.autofmt_xdate(rotation=0, ha="center")
ax.legend(framealpha=0.8)
fig.tight_layout()
return _fig_to_bytes(fig)
def chart_temp_hourly(start: datetime, end: datetime, label: str) -> bytes: def _data_temp_hourly(start: datetime, end: datetime):
data = fetch("/weather/hourly-aggregated-range", { data = fetch("/weather/hourly-aggregated-range", {
"start": start.isoformat(), "start": start.isoformat(), "end": end.isoformat(),
"end": end.isoformat(),
}) })
dates = [parse_dt(d["datetime"]) for d in data] dates = [parse_dt(d["datetime"]) for d in data]
temps = [d["temperature"] for d in data] temps = [d["temperature"] for d in data]
return dates, temps
fig, ax = plt.subplots(figsize=(12, 5))
ax.plot(dates, temps, color="#2a7be0", linewidth=1.5, label="Stundenmittel")
ax.set_title(f"Temperaturverlauf (Stundenmittel) {label}", def _data_rain_daily(start: datetime, end: datetime):
fontweight="bold", pad=14) 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_ylabel("Temperatur (°C)")
ax.set_xlim(start, end) ax.set_xlim(start, end)
ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m.")) ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m."))
ax.xaxis.set_major_locator(mdates.DayLocator(interval=3)) ax.xaxis.set_major_locator(mdates.DayLocator(interval=3))
fig.autofmt_xdate(rotation=0, ha="center") plt.setp(ax.get_xticklabels(), rotation=0, ha="center")
ax.legend(framealpha=0.8) ax.legend(framealpha=0.8)
fig.tight_layout()
return _fig_to_bytes(fig) # --- 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 ---
def chart_rain_daily(start: datetime, end: datetime, label: str) -> bytes: ax = axes[2]
# Das rain-Feld ist ein kumulativer Zähler; Tagessumme = max - min je Tag
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:
day_key = parse_dt(d["datetime"]).strftime("%Y-%m-%d")
by_day[day_key].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: # Zählerreset → nehme max als Tagessumme
daily = max(vals)
dates.append(datetime.fromisoformat(day_key).replace(tzinfo=timezone.utc))
rain.append(round(daily, 1))
fig, ax = plt.subplots(figsize=(12, 5))
colors = ["#2a7be0" if r > 0 else "#ccddee" for r in rain] colors = ["#2a7be0" if r > 0 else "#ccddee" for r in rain]
ax.bar(dates, rain, color=colors, width=0.7, label="Niederschlag") ax.bar(dates_r, rain, color=colors, width=0.7, label="Niederschlag")
total = sum(rain) total = sum(rain)
ax.set_title( ax.set_title(f"Niederschlag pro Tag (Summe: {total:.1f} mm)",
f"Niederschlag pro Tag {label} (Summe: {total:.1f} mm)", fontweight="bold", pad=10)
fontweight="bold", pad=14,
)
ax.set_ylabel("Niederschlag (mm)") ax.set_ylabel("Niederschlag (mm)")
ax.set_xlim(start - timedelta(hours=12), end + timedelta(hours=12)) ax.set_xlim(start - timedelta(hours=12), end + timedelta(hours=12))
ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m.")) ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m."))
ax.xaxis.set_major_locator(mdates.DayLocator(interval=3)) ax.xaxis.set_major_locator(mdates.DayLocator(interval=3))
fig.autofmt_xdate(rotation=0, ha="center") plt.setp(ax.get_xticklabels(), rotation=0, ha="center")
ax.legend(framealpha=0.8) ax.legend(framealpha=0.8)
fig.tight_layout()
return _fig_to_bytes(fig) 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)
def _fig_to_bytes(fig: plt.Figure) -> bytes:
ax = fig.axes[0]
ax.text(0.99, 0.03, "Wetterstation der Sternwarte Welzheim",
ha="right", va="bottom", fontsize=9, color="#bbbbbb",
transform=ax.transAxes)
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
fig.savefig(f.name, dpi=150, bbox_inches="tight") fig.savefig(f.name, dpi=150, bbox_inches="tight")
path = f.name path = f.name
@@ -176,47 +189,33 @@ def _fig_to_bytes(fig: plt.Figure) -> bytes:
return data return data
def send_email(charts: dict[str, bytes], label: str) -> None: def send_email(png: bytes, label: str) -> None:
if not all([MAIL_SERVER, MAIL_USER, MAIL_PASSWORD, MAIL_FROM, MAIL_TO]): if not all([MAIL_SERVER, MAIL_USER, MAIL_PASSWORD, MAIL_FROM, MAIL_TO]):
print("E-Mail-Konfiguration unvollständig Mail wird nicht gesendet.") print("E-Mail-Konfiguration unvollständig Mail wird nicht gesendet.")
return return
# multipart/related: HTML + CID-Bild werden als Einheit behandelt
related = MIMEMultipart("related")
msg = MIMEMultipart("mixed") msg = MIMEMultipart("mixed")
msg["Subject"] = f"{MAIL_SUBJECT} {label}" msg["Subject"] = f"{MAIL_SUBJECT} {label}"
msg["From"] = MAIL_FROM msg["From"] = MAIL_FROM
msg["To"] = ", ".join(MAIL_TO) msg["To"] = ", ".join(MAIL_TO)
msg.attach(related)
body = MIMEText( body = MIMEText(
f"<html><body>" f"<html><body>"
f"<h2>Wetterbericht {label}</h2>" f"<h2>Wetterbericht {label}</h2>"
f"<p>Anbei die Wetterdiagramme für {label}.</p>" f'<img src="cid:chart@weather" style="max-width:100%;"/>'
+ "".join( f"</body></html>",
f'<p><strong>{name}</strong><br>'
f'<img src="cid:{cid}" style="max-width:100%;"/></p>'
for (name, _), cid in zip(
charts.items(),
["chart1@weather", "chart2@weather", "chart3@weather"],
)
)
+ "</body></html>",
"html", "html",
) )
msg.attach(body) related.attach(body)
filenames = [ img = MIMEImage(png, name="wetterbericht.png")
"temperatur_minmax.png", img.add_header("Content-ID", "<chart@weather>")
"temperatur_stundenmittel.png", img.add_header("Content-Disposition", "inline", filename="wetterbericht.png")
"niederschlag_taglich.png", related.attach(img)
]
for (name, png), cid, fname in zip(
charts.items(),
["chart1@weather", "chart2@weather", "chart3@weather"],
filenames,
):
img = MIMEImage(png, name=fname)
img.add_header("Content-ID", f"<{cid}>")
img.add_header("Content-Disposition", "inline", filename=fname)
msg.attach(img)
with smtplib.SMTP(MAIL_SERVER, MAIL_PORT) as smtp: with smtplib.SMTP(MAIL_SERVER, MAIL_PORT) as smtp:
smtp.ehlo() smtp.ehlo()
@@ -231,25 +230,15 @@ def main() -> None:
start, end, label = last_month_range() start, end, label = last_month_range()
print(f"Hole Wetterdaten für {label} ({start.date()} {end.date()}) …") print(f"Hole Wetterdaten für {label} ({start.date()} {end.date()}) …")
charts = { png = create_combined_chart(start, end, label)
"Tages-Min / Tages-Max Temperatur": chart_temp_minmax(start, end, label),
"Temperatur Stundenmittel": chart_temp_hourly(start, end, label),
"Niederschlag pro Tag": chart_rain_daily(start, end, label),
}
print("Diagramme erstellt.")
os.makedirs("images", exist_ok=True) os.makedirs("images", exist_ok=True)
for fname, (_, png) in zip( path = os.path.join("images", "wetterbericht.png")
["temperatur_minmax.png", "temperatur_stundenmittel.png", "niederschlag_taglich.png"],
charts.items(),
):
path = os.path.join("images", fname)
with open(path, "wb") as f: with open(path, "wb") as f:
f.write(png) f.write(png)
print(f" {path}") print(f"Diagramm erstellt{path}")
send_email(charts, label) send_email(png, label)
if __name__ == "__main__": if __name__ == "__main__":