155 lines
5.4 KiB
Python
155 lines
5.4 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Dict, Optional
|
|
|
|
from loguru import logger
|
|
|
|
from src.utils.metrics import record_source_call
|
|
|
|
|
|
def _last_sunday(year: int, month: int) -> datetime:
|
|
if month == 12:
|
|
last_day = datetime(year + 1, 1, 1) - timedelta(days=1)
|
|
else:
|
|
last_day = datetime(year, month + 1, 1) - timedelta(days=1)
|
|
while last_day.weekday() != 6:
|
|
last_day -= timedelta(days=1)
|
|
return last_day
|
|
|
|
|
|
def _is_israel_dst(local_dt: datetime) -> bool:
|
|
start = (_last_sunday(local_dt.year, 3) - timedelta(days=2)).replace(
|
|
hour=2,
|
|
minute=0,
|
|
second=0,
|
|
microsecond=0,
|
|
)
|
|
end = _last_sunday(local_dt.year, 10).replace(
|
|
hour=2,
|
|
minute=0,
|
|
second=0,
|
|
microsecond=0,
|
|
)
|
|
return start <= local_dt < end
|
|
|
|
|
|
def _ims_obs_time_to_iso(value: str) -> Optional[str]:
|
|
raw = str(value or "").strip()
|
|
if not raw:
|
|
return None
|
|
try:
|
|
local_dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
except (ValueError, TypeError):
|
|
return None
|
|
if local_dt.tzinfo is not None:
|
|
return local_dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
offset_hours = 3 if _is_israel_dst(local_dt) else 2
|
|
return local_dt.replace(
|
|
tzinfo=timezone(timedelta(hours=offset_hours))
|
|
).isoformat()
|
|
|
|
|
|
class ImsSourceMixin:
|
|
"""Fetch realtime observations from Israel Meteorological Service (IMS).
|
|
|
|
Uses the hourly_observations_full endpoint which provides 10-minute data.
|
|
In this feed, TD = Temperature Dry (dry-bulb in °C), NOT dew point.
|
|
TT / TX1 / TN1 only exist in the plain hourly_observations endpoint.
|
|
|
|
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_full"
|
|
|
|
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_raw = max(obs_map.keys())
|
|
latest = obs_map[latest_time_raw].get(station_id) or {}
|
|
if not latest:
|
|
record_source_call("ims", "station", "empty", _elapsed_ms())
|
|
return None
|
|
|
|
def _f(key: str, source: dict = latest) -> Optional[float]:
|
|
raw = source.get(key)
|
|
if raw is None:
|
|
return None
|
|
try:
|
|
return float(raw)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
# TD = Temperature Dry (dry-bulb °C) in the 10-minute feed
|
|
temp = _f("TD")
|
|
rh = _f("RH")
|
|
# WS = wind speed in m/s (10-min average); convert to km/h and knots
|
|
wind_ms = _f("WS")
|
|
wind_kmh = round(wind_ms * 3.6, 1) if wind_ms is not None else None
|
|
wind_kt = round(wind_ms * 1.94384, 1) if wind_ms is not None else None
|
|
wind_dir = _f("WD")
|
|
|
|
# Compute max / min so far today from all 10-min slots
|
|
today_prefix = latest_time_raw[:10]
|
|
td_vals = []
|
|
for t, stations in obs_map.items():
|
|
if t.startswith(today_prefix):
|
|
td = _f("TD", source=(stations.get(station_id) or {}))
|
|
if td is not None:
|
|
td_vals.append(td)
|
|
max_so_far = round(max(td_vals), 1) if td_vals else None
|
|
min_so_far = round(min(td_vals), 1) if td_vals else None
|
|
obs_time = _ims_obs_time_to_iso(latest_time_raw) or latest_time_raw
|
|
|
|
result: Dict = {
|
|
"current": {
|
|
"temp": temp,
|
|
"humidity": rh,
|
|
"wind_speed_kmh": wind_kmh,
|
|
"wind_speed_kt": wind_kt,
|
|
"wind_dir": wind_dir,
|
|
"max_temp_so_far": max_so_far,
|
|
"min_temp_so_far": min_so_far,
|
|
},
|
|
"obs_time": obs_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 max={} min={}",
|
|
station_id,
|
|
obs_time,
|
|
temp,
|
|
rh,
|
|
wind_kmh,
|
|
max_so_far,
|
|
min_so_far,
|
|
)
|
|
return result
|
|
|
|
except Exception:
|
|
logger.exception("IMS fetch failed for station {}", station_id)
|
|
record_source_call("ims", "station", "error", _elapsed_ms())
|
|
return None
|