Fix city-local time display for nearby stations
This commit is contained in:
@@ -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))))
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from src.data_collection.city_registry import CITY_REGISTRY
|
from src.data_collection.city_registry import CITY_REGISTRY
|
||||||
|
from src.data_collection.city_time import get_city_utc_offset_seconds
|
||||||
|
|
||||||
|
|
||||||
CHINA_CMA_CITIES = {
|
CHINA_CMA_CITIES = {
|
||||||
@@ -31,7 +32,11 @@ def _safe_float(value: Any) -> Optional[float]:
|
|||||||
return None
|
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):
|
for candidate in (epoch_value, value):
|
||||||
if candidate is None or candidate == "":
|
if candidate is None or candidate == "":
|
||||||
continue
|
continue
|
||||||
@@ -54,16 +59,34 @@ def _parse_obs_datetime(value: Any, epoch_value: Any = None) -> Optional[datetim
|
|||||||
normalized = text.replace(" ", "T")
|
normalized = text.replace(" ", "T")
|
||||||
if normalized.endswith("Z"):
|
if normalized.endswith("Z"):
|
||||||
normalized = normalized[:-1] + "+00:00"
|
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:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
return None
|
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()
|
text = str(value or "").strip()
|
||||||
parsed = _parse_obs_datetime(value, epoch_value)
|
parsed = _parse_obs_datetime(value, epoch_value)
|
||||||
if parsed is not None:
|
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 ""
|
suffix = "Z" if parsed.tzinfo is not None and parsed.utcoffset() == timezone.utc.utcoffset(parsed) else ""
|
||||||
return parsed.strftime("%H:%M") + suffix
|
return parsed.strftime("%H:%M") + suffix
|
||||||
if not text:
|
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:
|
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
|
reference = delta_minutes if delta_minutes is not None else age_minutes
|
||||||
if reference is None:
|
if reference is None:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
@@ -112,17 +137,32 @@ def _sync_status(delta_minutes: Optional[int], age_minutes: Optional[int]) -> st
|
|||||||
def _enrich_station_timing(
|
def _enrich_station_timing(
|
||||||
anchor: Optional[Dict[str, Any]],
|
anchor: Optional[Dict[str, Any]],
|
||||||
rows: List[Dict[str, Any]],
|
rows: List[Dict[str, Any]],
|
||||||
|
display_utc_offset_seconds: Any = None,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
anchor = anchor or {}
|
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]] = []
|
enriched: List[Dict[str, Any]] = []
|
||||||
for row in rows:
|
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)
|
delta_minutes = _timing_delta_minutes(anchor_dt, station_dt)
|
||||||
age_minutes = _station_age_minutes(station_dt)
|
age_minutes = _station_age_minutes(station_dt)
|
||||||
status = _sync_status(delta_minutes, age_minutes)
|
status = _sync_status(delta_minutes, age_minutes)
|
||||||
enriched_row = dict(row)
|
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["age_minutes"] = age_minutes
|
||||||
enriched_row["time_delta_vs_anchor_minutes"] = delta_minutes
|
enriched_row["time_delta_vs_anchor_minutes"] = delta_minutes
|
||||||
enriched_row["sync_status"] = status
|
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"),
|
"report_time": metar.get("report_time"),
|
||||||
"receipt_time": metar.get("receipt_time"),
|
"receipt_time": metar.get("receipt_time"),
|
||||||
"obs_time_epoch": metar.get("obs_time_epoch"),
|
"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_speed_kt": _safe_float(current.get("wind_speed_kt")),
|
||||||
"wind_dir": _safe_float(current.get("wind_dir")),
|
"wind_dir": _safe_float(current.get("wind_dir")),
|
||||||
"humidity": _safe_float(current.get("humidity")),
|
"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,
|
is_settlement_anchor=False,
|
||||||
extra={
|
extra={
|
||||||
"obs_time_epoch": row.get("obs_time_epoch"),
|
"obs_time_epoch": row.get("obs_time_epoch"),
|
||||||
|
"obs_time_utc_offset_seconds": 0,
|
||||||
"wind_dir": _safe_float(row.get("wind_dir")),
|
"wind_dir": _safe_float(row.get("wind_dir")),
|
||||||
"wind_speed_kt": _safe_float(row.get("wind_speed_kt") or row.get("wind_speed")),
|
"wind_speed_kt": _safe_float(row.get("wind_speed_kt") or row.get("wind_speed")),
|
||||||
"raw_metar": row.get("raw_metar"),
|
"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]]:
|
def _nmc_rows(raw: Dict[str, Any], city: str) -> List[Dict[str, Any]]:
|
||||||
rows = raw.get("nmc_official_nearby") or []
|
rows = raw.get("nmc_official_nearby") or []
|
||||||
|
city_offset = get_city_utc_offset_seconds(city)
|
||||||
out: List[Dict[str, Any]] = []
|
out: List[Dict[str, Any]] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if not isinstance(row, dict):
|
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_airport_station=False,
|
||||||
is_settlement_anchor=False,
|
is_settlement_anchor=False,
|
||||||
extra={
|
extra={
|
||||||
|
"obs_time_utc_offset_seconds": city_offset,
|
||||||
"page_url": row.get("page_url"),
|
"page_url": row.get("page_url"),
|
||||||
"humidity": _safe_float(row.get("humidity")),
|
"humidity": _safe_float(row.get("humidity")),
|
||||||
"rain": _safe_float(row.get("rain")),
|
"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]:
|
def build_country_network_snapshot(city: str, raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
provider = get_country_network_provider(city)
|
provider = get_country_network_provider(city)
|
||||||
|
city_offset = get_city_utc_offset_seconds(city)
|
||||||
metadata = provider.settlement_station_metadata(city)
|
metadata = provider.settlement_station_metadata(city)
|
||||||
airport_primary = provider.airport_primary_current(city, raw) or {}
|
airport_primary = provider.airport_primary_current(city, raw) or {}
|
||||||
official_nearby = _enrich_station_timing(
|
official_nearby = _enrich_station_timing(
|
||||||
airport_primary,
|
airport_primary,
|
||||||
provider.official_nearby_current(city, raw),
|
provider.official_nearby_current(city, raw),
|
||||||
|
city_offset,
|
||||||
)
|
)
|
||||||
status = provider.official_network_status(city, raw)
|
status = provider.official_network_status(city, raw)
|
||||||
signals = _network_signals(airport_primary, official_nearby)
|
signals = _network_signals(airport_primary, official_nearby)
|
||||||
|
|||||||
@@ -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.country_networks import build_country_network_snapshot
|
||||||
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
|
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 src.data_collection.metar_sources import MetarSourceMixin
|
||||||
from web.analysis_service import _build_city_detail_payload, _build_intraday_meteorology
|
from web.analysis_service import _build_city_detail_payload, _build_intraday_meteorology
|
||||||
from web.core import CITIES
|
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"
|
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():
|
def test_paris_registry_uses_le_bourget_anchor():
|
||||||
paris = CITY_REGISTRY["paris"]
|
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():
|
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 = {
|
raw = {
|
||||||
"metar": {
|
"metar": {
|
||||||
"observation_time": "2026-04-06T10:00:00.000Z",
|
"observation_time": anchor_time.isoformat().replace("+00:00", "Z"),
|
||||||
"current": {"temp": 16.0},
|
"current": {"temp": 16.0},
|
||||||
},
|
},
|
||||||
"mgm_nearby": [
|
"mgm_nearby": [
|
||||||
@@ -60,7 +81,7 @@ def test_turkey_mgm_provider_returns_official_nearby_rows():
|
|||||||
"lat": 40.1,
|
"lat": 40.1,
|
||||||
"lon": 32.9,
|
"lon": 32.9,
|
||||||
"temp": 17.1,
|
"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():
|
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 = {
|
raw = {
|
||||||
"metar": {
|
"metar": {
|
||||||
"observation_time": "2026-04-06T10:00:00.000Z",
|
"observation_time": anchor_time.isoformat().replace("+00:00", "Z"),
|
||||||
"current": {"temp": 20.0},
|
"current": {"temp": 20.0},
|
||||||
},
|
},
|
||||||
"mgm_nearby": [
|
"mgm_nearby": [
|
||||||
@@ -90,7 +114,7 @@ def test_nearby_station_timing_marks_stale_rows_unusable_for_network_signal():
|
|||||||
"lat": 40.1,
|
"lat": 40.1,
|
||||||
"lon": 32.9,
|
"lon": 32.9,
|
||||||
"temp": 21.0,
|
"temp": 21.0,
|
||||||
"obs_time": "2026-04-06T10:20:00.000Z",
|
"obs_time": fresh_time.isoformat().replace("+00:00", "Z"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Stale Hot",
|
"name": "Stale Hot",
|
||||||
@@ -98,7 +122,7 @@ def test_nearby_station_timing_marks_stale_rows_unusable_for_network_signal():
|
|||||||
"lat": 40.2,
|
"lat": 40.2,
|
||||||
"lon": 33.0,
|
"lon": 33.0,
|
||||||
"temp": 26.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
|
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():
|
def test_china_provider_prefers_nmc_rows_when_available():
|
||||||
raw = {
|
raw = {
|
||||||
"metar": {
|
"metar": {
|
||||||
@@ -176,6 +251,32 @@ def test_china_provider_prefers_nmc_rows_when_available():
|
|||||||
assert snapshot["official_nearby"][0]["is_official"] is True
|
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():
|
def test_hko_provider_marks_explicit_official_station_as_anchor():
|
||||||
raw = {
|
raw = {
|
||||||
"settlement_current": {
|
"settlement_current": {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from src.analysis.deb_algorithm import calculate_dynamic_weights
|
|||||||
from src.analysis.settlement_rounding import apply_city_settlement
|
from src.analysis.settlement_rounding import apply_city_settlement
|
||||||
from src.data_collection.country_networks import build_country_network_snapshot
|
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_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.data_collection.nmc_sources import NMC_CITY_REFERENCES
|
||||||
from src.models.lgbm_daily_high import predict_lgbm_daily_high
|
from src.models.lgbm_daily_high import predict_lgbm_daily_high
|
||||||
|
|
||||||
@@ -1657,7 +1658,7 @@ def _analyze(
|
|||||||
except Exception:
|
except Exception:
|
||||||
utc_offset = None
|
utc_offset = None
|
||||||
if utc_offset is 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:
|
if obs_t and "T" in obs_t:
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromisoformat(str(obs_t).replace("Z", "+00:00"))
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
default_utc_offset = int(info.get("tz", 0) or 0)
|
default_utc_offset = get_city_utc_offset_seconds(city)
|
||||||
|
|
||||||
def _safe_call(fn):
|
def _safe_call(fn):
|
||||||
try:
|
try:
|
||||||
|
|||||||
+2
-1
@@ -17,6 +17,7 @@ from src.database.runtime_state import TrainingFeatureRecordRepository, TruthRec
|
|||||||
from src.analysis.settlement_rounding import apply_city_settlement
|
from src.analysis.settlement_rounding import apply_city_settlement
|
||||||
from src.data_collection.country_networks import get_country_network_provider
|
from src.data_collection.country_networks import get_country_network_provider
|
||||||
from src.data_collection.city_registry import ALIASES
|
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 src.utils.metrics import export_prometheus_metrics
|
||||||
from web.analysis_service import (
|
from web.analysis_service import (
|
||||||
_analyze,
|
_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()),
|
"display_name": str(city_meta.get("display_name") or city_meta.get("name") or name.title()),
|
||||||
"lat": info["lat"],
|
"lat": info["lat"],
|
||||||
"lon": info["lon"],
|
"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_level": risk.get("risk_level", "low"),
|
||||||
"risk_emoji": risk.get("risk_emoji", "🟢"),
|
"risk_emoji": risk.get("risk_emoji", "🟢"),
|
||||||
"airport": risk.get("airport_name", ""),
|
"airport": risk.get("airport_name", ""),
|
||||||
|
|||||||
Reference in New Issue
Block a user