diff --git a/src/data_collection/city_registry.py b/src/data_collection/city_registry.py index 2ec92f09..28bded4a 100644 --- a/src/data_collection/city_registry.py +++ b/src/data_collection/city_registry.py @@ -298,6 +298,9 @@ CITY_REGISTRY = { "airport_name": "Ben Gurion 机场", "distance_km": 14.8, "warning": "海陆风转换明显,午后海风可压制升温,最高温时点可能后移。", + "settlement_source": "ims", + "settlement_station_code": "225", + "settlement_station_label": "Lod Airport", }, "toronto": { "name": "Toronto", diff --git a/src/data_collection/country_networks.py b/src/data_collection/country_networks.py index b095b3e4..0dda29ce 100644 --- a/src/data_collection/country_networks.py +++ b/src/data_collection/country_networks.py @@ -186,6 +186,8 @@ def _provider_code_for_city(city: str) -> str: return "turkey_mgm" if normalized in {"busan", "seoul"}: return "korea_kma" + if normalized == "tel aviv": + return "israel_ims" if normalized == "moscow": return "russia_metar_cluster" 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 {} if sg_mss.get("temp_c") is not None: 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): def __init__(self) -> None: super().__init__("hongkong_hko", "HKO") @@ -899,6 +979,8 @@ def get_country_network_provider(city: str) -> CountryNetworkProvider: return JapanJmaNetworkProvider() if provider_code == "china_cma": return ChinaCmaNetworkProvider() + if provider_code == "israel_ims": + return IsraelImsNetworkProvider() if provider_code == "hongkong_hko": return HongKongHkoNetworkProvider() if provider_code == "taiwan_cwa": diff --git a/src/data_collection/ims_sources.py b/src/data_collection/ims_sources.py new file mode 100644 index 00000000..5234403b --- /dev/null +++ b/src/data_collection/ims_sources.py @@ -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 diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py index b46f7f9e..e1e0b3f9 100644 --- a/src/data_collection/weather_sources.py +++ b/src/data_collection/weather_sources.py @@ -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.madis_sources import MadisSourceMixin from src.data_collection.singapore_mss_sources import SingaporeMssSourceMixin +from src.data_collection.ims_sources import ImsSourceMixin 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 @@ -858,7 +859,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour if ( city_lower not in self.CITY_METAR_CLUSTERS or "mgm_nearby" in results - or settlement_source in {"hko", "cwa"} + or settlement_source in {"hko", "cwa", "ims"} ): return cluster_icaos = self.CITY_METAR_CLUSTERS[city_lower] @@ -886,6 +887,24 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour except Exception: 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( self, results: Dict, city_lower: str, use_fahrenheit: bool ) -> None: @@ -1364,6 +1383,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour self._attach_china_amsc_awos_data(results, city_lower, use_fahrenheit) self._attach_madis_hfmetar_data(results, city_lower) self._attach_singapore_mss_data(results, city_lower) + self._attach_israel_ims_data(results, city_lower) if include_nearby: self._attach_china_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_madis_hfmetar_data(results, city_lower) self._attach_singapore_mss_data(results, city_lower) + self._attach_israel_ims_data(results, city_lower) if include_nearby: self._attach_china_official_nearby(results, city_lower, use_fahrenheit) self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)