modified: app.py
modified: static/css/style.css modified: templates/weather.html
This commit is contained in:
27
app.py
27
app.py
@@ -193,6 +193,22 @@ def _round_temp(k):
|
|||||||
def _clamp(value, min_value, max_value):
|
def _clamp(value, min_value, max_value):
|
||||||
return max(min_value, min(max_value, 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):
|
def uv_risk_info(uv_index):
|
||||||
if uv_index is None:
|
if uv_index is None:
|
||||||
return "–", "na"
|
return "–", "na"
|
||||||
@@ -286,9 +302,9 @@ def temp_trend_info(forecast, step_hours=6):
|
|||||||
return None, None
|
return None, None
|
||||||
delta = round(t1 - t0, 1)
|
delta = round(t1 - t0, 1)
|
||||||
if delta >= 1.0:
|
if delta >= 1.0:
|
||||||
return delta, "waermer"
|
return delta, "wärmer"
|
||||||
if delta <= -1.0:
|
if delta <= -1.0:
|
||||||
return delta, "kaelter"
|
return delta, "kälter"
|
||||||
return delta, "konstant"
|
return delta, "konstant"
|
||||||
|
|
||||||
def _parse_warning_datetime(value):
|
def _parse_warning_datetime(value):
|
||||||
@@ -489,12 +505,15 @@ def get_mosmix_forecast(lat, lon, hours=72):
|
|||||||
wd = p.get("wind_direction")
|
wd = p.get("wind_direction")
|
||||||
wind_dir = float(wd) if not _isnan(wd) else None
|
wind_dir = float(wd) if not _isnan(wd) else None
|
||||||
uv_raw = p.get("uv_index")
|
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)
|
uv_label, uv_level = uv_risk_info(uv)
|
||||||
feels = feels_like(temp_c, wind_kmh, clouds)
|
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)
|
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)
|
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({
|
forecast.append({
|
||||||
"datetime": dt_local,
|
"datetime": dt_local,
|
||||||
"temp_c": temp_c,
|
"temp_c": temp_c,
|
||||||
|
|||||||
@@ -511,6 +511,7 @@ main { flex: 1; }
|
|||||||
.hero { padding: 2rem 1.25rem 1.25rem; }
|
.hero { padding: 2rem 1.25rem 1.25rem; }
|
||||||
.section { padding: 2rem 1.25rem 0; }
|
.section { padding: 2rem 1.25rem 0; }
|
||||||
.drow { grid-template-columns: 64px 1fr 120px; padding: 0.75rem 0.9rem; gap: 0.6rem; }
|
.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-metrics { border-radius: 10px; }
|
||||||
.hero-metric { padding: 0.7rem 0.9rem; min-width: 80px; }
|
.hero-metric { padding: 0.7rem 0.9rem; min-width: 80px; }
|
||||||
.hm-val { font-size: 0.95rem; }
|
.hm-val { font-size: 0.95rem; }
|
||||||
@@ -580,6 +581,45 @@ main { flex: 1; }
|
|||||||
border-radius: 99px; padding: 0.1rem 0.45rem;
|
border-radius: 99px; padding: 0.1rem 0.45rem;
|
||||||
margin-left: 0.4rem;
|
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 {
|
.recent-label {
|
||||||
font-size: 0.68rem; text-transform: uppercase;
|
font-size: 0.68rem; text-transform: uppercase;
|
||||||
letter-spacing: 1px; color: var(--muted);
|
letter-spacing: 1px; color: var(--muted);
|
||||||
|
|||||||
@@ -43,11 +43,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-metrics">
|
<div class="hero-metrics">
|
||||||
|
{% 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 = [
|
{% set items = [
|
||||||
("Gefühlt wie", (current.feels_like|string + " °C") if current.feels_like is not none else "–"),
|
("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 "–"),
|
("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")),
|
("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 "–"),
|
("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 %}
|
{% for label, val in items %}
|
||||||
<div class="hero-metric">
|
<div class="hero-metric">
|
||||||
@@ -138,6 +145,16 @@
|
|||||||
<!-- STÜNDLICH -->
|
<!-- STÜNDLICH -->
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h2 class="section-title">Stundenweise</h2>
|
<h2 class="section-title">Stundenweise</h2>
|
||||||
|
<div class="hourly-legend">
|
||||||
|
<span class="hl-label">Legende:</span>
|
||||||
|
<span class="hl-badge hl-conf hl-conf--hoch">Konfidenz hoch</span>
|
||||||
|
<span class="hl-badge hl-conf hl-conf--mittel">mittel</span>
|
||||||
|
<span class="hl-badge hl-conf hl-conf--niedrig">niedrig</span>
|
||||||
|
<span class="hl-sep">·</span>
|
||||||
|
<span class="hl-badge hl-act">Aktivität 0–100</span>
|
||||||
|
<span class="hl-sep">·</span>
|
||||||
|
<span class="hl-badge hl-uv">UV-Index</span>
|
||||||
|
</div>
|
||||||
<div class="hourly-strip-wrap">
|
<div class="hourly-strip-wrap">
|
||||||
<div class="hourly-strip">
|
<div class="hourly-strip">
|
||||||
{% for h in forecast %}
|
{% for h in forecast %}
|
||||||
@@ -190,6 +207,11 @@
|
|||||||
<!-- TAGESÜBERSICHT -->
|
<!-- TAGESÜBERSICHT -->
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h2 class="section-title">Tagesübersicht</h2>
|
<h2 class="section-title">Tagesübersicht</h2>
|
||||||
|
<div class="daily-header">
|
||||||
|
<span>Tag</span>
|
||||||
|
<span>Temperaturbereich</span>
|
||||||
|
<span>Min · Max · Nieder. · UV</span>
|
||||||
|
</div>
|
||||||
<div class="daily-list">
|
<div class="daily-list">
|
||||||
{# calc global min/max for bar scaling – do it in a loop to stay Jinja2-safe #}
|
{# 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) %}
|
{% set ns = namespace(g_min=99, g_max=-99) %}
|
||||||
|
|||||||
Reference in New Issue
Block a user