From 25424700ce77d99c2b447f5e6d53879d5897c59a Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Thu, 16 Apr 2026 15:29:26 +0800 Subject: [PATCH] Restore observed temperatures on map and trend charts --- frontend/hooks/useLeafletMap.ts | 26 ++++++++-- frontend/lib/dashboard-utils.ts | 88 ++++++++++++++++++++++++++++++++- web/analysis_service.py | 88 ++++++++++++++++++++++++++++++--- 3 files changed, 191 insertions(+), 11 deletions(-) diff --git a/frontend/hooks/useLeafletMap.ts b/frontend/hooks/useLeafletMap.ts index 69107d88..1a81c06c 100644 --- a/frontend/hooks/useLeafletMap.ts +++ b/frontend/hooks/useLeafletMap.ts @@ -52,6 +52,26 @@ function getMarkerDisplayOffset(cityName: string) { }; } +function pickMarkerTemperature( + snapshot?: Pick | CitySummary, +) { + if (!snapshot) return null; + const detail = snapshot as Partial; + const candidates = [ + snapshot.current?.temp, + detail.airport_primary?.temp, + detail.airport_current?.temp, + detail.center_station_candidate?.temp, + detail.official_nearby?.[0]?.temp, + detail.mgm_nearby?.[0]?.temp, + ]; + for (const value of candidates) { + const numeric = Number(value); + if (Number.isFinite(numeric)) return numeric; + } + return null; +} + function createMarkerIcon( city: CityListItem, snapshot?: Pick | CitySummary, @@ -60,8 +80,8 @@ function createMarkerIcon( const label = city.display_name; const unit = city.temp_unit === "fahrenheit" ? "°F" : "°C"; const shortName = label.length > 10 ? `${label.substring(0, 8)}...` : label; - const tempText = - snapshot?.current?.temp != null ? `${snapshot.current.temp}${unit}` : "--"; + const markerTemp = pickMarkerTemperature(snapshot); + const tempText = markerTemp != null ? `${markerTemp}${unit}` : "--"; const offset = getMarkerDisplayOffset(city.name); const styleAttr = offset.x || offset.y @@ -91,7 +111,7 @@ function getMarkerSignature( city.temp_unit, city.lat, city.lon, - snapshot?.current?.temp ?? "", + pickMarkerTemperature(snapshot) ?? "", ].join("|"); } diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index 8f484b72..0da120e2 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -323,6 +323,78 @@ function sortObservationItemsByTime(items: T }); } +function normalizeObservationTimeForChart( + value: unknown, + detail: CityDetail, +) { + const raw = String(value || "").trim(); + if (raw && !raw.includes("T")) { + return normalizeHm(raw) || raw; + } + return normalizeHm(detail.local_time) || normalizeHm(raw) || raw; +} + +function buildCurrentObservationFallback( + detail: CityDetail, +): Array<{ time?: string; temp?: number | null; sourceLabel?: string | null }> { + const candidates: Array<{ + sourceLabel?: string | null; + temp?: number | null; + time?: string | null; + }> = [ + { + sourceLabel: detail.current?.settlement_source_label, + temp: detail.current?.temp, + time: detail.current?.obs_time || detail.current?.report_time, + }, + { + sourceLabel: detail.airport_primary?.source_label || "METAR", + temp: detail.airport_primary?.temp, + time: detail.airport_primary?.obs_time || detail.airport_primary?.report_time, + }, + { + sourceLabel: detail.airport_current?.source_label || "METAR", + temp: detail.airport_current?.temp, + time: detail.airport_current?.obs_time || detail.airport_current?.report_time, + }, + { + sourceLabel: + detail.center_station_candidate?.source_label || + detail.center_station_candidate?.source_code, + temp: detail.center_station_candidate?.temp, + time: String((detail.center_station_candidate as Record | null | undefined)?.obs_time || ""), + }, + { + sourceLabel: + detail.official_nearby?.[0]?.source_label || + detail.official_nearby?.[0]?.source_code, + temp: detail.official_nearby?.[0]?.temp, + time: String((detail.official_nearby?.[0] as Record | null | undefined)?.obs_time || ""), + }, + { + sourceLabel: + detail.mgm_nearby?.[0]?.source_label || + detail.mgm_nearby?.[0]?.source_code, + temp: detail.mgm_nearby?.[0]?.temp, + time: String((detail.mgm_nearby?.[0] as Record | null | undefined)?.obs_time || ""), + }, + ]; + + const first = candidates.find((item) => { + const numeric = Number(item.temp); + return Number.isFinite(numeric); + }); + if (!first) return []; + + return [ + { + sourceLabel: first.sourceLabel, + temp: Number(first.temp), + time: normalizeObservationTimeForChart(first.time, detail), + }, + ]; +} + function interpolateSeriesAtMinutes( times: string[], values: Array, @@ -623,9 +695,19 @@ export function getTemperatureChartData( ? [{ time: detail.current.obs_time, temp: detail.current.temp }] : [] : []; + const currentObservationFallback = buildCurrentObservationFallback(detail); const metarObservationSource = detail.metar_today_obs?.length ? detail.metar_today_obs - : detail.trend?.recent || []; + : detail.trend?.recent?.length + ? detail.trend.recent + : currentObservationFallback; + const usingCurrentObservationFallback = + !detail.metar_today_obs?.length && + !detail.trend?.recent?.length && + currentObservationFallback.length > 0; + const currentFallbackTag = + currentObservationFallback[0]?.sourceLabel || + getObservationSourceTag(detail); const allowMetarFallback = settlementSource && observationCode !== "hko"; const shouldUseMetarFallback = allowMetarFallback && @@ -644,7 +726,9 @@ export function getTemperatureChartData( return `${icao} METAR`; })(); const observationDisplayTag = - observationCode === "wunderground" + usingCurrentObservationFallback + ? String(currentFallbackTag).toUpperCase() + : observationCode === "wunderground" ? metarFallbackTag : useSettlementObservationSource && shouldUseMetarFallback ? metarFallbackTag diff --git a/web/analysis_service.py b/web/analysis_service.py index 0925904b..81d9d417 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -30,6 +30,7 @@ from src.analysis.deb_algorithm import calculate_dynamic_weights from src.analysis.settlement_rounding import apply_city_settlement from src.data_collection.country_networks import build_country_network_snapshot from src.data_collection.city_registry import ALIASES, CITY_REGISTRY +from src.data_collection.nmc_sources import NMC_CITY_REFERENCES from src.models.lgbm_daily_high import predict_lgbm_daily_high TURKISH_MGM_CITIES = {"ankara", "istanbul"} @@ -52,6 +53,39 @@ _GROQ_COMMENTARY_CACHE_TTL_SEC = int( ) +def _format_observation_time_local(value: Any, utc_offset: int) -> str: + raw = str(value or "").strip() + if not raw: + return "" + if "T" in raw: + try: + dt = datetime.fromisoformat(raw.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone(timedelta(seconds=utc_offset))).strftime("%H:%M") + except Exception: + pass + match = re.search(r"(\d{1,2}):(\d{2})", raw) + if match: + return f"{int(match.group(1)):02d}:{match.group(2)}" + return raw[:16] + + +def _fetch_nmc_current_fallback(city: str, *, use_fahrenheit: bool) -> Dict[str, Any]: + city_key = str(city or "").strip().lower() + if city_key not in NMC_CITY_REFERENCES: + return {} + try: + payload = _weather.fetch_nmc_region_current( + city_key, + use_fahrenheit=use_fahrenheit, + ) + return payload if isinstance(payload, dict) else {} + except Exception as exc: + logger.debug("NMC current fallback failed city={}: {}", city_key, exc) + return {} + + def _record_analysis_cache_event(*, city: str, hit: bool, force_refresh: bool) -> None: now = datetime.now(timezone.utc).isoformat() with _ANALYSIS_CACHE_STATS_LOCK: @@ -1176,17 +1210,33 @@ def _analyze( sc_cur = settlement_current.get("current", {}) if settlement_current else {} use_settlement_current = settlement_source in {"hko", "cwa", "noaa", "wunderground"} and bool(sc_cur) primary_current = sc_cur if use_settlement_current else mc + current_source = settlement_source + current_source_label = settlement_source_label + current_station_code = settlement_current.get("station_code") + current_station_name = settlement_current.get("station_name") cur_temp = _sf(primary_current.get("temp")) if cur_temp is None: cur_temp = _sf(mc.get("temp")) if cur_temp is None: cur_temp = _sf(mg_cur.get("temp")) + if cur_temp is None: + nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f) + nmc_cur = nmc_fallback.get("current") or {} + nmc_temp = _sf(nmc_cur.get("temp")) + if nmc_temp is not None: + cur_temp = nmc_temp + current_source = "nmc" + current_source_label = "NMC" + current_station_code = nmc_fallback.get("station_code") + current_station_name = nmc_fallback.get("station_name") max_so_far = _sf(primary_current.get("max_temp_so_far")) if max_so_far is None: max_so_far = _sf(mc.get("max_temp_so_far")) if max_so_far is None: max_so_far = _sf(mg_cur.get("mgm_max_temp")) + if max_so_far is None: + max_so_far = cur_temp max_temp_time = primary_current.get("max_temp_time") if not max_temp_time and not use_settlement_current: @@ -1237,6 +1287,12 @@ def _analyze( ) except Exception: obs_time_str = str(obs_t)[:16] + if not obs_time_str and current_source == "nmc": + nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f) + obs_time_str = _format_observation_time_local( + nmc_fallback.get("publish_time") or nmc_fallback.get("timestamp"), + int(utc_offset or 0), + ) settlement_today_obs = [] if use_settlement_current: @@ -1826,10 +1882,10 @@ def _analyze( "max_temp_time": max_temp_time, "raw_max_so_far": raw_settlement_max, "wu_settlement": wu_settle, - "settlement_source": settlement_source, - "settlement_source_label": settlement_source_label, - "station_code": settlement_current.get("station_code"), - "station_name": settlement_current.get("station_name"), + "settlement_source": current_source, + "settlement_source_label": current_source_label, + "station_code": current_station_code, + "station_name": current_station_name, "obs_time": obs_time_str, "obs_age_min": None if use_settlement_current else metar_age_min, "report_time": primary_current.get("report_time"), @@ -2033,17 +2089,30 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: use_settlement_current = settlement_source in {"hko", "cwa", "noaa", "wunderground"} and bool(sc_cur) primary_current = sc_cur if use_settlement_current else mc + current_source = settlement_source + current_source_label = settlement_source_label + nmc_fallback: Dict[str, Any] = {} cur_temp = _sf(primary_current.get("temp")) if cur_temp is None: cur_temp = _sf(mc.get("temp")) if cur_temp is None: cur_temp = _sf(mg_cur.get("temp")) + if cur_temp is None: + nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f) + nmc_cur = nmc_fallback.get("current") or {} + nmc_temp = _sf(nmc_cur.get("temp")) + if nmc_temp is not None: + cur_temp = nmc_temp + current_source = "nmc" + current_source_label = "NMC" max_so_far = _sf(primary_current.get("max_temp_so_far")) if max_so_far is None: max_so_far = _sf(mc.get("max_temp_so_far")) if max_so_far is None: max_so_far = _sf(mg_cur.get("mgm_max_temp")) + if max_so_far is None: + max_so_far = cur_temp max_temp_time = primary_current.get("max_temp_time") if not max_temp_time and not use_settlement_current: @@ -2084,6 +2153,13 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: ) except Exception: obs_time_str = str(obs_t)[:16] + if not obs_time_str and current_source == "nmc": + if not nmc_fallback: + nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f) + obs_time_str = _format_observation_time_local( + nmc_fallback.get("publish_time") or nmc_fallback.get("timestamp"), + int(utc_offset or 0), + ) om_daily = (open_meteo.get("daily") or {}) if isinstance(open_meteo, dict) else {} om_hourly = (open_meteo.get("hourly") or {}) if isinstance(open_meteo, dict) else {} @@ -2200,8 +2276,8 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: "max_so_far": _sf(display_settlement_max), "max_temp_time": max_temp_time, "wu_settlement": _sf(wu_settle), - "settlement_source": settlement_source, - "settlement_source_label": settlement_source_label, + "settlement_source": current_source, + "settlement_source_label": current_source_label, "obs_time": obs_time_str or None, "obs_age_min": obs_age_min, },