modified: app.py
This commit is contained in:
235
app.py
235
app.py
@@ -118,6 +118,23 @@ def _extract_state_from_location(loc):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _extract_location_names(loc):
|
||||||
|
"""Return a list of normalised name strings (city, county, …) from a Nominatim result
|
||||||
|
for matching against DWD warning regionName fields."""
|
||||||
|
names = []
|
||||||
|
try:
|
||||||
|
raw = (loc.raw or {}).get("address", {})
|
||||||
|
for key in ("city", "town", "village", "hamlet", "municipality",
|
||||||
|
"city_district", "county", "district", "suburb", "borough"):
|
||||||
|
val = raw.get(key)
|
||||||
|
if val:
|
||||||
|
n = _normalize_text(val)
|
||||||
|
if n and len(n) >= 3 and n not in names:
|
||||||
|
names.append(n)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return names
|
||||||
|
|
||||||
def _client_ip(req):
|
def _client_ip(req):
|
||||||
forwarded = req.headers.get("X-Forwarded-For", "").strip()
|
forwarded = req.headers.get("X-Forwarded-For", "").strip()
|
||||||
if forwarded:
|
if forwarded:
|
||||||
@@ -147,12 +164,13 @@ def geocode_location(query):
|
|||||||
try:
|
try:
|
||||||
loc = _GEOLOCATOR.geocode(query, language="de", addressdetails=True, timeout=10)
|
loc = _GEOLOCATOR.geocode(query, language="de", addressdetails=True, timeout=10)
|
||||||
if loc:
|
if loc:
|
||||||
return loc.latitude, loc.longitude, loc.address, _extract_state_from_location(loc)
|
return (loc.latitude, loc.longitude, loc.address,
|
||||||
|
_extract_state_from_location(loc), _extract_location_names(loc))
|
||||||
except GeocoderTimedOut:
|
except GeocoderTimedOut:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
app.logger.exception("Geocoding failed for query '%s'", query)
|
app.logger.exception("Geocoding failed for query '%s'", query)
|
||||||
return None, None, None, None
|
return None, None, None, None, []
|
||||||
|
|
||||||
def haversine(lat1, lon1, lat2, lon2):
|
def haversine(lat1, lon1, lat2, lon2):
|
||||||
R = 6371
|
R = 6371
|
||||||
@@ -332,9 +350,20 @@ def get_sun_times(lat, lon, date=None):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None, None, None, None
|
return None, None, None, None
|
||||||
|
|
||||||
def get_dwd_warnings(lat, lon, state_hint=None):
|
def get_dwd_warnings(lat, lon, state_hint=None, location_names=None):
|
||||||
state_key = _normalize_text(state_hint)
|
"""Fetch DWD warnings and filter by regionName matching the user's municipality/county.
|
||||||
key = (round(lat, 1), round(lon, 1), state_key)
|
|
||||||
|
The DWD JSON uses warncell-IDs as keys and each warning carries a ``regionName``
|
||||||
|
field (e.g. "Krefeld", "Kreis Kleve - Niederrhein", "Sauerland") that identifies
|
||||||
|
the affected area far more precisely than the federal-state field. We only include
|
||||||
|
a warning when its regionName contains at least one of the place names extracted from
|
||||||
|
the geocoded location (city, county, …). Warnings for neighbouring NRW regions
|
||||||
|
(Eifel, Sauerland, …) are therefore never shown for Krefeld.
|
||||||
|
|
||||||
|
Duplicates (same event type + onset + expires) are removed before returning.
|
||||||
|
"""
|
||||||
|
loc_names = location_names or []
|
||||||
|
key = (round(lat, 1), round(lon, 1))
|
||||||
if key in _warn_cache:
|
if key in _warn_cache:
|
||||||
return _warn_cache[key]
|
return _warn_cache[key]
|
||||||
try:
|
try:
|
||||||
@@ -346,39 +375,47 @@ def get_dwd_warnings(lat, lon, state_hint=None):
|
|||||||
text = resp.text
|
text = resp.text
|
||||||
if text.startswith("warnWetter.loadWarnings("):
|
if text.startswith("warnWetter.loadWarnings("):
|
||||||
text = text[len("warnWetter.loadWarnings("):-2]
|
text = text[len("warnWetter.loadWarnings("):-2]
|
||||||
import json
|
import json as _json
|
||||||
data = json.loads(text)
|
data = _json.loads(text)
|
||||||
warnings = []
|
|
||||||
|
matched = []
|
||||||
for region_warns in data.get("warnings", {}).values():
|
for region_warns in data.get("warnings", {}).values():
|
||||||
for w in region_warns:
|
for w in (region_warns or []):
|
||||||
level = w.get("level", 0)
|
level = w.get("level", 0)
|
||||||
if level >= 1:
|
if level < 1:
|
||||||
warnings.append({
|
continue
|
||||||
"level": level,
|
region_name = w.get("regionName", "") or ""
|
||||||
"type": w.get("event", ""),
|
norm_region = _normalize_text(region_name)
|
||||||
"headline": w.get("headline", ""),
|
|
||||||
"description": w.get("description", ""),
|
# Require the user's city / county to appear in regionName
|
||||||
"state": w.get("state", ""),
|
region_match = False
|
||||||
"state_short": w.get("stateShort", ""),
|
for name in loc_names:
|
||||||
"onset": w.get("onset", ""),
|
if name and len(name) >= 3 and name in norm_region:
|
||||||
"expires": w.get("expires", ""),
|
region_match = True
|
||||||
})
|
break
|
||||||
filtered = []
|
if not region_match:
|
||||||
if state_hint:
|
continue
|
||||||
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 []
|
matched.append({
|
||||||
result_source.sort(key=lambda x: x["level"], reverse=True)
|
"level": level,
|
||||||
result = [
|
"type": w.get("event", ""),
|
||||||
{
|
"headline": w.get("headline", ""),
|
||||||
"level": w["level"],
|
"description": w.get("description", ""),
|
||||||
"type": w["type"],
|
"onset": w.get("onset", ""),
|
||||||
"headline": w["headline"],
|
"expires": w.get("expires", ""),
|
||||||
"description": w["description"],
|
})
|
||||||
"onset": w.get("onset", ""),
|
|
||||||
"expires": w.get("expires", ""),
|
# Deduplicate: same event type + onset time + expires time
|
||||||
}
|
seen = set()
|
||||||
for w in result_source[:5]
|
deduped = []
|
||||||
]
|
for w in matched:
|
||||||
|
dk = (w["type"], w["onset"], w["expires"])
|
||||||
|
if dk not in seen:
|
||||||
|
seen.add(dk)
|
||||||
|
deduped.append(w)
|
||||||
|
|
||||||
|
deduped.sort(key=lambda x: x["level"], reverse=True)
|
||||||
|
result = deduped[:5]
|
||||||
_warn_cache[key] = result
|
_warn_cache[key] = result
|
||||||
return result
|
return result
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -486,63 +523,76 @@ def get_mosmix_forecast(lat, lon, hours=72):
|
|||||||
return [], {}
|
return [], {}
|
||||||
|
|
||||||
def filter_unrealistic_warnings(warnings, forecast, now_local=None):
|
def filter_unrealistic_warnings(warnings, forecast, now_local=None):
|
||||||
"""
|
"""Remove warnings that contradict the MOSMIX forecast.
|
||||||
Filter out warnings that contradict the actual forecast.
|
|
||||||
E.g., frost warning when min temp is > 0°C in next 48 hours.
|
Frost warnings are suppressed when the minimum forecast temperature within the
|
||||||
|
warning window (or next 48 h if the window is unknown) stays above 3 °C.
|
||||||
|
Rain / heavy-rain warnings are suppressed when neither significant precipitation
|
||||||
|
nor a meaningful rain probability is forecast.
|
||||||
|
|
||||||
|
Both onset/expires timestamps from DWD and forecast datetimes are compared as
|
||||||
|
naive Berlin-local datetimes so no tz-mismatch can cause an empty overlap window.
|
||||||
"""
|
"""
|
||||||
if not warnings or not forecast:
|
if not warnings or not forecast:
|
||||||
return warnings
|
return warnings
|
||||||
|
|
||||||
filtered = []
|
|
||||||
frost_keywords = {"frost", "glatte", "glatt", "eis", "schnee"}
|
frost_keywords = {"frost", "glatte", "glatt", "eis", "schnee"}
|
||||||
rain_keywords = {"regen", "starkregen", "dauerregen"}
|
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:
|
result = []
|
||||||
continue
|
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 onset_dt and expires_dt:
|
# Discard already-expired warnings
|
||||||
relevant_hours = [
|
if now_local is not None and expires_dt is not None and expires_dt < now_local:
|
||||||
h for h in forecast[:48]
|
app.logger.info("Discarded expired warning: %s (expires %s)", w.get("headline"), expires_dt)
|
||||||
if h.get("datetime") is not None and onset_dt <= h["datetime"] <= expires_dt
|
continue
|
||||||
]
|
|
||||||
else:
|
|
||||||
relevant_hours = forecast[:48]
|
|
||||||
|
|
||||||
temps = [h.get("temp_c") for h in relevant_hours if h.get("temp_c") is not None]
|
# Select forecast hours that fall within the warning window.
|
||||||
precip = [h.get("precip_mm") for h in relevant_hours if h.get("precip_mm") is not None]
|
# Convert everything to pandas Timestamps (naive) so comparisons are type-safe.
|
||||||
rain_prob = [h.get("rain_prob") for h in relevant_hours if h.get("rain_prob") is not None]
|
if onset_dt is not None and expires_dt is not None:
|
||||||
min_temp = min(temps) if temps else None
|
ts_onset = pd.Timestamp(onset_dt)
|
||||||
max_precip = max(precip) if precip else 0
|
ts_expires = pd.Timestamp(expires_dt)
|
||||||
max_rain_prob = max(rain_prob) if rain_prob else 0
|
relevant = [
|
||||||
|
h for h in forecast[:48]
|
||||||
skip = False
|
if h.get("datetime") is not None
|
||||||
|
and ts_onset <= pd.Timestamp(h["datetime"]) <= ts_expires
|
||||||
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):
|
# If the window is entirely in the future and forecast doesn't reach it,
|
||||||
app.logger.info("Filtered frost warning: min_temp %.1f°C", min_temp)
|
# fall back to the full 48-h slice to be conservative.
|
||||||
skip = True
|
if not relevant:
|
||||||
|
relevant = forecast[:48]
|
||||||
if max_precip < 0.2 and max_rain_prob < 35:
|
else:
|
||||||
if any(kw in warn_type or kw in headline for kw in rain_keywords):
|
relevant = forecast[:48]
|
||||||
app.logger.info("Filtered rain warning: max_precip %.1f mm", max_precip)
|
|
||||||
skip = True
|
temps = [h["temp_c"] for h in relevant if h.get("temp_c") is not None]
|
||||||
|
precips = [h["precip_mm"] for h in relevant if h.get("precip_mm") is not None]
|
||||||
if not skip:
|
rainprobs = [h["rain_prob"] for h in relevant if h.get("rain_prob") is not None]
|
||||||
w["onset_dt"] = onset_dt
|
|
||||||
w["expires_dt"] = expires_dt
|
min_temp = min(temps) if temps else None
|
||||||
filtered.append(w)
|
max_precip = max(precips) if precips else 0.0
|
||||||
except Exception:
|
max_rain_prob = max(rainprobs) if rainprobs else 0
|
||||||
app.logger.exception("Error filtering unrealistic warnings")
|
|
||||||
return warnings
|
is_frost = any(kw in warn_type or kw in headline for kw in frost_keywords)
|
||||||
|
is_rain = any(kw in warn_type or kw in headline for kw in rain_keywords)
|
||||||
return filtered
|
|
||||||
|
skip = False
|
||||||
|
if is_frost and min_temp is not None and min_temp > 3:
|
||||||
|
app.logger.info("Suppressed frost warning (min %.1f °C): %s", min_temp, w.get("headline"))
|
||||||
|
skip = True
|
||||||
|
elif is_rain and max_precip < 0.2 and max_rain_prob < 35:
|
||||||
|
app.logger.info("Suppressed rain warning (max_precip %.1f mm): %s", max_precip, w.get("headline"))
|
||||||
|
skip = True
|
||||||
|
|
||||||
|
if not skip:
|
||||||
|
w["onset_dt"] = onset_dt
|
||||||
|
w["expires_dt"] = expires_dt
|
||||||
|
result.append(w)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
@@ -555,7 +605,7 @@ def wetter():
|
|||||||
lon_param = request.args.get("lon")
|
lon_param = request.args.get("lon")
|
||||||
|
|
||||||
# Geolocation via Browser-Koordinaten
|
# Geolocation via Browser-Koordinaten
|
||||||
lat, lon, display_name, state_hint = None, None, None, None
|
lat, lon, display_name, state_hint, location_names = None, None, None, None, []
|
||||||
if lat_param and lon_param:
|
if lat_param and lon_param:
|
||||||
try:
|
try:
|
||||||
lat = float(lat_param)
|
lat = float(lat_param)
|
||||||
@@ -563,18 +613,19 @@ def wetter():
|
|||||||
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
|
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
|
||||||
raise ValueError("Invalid coordinate range")
|
raise ValueError("Invalid coordinate range")
|
||||||
loc = _GEOLOCATOR.reverse((lat, lon), language="de", addressdetails=True, timeout=10)
|
loc = _GEOLOCATOR.reverse((lat, lon), language="de", addressdetails=True, timeout=10)
|
||||||
display_name = loc.address if loc else f"{lat:.2f}, {lon:.2f}"
|
display_name = loc.address if loc else f"{lat:.2f}, {lon:.2f}"
|
||||||
state_hint = _extract_state_from_location(loc)
|
state_hint = _extract_state_from_location(loc) if loc else None
|
||||||
|
location_names = _extract_location_names(loc) if loc else []
|
||||||
if not ort or ort == "Mein Standort":
|
if not ort or ort == "Mein Standort":
|
||||||
ort = display_name.split(",")[0]
|
ort = display_name.split(",")[0]
|
||||||
except Exception:
|
except Exception:
|
||||||
app.logger.info("Invalid or unusable browser coordinates for /wetter")
|
app.logger.info("Invalid or unusable browser coordinates for /wetter")
|
||||||
lat, lon, display_name, state_hint = None, None, None, None
|
lat, lon, display_name, state_hint, location_names = None, None, None, None, []
|
||||||
|
|
||||||
if lat is None:
|
if lat is None:
|
||||||
if not ort:
|
if not ort:
|
||||||
return render_template("index.html", error="Bitte einen Ort eingeben.")
|
return render_template("index.html", error="Bitte einen Ort eingeben.")
|
||||||
lat, lon, display_name, state_hint = geocode_location(ort)
|
lat, lon, display_name, state_hint, location_names = geocode_location(ort)
|
||||||
if lat is None:
|
if lat is None:
|
||||||
return render_template("index.html", error=f'Ort "{ort}" konnte nicht gefunden werden.')
|
return render_template("index.html", error=f'Ort "{ort}" konnte nicht gefunden werden.')
|
||||||
forecast, mosmix_station = get_mosmix_forecast(lat, lon, hours=72)
|
forecast, mosmix_station = get_mosmix_forecast(lat, lon, hours=72)
|
||||||
@@ -599,7 +650,7 @@ def wetter():
|
|||||||
current = forecast[current_idx]
|
current = forecast[current_idx]
|
||||||
forecast = forecast[current_idx:]
|
forecast = forecast[current_idx:]
|
||||||
sunrise, sunset, dawn, dusk = get_sun_times(lat, lon)
|
sunrise, sunset, dawn, dusk = get_sun_times(lat, lon)
|
||||||
warnings = get_dwd_warnings(lat, lon, state_hint=state_hint)
|
warnings = get_dwd_warnings(lat, lon, state_hint=state_hint, location_names=location_names)
|
||||||
warnings = filter_unrealistic_warnings(warnings, forecast, now_local=now_local_dt.replace(tzinfo=None))
|
warnings = filter_unrealistic_warnings(warnings, forecast, now_local=now_local_dt.replace(tzinfo=None))
|
||||||
pressure_delta, pressure_trend = pressure_trend_info(forecast)
|
pressure_delta, pressure_trend = pressure_trend_info(forecast)
|
||||||
temp_delta_6h, temp_trend_6h = temp_trend_info(forecast)
|
temp_delta_6h, temp_trend_6h = temp_trend_info(forecast)
|
||||||
|
|||||||
Reference in New Issue
Block a user