接入香港天文台 HKO + 流浮山 LFS 1分钟实时温度
HKO 公共天气 API 免费无注册,提供 1 分钟温度 CSV。 两个站点加入高频推送,与首尔/釜山同级。 Station: HK Observatory (HKO) 27.0°C, Lau Fau Shan (LFS) 25.9°C Interval: 60s Tested: pytest 176 passed, HKO API 实测通过
This commit is contained in:
@@ -813,7 +813,7 @@ def _build_advice_cn(
|
|||||||
return ",".join(parts) + "。"
|
return ",".join(parts) + "。"
|
||||||
|
|
||||||
|
|
||||||
_AIRPORT_ICAO_MAP = {"seoul": "RKSI", "busan": "RKPK", "tokyo": "RJTT", "ankara": "17128", "helsinki": "EFHK", "amsterdam": "EHAM", "istanbul": "17058", "paris": "LFPB"}
|
_AIRPORT_ICAO_MAP = {"seoul": "RKSI", "busan": "RKPK", "tokyo": "RJTT", "ankara": "17128", "helsinki": "EFHK", "amsterdam": "EHAM", "istanbul": "17058", "paris": "LFPB", "hong kong": "HKO", "lau fau shan": "LFS"}
|
||||||
|
|
||||||
|
|
||||||
def _calc_airport_rapid_temp_change(
|
def _calc_airport_rapid_temp_change(
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ class StartupCoordinator:
|
|||||||
details = {
|
details = {
|
||||||
"mode": "airport-periodic",
|
"mode": "airport-periodic",
|
||||||
"interval_sec": interval,
|
"interval_sec": interval,
|
||||||
"cities": ["seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "istanbul", "paris"],
|
"cities": ["seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "istanbul", "paris", "hong kong", "lau fau shan"],
|
||||||
"chat_targets": len(chat_ids),
|
"chat_targets": len(chat_ids),
|
||||||
"window": "DEB proximity ≤3°C",
|
"window": "DEB proximity ≤3°C",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""HKO (Hong Kong Observatory) 1-minute real-time data source.
|
||||||
|
|
||||||
|
Fetches 1-minute temperature from HKO's public regional-weather API
|
||||||
|
for Hong Kong Observatory (HKO) and Lau Fau Shan (LFS) stations.
|
||||||
|
No API key required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.utils.metrics import record_source_call
|
||||||
|
|
||||||
|
HKO_BASE_URL = "https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather"
|
||||||
|
HKO_STATIONS = {
|
||||||
|
"hong kong": {
|
||||||
|
"code": "HK Observatory",
|
||||||
|
"icao": "HKO",
|
||||||
|
"label": "HK Observatory 1min (HKO)",
|
||||||
|
},
|
||||||
|
"lau fau shan": {
|
||||||
|
"code": "Lau Fau Shan",
|
||||||
|
"icao": "LFS",
|
||||||
|
"label": "Lau Fau Shan 1min (HKO)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HkoObsSourceMixin:
|
||||||
|
def _hko_http_get(self, url: str) -> str:
|
||||||
|
getter = getattr(self, "_http_get", None)
|
||||||
|
if callable(getter):
|
||||||
|
resp = getter(url)
|
||||||
|
return resp.text if hasattr(resp, "text") else resp
|
||||||
|
resp = self.session.get(url, timeout=self.timeout)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.text
|
||||||
|
|
||||||
|
def fetch_hko_obs_current(
|
||||||
|
self,
|
||||||
|
city: str,
|
||||||
|
use_fahrenheit: bool = False,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
started = time.perf_counter()
|
||||||
|
city_key = str(city or "").strip().lower()
|
||||||
|
meta = HKO_STATIONS.get(city_key) or {}
|
||||||
|
if not meta:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache_key = f"hko_obs:{city_key}:{use_fahrenheit}"
|
||||||
|
now_ts = time.time()
|
||||||
|
with self._hko_obs_cache_lock:
|
||||||
|
cached = self._hko_obs_cache.get(cache_key)
|
||||||
|
if cached and now_ts - cached["t"] < self.hko_obs_cache_ttl_sec:
|
||||||
|
record_source_call("hko_obs", "current", "cache_hit",
|
||||||
|
(time.perf_counter() - started) * 1000.0)
|
||||||
|
return cached["d"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
csv_text = self._hko_http_get(
|
||||||
|
f"{HKO_BASE_URL}/latest_1min_temperature.csv"
|
||||||
|
)
|
||||||
|
reader = csv.DictReader(io.StringIO(csv_text))
|
||||||
|
temp_c = None
|
||||||
|
obs_time = None
|
||||||
|
for row in reader:
|
||||||
|
if row.get("Automatic Weather Station", "").strip() == meta["code"]:
|
||||||
|
try:
|
||||||
|
temp_c = float(row["Air Temperature(degree Celsius)"])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
obs_time = row.get("Date time", "")[:12]
|
||||||
|
break
|
||||||
|
|
||||||
|
if temp_c is None:
|
||||||
|
record_source_call("hko_obs", "current", "no_temperature",
|
||||||
|
(time.perf_counter() - started) * 1000.0)
|
||||||
|
return None
|
||||||
|
|
||||||
|
temp = round(temp_c * 9 / 5 + 32, 1) if use_fahrenheit else round(temp_c, 1)
|
||||||
|
obs_iso = None
|
||||||
|
if obs_time and len(obs_time) == 12:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(obs_time, "%Y%m%d%H%M")
|
||||||
|
obs_iso = dt.isoformat()
|
||||||
|
except Exception:
|
||||||
|
obs_iso = obs_time
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"source": "hko_obs",
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"station_code": meta["code"],
|
||||||
|
"station_name": meta["label"],
|
||||||
|
"icao": meta["icao"],
|
||||||
|
"obs_time": obs_iso or datetime.utcnow().isoformat(),
|
||||||
|
"current": {
|
||||||
|
"temp": temp,
|
||||||
|
},
|
||||||
|
"temp_c": temp_c,
|
||||||
|
}
|
||||||
|
|
||||||
|
with self._hko_obs_cache_lock:
|
||||||
|
self._hko_obs_cache[cache_key] = {"d": result, "t": now_ts}
|
||||||
|
record_source_call("hko_obs", "current", "success",
|
||||||
|
(time.perf_counter() - started) * 1000.0)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("HKO obs fetch failed city={} error={}", city_key, exc)
|
||||||
|
with self._hko_obs_cache_lock:
|
||||||
|
stale = self._hko_obs_cache.get(cache_key)
|
||||||
|
if stale:
|
||||||
|
record_source_call("hko_obs", "current", "stale_cache",
|
||||||
|
(time.perf_counter() - started) * 1000.0)
|
||||||
|
return stale["d"]
|
||||||
|
record_source_call("hko_obs", "current", "error",
|
||||||
|
(time.perf_counter() - started) * 1000.0)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_hko_obs_official_nearby(
|
||||||
|
self,
|
||||||
|
city: str,
|
||||||
|
use_fahrenheit: bool = False,
|
||||||
|
) -> list[Dict[str, Any]]:
|
||||||
|
current = self.fetch_hko_obs_current(city, use_fahrenheit=use_fahrenheit)
|
||||||
|
if not current:
|
||||||
|
return []
|
||||||
|
meta = HKO_STATIONS.get(str(city or "").strip().lower()) or {}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": meta.get("label") or "HKO Station",
|
||||||
|
"station_label": meta.get("label"),
|
||||||
|
"lat": 22.3020,
|
||||||
|
"lon": 114.1743,
|
||||||
|
"temp": (current.get("current") or {}).get("temp"),
|
||||||
|
"icao": meta.get("icao"),
|
||||||
|
"istNo": meta.get("icao"),
|
||||||
|
"source": "hko_obs",
|
||||||
|
"source_label": "HKO",
|
||||||
|
"obs_time": current.get("obs_time"),
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -17,10 +17,11 @@ from src.data_collection.nws_open_meteo_sources import NwsOpenMeteoSourceMixin
|
|||||||
from src.data_collection.amos_station_sources import AmosStationSourceMixin
|
from src.data_collection.amos_station_sources import AmosStationSourceMixin
|
||||||
from src.data_collection.fmi_sources import FmiSourceMixin
|
from src.data_collection.fmi_sources import FmiSourceMixin
|
||||||
from src.data_collection.knmi_sources import KnmiSourceMixin
|
from src.data_collection.knmi_sources import KnmiSourceMixin
|
||||||
|
from src.data_collection.hko_obs_sources import HkoObsSourceMixin
|
||||||
from src.database.db_manager import DBManager
|
from src.database.db_manager import DBManager
|
||||||
|
|
||||||
|
|
||||||
class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, JmaAmedasSourceMixin, RussiaStationSourceMixin, NmcSourceMixin, NwsOpenMeteoSourceMixin, AmosStationSourceMixin, FmiSourceMixin, KnmiSourceMixin):
|
class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSourceMixin, MgmSourceMixin, JmaAmedasSourceMixin, RussiaStationSourceMixin, NmcSourceMixin, NwsOpenMeteoSourceMixin, AmosStationSourceMixin, FmiSourceMixin, KnmiSourceMixin, HkoObsSourceMixin):
|
||||||
"""
|
"""
|
||||||
Multi-source weather data collector
|
Multi-source weather data collector
|
||||||
|
|
||||||
@@ -233,6 +234,11 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
)
|
)
|
||||||
self._knmi_cache: Dict[str, Dict] = {}
|
self._knmi_cache: Dict[str, Dict] = {}
|
||||||
self._knmi_cache_lock = threading.Lock()
|
self._knmi_cache_lock = threading.Lock()
|
||||||
|
self.hko_obs_cache_ttl_sec = int(
|
||||||
|
os.getenv("HKO_OBS_CACHE_TTL_SEC", "60")
|
||||||
|
)
|
||||||
|
self._hko_obs_cache: Dict[str, Dict] = {}
|
||||||
|
self._hko_obs_cache_lock = threading.Lock()
|
||||||
self.cwa_open_data_auth = (
|
self.cwa_open_data_auth = (
|
||||||
os.getenv("CWA_OPEN_DATA_AUTH")
|
os.getenv("CWA_OPEN_DATA_AUTH")
|
||||||
or os.getenv("CWA_OPEN_DATA_API_KEY")
|
or os.getenv("CWA_OPEN_DATA_API_KEY")
|
||||||
@@ -745,6 +751,8 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
self._fmi_cache.pop(f"fmi:{normalized}:{use_fahrenheit}", None)
|
self._fmi_cache.pop(f"fmi:{normalized}:{use_fahrenheit}", None)
|
||||||
with self._knmi_cache_lock:
|
with self._knmi_cache_lock:
|
||||||
self._knmi_cache.pop(f"knmi:{normalized}:{use_fahrenheit}", None)
|
self._knmi_cache.pop(f"knmi:{normalized}:{use_fahrenheit}", None)
|
||||||
|
with self._hko_obs_cache_lock:
|
||||||
|
self._hko_obs_cache.pop(f"hko_obs:{normalized}:{use_fahrenheit}", None)
|
||||||
with self._nmc_cache_lock:
|
with self._nmc_cache_lock:
|
||||||
self._nmc_cache.pop(f"{normalized}:{use_fahrenheit}", None)
|
self._nmc_cache.pop(f"{normalized}:{use_fahrenheit}", None)
|
||||||
with self._ru_station_cache_lock:
|
with self._ru_station_cache_lock:
|
||||||
@@ -958,6 +966,31 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("airport_obs_log append failed for fmi city={}", city_lower)
|
logger.exception("airport_obs_log append failed for fmi city={}", city_lower)
|
||||||
|
|
||||||
|
def _attach_hko_obs_official_nearby(
|
||||||
|
self, results: Dict, city_lower: str, use_fahrenheit: bool
|
||||||
|
) -> None:
|
||||||
|
if city_lower not in ("hong kong", "lau fau shan"):
|
||||||
|
return
|
||||||
|
rows = self.fetch_hko_obs_official_nearby(
|
||||||
|
city_lower, use_fahrenheit=use_fahrenheit
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
results["hko_obs_nearby"] = rows
|
||||||
|
if "mgm_nearby" not in results:
|
||||||
|
results["mgm_nearby"] = rows
|
||||||
|
results["nearby_source"] = "hko_obs"
|
||||||
|
try:
|
||||||
|
row = rows[0] if rows else {}
|
||||||
|
DBManager().append_airport_obs(
|
||||||
|
icao=str(row.get("icao") or "HKO"),
|
||||||
|
city=city_lower,
|
||||||
|
temp_c=row.get("temp"),
|
||||||
|
obs_time=str(row.get("obs_time") or datetime.now().isoformat()),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("airport_obs_log append failed for hko_obs city={}", city_lower)
|
||||||
|
|
||||||
def _attach_korean_amos_data(
|
def _attach_korean_amos_data(
|
||||||
self, results: Dict, city_lower: str, use_fahrenheit: bool
|
self, results: Dict, city_lower: str, use_fahrenheit: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1120,6 +1153,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)
|
self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
self._attach_fmi_official_nearby(results, city_lower, use_fahrenheit)
|
self._attach_fmi_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
self._attach_knmi_official_nearby(results, city_lower, use_fahrenheit)
|
self._attach_knmi_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
|
self._attach_hko_obs_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
self._attach_russia_official_nearby(results, city_lower, use_fahrenheit)
|
self._attach_russia_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
if city_lower == "warsaw":
|
if city_lower == "warsaw":
|
||||||
self._attach_warsaw_official_nearby(results, use_fahrenheit)
|
self._attach_warsaw_official_nearby(results, use_fahrenheit)
|
||||||
@@ -1164,6 +1198,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)
|
self._attach_japan_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
self._attach_fmi_official_nearby(results, city_lower, use_fahrenheit)
|
self._attach_fmi_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
self._attach_knmi_official_nearby(results, city_lower, use_fahrenheit)
|
self._attach_knmi_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
|
self._attach_hko_obs_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
self._attach_russia_official_nearby(results, city_lower, use_fahrenheit)
|
self._attach_russia_official_nearby(results, city_lower, use_fahrenheit)
|
||||||
if city_lower == "warsaw":
|
if city_lower == "warsaw":
|
||||||
self._attach_warsaw_official_nearby(results, use_fahrenheit)
|
self._attach_warsaw_official_nearby(results, use_fahrenheit)
|
||||||
|
|||||||
@@ -690,8 +690,8 @@ def start_trade_alert_push_loop(bot: Any, config: Dict[str, Any]) -> Optional[th
|
|||||||
|
|
||||||
# ── high-freq airport push loop ──
|
# ── high-freq airport push loop ──
|
||||||
|
|
||||||
HIGH_FREQ_AIRPORT_CITIES = {"seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "istanbul", "paris"}
|
HIGH_FREQ_AIRPORT_CITIES = {"seoul", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "istanbul", "paris", "hong kong", "lau fau shan"}
|
||||||
HIGH_FREQ_AIRPORT_ICAO = {"seoul": "RKSI", "busan": "RKPK", "tokyo": "RJTT", "ankara": "17128", "helsinki": "EFHK", "amsterdam": "EHAM", "istanbul": "17058", "paris": "LFPB"}
|
HIGH_FREQ_AIRPORT_ICAO = {"seoul": "RKSI", "busan": "RKPK", "tokyo": "RJTT", "ankara": "17128", "helsinki": "EFHK", "amsterdam": "EHAM", "istanbul": "17058", "paris": "LFPB", "hong kong": "HKO", "lau fau shan": "LFS"}
|
||||||
|
|
||||||
_AIRPORT_PUSH_STATE_PATH = os.path.join(
|
_AIRPORT_PUSH_STATE_PATH = os.path.join(
|
||||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
@@ -751,7 +751,8 @@ def _build_airport_status_message(
|
|||||||
) -> str:
|
) -> str:
|
||||||
_AIRPORT_EN = {"seoul": "Incheon", "busan": "Gimhae", "tokyo": "Haneda",
|
_AIRPORT_EN = {"seoul": "Incheon", "busan": "Gimhae", "tokyo": "Haneda",
|
||||||
"ankara": "Esenboğa", "helsinki": "Vantaa", "amsterdam": "Schiphol",
|
"ankara": "Esenboğa", "helsinki": "Vantaa", "amsterdam": "Schiphol",
|
||||||
"istanbul": "Airport", "paris": "Le Bourget"}
|
"istanbul": "Airport", "paris": "Le Bourget",
|
||||||
|
"hong kong": "Observatory", "lau fau shan": "Lau Fau Shan"}
|
||||||
en_name = city.title()
|
en_name = city.title()
|
||||||
ap_name = _AIRPORT_EN.get(city, "")
|
ap_name = _AIRPORT_EN.get(city, "")
|
||||||
time_suffix = f" {local_time}" if local_time else ""
|
time_suffix = f" {local_time}" if local_time else ""
|
||||||
@@ -816,7 +817,9 @@ _AIRPORT_PUSH_INTERVAL = {
|
|||||||
"helsinki": 600, # FMI 10-min
|
"helsinki": 600, # FMI 10-min
|
||||||
"amsterdam": 600, # KNMI 10-min
|
"amsterdam": 600, # KNMI 10-min
|
||||||
"istanbul": 600, # MGM ~10-min
|
"istanbul": 600, # MGM ~10-min
|
||||||
"paris": 900, # AROME HD 15-min model
|
"paris": 900, # AROME HD 15-min model
|
||||||
|
"hong kong": 60, # HKO 1-min
|
||||||
|
"lau fau shan": 60, # HKO 1-min
|
||||||
}
|
}
|
||||||
_DEB_PROXIMITY_THRESHOLD_C = 3.0
|
_DEB_PROXIMITY_THRESHOLD_C = 3.0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user