From 3294ccf45a75c765697cb46915b16678235bd886 Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 22 Apr 2026 14:33:00 +0200 Subject: [PATCH] modified: app.py modified: static/css/style.css modified: templates/weather.html --- app.py | 27 +++++++++++++++++++++++---- static/css/style.css | 40 ++++++++++++++++++++++++++++++++++++++++ templates/weather.html | 22 ++++++++++++++++++++++ 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index 99e4eef..f061f46 100644 --- a/app.py +++ b/app.py @@ -193,6 +193,22 @@ def _round_temp(k): def _clamp(value, min_value, max_value): return max(min_value, min(max_value, value)) +def _estimate_uv(dt_local, lat, cloud_pct): + """Rough UV index estimate for Central Europe when the MOSMIX station doesn't + provide the UVI parameter. Uses time-of-day, season, latitude, and cloud cover.""" + hour = dt_local.hour + dt_local.minute / 60.0 + if hour < 5.5 or hour > 20.5: + return 0.0 + noon = 13.0 # approximate solar noon in Germany (CET) + hour_factor = max(0.0, math.cos(math.pi * (hour - noon) / 14.0)) + # Typical clear-sky peak UV at solar noon for ~51°N, Jan–Dec + monthly_peak = [1.0, 1.8, 3.5, 5.0, 6.5, 7.5, 7.2, 6.2, 4.5, 2.5, 1.2, 0.8] + seasonal = monthly_peak[dt_local.month - 1] + lat_factor = max(0.5, 1.0 - (float(lat) - 51.0) * 0.015) + cloud_factor = 1.0 - (cloud_pct or 0) / 100.0 * 0.75 + uv = seasonal * hour_factor * lat_factor * cloud_factor + return round(max(0.0, uv), 1) + def uv_risk_info(uv_index): if uv_index is None: return "–", "na" @@ -286,9 +302,9 @@ def temp_trend_info(forecast, step_hours=6): return None, None delta = round(t1 - t0, 1) if delta >= 1.0: - return delta, "waermer" + return delta, "wärmer" if delta <= -1.0: - return delta, "kaelter" + return delta, "kälter" return delta, "konstant" def _parse_warning_datetime(value): @@ -489,12 +505,15 @@ def get_mosmix_forecast(lat, lon, hours=72): 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 + dt_local = pd.Timestamp(date_val).tz_convert(berlin).tz_localize(None) + if not _isnan(uv_raw): + uv = round(float(uv_raw), 1) + else: + uv = _estimate_uv(dt_local, lat, clouds) 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, diff --git a/static/css/style.css b/static/css/style.css index a3dbf18..b5a88ca 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -511,6 +511,7 @@ main { flex: 1; } .hero { padding: 2rem 1.25rem 1.25rem; } .section { padding: 2rem 1.25rem 0; } .drow { grid-template-columns: 64px 1fr 120px; padding: 0.75rem 0.9rem; gap: 0.6rem; } + .daily-header { grid-template-columns: 64px 1fr 120px; padding: 0.45rem 0.9rem; gap: 0.6rem; } .hero-metrics { border-radius: 10px; } .hero-metric { padding: 0.7rem 0.9rem; min-width: 80px; } .hm-val { font-size: 0.95rem; } @@ -580,6 +581,45 @@ main { flex: 1; } border-radius: 99px; padding: 0.1rem 0.45rem; margin-left: 0.4rem; } + +/* Hourly legend */ +.hourly-legend { + display: flex; flex-wrap: wrap; align-items: center; + gap: 0.4rem; margin-bottom: 0.75rem; +} +.hl-label { + font-size: 0.67rem; color: var(--muted); + text-transform: uppercase; letter-spacing: 0.8px; + font-weight: 600; margin-right: 0.2rem; +} +.hl-sep { color: var(--muted); font-size: 0.75rem; } +.hl-badge { + padding: 0.1rem 0.45rem; border-radius: 99px; + font-size: 0.65rem; font-weight: 600; +} +.hl-conf--hoch { color: #34d399; background: rgba(52,211,153,0.14); } +.hl-conf--mittel { color: #fbbf24; background: rgba(251,191,36,0.14); } +.hl-conf--niedrig { color: #f87171; background: rgba(248,113,113,0.14); } +.hl-act { color: #c7d2fe; background: rgba(129,140,248,0.15); } +.hl-uv { color: #a78bfa; background: rgba(167,139,250,0.12); } + +/* Daily list header */ +.daily-header { + display: grid; + grid-template-columns: 80px 1fr 160px; + padding: 0.45rem 1.2rem; + gap: 1rem; + font-size: 0.67rem; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.8px; + color: var(--muted); + border: 1px solid rgba(255,255,255,0.07); + border-bottom: none; + border-radius: var(--r) var(--r) 0 0; + background: rgba(255,255,255,0.02); +} +.daily-header + .daily-list { + border-radius: 0 0 var(--r) var(--r); +} .recent-label { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); diff --git a/templates/weather.html b/templates/weather.html index 98ef8ad..085ae9d 100644 --- a/templates/weather.html +++ b/templates/weather.html @@ -43,11 +43,18 @@
+ {% if current.uv_index is not none %} + {% set _uv_l, _uv_lv = uv_risk_info(current.uv_index) %} + {% set uv_curr_str = current.uv_index|string + " – " + _uv_l %} + {% else %} + {% set uv_curr_str = "–" %} + {% endif %} {% set items = [ ("Gefühlt wie", (current.feels_like|string + " °C") if current.feels_like is not none else "–"), ("Böen", (current.gust_kmh|string + " km/h") if current.gust_kmh is not none else "–"), ("Niederschlag", (current.precip_mm|string + " mm") if (current.precip_mm is not none and current.precip_mm > 0) else ((current.rain_prob|string + " %") if (current.rain_prob is not none and current.rain_prob > 0) else "0 mm")), ("Sonne", (current.sun_min|string + " min/h") if (current.sun_min is not none and current.sun_min > 0) else "–"), + ("UV-Index", uv_curr_str), ] %} {% for label, val in items %}
@@ -138,6 +145,16 @@

Stundenweise

+
+ Legende: + Konfidenz hoch + mittel + niedrig + · + Aktivität 0–100 + · + UV-Index +
{% for h in forecast %} @@ -190,6 +207,11 @@

Tagesübersicht

+
+ Tag + Temperaturbereich + Min  ·  Max  ·  Nieder.  ·  UV +
{# calc global min/max for bar scaling – do it in a loop to stay Jinja2-safe #} {% set ns = namespace(g_min=99, g_max=-99) %}