286 lines
11 KiB
Python
286 lines
11 KiB
Python
"""CoWIN (Community Weather Information Network) 1-minute real-time data source.
|
|
|
|
Fetches 1-minute temperature from HKU CoWIN API for Hong Kong.
|
|
Station 6087 (保良局陳守仁小學) provides true 1-minute observations.
|
|
No API key required.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Dict, Optional
|
|
from urllib.parse import urlencode
|
|
|
|
import requests
|
|
from loguru import logger
|
|
|
|
from src.data_collection.observation_source_gate import run_observation_source
|
|
from src.utils.metrics import record_source_call
|
|
|
|
COWIN_BASE_URL = os.getenv("COWIN_BASE_URL", "").strip() or "https://cowin.hku.hk"
|
|
COWIN_SERIES_URL = f"{COWIN_BASE_URL}/API/data/CoWIN/series"
|
|
COWIN_STATION_ID = int(os.getenv("COWIN_HK_STATION_ID", "6087"))
|
|
COWIN_STATION_LABEL = os.getenv("COWIN_HK_STATION_LABEL", "").strip() or "保良局陳守仁小學 1min (CoWIN)"
|
|
COWIN_HK_UTC_OFFSET_SECONDS = 8 * 60 * 60
|
|
|
|
|
|
def _cowin_tls_fallback_enabled() -> bool:
|
|
raw = os.getenv("COWIN_ALLOW_INSECURE_TLS_FALLBACK", "1").strip().lower()
|
|
return raw not in {"0", "false", "no", "off"}
|
|
|
|
|
|
def _is_tls_certificate_error(exc: Exception) -> bool:
|
|
return "CERTIFICATE_VERIFY_FAILED" in str(exc).upper()
|
|
|
|
|
|
def _cowin_obs_time_to_iso(value: Any) -> Optional[str]:
|
|
raw = str(value or "").strip()
|
|
if not raw:
|
|
return None
|
|
try:
|
|
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
except (ValueError, TypeError):
|
|
return None
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone(timedelta(seconds=COWIN_HK_UTC_OFFSET_SECONDS)))
|
|
return dt.isoformat()
|
|
|
|
|
|
class CowinSourceMixin:
|
|
|
|
def _cowin_http_get(self, url: str) -> requests.Response:
|
|
getter = getattr(self, "_http_get", None)
|
|
try:
|
|
if callable(getter):
|
|
return getter(url)
|
|
resp = self.session.get(url, timeout=self.timeout)
|
|
resp.raise_for_status()
|
|
return resp
|
|
except Exception as exc:
|
|
if not _cowin_tls_fallback_enabled() or not _is_tls_certificate_error(exc):
|
|
raise
|
|
logger.warning(
|
|
"CoWIN TLS verification failed; retrying unverified HTTPS for public station data: {}",
|
|
exc,
|
|
)
|
|
requests.packages.urllib3.disable_warnings() # type: ignore[attr-defined]
|
|
resp = requests.get(
|
|
url,
|
|
timeout=self.timeout,
|
|
verify=False,
|
|
headers={"User-Agent": getattr(self, "user_agent", "PolyWeather/1.0")},
|
|
)
|
|
resp.raise_for_status()
|
|
return resp
|
|
|
|
def _cowin_series_payload(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
resp = self._cowin_http_get(COWIN_SERIES_URL + "?" + urlencode(params))
|
|
return resp.json() if resp.content else {}
|
|
|
|
@staticmethod
|
|
def _cowin_local_day_bounds(now_utc: Optional[datetime] = None) -> tuple[str, datetime, datetime]:
|
|
utc_now = now_utc or datetime.now(timezone.utc)
|
|
if utc_now.tzinfo is None:
|
|
utc_now = utc_now.replace(tzinfo=timezone.utc)
|
|
else:
|
|
utc_now = utc_now.astimezone(timezone.utc)
|
|
hk_tz = timezone(timedelta(seconds=COWIN_HK_UTC_OFFSET_SECONDS))
|
|
local_now = utc_now.astimezone(hk_tz)
|
|
local_start = local_now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
return local_now.strftime("%Y-%m-%d"), local_start, local_now
|
|
|
|
def fetch_cowin_obs_current(
|
|
self,
|
|
city: str,
|
|
use_fahrenheit: bool = False,
|
|
) -> Optional[Dict[str, Any]]:
|
|
city_key = str(city or "").strip().lower()
|
|
interval_sec = max(
|
|
30,
|
|
int(os.getenv("POLYWEATHER_OBSERVATION_COLLECTOR_COWIN_SEC", "60") or "60"),
|
|
)
|
|
return run_observation_source(
|
|
"cowin_obs",
|
|
city_key,
|
|
interval_sec,
|
|
lambda: self._fetch_cowin_obs_current_uncached(
|
|
city_key,
|
|
use_fahrenheit=use_fahrenheit,
|
|
),
|
|
)
|
|
|
|
def _fetch_cowin_obs_current_uncached(
|
|
self,
|
|
city: str,
|
|
use_fahrenheit: bool = False,
|
|
) -> Optional[Dict[str, Any]]:
|
|
started = time.perf_counter()
|
|
city_key = str(city or "").strip().lower()
|
|
if city_key != "hong kong":
|
|
return None
|
|
|
|
cache_key = f"cowin_obs:{city_key}:{use_fahrenheit}"
|
|
now_ts = time.time()
|
|
with self._cowin_obs_cache_lock:
|
|
cached = self._cowin_obs_cache.get(cache_key)
|
|
if cached and now_ts - cached["t"] < self.cowin_obs_cache_ttl_sec:
|
|
record_source_call("cowin_obs", "current", "cache_hit",
|
|
(time.perf_counter() - started) * 1000.0)
|
|
return cached["d"]
|
|
|
|
try:
|
|
# CoWIN series API expects Hong Kong local timestamps.
|
|
hk_tz = timezone(timedelta(seconds=COWIN_HK_UTC_OFFSET_SECONDS))
|
|
now = datetime.now(timezone.utc).astimezone(hk_tz)
|
|
end_dt = now.strftime("%Y-%m-%dT%H:%M:%S")
|
|
start_dt = (now - timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%S")
|
|
|
|
params = {
|
|
"station_id": COWIN_STATION_ID,
|
|
"element_id": "temp",
|
|
"start_dt": start_dt,
|
|
"end_dt": end_dt,
|
|
}
|
|
payload = self._cowin_series_payload(params)
|
|
except Exception as exc:
|
|
logger.warning("CoWIN obs fetch failed city={} error={}", city_key, exc)
|
|
with self._cowin_obs_cache_lock:
|
|
stale = self._cowin_obs_cache.get(cache_key)
|
|
if stale:
|
|
record_source_call("cowin_obs", "current", "stale_cache",
|
|
(time.perf_counter() - started) * 1000.0)
|
|
return stale["d"]
|
|
record_source_call("cowin_obs", "current", "error",
|
|
(time.perf_counter() - started) * 1000.0)
|
|
return None
|
|
|
|
minutely = payload.get("minutely") if isinstance(payload, dict) else None
|
|
if not minutely or not isinstance(minutely, list) or not minutely:
|
|
record_source_call("cowin_obs", "current", "no_data",
|
|
(time.perf_counter() - started) * 1000.0)
|
|
return None
|
|
|
|
latest = minutely[-1]
|
|
try:
|
|
temp_c = float(latest["value1"])
|
|
except (KeyError, ValueError, TypeError):
|
|
record_source_call("cowin_obs", "current", "no_temperature",
|
|
(time.perf_counter() - started) * 1000.0)
|
|
return None
|
|
|
|
obs_time = _cowin_obs_time_to_iso(latest.get("obstime")) or str(latest.get("obstime") or "").strip()
|
|
|
|
temp = round(temp_c * 9 / 5 + 32, 1) if use_fahrenheit else round(temp_c, 1)
|
|
|
|
result = {
|
|
"source": "cowin_obs",
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"station_code": str(COWIN_STATION_ID),
|
|
"station_name": COWIN_STATION_LABEL,
|
|
"icao": f"COWIN{COWIN_STATION_ID}",
|
|
"obs_time": obs_time or datetime.now(timezone.utc).isoformat(),
|
|
"current": {
|
|
"temp": temp,
|
|
},
|
|
"temp_c": temp_c,
|
|
}
|
|
|
|
with self._cowin_obs_cache_lock:
|
|
self._cowin_obs_cache[cache_key] = {"d": result, "t": now_ts}
|
|
record_source_call("cowin_obs", "current", "success",
|
|
(time.perf_counter() - started) * 1000.0)
|
|
return result
|
|
|
|
def fetch_cowin_obs_today_series(
|
|
self,
|
|
city: str,
|
|
use_fahrenheit: bool = False,
|
|
now_utc: Optional[datetime] = None,
|
|
) -> list[Dict[str, Any]]:
|
|
started = time.perf_counter()
|
|
city_key = str(city or "").strip().lower()
|
|
if city_key != "hong kong":
|
|
return []
|
|
|
|
local_date_str, start_local, end_local = self._cowin_local_day_bounds(now_utc)
|
|
cache_key = f"cowin_obs_today:{city_key}:{use_fahrenheit}:{local_date_str}"
|
|
now_ts = time.time()
|
|
with self._cowin_obs_cache_lock:
|
|
cached = self._cowin_obs_cache.get(cache_key)
|
|
if cached and now_ts - cached["t"] < self.cowin_obs_cache_ttl_sec:
|
|
record_source_call("cowin_obs", "today_series", "cache_hit",
|
|
(time.perf_counter() - started) * 1000.0)
|
|
return cached["d"]
|
|
|
|
try:
|
|
payload = self._cowin_series_payload(
|
|
{
|
|
"station_id": COWIN_STATION_ID,
|
|
"element_id": "temp",
|
|
"start_dt": start_local.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
"end_dt": end_local.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
}
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("CoWIN today series fetch failed city={} error={}", city_key, exc)
|
|
record_source_call("cowin_obs", "today_series", "error",
|
|
(time.perf_counter() - started) * 1000.0)
|
|
return []
|
|
|
|
minutely = payload.get("minutely") if isinstance(payload, dict) else None
|
|
if not isinstance(minutely, list) or not minutely:
|
|
record_source_call("cowin_obs", "today_series", "no_data",
|
|
(time.perf_counter() - started) * 1000.0)
|
|
return []
|
|
|
|
hk_tz = timezone(timedelta(seconds=COWIN_HK_UTC_OFFSET_SECONDS))
|
|
points_by_time: Dict[str, Dict[str, Any]] = {}
|
|
for row in minutely:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
obs_iso = _cowin_obs_time_to_iso(row.get("obstime"))
|
|
if not obs_iso:
|
|
continue
|
|
try:
|
|
obs_dt = datetime.fromisoformat(obs_iso.replace("Z", "+00:00")).astimezone(hk_tz)
|
|
temp_c = float(row["value1"])
|
|
except (KeyError, ValueError, TypeError):
|
|
continue
|
|
if obs_dt.strftime("%Y-%m-%d") != local_date_str:
|
|
continue
|
|
temp = round(temp_c * 9 / 5 + 32, 1) if use_fahrenheit else round(temp_c, 1)
|
|
time_label = obs_dt.strftime("%H:%M")
|
|
points_by_time[time_label] = {"time": time_label, "temp": temp}
|
|
|
|
points = sorted(points_by_time.values(), key=lambda item: item["time"])
|
|
with self._cowin_obs_cache_lock:
|
|
self._cowin_obs_cache[cache_key] = {"d": points, "t": now_ts}
|
|
record_source_call("cowin_obs", "today_series", "success",
|
|
(time.perf_counter() - started) * 1000.0)
|
|
return points
|
|
|
|
def fetch_cowin_obs_official_nearby(
|
|
self,
|
|
city: str,
|
|
use_fahrenheit: bool = False,
|
|
) -> list[Dict[str, Any]]:
|
|
current = self.fetch_cowin_obs_current(city, use_fahrenheit=use_fahrenheit)
|
|
if not current:
|
|
return []
|
|
return [
|
|
{
|
|
"name": COWIN_STATION_LABEL,
|
|
"station_label": COWIN_STATION_LABEL,
|
|
"lat": 22.3050,
|
|
"lon": 114.1670,
|
|
"temp": (current.get("current") or {}).get("temp"),
|
|
"icao": f"COWIN{COWIN_STATION_ID}",
|
|
"istNo": str(COWIN_STATION_ID),
|
|
"source": "cowin_obs",
|
|
"source_label": "CoWIN 6087",
|
|
"obs_time": current.get("obs_time"),
|
|
}
|
|
]
|