From 164d0eb6a899e416b220d5ccf65fee8434ab5353 Mon Sep 17 00:00:00 2001 From: SimolZimol <70102430+SimolZimol@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:34:19 +0200 Subject: [PATCH] modified: __pycache__/app.cpython-310.pyc modified: app.py modified: requirements.txt modified: static/css/style.css modified: templates/base.html modified: templates/index.html modified: templates/weather.html --- __pycache__/app.cpython-310.pyc | Bin 8430 -> 11249 bytes app.py | 381 +++++++++++++++++--------------- requirements.txt | 2 + static/css/style.css | 75 +++++++ templates/base.html | 8 +- templates/index.html | 43 +++- templates/weather.html | 44 +++- 7 files changed, 364 insertions(+), 189 deletions(-) diff --git a/__pycache__/app.cpython-310.pyc b/__pycache__/app.cpython-310.pyc index 426bc3084219b08c6a8eda8d834b6302440236f1..c9fb2c7bf482184c4fd1476dddf9007c0885e7f5 100644 GIT binary patch literal 11249 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 delta 4990 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 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): + key = (round(lat, 1), round(lon, 1)) + 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", ""), + }) + warnings.sort(key=lambda x: x["level"], reverse=True) + result = warnings[:3] + _warn_cache[key] = result + return result + except Exception: + _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): - """Holt MOSMIX-Vorhersage für die nächsten Stunden.""" + cache_key = (round(lat,2), round(lon,2), hours) + if cache_key in _forecast_cache: + return _forecast_cache[cache_key] try: - # Zeitzone Berlin für alle Anzeige-Timestamps - try: - import zoneinfo - _berlin = zoneinfo.ZoneInfo("Europe/Berlin") - except ImportError: - import pytz - _berlin = pytz.timezone("Europe/Berlin") - + 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): + if df is None or (hasattr(df,"__len__") and len(df)==0): return [], {} - - # Polars → Pandas - if hasattr(df, "to_pandas"): - df = df.to_pandas() - + 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() + if hasattr(sdf,"to_pandas"): sdf = sdf.to_pandas() station_info = sdf.iloc[0].to_dict() - df = df.sort_values("date").copy() - - # Auf die nächsten `hours` Stunden begrenzen 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"): - params = {row["parameter"]: row["value"] for _, row in group.iterrows()} - - temp_c = _round_temp(params.get("temperature_air_mean_2m")) - ff = params.get("wind_speed") - wind_kmh = round(float(ff) * 3.6, 1) if not _isnan(ff) else None - fx1 = params.get("wind_gust_max_last_1h") - gust_kmh = round(float(fx1) * 3.6, 1) if not _isnan(fx1) else None - pppp = params.get("pressure_air_site_reduced") - pressure = round(float(pppp), 1) if not _isnan(pppp) else None - # Niederschlag: rr1 (gesamt) - rr1c = params.get("precipitation_height_significant_weather_last_1h") - rr1 = params.get("precipitation_height_last_1h") - precip_raw = rr1c if not _isnan(rr1c) else (rr1 if not _isnan(rr1) else None) - precip = round(float(precip_raw), 1) if precip_raw is not None else 0.0 - - # Regenwahrscheinlichkeit (Wert kommt als Bruchteil 0.0–1.0) - rprob_raw = params.get("probability_precipitation_height_gt_0_1mm_last_1h") - rain_prob = round(float(rprob_raw) * 100) if not _isnan(rprob_raw) else None - - n = params.get("cloud_cover_total") - clouds = round(float(n)) if not _isnan(n) else None - sun = params.get("sunshine_duration") - sun_min = round(float(sun) / 60) if not _isnan(sun) else 0 - wind_dir_v = params.get("wind_direction") - wind_dir = float(wind_dir_v) if not _isnan(wind_dir_v) else None - + 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 + feels = feels_like(temp_c, wind_kmh, clouds) + dt_local = pd.Timestamp(date_val).tz_convert(berlin).tz_localize(None) forecast.append({ - "datetime": pd.Timestamp(date_val).tz_convert(_berlin).tz_localize(None), + "datetime": dt_local, "temp_c": temp_c, + "feels_like": feels, "wind_kmh": wind_kmh, "gust_kmh": gust_kmh, "pressure_hpa": pressure, @@ -137,182 +218,126 @@ def get_mosmix_forecast(lat, lon, hours=72): "cloud_pct": clouds, "sun_min": sun_min, "wind_dir": wind_dir, + "uv_index": uv, "icon": weather_icon(clouds, precip, rain_prob, temp_c), }) - - return forecast, station_info + result_data = (forecast, station_info) + _forecast_cache[cache_key] = result_data + return result_data except Exception: traceback.print_exc() 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): - # Schnee - 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 "❄️" - # Regen (tatsächlicher Niederschlag) - if precip_mm and precip_mm > 0.2: - return "🌧️" - # Hohe Regenwahrscheinlichkeit - if rain_prob is not None and rain_prob >= 60: - return "🌦️" - if rain_prob is not None and rain_prob >= 30: - if 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 "☀️" - - -# ── Routen ─────────────────────────────────────────────────────────────────── - @app.route("/") def index(): return render_template("index.html") - @app.route("/wetter", methods=["GET"]) def wetter(): - ort = request.args.get("ort", "").strip() - if not ort: - return render_template("index.html", error="Bitte einen Ort eingeben.") + 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 = None, None, None + if lat_param and lon_param: + try: + lat = float(lat_param) + lon = float(lon_param) + geolocator = Nominatim(user_agent="skywatcher-app/1.0") + loc = geolocator.reverse((lat, lon), language="de", timeout=10) + display_name = loc.address if loc else f"{lat:.2f}, {lon:.2f}" + if not ort or ort == "Mein Standort": + ort = display_name.split(",")[0] + except Exception: + lat, lon, display_name = None, None, None - lat, lon, display_name = geocode_location(ort) if lat is None: - return render_template("index.html", - error=f'Ort "{ort}" konnte nicht gefunden werden.') - + if not ort: + return render_template("index.html", error="Bitte einen Ort eingeben.") + lat, lon, display_name = 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 von DWD verfügbar. Bitte später erneut versuchen.", - ) - + 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) - - # "Aktuell" = erste Stunde >= jetzt (Berliner Lokalzeit) - now_utc = _dt.datetime.now(_dt.timezone.utc).replace(tzinfo=None) - try: - import zoneinfo - berlin = zoneinfo.ZoneInfo("Europe/Berlin") - except ImportError: - import pytz - berlin = pytz.timezone("Europe/Berlin") - now_local_dt = _dt.datetime.now(berlin) - now_local = now_local_dt.strftime("%H:%M") - # Auf volle Stunde abrunden → aktuelle Stunde als Startpunkt + 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 + 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] - # Stundenliste ab jetzt kürzen + current = forecast[current_idx] forecast = forecast[current_idx:] - - # Tageszusammenfassung + sunrise, sunset, dawn, dusk = get_sun_times(lat, lon) + warnings = get_dwd_warnings(lat, lon) daily = {} for h in forecast: dt = h["datetime"] - day = dt.date() if hasattr(dt, "date") else str(dt)[:10] + 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": []} - if h["temp_c"] is not None: - daily[day]["temps"].append(h["temp_c"]) + daily[day] = {"temps":[], "precip":0.0, "cloud":[], "wind":[], "icons":[], "rain_prob":[], "uv":[]} + 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["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), + "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, + "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": max(set(d["icons"]), key=d["icons"].count), }) - - # Chart-Daten (erste 48 h) chart_labels, chart_temps, 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]) + 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_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, - forecast=forecast[:48], - daily=daily_summary, - chart_labels=chart_labels, - chart_temps=chart_temps, - chart_precip=chart_precip, - chart_rain_prob=chart_rain_prob, + 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, + sunrise=sunrise, sunset=sunset, + warnings=warnings, + forecast=forecast[:48], daily=daily_summary, + chart_labels=chart_labels, chart_temps=chart_temps, + chart_precip=chart_precip, chart_rain_prob=chart_rain_prob, wind_dir_name=wind_direction_name, ) - @app.route("/api/suggest") def suggest(): - q = request.args.get("q", "").strip() - if len(q) < 2: - return jsonify([]) - geolocator = Nominatim(user_agent="dwd-wetter-app/1.0") + q = request.args.get("q","").strip() + if len(q) < 2: return jsonify([]) + geolocator = Nominatim(user_agent="skywatcher-app/1.0") try: - results = geolocator.geocode( - q, exactly_one=False, limit=5, language="de", - addressdetails=True, timeout=5, - ) - return jsonify( - [{"name": r.address, "lat": r.latitude, "lon": r.longitude} - for r in results] - if results else [] - ) + results = geolocator.geocode(q, exactly_one=False, limit=5, language="de", addressdetails=True, timeout=5) + return jsonify([{"name":r.address,"lat":r.latitude,"lon":r.longitude} for r in results] if results else []) except Exception: return jsonify([]) - if __name__ == "__main__": - app.run(debug=True, host="0.0.0.0", port=5000) + app.run(debug=True, host="0.0.0.0", port=5000) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b7dce63..d707ac8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,5 @@ pandas>=2.0.0 numpy>=1.24.0 requests>=2.31.0 gunicorn>=21.2.0 +cachetools>=5.3.0 +astral>=3.2 diff --git a/static/css/style.css b/static/css/style.css index e7e398a..9b5c20e 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -480,3 +480,78 @@ main { flex: 1; } .drow-right { flex-wrap: wrap; justify-content: flex-end; } .drow-precip { display: none; } } + +/* ═══════════════════════════════════════════════════════════ + WARNINGS +═══════════════════════════════════════════════════════════ */ +.warnings { + max-width: 1000px; margin: 0 auto; + padding: 1rem 2rem 0; + display: flex; flex-direction: column; gap: 0.5rem; +} +.warn-item { + display: flex; gap: 0.75rem; align-items: flex-start; + padding: 0.75rem 1rem; + border-radius: var(--r); + font-size: 0.85rem; + border-left: 3px solid; +} +.warn-lvl-1 { background: rgba(255,200,50,0.08); border-color: #ffc832; color: #ffc832; } +.warn-lvl-2 { background: rgba(255,120,30,0.1); border-color: #ff781e; color: #ff9a5c; } +.warn-lvl-3, .warn-lvl-4 { background: rgba(255,60,60,0.1); border-color: #ff4040; color: #ff7070; } +.warn-icon { font-size: 1.1rem; flex-shrink: 0; line-height: 1.3; } +.warn-item strong { color: inherit; } +.warn-item div { display: flex; flex-direction: column; gap: 0.2rem; } +.warn-desc { color: var(--muted2); font-size: 0.78rem; margin: 0; } + +/* ═══════════════════════════════════════════════════════════ + UV INDEX + RECENT LABELS +═══════════════════════════════════════════════════════════ */ +.hcard-uv { + font-size: 0.65rem; color: #a78bfa; font-weight: 600; + background: rgba(167,139,250,0.12); + border-radius: 99px; padding: 0.1rem 0.45rem; +} +.drow-uv { + font-size: 0.68rem; color: #a78bfa; font-weight: 600; + background: rgba(167,139,250,0.12); + border-radius: 99px; padding: 0.1rem 0.45rem; + margin-left: 0.4rem; +} +.recent-label { + font-size: 0.68rem; text-transform: uppercase; + letter-spacing: 1px; color: var(--muted); + font-weight: 600; margin-top: 0.5rem; +} +.home-chips--recent .chip--recent { + background: rgba(80,180,255,0.06); + border-color: rgba(80,180,255,0.2); + color: var(--blue); +} +.home-chips--recent .chip--recent:hover { + background: rgba(80,180,255,0.14); + border-color: rgba(80,180,255,0.4); + text-decoration: none; +} + +/* ═══════════════════════════════════════════════════════════ + STANDORT BUTTON +═══════════════════════════════════════════════════════════ */ +.btn-location { + display: inline-flex; align-items: center; gap: 0.5rem; + margin-top: 1rem; + padding: 0.5rem 1rem; + background: transparent; + border: 1px solid rgba(255,255,255,0.12); + border-radius: 99px; + font: inherit; font-size: 0.82rem; + color: var(--muted2); cursor: pointer; + transition: all 0.15s; +} +.btn-location svg { width: 14px; height: 14px; color: var(--blue); } +.btn-location:hover { + background: rgba(80,180,255,0.07); + border-color: rgba(80,180,255,0.3); + color: var(--blue); +} +.btn-location:disabled { opacity: 0.5; cursor: not-allowed; } diff --git a/templates/base.html b/templates/base.html index f30e299..6b99090 100644 --- a/templates/base.html +++ b/templates/base.html @@ -3,15 +3,15 @@ - {% block title %}Wetter{% endblock %} + {% block title %}Skywatcher{% endblock %} - + {% block head %}{% endblock %}