modified: app.py

modified:   static/css/style.css
	new file:   static/icons/Bildnachweis.txt
	new file:   static/icons/blitz.png
	new file:   static/icons/mond.png
	new file:   static/icons/regen.png
	new file:   static/icons/schnee.png
	new file:   static/icons/sonne.png
	new file:   static/icons/wolke.png
	new file:   static/icons/wolkig(1).png
	new file:   static/icons/wolkig(2).png
	new file:   static/icons/wolkig.png
	modified:   templates/weather.html
This commit is contained in:
simon
2026-04-24 10:15:41 +02:00
parent c6cfce6bda
commit f987cd6c09
13 changed files with 88 additions and 89 deletions

144
app.py
View File

@@ -73,31 +73,6 @@ MOSMIX_PARAMS = [
"hourly/large/uv_index",
]
# ── Wetter-Icons (OpenWeatherMap CDN) ────────────────────────────────────────
_OWM = "https://openweathermap.org/img/wn/"
# Day icons keyed by the emoji that weather_icon() / pick_daily_icon() returns
ICON_URLS_DAY = {
"☀️": _OWM + "01d@2x.png", # klar
"": _OWM + "02d@2x.png", # leicht bewölkt
"☁️": _OWM + "04d@2x.png", # bedeckt
"🌧️": _OWM + "10d@2x.png", # Regen
"🌦️": _OWM + "09d@2x.png", # Regenschauer
"❄️": _OWM + "13d@2x.png", # Schnee
}
# Overrides for night hours (205 Uhr): only clear/partly change visually
ICON_URLS_NIGHT = {
"☀️": _OWM + "01n@2x.png", # klare Nacht (Mond)
"": _OWM + "02n@2x.png", # leicht bewölkt nachts
}
def _icon_url(emoji, hour=12):
"""Return OWM icon URL. Uses night variants for clear/partly-cloudy between 205 Uhr."""
e = str(emoji)
is_night = hour >= 20 or hour < 6
if is_night and e in ICON_URLS_NIGHT:
return ICON_URLS_NIGHT[e]
return ICON_URLS_DAY.get(e, _OWM + "03d@2x.png")
def _get_berlin():
try:
import zoneinfo
@@ -350,60 +325,76 @@ def _parse_warning_datetime(value):
except Exception:
return None
# ── Icon key → static/icons/{key}.png mapping ────────────────────────────────
# sonne = clear sky
# wolkig(2) = sun behind one cloud (partly cloudy)
# wolke = single cloud (mostly cloudy)
# wolkig = two clouds (overcast)
# wolkig(1) = sun + cloud + rain drops (showers)
# regen = heavy cloud + rain (rain)
# schnee = cloud + snowflakes
# blitz = two clouds + lightning (thunderstorm not yet used, needs data)
def pick_daily_icon(hours):
"""Choose the most representative icon key for a whole day."""
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 "🌧️"
return "sonne"
# Snow / sleet
if any(
h.get("temp_c") is not None and h["temp_c"] <= 2
and ((h.get("precip_mm") or 0) > 0.1 or (h.get("rain_prob") or 0) >= 40)
for h in hours
):
return "schnee"
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 "☀️"
# Significant rain/showers
if any((h.get("precip_mm") or 0) >= 0.8 or (h.get("rain_prob") or 0) >= 65 for h in hours):
return "regen" if avg_cloud >= 75 else "wolkig(1)"
# Light showers
if any((h.get("precip_mm") or 0) > 0.1 or (h.get("rain_prob") or 0) >= 35 for h in hours):
return "wolkig(1)"
# Cloud cover
if avg_cloud > 80: return "wolkig"
if avg_cloud > 50: return "wolke"
if avg_cloud > 20: return "wolkig(2)"
return "sonne"
def feels_like(temp_c, wind_kmh, cloud_pct):
"""Apparent / perceived temperature.
Uses the JAG/TI wind-chill formula blended smoothly across the full
temperature range so there is no abrupt jump at the old 10 °C threshold.
Above 27 °C the Rothfusz heat index (assumed RH 60 %) is applied.
The previous cloud-based sunshine bonus has been removed: cloud cover
fluctuates strongly hour to hour, which caused the felt temperature to
jump by several degrees without any real change in conditions.
* ≤10 °C + wind >4.8 km/h → Windchill (JAG/TI formula)
* ≥27 °C → Heat index (Rothfusz, RH 60 %)
* 1027 °C → Small sun/wind corrections:
- Very clear sky (<20 % clouds): up to +2 °C
- Notable wind (>20 km/h): up to -2 °C
Only reported when result differs by ≥1 °C from actual temp.
"""
if temp_c is None:
return None
# ── Hot range: heat index (Rothfusz, RH 60 %) ────────────────────
# ── Wind chill (cold range) ───────────────────────────────────────
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)
# ── Heat index (hot range) ────────────────────────────────────────
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)
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)
# ── Wind-chill (JAG/TI), blended into mild range ──────────────────
# Full wind-chill effect at ≤ 5 °C, linearly faded to zero at ≥ 20 °C.
# This avoids the hard jump that the old ≤ 10 °C threshold caused.
wind = wind_kmh or 0
# ── Mild range: conservative corrections only ─────────────────────
adjusted = float(temp_c)
if wind > 4.8:
wc = (13.12
+ 0.6215 * temp_c
- 11.37 * (wind ** 0.16)
+ 0.3965 * temp_c * (wind ** 0.16))
blend = _clamp((20.0 - temp_c) / 15.0, 0.0, 1.0)
adjusted = temp_c * (1.0 - blend) + wc * blend
# Sunshine: very clear sky (<20 % clouds) adds max +2 °C
if cloud_pct is not None and cloud_pct < 20:
adjusted += (20 - cloud_pct) / 20 * 2.0
# Wind: only meaningful wind (>20 km/h) cools, max -2 °C
if wind_kmh is not None and wind_kmh > 20:
adjusted -= _clamp((wind_kmh - 20) / 60 * 2.0, 0, 2.0)
result = round(adjusted, 1)
return result if abs(result - temp_c) >= 0.5 else temp_c
return result if abs(result - temp_c) >= 1.0 else temp_c
def get_sun_times(lat, lon, date=None):
try:
@@ -496,18 +487,24 @@ def wind_direction_name(degrees):
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 "🌦️"
"""Return the icon key for static/icons/{key}.png."""
# Snow / sleet
if temp_c is not None and temp_c <= 2 and (
(precip_mm and precip_mm > 0.1) or (rain_prob and rain_prob >= 40)
):
return "schnee"
# Rain with some sun visible (showers)
if (precip_mm and precip_mm > 0.1) or (rain_prob is not None and rain_prob >= 50):
return "wolkig(1)" if (cloud_pct is None or cloud_pct < 75) else "regen"
# Light rain probability: showers icon when partially cloudy
if rain_prob is not None and rain_prob >= 30 and cloud_pct is not None and cloud_pct > 50:
return "🌦️"
return "wolkig(1)"
# Cloud cover only
if cloud_pct is not None:
if cloud_pct > 80: return "☁️"
if cloud_pct > 35: return ""
return "☀️"
if cloud_pct > 80: return "wolkig"
if cloud_pct > 50: return "wolke"
if cloud_pct > 20: return "wolkig(2)"
return "sonne"
def get_mosmix_forecast(lat, lon, hours=72):
cache_key = (round(lat,2), round(lon,2), hours)
@@ -774,7 +771,6 @@ def wetter():
chart_precip=chart_precip, chart_rain_prob=chart_rain_prob,
wind_dir_name=wind_direction_name,
uv_risk_info=uv_risk_info,
icon_url=_icon_url,
)
@app.route("/api/suggest")