diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx index 501fec31..c849c182 100644 --- a/frontend/components/dashboard/PanelSections.tsx +++ b/frontend/components/dashboard/PanelSections.tsx @@ -366,6 +366,14 @@ export function HeroSummary() { current.max_so_far != null && current.temp != null && current.max_so_far <= current.temp; + const currentObsText = + current.temp != null + ? `${current.temp}${data.temp_symbol} @${current.obs_time || "--"}` + : data.metar_status?.stale_for_today + ? locale === "en-US" + ? "No same-day METAR" + : "今日暂无 METAR" + : "--"; return (
@@ -393,9 +401,7 @@ export function HeroSummary() { {locale === "en-US" ? "Current Obs" : "当前实测"} - {current.temp != null - ? `${current.temp}${data.temp_symbol} @${current.obs_time || "--"}` - : "--"} + {currentObsText}
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 668d2972..8b7c1c72 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -64,6 +64,7 @@ export interface CurrentConditions { station_name?: string | null; obs_time: string | null; obs_age_min: number | null; + observation_status?: "live" | "missing" | "stale" | string | null; wind_speed_kt: number | null; wind_dir: number | null; humidity: number | null; @@ -100,6 +101,9 @@ export interface AirportCurrentConditions { is_airport_station?: boolean; is_official?: boolean; is_settlement_anchor?: boolean; + stale_for_today?: boolean; + last_observation_local_date?: string | null; + current_local_date?: string | null; } export interface NearbyStation { @@ -466,6 +470,14 @@ export interface CityDetail { time?: string; temp?: number | null; }>; + metar_status?: { + available_for_today?: boolean; + stale_for_today?: boolean; + last_observation_time?: string | null; + last_observation_local_date?: string | null; + current_local_date?: string | null; + last_temp?: number | null; + }; settlement_today_obs?: Array<{ time?: string; temp?: number | null; diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index 727e1acd..9ac57327 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -1060,6 +1060,18 @@ export function getTemperatureChartData( : `${metarFallbackTag}: ${airportRecentText}`, ); } + if (detail.metar_status?.stale_for_today) { + const dateText = detail.metar_status.last_observation_local_date || ""; + const tempText = + detail.metar_status.last_temp != null + ? `${detail.metar_status.last_temp}${detail.temp_symbol}` + : ""; + legendParts.push( + isEnglish(locale) + ? `No same-day ${metarFallbackTag} report yet; latest report${dateText ? ` was ${dateText}` : ""}${tempText ? ` at ${tempText}` : ""}.` + : `今日暂无同日 ${metarFallbackTag} 报文;最近一报${dateText ? `为 ${dateText}` : ""}${tempText ? `,${tempText}` : ""}。`, + ); + } if (shouldUseMetarFallback) { legendParts.push( isEnglish(locale) diff --git a/src/data_collection/metar_sources.py b/src/data_collection/metar_sources.py index bf8e50fa..7fbb6d1f 100644 --- a/src/data_collection/metar_sources.py +++ b/src/data_collection/metar_sources.py @@ -202,6 +202,16 @@ class MetarSourceMixin: now_utc = datetime.now(timezone.utc) local_now = now_utc + timedelta(seconds=utc_offset) + current_local_date = local_now.date().isoformat() + observation_local_date = None + if obs_dt: + observation_local_date = ( + obs_dt + timedelta(seconds=utc_offset) + ).date().isoformat() + stale_for_today = bool( + observation_local_date + and observation_local_date != current_local_date + ) local_midnight = local_now.replace(hour=0, minute=0, second=0, microsecond=0) utc_midnight = local_midnight - timedelta(seconds=utc_offset) @@ -288,6 +298,9 @@ class MetarSourceMixin: "station_name": latest.get("name", icao), "timestamp": datetime.utcnow().isoformat(), "observation_time": obs_time, + "observation_local_date": observation_local_date, + "current_local_date": current_local_date, + "stale_for_today": stale_for_today, "report_time": latest.get("reportTime"), "receipt_time": latest.get("receiptTime"), "obs_time_epoch": latest.get("obsTime"), diff --git a/tests/test_country_networks.py b/tests/test_country_networks.py index 1192088f..35e40b4e 100644 --- a/tests/test_country_networks.py +++ b/tests/test_country_networks.py @@ -1,8 +1,10 @@ +import threading from datetime import datetime, timedelta, timezone 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.city_time import CITY_TIME_ZONES, get_city_utc_offset_seconds +from src.data_collection import metar_sources from src.data_collection.metar_sources import MetarSourceMixin from web.analysis_service import _build_city_detail_payload, _build_intraday_meteorology from web.core import CITIES @@ -66,6 +68,51 @@ def test_turkey_metar_uses_fast_cache_ttl(): assert source._metar_cache_ttl_for_city("karachi", "OPKC") == 600 +def test_metar_marks_previous_local_day_report_stale(monkeypatch): + class FixedDateTime(datetime): + @classmethod + def now(cls, tz=None): + value = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc) + return value if tz is None else value.astimezone(tz) + + @classmethod + def utcnow(cls): + return datetime(2026, 4, 21, 12, 0) + + class FakeResponse: + def raise_for_status(self): + return None + + def json(self): + return [ + { + "rawOb": "METAR DNMM 200600Z 00000KT 9999 SCT015 26/25 Q1012", + "reportTime": "2026-04-20T06:00:00Z", + "receiptTime": "2026-04-20T06:12:07Z", + "temp": 26, + "dewp": 25, + "name": "Murtala Muhammed International Airport", + } + ] + + source = _DummyMetarSource() + source._metar_cache = {} + source._metar_cache_lock = threading.Lock() + source.metar_timeout_sec = 1 + source.metar_latest_timeout_sec = 1 + source.timeout = 1 + source._http_get = lambda *args, **kwargs: FakeResponse() + + monkeypatch.setattr(metar_sources, "datetime", FixedDateTime) + + result = source.fetch_metar("lagos", utc_offset=3600) + + assert result["stale_for_today"] is True + assert result["observation_local_date"] == "2026-04-20" + assert result["current_local_date"] == "2026-04-21" + assert result["today_obs"] == [] + + def test_turkey_mgm_provider_returns_official_nearby_rows(): anchor_time = datetime.now(timezone.utc).replace(microsecond=0) station_time = anchor_time + timedelta(minutes=8) diff --git a/web/analysis_service.py b/web/analysis_service.py index beaceefd..9fb2e90b 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -99,6 +99,39 @@ def _is_plausible_city_temp(city: str, value: Any, unit: str = "°C") -> bool: return temp >= min_value +def _parse_utc_datetime(value: Any) -> Optional[datetime]: + raw = str(value or "").strip() + if not raw or "T" not in raw: + return None + try: + dt = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except Exception: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _metar_is_current_local_day( + metar: Dict[str, Any], + *, + local_date: str, + utc_offset: int, +) -> bool: + if not isinstance(metar, dict) or not metar: + return False + if metar.get("stale_for_today") is True: + return False + observation_local_date = str(metar.get("observation_local_date") or "").strip() + if observation_local_date: + return observation_local_date == local_date + obs_dt = _parse_utc_datetime(metar.get("observation_time")) + if obs_dt is None: + return True + local_dt = obs_dt.astimezone(timezone(timedelta(seconds=utc_offset))) + return local_dt.strftime("%Y-%m-%d") == local_date + + def _record_analysis_cache_event(*, city: str, hit: bool, force_refresh: bool) -> None: now = datetime.now(timezone.utc).isoformat() with _ANALYSIS_CACHE_STATS_LOCK: @@ -1576,12 +1609,46 @@ def _analyze( else {} ) + # 优先从 API 获取偏移;若缺失则尝试 NWS 动态偏移;最后回退静态配置。 + # 当前日期/时间必须来自运行时钟,不能使用 Open-Meteo 缓存里的 local_time。 + utc_offset = om.get("utc_offset") + if utc_offset is None: + try: + nws_periods = (raw.get("nws", {}) or {}).get("forecast_periods", []) or [] + if nws_periods: + first_start = nws_periods[0].get("start_time") + if first_start: + maybe_dt = datetime.fromisoformat(str(first_start)) + if maybe_dt.utcoffset() is not None: + utc_offset = int(maybe_dt.utcoffset().total_seconds()) + except Exception: + utc_offset = None + if utc_offset is None: + utc_offset = get_city_utc_offset_seconds(city) + try: + utc_offset = int(utc_offset or 0) + except Exception: + utc_offset = get_city_utc_offset_seconds(city) + now_utc = datetime.now(timezone.utc) + local_now = now_utc + timedelta(seconds=utc_offset) + local_date_str = local_now.strftime("%Y-%m-%d") + local_hour = local_now.hour + local_minute = local_now.minute + local_time_str = f"{local_hour:02d}:{local_minute:02d}" + local_hour_frac = local_hour + local_minute / 60 + metar_current_is_today = _metar_is_current_local_day( + metar, + local_date=local_date_str, + utc_offset=int(utc_offset or 0), + ) + # ── 2. Current conditions (city-specific settlement source first, then METAR/MGM fallback) ── mc = metar.get("current", {}) if metar else {} mg_cur = mgm.get("current", {}) if mgm else {} 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 + live_mc = mc if metar_current_is_today else {} + primary_current = sc_cur if use_settlement_current else live_mc current_source = settlement_source current_source_label = settlement_source_label current_station_code = settlement_current.get("station_code") @@ -1590,7 +1657,7 @@ def _analyze( if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym): cur_temp = None if cur_temp is None: - cur_temp = _sf(mc.get("temp")) + cur_temp = _sf(live_mc.get("temp")) if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym): cur_temp = None if cur_temp is None: @@ -1612,7 +1679,7 @@ def _analyze( if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym): max_so_far = None if max_so_far is None: - max_so_far = _sf(mc.get("max_temp_so_far")) + max_so_far = _sf(live_mc.get("max_temp_so_far")) if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym): max_so_far = None if max_so_far is None: @@ -1624,7 +1691,7 @@ def _analyze( max_temp_time = primary_current.get("max_temp_time") if not max_temp_time and not use_settlement_current: - max_temp_time = mc.get("max_temp_time") + max_temp_time = live_mc.get("max_temp_time") if not max_temp_time: max_temp_time = mg_cur.get("time", "") if " " in max_temp_time: @@ -1642,28 +1709,13 @@ def _analyze( obs_t = "" if use_settlement_current: obs_t = str(settlement_current.get("observation_time") or "").strip() - if not obs_t: + if not obs_t and metar_current_is_today: obs_t = metar.get("observation_time", "") if metar else "" - # 优先从 API 获取偏移;若缺失则尝试 NWS 动态偏移;最后回退静态配置 - utc_offset = om.get("utc_offset") - if utc_offset is None: - try: - nws_periods = (raw.get("nws", {}) or {}).get("forecast_periods", []) or [] - if nws_periods: - first_start = nws_periods[0].get("start_time") - if first_start: - maybe_dt = datetime.fromisoformat(str(first_start)) - if maybe_dt.utcoffset() is not None: - utc_offset = int(maybe_dt.utcoffset().total_seconds()) - except Exception: - utc_offset = None - if utc_offset is None: - utc_offset = get_city_utc_offset_seconds(city) if obs_t and "T" in obs_t: try: - dt = datetime.fromisoformat(str(obs_t).replace("Z", "+00:00")) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) + dt = _parse_utc_datetime(obs_t) + if dt is None: + raise ValueError("invalid observation time") local_dt = dt.astimezone(timezone(timedelta(seconds=utc_offset))) obs_time_str = local_dt.strftime("%H:%M") metar_age_min = int( @@ -1679,6 +1731,15 @@ def _analyze( ) airport_primary_current = dict(network_snapshot.get("airport_primary_current") or {}) + if ( + airport_primary_current.get("source_code") == "metar" + and metar + and not metar_current_is_today + ): + airport_primary_current["temp"] = None + airport_primary_current["stale_for_today"] = True + airport_primary_current["last_observation_local_date"] = metar.get("observation_local_date") + airport_primary_current["current_local_date"] = local_date_str if ( airport_primary_current.get("source_code") == "metar" and obs_time_str @@ -1717,12 +1778,16 @@ def _analyze( metar_today_obs_payload = [ {"time": t, "temp": v} - for t, v in (metar.get("today_obs", []) if metar else []) + for t, v in ( + metar.get("today_obs", []) if metar and metar_current_is_today else [] + ) if _is_plausible_city_temp(city, v, sym) ] metar_recent_obs_payload = [ point - for point in (metar.get("recent_obs", []) if metar else []) + for point in ( + metar.get("recent_obs", []) if metar and metar_current_is_today else [] + ) if isinstance(point, dict) and _is_plausible_city_temp(city, point.get("temp"), sym) ] @@ -1736,29 +1801,7 @@ def _analyze( 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", "") - local_hour, local_minute = 12, 0 - now_utc = datetime.now(timezone.utc) - local_now = now_utc + timedelta(seconds=utc_offset) - local_date_str = local_now.strftime("%Y-%m-%d") - - try: - if local_time_full: - local_date_str = local_time_full.split(" ")[0] - tp = local_time_full.split(" ")[1].split(":") - local_hour = int(tp[0]) - local_minute = int(tp[1]) if len(tp) > 1 else 0 - else: - local_hour = local_now.hour - local_minute = local_now.minute - except Exception: - local_hour = local_now.hour - local_minute = local_now.minute - local_time_str = f"{local_hour:02d}:{local_minute:02d}" - local_hour_frac = local_hour + local_minute / 60 - - # ── 4. Daily forecast ── + # ── 3. Daily forecast ── daily = om.get("daily", {}) dates = daily.get("time", [])[:5] maxtemps = daily.get("temperature_2m_max", [])[:5] @@ -2288,6 +2331,7 @@ def _analyze( "station_name": current_station_name, "obs_time": obs_time_str, "obs_age_min": None if use_settlement_current else metar_age_min, + "observation_status": "live" if cur_temp is not None else "missing", "report_time": primary_current.get("report_time"), "receipt_time": primary_current.get("receipt_time"), "obs_time_epoch": primary_current.get("obs_time_epoch"), @@ -2303,7 +2347,7 @@ def _analyze( "raw_metar": primary_current.get("raw_metar"), }, "airport_current": { - "temp": _sf(mc.get("temp")), + "temp": _sf(live_mc.get("temp")), "obs_time": obs_time_str, "max_so_far": airport_max_so_far, "max_temp_time": airport_max_temp_time, @@ -2311,14 +2355,17 @@ def _analyze( "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")), + "wind_speed_kt": _sf(live_mc.get("wind_speed_kt")), + "wind_dir": _sf(live_mc.get("wind_dir")), + "humidity": _sf(live_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"), + "visibility_mi": _sf(live_mc.get("visibility_mi")), + "wx_desc": live_mc.get("wx_desc"), + "raw_metar": live_mc.get("raw_metar"), "source_label": "METAR", + "stale_for_today": bool(metar) and not metar_current_is_today, + "last_observation_local_date": metar.get("observation_local_date") if metar else None, + "current_local_date": local_date_str, }, "settlement_station": network_snapshot.get("settlement_station") or {}, "airport_primary": airport_primary_current, @@ -2387,6 +2434,14 @@ def _analyze( else {}, "metar_today_obs": metar_today_obs_payload, "metar_recent_obs": metar_recent_obs_payload, + "metar_status": { + "available_for_today": metar_current_is_today, + "stale_for_today": bool(metar) and not metar_current_is_today, + "last_observation_time": metar.get("observation_time") if metar else None, + "last_observation_local_date": metar.get("observation_local_date") if metar else None, + "current_local_date": local_date_str, + "last_temp": _sf(mc.get("temp")) if mc else None, + }, "settlement_today_obs": settlement_today_obs, "ai_analysis": "", "updated_at": datetime.now(timezone.utc).isoformat(), @@ -2487,16 +2542,29 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: utc_offset = int(utc_offset or 0) except Exception: utc_offset = default_utc_offset + now_utc = datetime.now(timezone.utc) + local_now = now_utc + timedelta(seconds=utc_offset) + local_date_str = local_now.strftime("%Y-%m-%d") + local_hour = local_now.hour + local_minute = local_now.minute + local_time_str = f"{local_hour:02d}:{local_minute:02d}" + local_hour_frac = local_hour + local_minute / 60.0 metar = fetched.get("metar") or {} mgm = fetched.get("mgm") or {} nws = fetched.get("nws") or {} hko_forecast = fetched.get("hko_forecast") + metar_current_is_today = _metar_is_current_local_day( + metar, + local_date=local_date_str, + utc_offset=int(utc_offset or 0), + ) sc_cur = settlement_current.get("current") or {} mc = metar.get("current") or {} + live_mc = mc if metar_current_is_today else {} mg_cur = mgm.get("current") or {} use_settlement_current = settlement_source in {"hko", "cwa", "noaa", "wunderground"} and bool(sc_cur) - primary_current = sc_cur if use_settlement_current else mc + primary_current = sc_cur if use_settlement_current else live_mc current_source = settlement_source current_source_label = settlement_source_label @@ -2505,7 +2573,7 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym): cur_temp = None if cur_temp is None: - cur_temp = _sf(mc.get("temp")) + cur_temp = _sf(live_mc.get("temp")) if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym): cur_temp = None if cur_temp is None: @@ -2525,7 +2593,7 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym): max_so_far = None if max_so_far is None: - max_so_far = _sf(mc.get("max_temp_so_far")) + max_so_far = _sf(live_mc.get("max_temp_so_far")) if max_so_far is not None and not _is_plausible_city_temp(city, max_so_far, sym): max_so_far = None if max_so_far is None: @@ -2537,7 +2605,7 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: max_temp_time = primary_current.get("max_temp_time") if not max_temp_time and not use_settlement_current: - max_temp_time = mc.get("max_temp_time") + max_temp_time = live_mc.get("max_temp_time") if not max_temp_time: mgm_time = str(mg_cur.get("time") or "") if " " in mgm_time: @@ -2560,13 +2628,13 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: obs_t = "" if use_settlement_current: obs_t = str(settlement_current.get("observation_time") or "").strip() - if not obs_t: + if not obs_t and metar_current_is_today: obs_t = str(metar.get("observation_time") or "").strip() if obs_t and "T" in obs_t: try: - dt = datetime.fromisoformat(obs_t.replace("Z", "+00:00")) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) + dt = _parse_utc_datetime(obs_t) + if dt is None: + raise ValueError("invalid observation time") local_dt = dt.astimezone(timezone(timedelta(seconds=utc_offset))) obs_time_str = local_dt.strftime("%H:%M") obs_age_min = int( @@ -2625,23 +2693,6 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: if deb_val is None: deb_val = om_today - local_time_full = (open_meteo.get("current") or {}).get("local_time", "") - now_utc = datetime.now(timezone.utc) - local_now = now_utc + timedelta(seconds=utc_offset) - local_date_str = local_now.strftime("%Y-%m-%d") - local_hour = local_now.hour - local_minute = local_now.minute - try: - if local_time_full: - local_date_str = str(local_time_full).split(" ")[0] - tp = str(local_time_full).split(" ")[1].split(":") - local_hour = int(tp[0]) - local_minute = int(tp[1]) if len(tp) > 1 else 0 - except Exception: - pass - local_time_str = f"{local_hour:02d}:{local_minute:02d}" - local_hour_frac = local_hour + local_minute / 60.0 - settlement_today_obs = [] if use_settlement_current: explicit_obs = settlement_current.get("today_obs") or [] @@ -2663,7 +2714,11 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: metar_today_obs_payload = [ {"time": obs_time, "temp": obs_temp} - for obs_time, obs_temp in ((metar.get("today_obs") or []) if isinstance(metar, dict) else []) + for obs_time, obs_temp in ( + (metar.get("today_obs") or []) + if isinstance(metar, dict) and metar_current_is_today + else [] + ) ] deviation_monitor = _build_deviation_monitor( @@ -2702,6 +2757,7 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: "settlement_source_label": current_source_label, "obs_time": obs_time_str or None, "obs_age_min": obs_age_min, + "observation_status": "live" if cur_temp is not None else "missing", }, "deb": {"prediction": _sf(deb_val)}, "deviation_monitor": deviation_monitor or {},