Files
PolyWeather/src/data_collection/cowin_sources.py
T
2026-06-05 14:38:58 +08:00

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"),
}
]