Tel Aviv 机场观测切换至 IMS Lod Airport 实时数据源
新增 IMS (Israel Meteorological Service) 数据抓取模块,以 Lod Airport (station 225) 官方观测替代 NOAA METAR LLBG 作为 airport_primary。 IMS 数据接入 _airport_primary_from_raw 优先级链,高于 plain METAR 但保留 METAR 集群回退路径。 Constraint: IMS API (ims.gov.il/en/hourly_observations) 仅每小时更新 Confidence: high Scope-risk: 仅影响 Tel Aviv 城市,其余 51 城数据流不变 Tested: 端到端验证 IMS 取数 → airport_primary → provider 路由链
This commit is contained in:
@@ -298,6 +298,9 @@ CITY_REGISTRY = {
|
|||||||
"airport_name": "Ben Gurion 机场",
|
"airport_name": "Ben Gurion 机场",
|
||||||
"distance_km": 14.8,
|
"distance_km": 14.8,
|
||||||
"warning": "海陆风转换明显,午后海风可压制升温,最高温时点可能后移。",
|
"warning": "海陆风转换明显,午后海风可压制升温,最高温时点可能后移。",
|
||||||
|
"settlement_source": "ims",
|
||||||
|
"settlement_station_code": "225",
|
||||||
|
"settlement_station_label": "Lod Airport",
|
||||||
},
|
},
|
||||||
"toronto": {
|
"toronto": {
|
||||||
"name": "Toronto",
|
"name": "Toronto",
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ def _provider_code_for_city(city: str) -> str:
|
|||||||
return "turkey_mgm"
|
return "turkey_mgm"
|
||||||
if normalized in {"busan", "seoul"}:
|
if normalized in {"busan", "seoul"}:
|
||||||
return "korea_kma"
|
return "korea_kma"
|
||||||
|
if normalized == "tel aviv":
|
||||||
|
return "israel_ims"
|
||||||
if normalized == "moscow":
|
if normalized == "moscow":
|
||||||
return "russia_metar_cluster"
|
return "russia_metar_cluster"
|
||||||
if settlement_source == "hko":
|
if settlement_source == "hko":
|
||||||
@@ -420,6 +422,39 @@ def _airport_primary_from_raw(city: str, raw: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ims = raw.get("ims") or {}
|
||||||
|
ims_current = ims.get("current") or {}
|
||||||
|
if ims_current.get("temp") is not None:
|
||||||
|
return _normalize_station_row(
|
||||||
|
station_code=meta.get("icao") or ims.get("station_id") or "LLBG",
|
||||||
|
station_label=meta.get("airport_name") or ims.get("station_label") or "Ben Gurion Airport",
|
||||||
|
temp=_safe_float(ims_current["temp"]),
|
||||||
|
obs_time=ims.get("obs_time") or metar.get("observation_time"),
|
||||||
|
source_code="ims",
|
||||||
|
source_label="IMS Lod Airport",
|
||||||
|
is_official=True,
|
||||||
|
is_airport_station=True,
|
||||||
|
is_settlement_anchor=False,
|
||||||
|
extra={
|
||||||
|
"max_so_far": _safe_float(ims_current.get("max_temp_so_far")),
|
||||||
|
"max_temp_time": current.get("max_temp_time"),
|
||||||
|
"obs_age_min": None,
|
||||||
|
"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(ims_current.get("wind_speed_kt"))
|
||||||
|
or _safe_float(current.get("wind_speed_kt")),
|
||||||
|
"wind_dir": _safe_float(ims_current.get("wind_dir"))
|
||||||
|
or _safe_float(current.get("wind_dir")),
|
||||||
|
"humidity": _safe_float(ims_current.get("humidity"))
|
||||||
|
or _safe_float(current.get("humidity")),
|
||||||
|
"visibility_mi": _safe_float(current.get("visibility_mi")),
|
||||||
|
"wx_desc": current.get("wx_desc"),
|
||||||
|
"raw_metar": current.get("raw_metar"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
sg_mss = raw.get("singapore_mss_current") or {}
|
sg_mss = raw.get("singapore_mss_current") or {}
|
||||||
if sg_mss.get("temp_c") is not None:
|
if sg_mss.get("temp_c") is not None:
|
||||||
return _normalize_station_row(
|
return _normalize_station_row(
|
||||||
@@ -869,6 +904,51 @@ class RussiaStationWebNetworkProvider(CountryNetworkProvider):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class IsraelImsNetworkProvider(CountryNetworkProvider):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("israel_ims", "IMS Lod Airport")
|
||||||
|
|
||||||
|
def official_nearby_current(self, city: str, raw: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||||
|
ims = raw.get("ims") or {}
|
||||||
|
ims_current = ims.get("current") or {}
|
||||||
|
if ims_current.get("temp") is not None:
|
||||||
|
row = _normalize_station_row(
|
||||||
|
station_code="LLBG",
|
||||||
|
station_label=ims.get("station_label") or "Lod Airport",
|
||||||
|
temp=ims_current.get("temp"),
|
||||||
|
lat=ims.get("lat"),
|
||||||
|
lon=ims.get("lon"),
|
||||||
|
obs_time=ims.get("obs_time"),
|
||||||
|
source_code="ims",
|
||||||
|
source_label="IMS Lod Airport",
|
||||||
|
is_official=True,
|
||||||
|
is_airport_station=True,
|
||||||
|
is_settlement_anchor=False,
|
||||||
|
)
|
||||||
|
return [row]
|
||||||
|
return _metar_cluster_rows(raw)
|
||||||
|
|
||||||
|
def official_network_status(self, city: str, raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
ims = raw.get("ims") or {}
|
||||||
|
has_ims = (ims.get("current") or {}).get("temp") is not None
|
||||||
|
if has_ims:
|
||||||
|
return {
|
||||||
|
"provider_code": self.provider_code,
|
||||||
|
"provider_label": self.provider_label,
|
||||||
|
"available": True,
|
||||||
|
"mode": "official_active",
|
||||||
|
"row_count": 1,
|
||||||
|
}
|
||||||
|
rows = _metar_cluster_rows(raw)
|
||||||
|
return {
|
||||||
|
"provider_code": self.provider_code,
|
||||||
|
"provider_label": self.provider_label,
|
||||||
|
"available": bool(rows),
|
||||||
|
"mode": "fallback_metar_cluster" if rows else "no_official_network",
|
||||||
|
"row_count": len(rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class HongKongHkoNetworkProvider(CountryNetworkProvider):
|
class HongKongHkoNetworkProvider(CountryNetworkProvider):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__("hongkong_hko", "HKO")
|
super().__init__("hongkong_hko", "HKO")
|
||||||
@@ -899,6 +979,8 @@ def get_country_network_provider(city: str) -> CountryNetworkProvider:
|
|||||||
return JapanJmaNetworkProvider()
|
return JapanJmaNetworkProvider()
|
||||||
if provider_code == "china_cma":
|
if provider_code == "china_cma":
|
||||||
return ChinaCmaNetworkProvider()
|
return ChinaCmaNetworkProvider()
|
||||||
|
if provider_code == "israel_ims":
|
||||||
|
return IsraelImsNetworkProvider()
|
||||||
if provider_code == "hongkong_hko":
|
if provider_code == "hongkong_hko":
|
||||||
return HongKongHkoNetworkProvider()
|
return HongKongHkoNetworkProvider()
|
||||||
if provider_code == "taiwan_cwa":
|
if provider_code == "taiwan_cwa":
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.utils.metrics import record_source_call
|
||||||
|
|
||||||
|
|
||||||
|
class ImsSourceMixin:
|
||||||
|
"""Fetch realtime observations from Israel Meteorological Service (IMS).
|
||||||
|
|
||||||
|
The IMS public API at /en/hourly_observations returns a JSON payload with
|
||||||
|
data.hourly_observations_map keyed by timestamp and station ID, covering all
|
||||||
|
active IMS stations for the current day.
|
||||||
|
|
||||||
|
Station 225 = Lod Airport (Ben Gurion / LLBG), elevation 40 m.
|
||||||
|
"""
|
||||||
|
|
||||||
|
IMS_LOD_AIRPORT_STATION = "225"
|
||||||
|
IMS_OBSERVATIONS_URL = "https://ims.gov.il/en/hourly_observations"
|
||||||
|
|
||||||
|
def fetch_from_ims(self, station_id: str = "225") -> Optional[Dict]:
|
||||||
|
started = datetime.now()
|
||||||
|
|
||||||
|
def _elapsed_ms() -> float:
|
||||||
|
return (datetime.now() - started).total_seconds() * 1000.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = self._http_get(self.IMS_OBSERVATIONS_URL, timeout=self.timeout)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.warning("IMS API returned HTTP {}", resp.status_code)
|
||||||
|
record_source_call("ims", "station", "error", _elapsed_ms())
|
||||||
|
return None
|
||||||
|
|
||||||
|
body = resp.json()
|
||||||
|
obs_map = (body.get("data") or {}).get("hourly_observations_map") or {}
|
||||||
|
if not obs_map:
|
||||||
|
record_source_call("ims", "station", "empty", _elapsed_ms())
|
||||||
|
return None
|
||||||
|
|
||||||
|
latest_time = max(obs_map.keys())
|
||||||
|
latest = obs_map[latest_time].get(station_id) or {}
|
||||||
|
if not latest:
|
||||||
|
record_source_call("ims", "station", "empty", _elapsed_ms())
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _f(key: str) -> Optional[float]:
|
||||||
|
raw = latest.get(key)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
temp = _f("TT")
|
||||||
|
rh = _f("RH")
|
||||||
|
wind_kmh = _f("FF")
|
||||||
|
wind_dir = _f("DD")
|
||||||
|
tx1 = _f("TX1")
|
||||||
|
tn1 = _f("TN1")
|
||||||
|
td = _f("TD")
|
||||||
|
|
||||||
|
wind_kt = round(wind_kmh / 1.852, 1) if wind_kmh is not None else None
|
||||||
|
|
||||||
|
result: Dict = {
|
||||||
|
"current": {
|
||||||
|
"temp": temp,
|
||||||
|
"humidity": rh,
|
||||||
|
"wind_speed_kmh": wind_kmh,
|
||||||
|
"wind_speed_kt": wind_kt,
|
||||||
|
"wind_dir": wind_dir,
|
||||||
|
"dew_point": td,
|
||||||
|
"max_temp_so_far": tx1,
|
||||||
|
"min_temp_so_far": tn1,
|
||||||
|
},
|
||||||
|
"obs_time": latest_time,
|
||||||
|
"station_id": station_id,
|
||||||
|
"station_label": "Lod Airport",
|
||||||
|
"lat": 32.002943,
|
||||||
|
"lon": 34.891534,
|
||||||
|
"elevation_m": 40,
|
||||||
|
}
|
||||||
|
record_source_call("ims", "station", "success", _elapsed_ms())
|
||||||
|
logger.info(
|
||||||
|
"IMS Lod Airport (s{}) {}: temp={}°C RH={}% wind={}km/h",
|
||||||
|
station_id,
|
||||||
|
latest_time,
|
||||||
|
temp,
|
||||||
|
rh,
|
||||||
|
wind_kmh,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("IMS fetch failed for station {}", station_id)
|
||||||
|
record_source_call("ims", "station", "error", _elapsed_ms())
|
||||||
|
return None
|
||||||
@@ -19,10 +19,11 @@ from src.data_collection.knmi_sources import KnmiSourceMixin
|
|||||||
from src.data_collection.hko_obs_sources import HkoObsSourceMixin
|
from src.data_collection.hko_obs_sources import HkoObsSourceMixin
|
||||||
from src.data_collection.madis_sources import MadisSourceMixin
|
from src.data_collection.madis_sources import MadisSourceMixin
|
||||||
from src.data_collection.singapore_mss_sources import SingaporeMssSourceMixin
|
from src.data_collection.singapore_mss_sources import SingaporeMssSourceMixin
|
||||||
|
from src.data_collection.ims_sources import ImsSourceMixin
|
||||||
from src.database.db_manager import DBManager
|
from src.database.db_manager import DBManager
|
||||||
|
|
||||||
|
|
||||||
class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, JmaAmedasSourceMixin, NwsOpenMeteoSourceMixin, AmosStationSourceMixin, AmscAwosSourceMixin, FmiSourceMixin, KnmiSourceMixin, HkoObsSourceMixin, MadisSourceMixin, SingaporeMssSourceMixin):
|
class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, JmaAmedasSourceMixin, NwsOpenMeteoSourceMixin, AmosStationSourceMixin, AmscAwosSourceMixin, FmiSourceMixin, KnmiSourceMixin, HkoObsSourceMixin, MadisSourceMixin, SingaporeMssSourceMixin, ImsSourceMixin):
|
||||||
"""
|
"""
|
||||||
Multi-source weather data collector
|
Multi-source weather data collector
|
||||||
|
|
||||||
@@ -858,7 +859,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
if (
|
if (
|
||||||
city_lower not in self.CITY_METAR_CLUSTERS
|
city_lower not in self.CITY_METAR_CLUSTERS
|
||||||
or "mgm_nearby" in results
|
or "mgm_nearby" in results
|
||||||
or settlement_source in {"hko", "cwa"}
|
or settlement_source in {"hko", "cwa", "ims"}
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
cluster_icaos = self.CITY_METAR_CLUSTERS[city_lower]
|
cluster_icaos = self.CITY_METAR_CLUSTERS[city_lower]
|
||||||
@@ -886,6 +887,24 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("airport_obs_log append failed for metar cluster city={}", city_lower)
|
logger.exception("airport_obs_log append failed for metar cluster city={}", city_lower)
|
||||||
|
|
||||||
|
def _attach_israel_ims_data(self, results: Dict, city_lower: str) -> None:
|
||||||
|
if city_lower != "tel aviv":
|
||||||
|
return
|
||||||
|
ims_data = self.fetch_from_ims(self.IMS_LOD_AIRPORT_STATION)
|
||||||
|
if ims_data:
|
||||||
|
results["ims"] = ims_data
|
||||||
|
try:
|
||||||
|
ims_current = ims_data.get("current") or {}
|
||||||
|
DBManager().append_airport_obs(
|
||||||
|
icao="LLBG",
|
||||||
|
city=city_lower,
|
||||||
|
temp_c=ims_current.get("temp"),
|
||||||
|
wind_kt=ims_current.get("wind_speed_kt"),
|
||||||
|
obs_time=ims_data.get("obs_time") or datetime.now().isoformat(),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("airport_obs_log append failed for ims city={}", city_lower)
|
||||||
|
|
||||||
def _attach_china_official_nearby(
|
def _attach_china_official_nearby(
|
||||||
self, results: Dict, city_lower: str, use_fahrenheit: bool
|
self, results: Dict, city_lower: str, use_fahrenheit: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1364,6 +1383,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
self._attach_china_amsc_awos_data(results, city_lower, use_fahrenheit)
|
self._attach_china_amsc_awos_data(results, city_lower, use_fahrenheit)
|
||||||
self._attach_madis_hfmetar_data(results, city_lower)
|
self._attach_madis_hfmetar_data(results, city_lower)
|
||||||
self._attach_singapore_mss_data(results, city_lower)
|
self._attach_singapore_mss_data(results, city_lower)
|
||||||
|
self._attach_israel_ims_data(results, city_lower)
|
||||||
if include_nearby:
|
if include_nearby:
|
||||||
self._attach_china_official_nearby(results, city_lower, use_fahrenheit)
|
self._attach_china_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)
|
self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
@@ -1412,6 +1432,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
self._attach_china_amsc_awos_data(results, city_lower, use_fahrenheit)
|
self._attach_china_amsc_awos_data(results, city_lower, use_fahrenheit)
|
||||||
self._attach_madis_hfmetar_data(results, city_lower)
|
self._attach_madis_hfmetar_data(results, city_lower)
|
||||||
self._attach_singapore_mss_data(results, city_lower)
|
self._attach_singapore_mss_data(results, city_lower)
|
||||||
|
self._attach_israel_ims_data(results, city_lower)
|
||||||
if include_nearby:
|
if include_nearby:
|
||||||
self._attach_china_official_nearby(results, city_lower, use_fahrenheit)
|
self._attach_china_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)
|
self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
|
|||||||
Reference in New Issue
Block a user