Fix city-local time display for nearby stations

This commit is contained in:
2569718930@qq.com
2026-04-19 15:37:13 +08:00
parent 7e2209e928
commit d6cc3b1b90
5 changed files with 337 additions and 15 deletions
+173
View File
@@ -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))))
+53 -7
View File
@@ -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)