From d6cc3b1b908b3badf8f621c57438f888f70e265b Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 19 Apr 2026 15:37:13 +0800 Subject: [PATCH] Fix city-local time display for nearby stations --- src/data_collection/city_time.py | 173 ++++++++++++++++++++++++ src/data_collection/country_networks.py | 60 +++++++- tests/test_country_networks.py | 111 ++++++++++++++- web/analysis_service.py | 5 +- web/routes.py | 3 +- 5 files changed, 337 insertions(+), 15 deletions(-) create mode 100644 src/data_collection/city_time.py diff --git a/src/data_collection/city_time.py b/src/data_collection/city_time.py new file mode 100644 index 00000000..74f288e6 --- /dev/null +++ b/src/data_collection/city_time.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +from src.data_collection.city_registry import CITY_REGISTRY + +try: + from zoneinfo import ZoneInfo +except Exception: # pragma: no cover - Python always has zoneinfo in supported runtimes. + ZoneInfo = None # type: ignore[assignment] + + +CITY_TIME_ZONES = { + "amsterdam": "Europe/Amsterdam", + "ankara": "Europe/Istanbul", + "atlanta": "America/New_York", + "aurora": "America/Denver", + "austin": "America/Chicago", + "beijing": "Asia/Shanghai", + "buenos aires": "America/Argentina/Buenos_Aires", + "busan": "Asia/Seoul", + "cape town": "Africa/Johannesburg", + "chengdu": "Asia/Shanghai", + "chicago": "America/Chicago", + "chongqing": "Asia/Shanghai", + "dallas": "America/Chicago", + "guangzhou": "Asia/Shanghai", + "helsinki": "Europe/Helsinki", + "hong kong": "Asia/Hong_Kong", + "houston": "America/Chicago", + "istanbul": "Europe/Istanbul", + "jakarta": "Asia/Jakarta", + "jeddah": "Asia/Riyadh", + "karachi": "Asia/Karachi", + "kuala lumpur": "Asia/Kuala_Lumpur", + "lagos": "Africa/Lagos", + "lau fau shan": "Asia/Hong_Kong", + "london": "Europe/London", + "los angeles": "America/Los_Angeles", + "lucknow": "Asia/Kolkata", + "madrid": "Europe/Madrid", + "manila": "Asia/Manila", + "masroor air base": "Asia/Karachi", + "mexico city": "America/Mexico_City", + "miami": "America/New_York", + "milan": "Europe/Rome", + "moscow": "Europe/Moscow", + "munich": "Europe/Berlin", + "new york": "America/New_York", + "panama city": "America/Panama", + "paris": "Europe/Paris", + "san francisco": "America/Los_Angeles", + "sao paulo": "America/Sao_Paulo", + "seattle": "America/Los_Angeles", + "seoul": "Asia/Seoul", + "shanghai": "Asia/Shanghai", + "shenzhen": "Asia/Shanghai", + "singapore": "Asia/Singapore", + "taipei": "Asia/Taipei", + "tel aviv": "Asia/Jerusalem", + "tokyo": "Asia/Tokyo", + "toronto": "America/Toronto", + "warsaw": "Europe/Warsaw", + "wellington": "Pacific/Auckland", + "wuhan": "Asia/Shanghai", +} + + +def normalize_city_key(city: Any) -> str: + return str(city or "").strip().lower() + + +def get_city_timezone_name(city: Any) -> Optional[str]: + return CITY_TIME_ZONES.get(normalize_city_key(city)) + + +def _last_weekday(year: int, month: int, weekday: int) -> datetime: + if month == 12: + candidate = datetime(year + 1, 1, 1, tzinfo=timezone.utc) - timedelta(days=1) + else: + candidate = datetime(year, month + 1, 1, tzinfo=timezone.utc) - timedelta(days=1) + while candidate.weekday() != weekday: + candidate -= timedelta(days=1) + return candidate + + +def _nth_weekday(year: int, month: int, weekday: int, n: int) -> datetime: + candidate = datetime(year, month, 1, tzinfo=timezone.utc) + while candidate.weekday() != weekday: + candidate += timedelta(days=1) + return candidate + timedelta(days=7 * (n - 1)) + + +def _fallback_zone_offset_seconds(tz_name: str, at: datetime, standard_offset: int) -> int: + moment = at.astimezone(timezone.utc) + year = moment.year + + if tz_name in { + "Europe/Amsterdam", + "Europe/Berlin", + "Europe/Helsinki", + "Europe/London", + "Europe/Madrid", + "Europe/Paris", + "Europe/Rome", + "Europe/Warsaw", + }: + start = _last_weekday(year, 3, 6).replace(hour=1, minute=0, second=0, microsecond=0) + end = _last_weekday(year, 10, 6).replace(hour=1, minute=0, second=0, microsecond=0) + return standard_offset + 3600 if start <= moment < end else standard_offset + + north_america_standard = { + "America/New_York": -18000, + "America/Toronto": -18000, + "America/Chicago": -21600, + "America/Denver": -25200, + "America/Los_Angeles": -28800, + } + if tz_name in north_america_standard: + standard = north_america_standard[tz_name] + start_hour_utc = 2 - (standard // 3600) + end_hour_utc = 1 - (standard // 3600) + start = _nth_weekday(year, 3, 6, 2).replace(hour=start_hour_utc, minute=0, second=0, microsecond=0) + end = _nth_weekday(year, 11, 6, 1).replace(hour=end_hour_utc, minute=0, second=0, microsecond=0) + return standard + 3600 if start <= moment < end else standard + + if tz_name == "Pacific/Auckland": + standard = 43200 + start_local = _last_weekday(year, 9, 6).replace(hour=2, minute=0, second=0, microsecond=0) + start = (start_local - timedelta(seconds=standard)).replace(tzinfo=timezone.utc) + end_local = _nth_weekday(year, 4, 6, 1).replace(hour=3, minute=0, second=0, microsecond=0) + end = (end_local - timedelta(seconds=standard + 3600)).replace(tzinfo=timezone.utc) + return standard + 3600 if moment >= start or moment < end else standard + + if tz_name == "Asia/Jerusalem": + start = (_last_weekday(year, 3, 6) - timedelta(days=2)).replace(hour=0, minute=0, second=0, microsecond=0) + end = _last_weekday(year, 10, 6).replace(hour=0, minute=0, second=0, microsecond=0) + return 10800 if start <= moment < end else 7200 + + return standard_offset + + +def get_city_utc_offset_seconds(city: Any, at: Optional[datetime] = None) -> int: + key = normalize_city_key(city) + meta = CITY_REGISTRY.get(key, {}) or {} + fallback = int(meta.get("tz_offset") or 0) + tz_name = CITY_TIME_ZONES.get(key) + if not tz_name or ZoneInfo is None: + return _fallback_zone_offset_seconds(tz_name or "", at or datetime.now(timezone.utc), fallback) + try: + moment = at or datetime.now(timezone.utc) + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + offset = moment.astimezone(ZoneInfo(tz_name)).utcoffset() + if offset is None: + return _fallback_zone_offset_seconds(tz_name, moment, fallback) + return int(offset.total_seconds()) + except Exception: + return _fallback_zone_offset_seconds(tz_name, at or datetime.now(timezone.utc), fallback) + + +def city_local_datetime(city: Any, at: Optional[datetime] = None) -> datetime: + moment = at or datetime.now(timezone.utc) + if moment.tzinfo is None: + moment = moment.replace(tzinfo=timezone.utc) + tz_name = get_city_timezone_name(city) + if tz_name and ZoneInfo is not None: + try: + return moment.astimezone(ZoneInfo(tz_name)) + except Exception: + pass + return moment.astimezone(timezone(timedelta(seconds=get_city_utc_offset_seconds(city, moment)))) diff --git a/src/data_collection/country_networks.py b/src/data_collection/country_networks.py index b1397963..bfde33a2 100644 --- a/src/data_collection/country_networks.py +++ b/src/data_collection/country_networks.py @@ -1,10 +1,11 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional from src.data_collection.city_registry import CITY_REGISTRY +from src.data_collection.city_time import get_city_utc_offset_seconds CHINA_CMA_CITIES = { @@ -31,7 +32,11 @@ def _safe_float(value: Any) -> Optional[float]: return None -def _parse_obs_datetime(value: Any, epoch_value: Any = None) -> Optional[datetime]: +def _parse_obs_datetime( + value: Any, + epoch_value: Any = None, + utc_offset_seconds: Any = None, +) -> Optional[datetime]: for candidate in (epoch_value, value): if candidate is None or candidate == "": continue @@ -54,16 +59,34 @@ def _parse_obs_datetime(value: Any, epoch_value: Any = None) -> Optional[datetim normalized = text.replace(" ", "T") if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" - return datetime.fromisoformat(normalized) + parsed = datetime.fromisoformat(normalized) + if parsed.tzinfo is None and utc_offset_seconds is not None: + try: + offset = int(utc_offset_seconds) + parsed = parsed.replace(tzinfo=timezone(timedelta(seconds=offset))) + except Exception: + pass + return parsed except Exception: continue return None -def _format_obs_time_label(value: Any, epoch_value: Any = None) -> Optional[str]: +def _format_obs_time_label( + value: Any, + epoch_value: Any = None, + display_utc_offset_seconds: Any = None, +) -> Optional[str]: text = str(value or "").strip() parsed = _parse_obs_datetime(value, epoch_value) if parsed is not None: + if display_utc_offset_seconds is not None and parsed.tzinfo is not None: + try: + offset = int(display_utc_offset_seconds) + local_tz = timezone(timedelta(seconds=offset)) + return parsed.astimezone(local_tz).strftime("%H:%M") + except Exception: + pass suffix = "Z" if parsed.tzinfo is not None and parsed.utcoffset() == timezone.utc.utcoffset(parsed) else "" return parsed.strftime("%H:%M") + suffix if not text: @@ -97,6 +120,8 @@ def _station_age_minutes(station_dt: Optional[datetime]) -> Optional[int]: def _sync_status(delta_minutes: Optional[int], age_minutes: Optional[int]) -> str: + if age_minutes is not None and age_minutes > 60: + return "stale" reference = delta_minutes if delta_minutes is not None else age_minutes if reference is None: return "unknown" @@ -112,17 +137,32 @@ def _sync_status(delta_minutes: Optional[int], age_minutes: Optional[int]) -> st def _enrich_station_timing( anchor: Optional[Dict[str, Any]], rows: List[Dict[str, Any]], + display_utc_offset_seconds: Any = None, ) -> List[Dict[str, Any]]: anchor = anchor or {} - anchor_dt = _parse_obs_datetime(anchor.get("obs_time"), anchor.get("obs_time_epoch")) + anchor_dt = _parse_obs_datetime( + anchor.get("obs_time"), + anchor.get("obs_time_epoch"), + anchor.get("obs_time_utc_offset_seconds"), + ) enriched: List[Dict[str, Any]] = [] for row in rows: - station_dt = _parse_obs_datetime(row.get("obs_time"), row.get("obs_time_epoch")) + station_dt = _parse_obs_datetime( + row.get("obs_time"), + row.get("obs_time_epoch"), + row.get("obs_time_utc_offset_seconds"), + ) delta_minutes = _timing_delta_minutes(anchor_dt, station_dt) age_minutes = _station_age_minutes(station_dt) status = _sync_status(delta_minutes, age_minutes) enriched_row = dict(row) - enriched_row["obs_time_label"] = _format_obs_time_label(row.get("obs_time"), row.get("obs_time_epoch")) + enriched_row["obs_time_label"] = _format_obs_time_label( + row.get("obs_time"), + row.get("obs_time_epoch"), + display_utc_offset_seconds, + ) + if display_utc_offset_seconds is not None and enriched_row.get("obs_time_label"): + enriched_row["obs_time_display_tz"] = "city_local" enriched_row["age_minutes"] = age_minutes enriched_row["time_delta_vs_anchor_minutes"] = delta_minutes enriched_row["sync_status"] = status @@ -216,6 +256,7 @@ def _airport_primary_from_raw(city: str, raw: Dict[str, Any]) -> Dict[str, Any]: "report_time": metar.get("report_time"), "receipt_time": metar.get("receipt_time"), "obs_time_epoch": metar.get("obs_time_epoch"), + "obs_time_utc_offset_seconds": 0, "wind_speed_kt": _safe_float(current.get("wind_speed_kt")), "wind_dir": _safe_float(current.get("wind_dir")), "humidity": _safe_float(current.get("humidity")), @@ -247,6 +288,7 @@ def _metar_cluster_rows(raw: Dict[str, Any]) -> List[Dict[str, Any]]: is_settlement_anchor=False, extra={ "obs_time_epoch": row.get("obs_time_epoch"), + "obs_time_utc_offset_seconds": 0, "wind_dir": _safe_float(row.get("wind_dir")), "wind_speed_kt": _safe_float(row.get("wind_speed_kt") or row.get("wind_speed")), "raw_metar": row.get("raw_metar"), @@ -258,6 +300,7 @@ def _metar_cluster_rows(raw: Dict[str, Any]) -> List[Dict[str, Any]]: def _nmc_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]: rows = raw.get("nmc_official_nearby") or [] + city_offset = get_city_utc_offset_seconds(city) out: List[Dict[str, Any]] = [] for row in rows: if not isinstance(row, dict): @@ -276,6 +319,7 @@ def _nmc_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]: is_airport_station=False, is_settlement_anchor=False, extra={ + "obs_time_utc_offset_seconds": city_offset, "page_url": row.get("page_url"), "humidity": _safe_float(row.get("humidity")), "rain": _safe_float(row.get("rain")), @@ -697,11 +741,13 @@ def get_country_network_provider(city: str) -> CountryNetworkProvider: def build_country_network_snapshot(city: str, raw: Dict[str, Any]) -> Dict[str, Any]: provider = get_country_network_provider(city) + city_offset = get_city_utc_offset_seconds(city) metadata = provider.settlement_station_metadata(city) airport_primary = provider.airport_primary_current(city, raw) or {} official_nearby = _enrich_station_timing( airport_primary, provider.official_nearby_current(city, raw), + city_offset, ) status = provider.official_network_status(city, raw) signals = _network_signals(airport_primary, official_nearby) diff --git a/tests/test_country_networks.py b/tests/test_country_networks.py index 0dbde8d2..d788f242 100644 --- a/tests/test_country_networks.py +++ b/tests/test_country_networks.py @@ -1,5 +1,8 @@ +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.metar_sources import MetarSourceMixin from web.analysis_service import _build_city_detail_payload, _build_intraday_meteorology from web.core import CITIES @@ -26,6 +29,22 @@ def test_new_south_asia_city_registry_entries_are_wired(): assert CITIES["masroor air base"]["settlement_source"] == "metar" +def test_city_time_zone_mapping_covers_every_city_and_dst_offsets(): + missing = sorted(set(CITY_REGISTRY) - set(CITY_TIME_ZONES)) + assert missing == [] + + april = datetime(2026, 4, 19, 6, 30, tzinfo=timezone.utc) + + assert get_city_utc_offset_seconds("guangzhou", april) == 28800 + assert get_city_utc_offset_seconds("ankara", april) == 10800 + assert get_city_utc_offset_seconds("london", april) == 3600 + assert get_city_utc_offset_seconds("paris", april) == 7200 + assert get_city_utc_offset_seconds("new york", april) == -14400 + assert get_city_utc_offset_seconds("chicago", april) == -18000 + assert get_city_utc_offset_seconds("los angeles", april) == -25200 + assert get_city_utc_offset_seconds("wellington", april) == 43200 + + def test_paris_registry_uses_le_bourget_anchor(): paris = CITY_REGISTRY["paris"] @@ -48,9 +67,11 @@ def test_turkey_metar_uses_fast_cache_ttl(): 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) raw = { "metar": { - "observation_time": "2026-04-06T10:00:00.000Z", + "observation_time": anchor_time.isoformat().replace("+00:00", "Z"), "current": {"temp": 16.0}, }, "mgm_nearby": [ @@ -60,7 +81,7 @@ def test_turkey_mgm_provider_returns_official_nearby_rows(): "lat": 40.1, "lon": 32.9, "temp": 17.1, - "obs_time": "2026-04-06T10:08:00.000Z", + "obs_time": station_time.isoformat().replace("+00:00", "Z"), } ], } @@ -78,9 +99,12 @@ def test_turkey_mgm_provider_returns_official_nearby_rows(): def test_nearby_station_timing_marks_stale_rows_unusable_for_network_signal(): + anchor_time = datetime.now(timezone.utc).replace(microsecond=0) + fresh_time = anchor_time + timedelta(minutes=20) + stale_time = anchor_time - timedelta(minutes=90) raw = { "metar": { - "observation_time": "2026-04-06T10:00:00.000Z", + "observation_time": anchor_time.isoformat().replace("+00:00", "Z"), "current": {"temp": 20.0}, }, "mgm_nearby": [ @@ -90,7 +114,7 @@ def test_nearby_station_timing_marks_stale_rows_unusable_for_network_signal(): "lat": 40.1, "lon": 32.9, "temp": 21.0, - "obs_time": "2026-04-06T10:20:00.000Z", + "obs_time": fresh_time.isoformat().replace("+00:00", "Z"), }, { "name": "Stale Hot", @@ -98,7 +122,7 @@ def test_nearby_station_timing_marks_stale_rows_unusable_for_network_signal(): "lat": 40.2, "lon": 33.0, "temp": 26.0, - "obs_time": "2026-04-06T08:30:00.000Z", + "obs_time": stale_time.isoformat().replace("+00:00", "Z"), }, ], } @@ -140,6 +164,57 @@ def test_china_provider_falls_back_to_metar_cluster_without_replacing_airport_an assert snapshot["official_nearby"][0]["is_official"] is False +def test_metar_cluster_obs_time_label_uses_city_local_time(): + raw = { + "metar": { + "observation_time": "2026-04-19T06:30:00.000Z", + "current": {"temp": 32.0}, + }, + "mgm_nearby": [ + { + "name": "Guangzhou/Baiyun", + "icao": "ZGGG", + "lat": 23.39, + "lon": 113.30, + "temp": 32.0, + "obs_time": "2026-04-19T06:30:00.000Z", + } + ], + } + + snapshot = build_country_network_snapshot("guangzhou", raw) + row = snapshot["official_nearby"][0] + + assert row["source_code"] == "metar_cluster" + assert row["obs_time_label"] == "14:30" + assert row["obs_time_display_tz"] == "city_local" + + +def test_metar_cluster_obs_time_label_uses_dst_aware_city_time(): + raw = { + "metar": { + "observation_time": "2026-04-19T06:30:00.000Z", + "current": {"temp": 12.0}, + }, + "mgm_nearby": [ + { + "name": "London test", + "icao": "EGLC", + "lat": 51.50, + "lon": -0.05, + "temp": 12.0, + "obs_time": "2026-04-19T06:30:00.000Z", + } + ], + } + + snapshot = build_country_network_snapshot("london", raw) + row = snapshot["official_nearby"][0] + + assert row["source_code"] == "metar_cluster" + assert row["obs_time_label"] == "07:30" + + def test_china_provider_prefers_nmc_rows_when_available(): raw = { "metar": { @@ -176,6 +251,32 @@ def test_china_provider_prefers_nmc_rows_when_available(): assert snapshot["official_nearby"][0]["is_official"] is True +def test_china_nmc_local_time_stale_when_absolute_age_is_old(): + raw = { + "metar": { + "observation_time": "2020-01-01T06:30:00.000Z", + "current": {"temp": 32.0}, + }, + "nmc_official_nearby": [ + { + "name": "广州区域实况 (NMC)", + "icao": "atcGz", + "lat": 23.39, + "lon": 113.30, + "temp": 32.0, + "obs_time": "2020-01-01 06:30", + } + ], + } + + snapshot = build_country_network_snapshot("guangzhou", raw) + row = snapshot["official_nearby"][0] + + assert row["source_code"] == "nmc" + assert row["sync_status"] == "stale" + assert row["usable_for_intraday"] is False + + def test_hko_provider_marks_explicit_official_station_as_anchor(): raw = { "settlement_current": { diff --git a/web/analysis_service.py b/web/analysis_service.py index 3632ddb6..beaceefd 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.city_time import get_city_utc_offset_seconds from src.data_collection.nmc_sources import NMC_CITY_REFERENCES from src.models.lgbm_daily_high import predict_lgbm_daily_high @@ -1657,7 +1658,7 @@ def _analyze( except Exception: utc_offset = None if utc_offset is None: - utc_offset = info.get("tz", 0) + 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")) @@ -2441,7 +2442,7 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: except Exception: pass - default_utc_offset = int(info.get("tz", 0) or 0) + default_utc_offset = get_city_utc_offset_seconds(city) def _safe_call(fn): try: diff --git a/web/routes.py b/web/routes.py index 5f9b67f6..5abdaf6a 100644 --- a/web/routes.py +++ b/web/routes.py @@ -17,6 +17,7 @@ from src.database.runtime_state import TrainingFeatureRecordRepository, TruthRec from src.analysis.settlement_rounding import apply_city_settlement from src.data_collection.country_networks import get_country_network_provider from src.data_collection.city_registry import ALIASES +from src.data_collection.city_time import get_city_utc_offset_seconds from src.utils.metrics import export_prometheus_metrics from web.analysis_service import ( _analyze, @@ -742,7 +743,7 @@ async def list_cities(request: Request): "display_name": str(city_meta.get("display_name") or city_meta.get("name") or name.title()), "lat": info["lat"], "lon": info["lon"], - "utc_offset_seconds": info.get("tz", 0), + "utc_offset_seconds": get_city_utc_offset_seconds(name), "risk_level": risk.get("risk_level", "low"), "risk_emoji": risk.get("risk_emoji", "🟢"), "airport": risk.get("airport_name", ""),