diff --git a/data/probability_training_snapshots.jsonl b/data/probability_training_snapshots.jsonl index 4452491e..a3eae55f 100644 --- a/data/probability_training_snapshots.jsonl +++ b/data/probability_training_snapshots.jsonl @@ -119,3 +119,4 @@ {"city": "hong kong", "timestamp": "2026-03-23T21:10:00+08:00", "date": "2026-03-23", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.6806250000000001, "deb_prediction": 25.2, "ensemble": {"p10": 26.3, "median": 26.4, "p90": 26.6}, "multi_model": {"Open-Meteo": 24.8, "HKO(港天文)": 27.0, "ECMWF": 25.4, "GFS": 25.1, "ICON": 24.8, "GEM": 25.3, "JMA": 23.6}, "max_so_far": 27.4, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null} {"city": "shenzhen", "timestamp": "2026-03-25T08:43:15.528748+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.18016764322916676, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 29.0, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null} {"city": "shenzhen", "timestamp": "2026-03-25T08:57:11.783182+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": 26.7, "raw_sigma": 0.18016764322916676, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 26.7, "peak_status": "past", "prob_snapshot": [{"v": 27, "p": 1.0}], "shadow_prob_snapshot": [{"v": 27, "p": 1.0}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 26.7, "calibrated_sigma": 0.24322631835937514} +{"city": "shenzhen", "timestamp": "2026-03-25T09:32:32+00:00", "date": "2026-03-25", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.16637912326388898, "deb_prediction": 28.1, "ensemble": {"p10": 30.5, "median": 31.4, "p90": 31.8}, "multi_model": {"Open-Meteo": 26.6, "ECMWF": 28.8, "GFS": 30.3, "ICON": 26.6, "GEM": 30.7, "JMA": 25.5}, "max_so_far": 28.9, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null} diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx index 508064fe..a2d01340 100644 --- a/frontend/components/dashboard/FutureForecastModal.tsx +++ b/frontend/components/dashboard/FutureForecastModal.tsx @@ -256,6 +256,24 @@ function DailyTemperatureChart({ dateStr }: { dateStr: string }) { pointRadius: 5, }); + if ( + todayChartData.datasets.airportMetarPoints?.some((value) => value != null) + ) { + datasets.push({ + backgroundColor: "#60a5fa", + borderColor: "#60a5fa", + borderWidth: 1, + data: todayChartData.datasets.airportMetarPoints, + fill: false, + label: + locale === "en-US" ? "Airport METAR" : "机场 METAR", + order: 0, + pointHoverRadius: 6, + pointRadius: 4, + showLine: false, + }); + } + if (todayChartData.datasets.mgmPoints.some((value) => value != null)) { datasets.push({ backgroundColor: "#facc15", @@ -759,6 +777,22 @@ export function FutureForecastModal() { : risk.airport ? `${risk.airport}${risk.icao ? ` (${risk.icao})` : ""}` : "--"; + const airportCurrentText = + detail.airport_current?.temp != null + ? `${detail.airport_current.temp}${detail.temp_symbol}${ + detail.airport_current?.obs_time + ? ` @${detail.airport_current.obs_time}` + : "" + }` + : "--"; + const airportMaxText = + detail.airport_current?.max_so_far != null + ? `${detail.airport_current.max_so_far}${detail.temp_symbol}${ + detail.airport_current?.max_temp_time + ? ` @${detail.airport_current.max_temp_time}` + : "" + }` + : "--"; const displayedUpperAirSummary = marketAwareUpperAirCue?.summary || view.front.upperAirSummary; const displayedUpperAirMetrics = (view.front.upperAirMetrics || []).map( @@ -1061,6 +1095,26 @@ export function FutureForecastModal() { {settlementProfileValue} + {settlementSourceCode === "wunderground" ? ( +
+ + {locale === "en-US" + ? "Airport METAR" + : "机场 METAR"} + + {airportCurrentText} +
+ ) : null} + {settlementSourceCode === "wunderground" ? ( +
+ + {locale === "en-US" + ? "Airport high" + : "机场目前最高温"} + + {airportMaxText} +
+ ) : null}
{t("section.distance")} diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 8016cb0d..7ecbae88 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -66,6 +66,25 @@ export interface CurrentConditions { dewpoint?: number | null; } +export interface AirportCurrentConditions { + temp: number | null; + obs_time: string | null; + max_so_far?: number | null; + max_temp_time?: string | null; + obs_age_min?: number | null; + report_time?: string | null; + receipt_time?: string | null; + obs_time_epoch?: number | null; + wind_speed_kt?: number | null; + wind_dir?: number | null; + humidity?: number | null; + cloud_desc?: string | null; + visibility_mi?: number | null; + wx_desc?: string | null; + raw_metar?: string | null; + source_label?: string | null; +} + export interface NearbyStation { name?: string; icao?: string; @@ -271,6 +290,7 @@ export interface CityDetail { local_date: string; risk: DashboardRisk; current: CurrentConditions; + airport_current?: AirportCurrentConditions; mgm?: MgmData; mgm_nearby?: NearbyStation[]; nearby_source?: string; diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index c5c8dbfa..97909ab6 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -284,6 +284,10 @@ export function getTemperatureChartData( ? metarObservationSource : officialObservationSource : metarObservationSource; + const airportMetarSource = + settlementSource && observationCode === "wunderground" + ? metarObservationSource + : []; const metarFallbackTag = (() => { const icao = String(detail.risk?.icao || "").trim().toUpperCase(); if (!icao) return "METAR"; @@ -314,6 +318,20 @@ export function getTemperatureChartData( existing == null ? temp : Math.max(Number(existing), Number(temp)); } }); + const airportMetarPoints = new Array(times.length).fill(null); + airportMetarSource.forEach((item) => { + const parts = String(item.time || "").split(":"); + const hour = Number.parseInt(parts[0], 10); + if (Number.isNaN(hour)) return; + const key = `${String(hour).padStart(2, "0")}:00`; + const index = times.indexOf(key); + const temp = item.temp ?? null; + if (index >= 0 && temp != null) { + const existing = airportMetarPoints[index]; + airportMetarPoints[index] = + existing == null ? temp : Math.max(Number(existing), Number(temp)); + } + }); const mgmPoints = new Array(times.length).fill(null); if (detail.mgm?.temp != null && detail.mgm?.time) { @@ -344,6 +362,7 @@ export function getTemperatureChartData( const allValues = [ ...debTemps.filter((value) => value != null), ...metarPoints.filter((value) => value != null), + ...airportMetarPoints.filter((value) => value != null), ...mgmPoints.filter((value) => value != null), ...mgmHourlyPoints.filter((value) => value != null), ] as number[]; @@ -414,6 +433,18 @@ export function getTemperatureChartData( .join(" -> "); legendParts.push(`${observationDisplayTag}: ${recentText}`); } + if (airportMetarSource.length > 0) { + const airportRecentText = [...airportMetarSource] + .slice(-4) + .reverse() + .map((item) => `${item.temp}${detail.temp_symbol}@${item.time}`) + .join(" -> "); + legendParts.push( + isEnglish(locale) + ? `${metarFallbackTag}: ${airportRecentText}` + : `${metarFallbackTag}: ${airportRecentText}`, + ); + } if (shouldUseMetarFallback) { legendParts.push( isEnglish(locale) @@ -453,6 +484,7 @@ export function getTemperatureChartData( return { datasets: { + airportMetarPoints, debFuture, debPast, hasMgmHourly, diff --git a/src/data_collection/wunderground_sources.py b/src/data_collection/wunderground_sources.py index d456b115..85d14b2c 100644 --- a/src/data_collection/wunderground_sources.py +++ b/src/data_collection/wunderground_sources.py @@ -1,7 +1,8 @@ from __future__ import annotations +import json import re -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any, Dict, Optional from loguru import logger @@ -43,6 +44,65 @@ class WundergroundSourceMixin: logger.warning(f"Wunderground page fetch failed url={url}: {exc}") return None + @staticmethod + def _wu_extract_app_state(html: str) -> Optional[Dict[str, Any]]: + match = re.search( + r'', + html, + re.IGNORECASE | re.DOTALL, + ) + if not match: + return None + try: + payload = json.loads(str(match.group(1) or "")) + return payload if isinstance(payload, dict) else None + except Exception: + return None + + @staticmethod + def _wu_extract_station_history_url(html: str) -> Optional[str]: + match = re.search( + r']*class="station-name"[^>]*>\s*]+href="([^"]*?/history/daily/[^"]+)"', + html, + re.IGNORECASE | re.DOTALL, + ) + if not match: + return None + href = str(match.group(1) or "").strip() + if not href: + return None + if href.startswith("http://") or href.startswith("https://"): + return href + return f"https://www.wunderground.com{href}" + + @staticmethod + def _wu_extract_station_id_from_history_url(url: Optional[str]) -> Optional[str]: + text = str(url or "").strip() + if not text: + return None + match = re.search(r"/history/daily/(?:[^/]+/){1,3}([^/]+)/date/", text, re.IGNORECASE) + return str(match.group(1) or "").strip() or None if match else None + + @staticmethod + def _wu_extract_station_name_from_history_url(url: Optional[str]) -> Optional[str]: + text = str(url or "").strip() + if not text: + return None + match = re.search(r"/history/daily/(?:[^/]+/){1,3}([^/]+)/date/", text, re.IGNORECASE) + return str(match.group(1) or "").strip() or None if match else None + + @staticmethod + def _safe_float(value: Any) -> Optional[float]: + if value is None: + return None + text = str(value).strip() + if not text: + return None + try: + return float(text) + except Exception: + return None + @staticmethod def _wu_extract_station_name(html: str, fallback_icao: str) -> Optional[str]: pattern = re.compile( @@ -94,6 +154,44 @@ class WundergroundSourceMixin: return round((float(value) - 32.0) * 5.0 / 9.0, 1) return round(float(value), 1) + @staticmethod + def _wu_parse_observation_iso(raw: Optional[str], utc_offset_seconds: int) -> Optional[str]: + text = str(raw or "").strip() + if not text: + return None + try: + parsed = datetime.strptime(text, "%Y-%m-%dT%H:%M:%S%z") + return parsed.astimezone(timezone.utc).isoformat() + except Exception: + pass + try: + parsed = datetime.strptime(text, "%Y-%m-%d %H:%M:%S") + parsed = parsed.replace(tzinfo=timezone(timedelta(seconds=utc_offset_seconds))) + return parsed.astimezone(timezone.utc).isoformat() + except Exception: + return None + + @staticmethod + def _wu_find_current_observation_block( + app_state: Dict[str, Any], + *, + icao: Optional[str], + ) -> Optional[Dict[str, Any]]: + normalized_icao = str(icao or "").strip().upper() + fallback_block: Optional[Dict[str, Any]] = None + for value in app_state.values(): + if not isinstance(value, dict): + continue + url = str(value.get("u") or "") + body = value.get("b") + if not isinstance(body, dict) or "observations/current" not in url: + continue + if normalized_icao and f"icaoCode={normalized_icao}" in url: + return body + if fallback_block is None: + fallback_block = body + return fallback_block + def fetch_wunderground_settlement_current( self, city: str, @@ -112,44 +210,108 @@ class WundergroundSourceMixin: if not html: return None - fallback_icao = str(icao or "").strip() - station_name = station_label or self._wu_extract_station_name(html, fallback_icao) - display_temp, display_unit = self._wu_extract_station_temperature( - html, - station_name=station_name, + city_meta = CITY_REGISTRY.get(normalized_city) or {} + utc_offset_seconds = int(city_meta.get("tz_offset") or 0) + fallback_icao = str(icao or "").strip().upper() + history_url = self._wu_extract_station_history_url(html) + station_id = self._wu_extract_station_id_from_history_url(history_url) + station_name = ( + station_label + or self._wu_extract_station_name(html, fallback_icao) + or self._wu_extract_station_name_from_history_url(history_url) + or fallback_icao + or str(city or "").title() ) - temp_c = self._wu_to_celsius(display_temp, display_unit) + + app_state = self._wu_extract_app_state(html) or {} + current_block = self._wu_find_current_observation_block(app_state, icao=fallback_icao) + + display_temp = None + display_unit = "F" + obs_iso = None + humidity = None + wind_speed_kt = None + wind_dir = None + wx_phrase = None + dewpoint_c = None + official_high_c = None + official_low_c = None + + if current_block: + display_temp = self._safe_float(current_block.get("temperature")) + obs_iso = self._wu_parse_observation_iso( + current_block.get("validTimeLocal"), + utc_offset_seconds, + ) + humidity = self._safe_float(current_block.get("relativeHumidity")) + wind_speed_kt = self._safe_float(current_block.get("windSpeed")) + wind_dir = str(current_block.get("windDirectionCardinal") or "").strip() or None + wx_phrase = str(current_block.get("wxPhraseLong") or "").strip() or None + dewpoint_c = self._wu_to_celsius( + self._safe_float(current_block.get("temperatureDewPoint")), + "F", + ) + official_high_c = self._wu_to_celsius( + self._safe_float( + current_block.get("temperatureMaxSince7Am") + or current_block.get("temperatureMax24Hour") + ), + "F", + ) + official_low_c = self._wu_to_celsius( + self._safe_float(current_block.get("temperatureMin24Hour")), + "F", + ) + + if display_temp is None: + display_temp, display_unit = self._wu_extract_station_temperature( + html, + station_name=station_name, + ) + temp_c = self._wu_to_celsius(self._safe_float(display_temp), display_unit) if temp_c is None: logger.warning(f"Wunderground temperature parse failed city={city} url={url}") return None - city_meta = CITY_REGISTRY.get(normalized_city) or {} - utc_offset_seconds = int(city_meta.get("tz_offset") or 0) - obs_iso = datetime.now(timezone.utc).isoformat() + obs_iso = obs_iso or datetime.now(timezone.utc).isoformat() today_obs = self._update_official_today_obs( source_code="wunderground", - station_code=fallback_icao or normalized_city, + station_code=station_id or fallback_icao or normalized_city, obs_iso=obs_iso, current_temp=temp_c, utc_offset_seconds=utc_offset_seconds, ) - max_so_far = None - max_temp_time = None - today_low = None + + sampled_max = None + sampled_max_time = None + sampled_low = None if today_obs: hottest = max(today_obs, key=lambda item: float(item.get("temp") or -999)) coldest = min(today_obs, key=lambda item: float(item.get("temp") or 999)) - max_so_far = self._wu_to_celsius(float(hottest.get("temp")), "C") - today_low = self._wu_to_celsius(float(coldest.get("temp")), "C") - max_temp_time = str(hottest.get("time") or "").strip() or None + sampled_max = self._wu_to_celsius(float(hottest.get("temp")), "C") + sampled_max_time = str(hottest.get("time") or "").strip() or None + sampled_low = self._wu_to_celsius(float(coldest.get("temp")), "C") + + max_so_far = official_high_c if official_high_c is not None else sampled_max + today_low = official_low_c if official_low_c is not None else sampled_low + max_temp_time = sampled_max_time + if max_so_far is not None and abs(float(max_so_far) - float(temp_c)) < 0.05: + try: + local_obs = datetime.fromisoformat(obs_iso.replace("Z", "+00:00")).astimezone( + timezone(timedelta(seconds=utc_offset_seconds)) + ) + max_temp_time = local_obs.strftime("%H:%M") + except Exception: + pass payload: Dict[str, Any] = { "source": "wunderground", "source_label": "Wunderground", - "station_code": fallback_icao or None, - "station_name": station_name or fallback_icao or str(city or "").title(), + "station_code": station_id or fallback_icao or None, + "station_name": station_name, "observation_time": obs_iso, "source_url": url, + "history_url": history_url, "current": { "temp": temp_c, "display_temp": display_temp, @@ -157,9 +319,11 @@ class WundergroundSourceMixin: "max_temp_so_far": max_so_far, "max_temp_time": max_temp_time, "today_low": today_low, - "humidity": None, - "wind_speed_kt": None, - "wind_dir": None, + "humidity": humidity, + "wind_speed_kt": wind_speed_kt, + "wind_dir": wind_dir, + "cloud_desc": wx_phrase, + "dewpoint": dewpoint_c, }, "today_obs": today_obs, "unit": "celsius", diff --git a/web/analysis_service.py b/web/analysis_service.py index c60884c7..381fa95c 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -760,6 +760,15 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: for t, v in (metar.get("today_obs", []) if metar else []) ] metar_recent_obs_payload = metar.get("recent_obs", []) if metar else [] + airport_max_so_far = None + airport_max_temp_time = None + for point in metar_today_obs_payload: + value = _sf(point.get("temp")) if isinstance(point, dict) else None + if value is None: + continue + if airport_max_so_far is None or value >= airport_max_so_far: + airport_max_so_far = value + airport_max_temp_time = str(point.get("time") or "") or None # ── 3. Local time parsing ── local_time_full = om.get("current", {}).get("local_time", "") @@ -1272,10 +1281,10 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: "settlement_source": settlement_source, "settlement_source_label": settlement_source_label, "obs_time": obs_time_str, - "obs_age_min": metar_age_min, - "report_time": metar.get("report_time") if metar else None, - "receipt_time": metar.get("receipt_time") if metar else None, - "obs_time_epoch": metar.get("obs_time_epoch") if metar else None, + "obs_age_min": None if use_settlement_current else metar_age_min, + "report_time": primary_current.get("report_time"), + "receipt_time": primary_current.get("receipt_time"), + "obs_time_epoch": primary_current.get("obs_time_epoch"), "wind_speed_kt": _sf(primary_current.get("wind_speed_kt")), "wind_dir": _sf(primary_current.get("wind_dir")), "humidity": _sf(primary_current.get("humidity")), @@ -1287,6 +1296,24 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]: "wx_desc": primary_current.get("wx_desc"), "raw_metar": primary_current.get("raw_metar"), }, + "airport_current": { + "temp": _sf(mc.get("temp")), + "obs_time": metar.get("obs_time"), + "max_so_far": airport_max_so_far, + "max_temp_time": airport_max_temp_time, + "obs_age_min": metar_age_min, + "report_time": metar.get("report_time") if metar else None, + "receipt_time": metar.get("receipt_time") if metar else None, + "obs_time_epoch": metar.get("obs_time_epoch") if metar else None, + "wind_speed_kt": _sf(mc.get("wind_speed_kt")), + "wind_dir": _sf(mc.get("wind_dir")), + "humidity": _sf(mc.get("humidity")), + "cloud_desc": metar.get("cloud_desc") if metar else None, + "visibility_mi": _sf(mc.get("visibility_mi")), + "wx_desc": mc.get("wx_desc"), + "raw_metar": mc.get("raw_metar"), + "source_label": "METAR", + }, "mgm": mgm_data, "mgm_nearby": raw.get("mgm_nearby", []), "nearby_source": raw.get("nearby_source") or ("mgm" if city.lower() == "ankara" else "metar_cluster"), @@ -1489,12 +1516,12 @@ def _build_city_detail_payload( "official": { "available": bool(data.get("current", {}).get("temp") is not None), "metar": { - "observation_time": data.get("current", {}).get("obs_time"), - "obs_age_min": data.get("current", {}).get("obs_age_min"), - "report_time": data.get("current", {}).get("report_time"), - "receipt_time": data.get("current", {}).get("receipt_time"), - "raw_metar": data.get("current", {}).get("raw_metar"), - "current": data.get("current"), + "observation_time": data.get("airport_current", {}).get("obs_time"), + "obs_age_min": data.get("airport_current", {}).get("obs_age_min"), + "report_time": data.get("airport_current", {}).get("report_time"), + "receipt_time": data.get("airport_current", {}).get("receipt_time"), + "raw_metar": data.get("airport_current", {}).get("raw_metar"), + "current": data.get("airport_current") or {}, }, "taf": data.get("taf") or {}, "weather_gov": {}, @@ -1521,6 +1548,7 @@ def _build_city_detail_payload( "taf": data.get("taf") or {}, "market_scan": market_scan, "risk": data.get("risk"), + "airport_current": data.get("airport_current") or {}, "nearby_source": data.get("nearby_source") or ("mgm" if data.get("name") == "ankara" else "metar_cluster"), "ai_analysis": data.get("ai_analysis") or "", "errors": {},