Initial commit: Wetterdiagramme der Sternwarte Welzheim
Erstellt monatliche Wetterdiagramme (Temperatur Min/Max, Stundenmittel, Niederschlag) aus der Wetterstation-API und verschickt sie per Mail. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
images/
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Wetterstation API
|
||||||
|
API_BASE_URL=https://stwwetter.fuerst-stuttgart.de/api
|
||||||
|
|
||||||
|
# Berichtszeitraum in Tagen (Standard: 30)
|
||||||
|
REPORT_DAYS=30
|
||||||
|
|
||||||
|
# SMTP-Server Konfiguration
|
||||||
|
MAIL_SERVER=smtp.example.com
|
||||||
|
MAIL_PORT=587
|
||||||
|
MAIL_USER=user@example.com
|
||||||
|
MAIL_PASSWORD=secret
|
||||||
|
MAIL_FROM=user@example.com
|
||||||
|
|
||||||
|
# Empfänger (Komma-getrennt für mehrere Adressen)
|
||||||
|
MAIL_TO=empfaenger1@example.com,empfaenger2@example.com
|
||||||
|
|
||||||
|
# Betreff
|
||||||
|
MAIL_SUBJECT=Wetterbericht letzter Monat
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# Credentials
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Generierte Bilder
|
||||||
|
images/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Tools
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY weather_report.py .
|
||||||
|
|
||||||
|
RUN mkdir -p images
|
||||||
|
|
||||||
|
CMD ["python", "weather_report.py"]
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Deploy Script für weather2oag
|
||||||
|
# Baut das Docker Image und lädt es zu docker.citysensor.de hoch
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Konfiguration
|
||||||
|
REGISTRY="docker.citysensor.de"
|
||||||
|
IMAGE_NAME="wetter-report"
|
||||||
|
TAG="${TAG:-$(date +%Y%m%d%H%M)}" # default Datum
|
||||||
|
|
||||||
|
# Build-Datum und Version aus pyproject.toml
|
||||||
|
BUILD_DATE=$(date +%d.%m.%Y)
|
||||||
|
VERSION=$(grep '^version' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo " Deploy Script"
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Registry: ${REGISTRY}"
|
||||||
|
echo "Image: ${IMAGE_NAME}"
|
||||||
|
echo "Version: ${VERSION}"
|
||||||
|
echo "Tag: ${TAG}"
|
||||||
|
echo "Build-Datum: ${BUILD_DATE}"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. Login zur Registry (falls noch nicht eingeloggt)
|
||||||
|
echo ">>> Login zu ${REGISTRY}..."
|
||||||
|
docker login "${REGISTRY}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 2. Multiplatform Builder einrichten (docker-container driver erforderlich)
|
||||||
|
echo ">>> Richte Multiplatform Builder ein..."
|
||||||
|
if ! docker buildx inspect multiplatform-builder &>/dev/null; then
|
||||||
|
docker buildx create --name multiplatform-builder --driver docker-container --bootstrap
|
||||||
|
fi
|
||||||
|
docker buildx use multiplatform-builder
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
FULL_IMAGE="${REGISTRY}/${IMAGE_NAME}:${TAG}"
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo ">>> Baue ${IMAGE_NAME}..."
|
||||||
|
echo ">>> Image: ${FULL_IMAGE}"
|
||||||
|
echo "=========================================="
|
||||||
|
|
||||||
|
# 3. Docker Image bauen und pushen (Multiplatform)
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--build-arg BUILD_DATE="${BUILD_DATE}" \
|
||||||
|
--build-arg VERSION="${VERSION}" \
|
||||||
|
-t "${FULL_IMAGE}" \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
|
||||||
|
# 4. Tagge auch als :${VERSION} und :latest
|
||||||
|
echo ">>> Tagge als :${VERSION} und :latest..."
|
||||||
|
docker buildx imagetools create \
|
||||||
|
-t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" \
|
||||||
|
-t "${REGISTRY}/${IMAGE_NAME}:latest" \
|
||||||
|
"${FULL_IMAGE}"
|
||||||
|
|
||||||
|
echo "✓ ${IMAGE_NAME} erfolgreich gebaut und gepusht!"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "✓ Deploy erfolgreich abgeschlossen!"
|
||||||
|
echo "=========================================="
|
||||||
|
echo "Registry: ${REGISTRY}"
|
||||||
|
echo "Image: ${IMAGE_NAME}"
|
||||||
|
echo "Version: ${VERSION}"
|
||||||
|
echo "Tag: ${TAG}"
|
||||||
|
echo ""
|
||||||
|
echo "Auf dem Server ausführen:"
|
||||||
|
echo " docker compose pull"
|
||||||
|
echo " docker compose run --rm ${IMAGE_NAME}"
|
||||||
|
echo ""
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
services:
|
||||||
|
wetter-report:
|
||||||
|
image: docker.citysensor.de/wetter-report:latest
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./images:/app/images
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[project]
|
||||||
|
name = "weather2oag"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Monatlicher Wetterbericht der Sternwarte Welzheim"
|
||||||
|
requires-python = ">=3.12"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
certifi==2026.5.20
|
||||||
|
charset-normalizer==3.4.7
|
||||||
|
contourpy==1.3.0
|
||||||
|
cycler==0.12.1
|
||||||
|
fonttools==4.60.2
|
||||||
|
idna==3.16
|
||||||
|
importlib_resources==6.5.2
|
||||||
|
kiwisolver==1.4.7
|
||||||
|
matplotlib==3.9.4
|
||||||
|
numpy==2.0.2
|
||||||
|
packaging==26.2
|
||||||
|
pillow==11.3.0
|
||||||
|
pyparsing==3.3.2
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
python-dotenv==1.2.1
|
||||||
|
requests==2.32.5
|
||||||
|
six==1.17.0
|
||||||
|
urllib3==2.6.3
|
||||||
|
zipp==3.23.1
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Erstellt Wetterdiagramme für den letzten Kalendermonat und verschickt sie per Mail."""
|
||||||
|
|
||||||
|
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.pyplot as plt
|
||||||
|
import requests
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
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]:
|
||||||
|
"""Gibt (start, end, Monatsname) für den letzten Kalendermonat zurück."""
|
||||||
|
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) # letzter Moment des Vormonats
|
||||||
|
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 chart_temp_minmax(start: datetime, end: datetime, label: str) -> bytes:
|
||||||
|
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]
|
||||||
|
|
||||||
|
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:
|
||||||
|
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]
|
||||||
|
|
||||||
|
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}",
|
||||||
|
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_rain_daily(start: datetime, end: datetime, label: str) -> bytes:
|
||||||
|
# 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]
|
||||||
|
ax.bar(dates, rain, color=colors, width=0.7, label="Niederschlag")
|
||||||
|
|
||||||
|
total = sum(rain)
|
||||||
|
ax.set_title(
|
||||||
|
f"Niederschlag pro Tag – {label} (Summe: {total:.1f} mm)",
|
||||||
|
fontweight="bold", pad=14,
|
||||||
|
)
|
||||||
|
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))
|
||||||
|
fig.autofmt_xdate(rotation=0, ha="center")
|
||||||
|
ax.legend(framealpha=0.8)
|
||||||
|
fig.tight_layout()
|
||||||
|
|
||||||
|
return _fig_to_bytes(fig)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
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(charts: dict[str, 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
|
||||||
|
|
||||||
|
msg = MIMEMultipart("mixed")
|
||||||
|
msg["Subject"] = f"{MAIL_SUBJECT} – {label}"
|
||||||
|
msg["From"] = MAIL_FROM
|
||||||
|
msg["To"] = ", ".join(MAIL_TO)
|
||||||
|
|
||||||
|
body = MIMEText(
|
||||||
|
f"<html><body>"
|
||||||
|
f"<h2>Wetterbericht – {label}</h2>"
|
||||||
|
f"<p>Anbei die Wetterdiagramme für {label}.</p>"
|
||||||
|
+ "".join(
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
msg.attach(body)
|
||||||
|
|
||||||
|
filenames = [
|
||||||
|
"temperatur_minmax.png",
|
||||||
|
"temperatur_stundenmittel.png",
|
||||||
|
"niederschlag_taglich.png",
|
||||||
|
]
|
||||||
|
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:
|
||||||
|
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()}) …")
|
||||||
|
|
||||||
|
charts = {
|
||||||
|
"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)
|
||||||
|
for fname, (_, png) in zip(
|
||||||
|
["temperatur_minmax.png", "temperatur_stundenmittel.png", "niederschlag_taglich.png"],
|
||||||
|
charts.items(),
|
||||||
|
):
|
||||||
|
path = os.path.join("images", fname)
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(png)
|
||||||
|
print(f" → {path}")
|
||||||
|
|
||||||
|
send_email(charts, label)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user