Files
wetter/app.py
simon 778c024335 modified: app.py
modified:   static/css/style.css
	modified:   templates/weather.html
2026-04-22 13:48:21 +02:00

684 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
from flask import Flask, render_template, request, jsonify
from geopy.geocoders import Nominatim
from geopy.exc import GeocoderTimedOut
import math
import pandas as pd
import datetime as _dt
import logging
from collections import deque
from threading import Lock
from time import time
from wetterdienst.provider.dwd.mosmix import DwdMosmixRequest
from cachetools import TTLCache
from astral import LocationInfo
from astral.sun import sun as astral_sun
import requests as _requests
app = Flask(__name__)
app.logger.setLevel(logging.INFO)
_GEOLOCATOR = Nominatim(user_agent="skywatcher-app/1.0")
# ── Zeitzone ────────────────────────────────────────────────────────────
try:
import zoneinfo
BERLIN = zoneinfo.ZoneInfo("Europe/Berlin")
except ImportError:
import pytz
BERLIN = pytz.timezone("Europe/Berlin")
# ── Cache: Forecasts 45 Min, DWD-Warnungen 15 Min ────────────────────────────
_forecast_cache = TTLCache(maxsize=64, ttl=2700)
_warn_cache = TTLCache(maxsize=32, ttl=900)
_suggest_cache = TTLCache(maxsize=256, ttl=900)
# API protection: simple in-memory rate limiting by client IP.
_suggest_rate_lock = Lock()
_suggest_rate_hits = {}
_SUGGEST_RATE_WINDOW_SECONDS = 60
_SUGGEST_RATE_MAX_REQUESTS = 30
STATE_ALIASES = {
"baden-wurttemberg": {"baden-wurttemberg", "baden wuerttemberg", "bw"},
"bayern": {"bayern", "by"},
"berlin": {"berlin", "be"},
"brandenburg": {"brandenburg", "bb"},
"bremen": {"bremen", "hb"},
"hamburg": {"hamburg", "hh"},
"hessen": {"hessen", "he"},
"mecklenburg-vorpommern": {"mecklenburg-vorpommern", "mecklenburg vorpommern", "mv"},
"niedersachsen": {"niedersachsen", "ni"},
"nordrhein-westfalen": {"nordrhein-westfalen", "nordrhein westfalen", "nrw", "nw"},
"rheinland-pfalz": {"rheinland-pfalz", "rheinland pfalz", "rp"},
"saarland": {"saarland", "sl"},
"sachsen": {"sachsen", "sn"},
"sachsen-anhalt": {"sachsen-anhalt", "sachsen anhalt", "st"},
"schleswig-holstein": {"schleswig-holstein", "schleswig holstein", "sh"},
"thuringen": {"thuringen", "thueringen", "th"},
}
# ── MOSMIX Parameter ─────────────────────────────────────────────────────────
MOSMIX_PARAMS = [
"hourly/large/temperature_air_mean_2m",
"hourly/large/wind_speed",
"hourly/large/wind_direction",
"hourly/large/wind_gust_max_last_1h",
"hourly/large/cloud_cover_total",
"hourly/large/pressure_air_site_reduced",
"hourly/large/precipitation_height_last_1h",
"hourly/large/sunshine_duration",
"hourly/large/probability_precipitation_height_gt_0_1mm_last_1h",
"hourly/large/uv_index",
]
def _get_berlin():
try:
import zoneinfo
return zoneinfo.ZoneInfo("Europe/Berlin")
except ImportError:
import pytz
return pytz.timezone("Europe/Berlin")
def _normalize_text(value):
if not value:
return ""
text = str(value).strip().lower()
replacements = {
"ä": "a",
"ö": "o",
"ü": "u",
"ß": "ss",
}
for src, dst in replacements.items():
text = text.replace(src, dst)
return " ".join(text.split())
def _state_tokens(state_name):
normalized = _normalize_text(state_name)
if not normalized:
return set()
for _, aliases in STATE_ALIASES.items():
if normalized in aliases:
return aliases
return {normalized}
def _warning_matches_state(state_hint, warning_state, warning_state_short):
hint_tokens = _state_tokens(state_hint)
warning_tokens = _state_tokens(warning_state)
warning_short = _normalize_text(warning_state_short)
if warning_short:
warning_tokens.add(warning_short)
return bool(hint_tokens and warning_tokens and hint_tokens.intersection(warning_tokens))
def _extract_state_from_location(loc):
try:
return (loc.raw or {}).get("address", {}).get("state")
except Exception:
return None
def _client_ip(req):
forwarded = req.headers.get("X-Forwarded-For", "").strip()
if forwarded:
return forwarded.split(",")[0].strip()
return req.remote_addr or "unknown"
def _is_suggest_rate_limited(client_ip):
now = time()
with _suggest_rate_lock:
hits = _suggest_rate_hits.get(client_ip)
if hits is None:
hits = deque()
_suggest_rate_hits[client_ip] = hits
cutoff = now - _SUGGEST_RATE_WINDOW_SECONDS
while hits and hits[0] < cutoff:
hits.popleft()
if len(hits) >= _SUGGEST_RATE_MAX_REQUESTS:
return True
hits.append(now)
if len(hits) == 1:
stale_ips = [ip for ip, values in _suggest_rate_hits.items() if not values or values[-1] < cutoff]
for stale_ip in stale_ips:
_suggest_rate_hits.pop(stale_ip, None)
return False
def geocode_location(query):
try:
loc = _GEOLOCATOR.geocode(query, language="de", addressdetails=True, timeout=10)
if loc:
return loc.latitude, loc.longitude, loc.address, _extract_state_from_location(loc)
except GeocoderTimedOut:
pass
except Exception:
app.logger.exception("Geocoding failed for query '%s'", query)
return None, None, None, None
def haversine(lat1, lon1, lat2, lon2):
R = 6371
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat/2)**2 + math.cos(math.radians(lat1))*math.cos(math.radians(lat2))*math.sin(dlon/2)**2)
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
def _isnan(v):
try:
return math.isnan(float(v))
except (TypeError, ValueError):
return True
def _round_temp(k):
if k is None or _isnan(k):
return None
return round(float(k), 1)
def _clamp(value, min_value, max_value):
return max(min_value, min(max_value, value))
def uv_risk_info(uv_index):
if uv_index is None:
return "", "na"
uv = float(uv_index)
if uv < 3:
return "niedrig", "low"
if uv < 6:
return "moderat", "moderate"
if uv < 8:
return "hoch", "high"
if uv < 11:
return "sehr hoch", "very-high"
return "extrem", "extreme"
def hour_confidence_score(temp_c, precip_mm, rain_prob, wind_kmh, gust_kmh, cloud_pct):
score = 100
if temp_c is None:
score -= 18
if precip_mm is not None:
score -= _clamp(precip_mm * 20, 0, 40)
if rain_prob is not None:
score -= _clamp(rain_prob * 0.35, 0, 30)
if wind_kmh is not None:
score -= _clamp((wind_kmh - 25) * 0.6, 0, 18)
if gust_kmh is not None:
score -= _clamp((gust_kmh - 45) * 0.45, 0, 16)
if cloud_pct is not None:
score -= _clamp((cloud_pct - 85) * 0.4, 0, 8)
score = int(round(_clamp(score, 5, 99)))
if score >= 80:
return score, "hoch"
if score >= 60:
return score, "mittel"
return score, "niedrig"
def activity_score(temp_c, precip_mm, rain_prob, wind_kmh, gust_kmh, uv_index):
score = 100.0
if temp_c is not None:
score -= abs(temp_c - 20) * 3.5
if precip_mm is not None:
score -= _clamp(precip_mm * 35, 0, 45)
if rain_prob is not None:
score -= _clamp(rain_prob * 0.45, 0, 35)
if wind_kmh is not None:
score -= _clamp((wind_kmh - 18) * 0.7, 0, 16)
if gust_kmh is not None:
score -= _clamp((gust_kmh - 35) * 0.55, 0, 12)
if uv_index is not None and uv_index > 6:
score -= _clamp((uv_index - 6) * 6, 0, 16)
return int(round(_clamp(score, 0, 100)))
def best_activity_window(forecast, horizon_hours=24, window_size=2):
hours = forecast[:horizon_hours]
if len(hours) < window_size:
return None
best = None
for i in range(0, len(hours) - window_size + 1):
segment = hours[i:i + window_size]
scores = [h.get("activity_score") for h in segment if h.get("activity_score") is not None]
if not scores:
continue
avg_score = round(sum(scores) / len(scores))
if best is None or avg_score > best["score"]:
best = {
"start": segment[0]["datetime"],
"end": segment[-1]["datetime"],
"score": int(avg_score),
}
return best
def pressure_trend_info(forecast, step_hours=6):
if len(forecast) <= step_hours:
return None, None
p0 = forecast[0].get("pressure_hpa")
p1 = forecast[step_hours].get("pressure_hpa")
if p0 is None or p1 is None:
return None, None
delta = round(p1 - p0, 1)
if delta >= 1.5:
return delta, "steigend"
if delta <= -1.5:
return delta, "fallend"
return delta, "stabil"
def temp_trend_info(forecast, step_hours=6):
if len(forecast) <= step_hours:
return None, None
t0 = forecast[0].get("temp_c")
t1 = forecast[step_hours].get("temp_c")
if t0 is None or t1 is None:
return None, None
delta = round(t1 - t0, 1)
if delta >= 1.0:
return delta, "waermer"
if delta <= -1.0:
return delta, "kaelter"
return delta, "konstant"
def _parse_warning_datetime(value):
if value in (None, ""):
return None
try:
if isinstance(value, (int, float)):
ts = pd.Timestamp(value, unit="ms", tz="UTC")
else:
v = str(value).strip()
if v.isdigit():
ts = pd.Timestamp(int(v), unit="ms", tz="UTC")
else:
ts = pd.Timestamp(v)
if ts.tzinfo is None:
ts = ts.tz_localize("UTC")
return ts.tz_convert(_get_berlin()).tz_localize(None).to_pydatetime()
except Exception:
return None
def pick_daily_icon(hours):
if not hours:
return "☀️"
if any((h.get("temp_c") is not None and h["temp_c"] <= 0 and ((h.get("precip_mm") or 0) > 0.1 or (h.get("rain_prob") or 0) >= 50)) for h in hours):
return "❄️"
if any(((h.get("precip_mm") or 0) >= 0.6 or (h.get("rain_prob") or 0) >= 70) for h in hours):
return "🌧️"
clouds = [h.get("cloud_pct") for h in hours if h.get("cloud_pct") is not None]
avg_cloud = sum(clouds) / len(clouds) if clouds else 0
if avg_cloud > 80:
return "☁️"
if avg_cloud > 40:
return ""
return "☀️"
def feels_like(temp_c, wind_kmh, cloud_pct):
if temp_c is None:
return None
if temp_c <= 10 and wind_kmh is not None and wind_kmh > 4.8:
v = wind_kmh
wc = 13.12 + 0.6215*temp_c - 11.37*(v**0.16) + 0.3965*temp_c*(v**0.16)
return round(wc, 1)
if temp_c >= 27:
rh = 60
hi = (-8.78469475556 + 1.61139411*temp_c + 2.33854883889*rh
- 0.14611605*temp_c*rh - 0.012308094*temp_c**2
- 0.016424828*rh**2 + 0.002211732*temp_c**2*rh
+ 0.00072546*temp_c*rh**2 - 0.000003582*temp_c**2*rh**2)
return round(hi, 1)
return temp_c
def get_sun_times(lat, lon, date=None):
try:
loc = LocationInfo(latitude=lat, longitude=lon, timezone="Europe/Berlin")
d = date or _dt.date.today()
s = astral_sun(loc.observer, date=d, tzinfo=_get_berlin())
return (s["sunrise"].strftime("%H:%M"), s["sunset"].strftime("%H:%M"),
s["dawn"].strftime("%H:%M"), s["dusk"].strftime("%H:%M"))
except Exception:
return None, None, None, None
def get_dwd_warnings(lat, lon, state_hint=None):
state_key = _normalize_text(state_hint)
key = (round(lat, 1), round(lon, 1), state_key)
if key in _warn_cache:
return _warn_cache[key]
try:
url = "https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json"
resp = _requests.get(url, timeout=8)
if resp.status_code != 200:
_warn_cache[key] = []
return []
text = resp.text
if text.startswith("warnWetter.loadWarnings("):
text = text[len("warnWetter.loadWarnings("):-2]
import json
data = json.loads(text)
warnings = []
for region_warns in data.get("warnings", {}).values():
for w in region_warns:
level = w.get("level", 0)
if level >= 1:
warnings.append({
"level": level,
"type": w.get("event", ""),
"headline": w.get("headline", ""),
"description": w.get("description", ""),
"state": w.get("state", ""),
"state_short": w.get("stateShort", ""),
"onset": w.get("onset", ""),
"expires": w.get("expires", ""),
})
filtered = []
if state_hint:
filtered = [w for w in warnings if _warning_matches_state(state_hint, w.get("state"), w.get("state_short"))]
result_source = filtered if filtered else []
result_source.sort(key=lambda x: x["level"], reverse=True)
result = [
{
"level": w["level"],
"type": w["type"],
"headline": w["headline"],
"description": w["description"],
"onset": w.get("onset", ""),
"expires": w.get("expires", ""),
}
for w in result_source[:5]
]
_warn_cache[key] = result
return result
except Exception:
app.logger.exception("Could not load DWD warnings")
_warn_cache[key] = []
return []
def wind_direction_name(degrees):
if degrees is None or _isnan(degrees):
return ""
dirs = ["N","NNO","NO","ONO","O","OSO","SO","SSO","S","SSW","SW","WSW","W","WNW","NW","NNW"]
idx = round(float(degrees)/22.5) % 16
return dirs[idx]
def weather_icon(cloud_pct, precip_mm, rain_prob, temp_c):
if temp_c is not None and temp_c <= 0 and (precip_mm and precip_mm > 0 or rain_prob and rain_prob >= 40):
return "❄️"
if precip_mm and precip_mm > 0.2:
return "🌧️"
if rain_prob is not None and rain_prob >= 60:
return "🌦️"
if rain_prob is not None and rain_prob >= 30 and cloud_pct is not None and cloud_pct > 50:
return "🌦️"
if cloud_pct is not None:
if cloud_pct > 80: return "☁️"
if cloud_pct > 35: return ""
return "☀️"
def get_mosmix_forecast(lat, lon, hours=72):
cache_key = (round(lat,2), round(lon,2), hours)
if cache_key in _forecast_cache:
return _forecast_cache[cache_key]
try:
berlin = _get_berlin()
req = DwdMosmixRequest(parameters=MOSMIX_PARAMS)
nearest = req.filter_by_rank(latlon=(lat, lon), rank=1)
result = nearest.values.all()
df = result.df
if df is None or (hasattr(df,"__len__") and len(df)==0):
return [], {}
if hasattr(df,"to_pandas"): df = df.to_pandas()
station_info = {}
sdf = nearest.df
if sdf is not None and len(sdf) > 0:
if hasattr(sdf,"to_pandas"): sdf = sdf.to_pandas()
station_info = sdf.iloc[0].to_dict()
df = df.sort_values("date").copy()
min_date = df["date"].min()
cutoff = min_date + pd.Timedelta(hours=hours)
df = df[df["date"] <= cutoff]
forecast = []
for date_val, group in df.groupby("date"):
p = {row["parameter"]: row["value"] for _, row in group.iterrows()}
temp_c = _round_temp(p.get("temperature_air_mean_2m"))
ff = p.get("wind_speed")
wind_kmh = round(float(ff)*3.6,1) if not _isnan(ff) else None
fx1 = p.get("wind_gust_max_last_1h")
gust_kmh = round(float(fx1)*3.6,1) if not _isnan(fx1) else None
pppp = p.get("pressure_air_site_reduced")
pressure = round(float(pppp),1) if not _isnan(pppp) else None
rr1c = p.get("precipitation_height_significant_weather_last_1h")
rr1 = p.get("precipitation_height_last_1h")
prec_raw = rr1c if not _isnan(rr1c) else (rr1 if not _isnan(rr1) else None)
precip = round(float(prec_raw),1) if prec_raw is not None else 0.0
rprob = p.get("probability_precipitation_height_gt_0_1mm_last_1h")
rain_prob = round(float(rprob)*100) if not _isnan(rprob) else None
n = p.get("cloud_cover_total")
clouds = round(float(n)) if not _isnan(n) else None
sun = p.get("sunshine_duration")
sun_min = round(float(sun)/60) if not _isnan(sun) else 0
wd = p.get("wind_direction")
wind_dir = float(wd) if not _isnan(wd) else None
uv_raw = p.get("uv_index")
uv = round(float(uv_raw),1) if not _isnan(uv_raw) else None
uv_label, uv_level = uv_risk_info(uv)
feels = feels_like(temp_c, wind_kmh, clouds)
confidence_score, confidence_label = hour_confidence_score(temp_c, precip, rain_prob, wind_kmh, gust_kmh, clouds)
a_score = activity_score(temp_c, precip, rain_prob, wind_kmh, gust_kmh, uv)
dt_local = pd.Timestamp(date_val).tz_convert(berlin).tz_localize(None)
forecast.append({
"datetime": dt_local,
"temp_c": temp_c,
"feels_like": feels,
"wind_kmh": wind_kmh,
"gust_kmh": gust_kmh,
"pressure_hpa": pressure,
"precip_mm": precip,
"rain_prob": rain_prob,
"cloud_pct": clouds,
"sun_min": sun_min,
"wind_dir": wind_dir,
"uv_index": uv,
"uv_label": uv_label,
"uv_level": uv_level,
"confidence": confidence_score,
"confidence_label": confidence_label,
"activity_score": a_score,
"icon": weather_icon(clouds, precip, rain_prob, temp_c),
})
result_data = (forecast, station_info)
_forecast_cache[cache_key] = result_data
return result_data
except Exception:
app.logger.exception("MOSMIX forecast loading failed")
return [], {}
def filter_unrealistic_warnings(warnings, forecast, now_local=None):
"""
Filter out warnings that contradict the actual forecast.
E.g., frost warning when min temp is > 0°C in next 48 hours.
"""
if not warnings or not forecast:
return warnings
filtered = []
frost_keywords = {"frost", "glatte", "glatt", "eis", "schnee"}
rain_keywords = {"regen", "starkregen", "dauerregen"}
try:
for w in warnings:
warn_type = _normalize_text(w.get("type", ""))
headline = _normalize_text(w.get("headline", ""))
onset_dt = _parse_warning_datetime(w.get("onset"))
expires_dt = _parse_warning_datetime(w.get("expires"))
if now_local and expires_dt and expires_dt < now_local:
continue
if onset_dt and expires_dt:
relevant_hours = [
h for h in forecast[:48]
if h.get("datetime") is not None and onset_dt <= h["datetime"] <= expires_dt
]
else:
relevant_hours = forecast[:48]
temps = [h.get("temp_c") for h in relevant_hours if h.get("temp_c") is not None]
precip = [h.get("precip_mm") for h in relevant_hours if h.get("precip_mm") is not None]
rain_prob = [h.get("rain_prob") for h in relevant_hours if h.get("rain_prob") is not None]
min_temp = min(temps) if temps else None
max_precip = max(precip) if precip else 0
max_rain_prob = max(rain_prob) if rain_prob else 0
skip = False
if min_temp is not None and min_temp > 3:
if any(kw in warn_type or kw in headline for kw in frost_keywords):
app.logger.info("Filtered frost warning: min_temp %.1f°C", min_temp)
skip = True
if max_precip < 0.2 and max_rain_prob < 35:
if any(kw in warn_type or kw in headline for kw in rain_keywords):
app.logger.info("Filtered rain warning: max_precip %.1f mm", max_precip)
skip = True
if not skip:
w["onset_dt"] = onset_dt
w["expires_dt"] = expires_dt
filtered.append(w)
except Exception:
app.logger.exception("Error filtering unrealistic warnings")
return warnings
return filtered
@app.route("/")
def index():
return render_template("index.html")
@app.route("/wetter", methods=["GET"])
def wetter():
ort = request.args.get("ort","").strip()
lat_param = request.args.get("lat")
lon_param = request.args.get("lon")
# Geolocation via Browser-Koordinaten
lat, lon, display_name, state_hint = None, None, None, None
if lat_param and lon_param:
try:
lat = float(lat_param)
lon = float(lon_param)
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
raise ValueError("Invalid coordinate range")
loc = _GEOLOCATOR.reverse((lat, lon), language="de", addressdetails=True, timeout=10)
display_name = loc.address if loc else f"{lat:.2f}, {lon:.2f}"
state_hint = _extract_state_from_location(loc)
if not ort or ort == "Mein Standort":
ort = display_name.split(",")[0]
except Exception:
app.logger.info("Invalid or unusable browser coordinates for /wetter")
lat, lon, display_name, state_hint = None, None, None, None
if lat is None:
if not ort:
return render_template("index.html", error="Bitte einen Ort eingeben.")
lat, lon, display_name, state_hint = geocode_location(ort)
if lat is None:
return render_template("index.html", error=f'Ort "{ort}" konnte nicht gefunden werden.')
forecast, mosmix_station = get_mosmix_forecast(lat, lon, hours=72)
if not forecast:
return render_template("index.html", error="Keine Wetterdaten verfügbar. Bitte später erneut versuchen.")
station_name = mosmix_station.get("name", ort)
station_id = mosmix_station.get("station_id", "")
station_lat = float(mosmix_station.get("latitude", lat))
station_lon = float(mosmix_station.get("longitude", lon))
station_dist = round(haversine(lat, lon, station_lat, station_lon), 1)
berlin = _get_berlin()
now_local_dt = _dt.datetime.now(berlin)
now_local = now_local_dt.strftime("%H:%M")
now_berlin_naive = now_local_dt.replace(minute=0, second=0, microsecond=0, tzinfo=None)
current_idx = 0
for i, h in enumerate(forecast):
dt = h["datetime"]
dt_naive = dt.replace(tzinfo=None) if hasattr(dt,"tzinfo") and dt.tzinfo is not None else dt
if dt_naive >= now_berlin_naive:
current_idx = i
break
current = forecast[current_idx]
forecast = forecast[current_idx:]
sunrise, sunset, dawn, dusk = get_sun_times(lat, lon)
warnings = get_dwd_warnings(lat, lon, state_hint=state_hint)
warnings = filter_unrealistic_warnings(warnings, forecast, now_local=now_local_dt.replace(tzinfo=None))
pressure_delta, pressure_trend = pressure_trend_info(forecast)
temp_delta_6h, temp_trend_6h = temp_trend_info(forecast)
best_window = best_activity_window(forecast, horizon_hours=24, window_size=2)
daily = {}
for h in forecast:
dt = h["datetime"]
day = dt.date() if hasattr(dt,"date") else str(dt)[:10]
if day not in daily:
daily[day] = {"temps":[], "precip":0.0, "cloud":[], "wind":[], "icons":[], "rain_prob":[], "uv":[], "hours":[]}
daily[day]["hours"].append(h)
if h["temp_c"] is not None: daily[day]["temps"].append(h["temp_c"])
daily[day]["precip"] += h.get("precip_mm") or 0
if h["cloud_pct"] is not None: daily[day]["cloud"].append(h["cloud_pct"])
if h["wind_kmh"] is not None: daily[day]["wind"].append(h["wind_kmh"])
if h.get("rain_prob") is not None: daily[day]["rain_prob"].append(h["rain_prob"])
if h.get("uv_index") is not None: daily[day]["uv"].append(h["uv_index"])
daily[day]["icons"].append(h["icon"])
daily_summary = []
for day, d in daily.items():
daily_summary.append({
"date": day,
"temp_min": min(d["temps"]) if d["temps"] else None,
"temp_max": max(d["temps"]) if d["temps"] else None,
"precip": round(d["precip"],1),
"rain_prob": max(d["rain_prob"]) if d["rain_prob"] else None,
"cloud": round(sum(d["cloud"])/len(d["cloud"])) if d["cloud"] else None,
"wind_max": max(d["wind"]) if d["wind"] else None,
"uv_max": max(d["uv"]) if d["uv"] else None,
"icon": pick_daily_icon(d["hours"]),
})
chart_labels, chart_temps, chart_feels, chart_precip, chart_rain_prob = [], [], [], [], []
for h in forecast[:48]:
dt = h["datetime"]
label = dt.strftime("%d.%m %H:%M") if hasattr(dt,"strftime") else str(dt)[5:16]
chart_labels.append(label)
chart_temps.append(h["temp_c"])
chart_feels.append(h.get("feels_like"))
chart_precip.append(h.get("precip_mm") or 0)
chart_rain_prob.append(h.get("rain_prob") or 0)
return render_template(
"weather.html",
ort=ort, display_name=display_name, lat=lat, lon=lon,
station_name=station_name, station_id=station_id, station_dist=station_dist,
current=current, now_local=now_local,
pressure_delta=pressure_delta, pressure_trend=pressure_trend,
temp_delta_6h=temp_delta_6h, temp_trend_6h=temp_trend_6h,
best_window=best_window,
sunrise=sunrise, sunset=sunset,
warnings=warnings,
forecast=forecast[:48], daily=daily_summary,
chart_labels=chart_labels, chart_temps=chart_temps, chart_feels=chart_feels,
chart_precip=chart_precip, chart_rain_prob=chart_rain_prob,
wind_dir_name=wind_direction_name,
uv_risk_info=uv_risk_info,
)
@app.route("/api/suggest")
def suggest():
q = request.args.get("q","").strip()
if len(q) < 2:
return jsonify([])
client_ip = _client_ip(request)
if _is_suggest_rate_limited(client_ip):
return jsonify({"error": "Zu viele Anfragen. Bitte kurz warten."}), 429
cache_key = _normalize_text(q)
if cache_key in _suggest_cache:
return jsonify(_suggest_cache[cache_key])
try:
results = _GEOLOCATOR.geocode(q, exactly_one=False, limit=5, language="de", addressdetails=True, timeout=5)
payload = [{"name": r.address, "lat": r.latitude, "lon": r.longitude} for r in results] if results else []
_suggest_cache[cache_key] = payload
return jsonify(payload)
except Exception:
app.logger.exception("Suggest lookup failed for query '%s'", q)
return jsonify([])
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=5000)