Compare commits

...

5 Commits

Author SHA1 Message Date
admin d12228e662 Merge branch 'all_in_one' into main
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:34:16 +02:00
admin 5e9a5f62dc Version auf 1.1.0 erhöht
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:34:09 +02:00
admin 97fdc8e2ad Footer mit E-Mail, Watermark und Version statt Chart-Watermark
Wetterstation-Text, mailto und v{VERSION} · Datum werden als
dreispaltiger Footer unterhalb des Rahmens angezeigt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:29:59 +02:00
admin 0a30d79f71 Fix: Bild erschien doppelt in der Mail
multipart/mixed mit CID-Referenz ließ Mail-Clients das Bild inline
und als Anhang rendern. Korrektur auf multipart/related als Container
für HTML + Bild.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:23:13 +02:00
admin e1bda88ab4 Alle 3 Charts in einem kombinierten Bild zusammengefasst
Ein einziges PNG mit drei gestapelten Subplots und Rahmen um die
gesamte Figure. Mail enthält nur noch einen Anhang.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 11:21:18 +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"
+107 -118
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"], with open(path, "wb") as f:
charts.items(), f.write(png)
): print(f"Diagramm erstellt → {path}")
path = os.path.join("images", fname)
with open(path, "wb") as f:
f.write(png)
print(f"{path}")
send_email(charts, label) send_email(png, label)
if __name__ == "__main__": if __name__ == "__main__":