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 __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)
+106 -5
View File
@@ -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": {
+3 -2
View File
@@ -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
View File
@@ -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", ""),