Files
PolyWeather/src/data_collection/metar_sources.py
T
2569718930@qq.com 30b289ec8f 修复机场高频推送:东京ICAO不匹配、釜山缺趋势数据、港/流浮频率及obs_time去重
- 东京 HIGH_FREQ_AIRPORT_ICAO 从 RJTT 改为 JMA 站号 44166
- 釜山 METAR 集群数据写入 airport_obs_log 供趋势检测
- 香港/流浮山推送间隔从 60s 改为 600s(匹配实际 10min 更新)
- 新增 obs_time 去重:同一观测数据不重复推送

Constraint: JMA AMeDAS 使用站号而非 ICAO 作为站点标识
Tested: ruff check 通过
2026-05-13 13:45:00 +08:00

474 lines
20 KiB
Python

from __future__ import annotations
import re
import time
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional
import httpx
from loguru import logger
from src.utils.metrics import record_source_call
class MetarSourceMixin:
def _get_min_plausible_metar_temp_c(
self, city: str, icao: Optional[str] = None
) -> Optional[float]:
normalized = str(city or "").strip().lower()
city_meta = (getattr(self, "CITY_REGISTRY", {}) or {}).get(normalized) or {}
if not city_meta and icao:
icao_upper = str(icao or "").strip().upper()
for candidate in (getattr(self, "CITY_REGISTRY", {}) or {}).values():
if str(candidate.get("icao") or "").strip().upper() == icao_upper:
city_meta = candidate
break
raw = city_meta.get("min_plausible_metar_temp_c")
try:
value = float(raw)
except (TypeError, ValueError):
return None
return value if value > -80 else None
def _is_plausible_metar_temp_c(
self, temp_c: object, city: str, icao: Optional[str] = None
) -> bool:
try:
value = float(temp_c)
except (TypeError, ValueError):
return False
minimum = self._get_min_plausible_metar_temp_c(city, icao)
if minimum is None:
return True
if value < minimum:
logger.warning(
"Discarding implausible METAR temperature city={} icao={} temp_c={} min_c={}",
city,
icao,
value,
minimum,
)
return False
return True
def _metar_cache_ttl_for_city(self, city: str, icao: Optional[str] = None) -> int:
normalized = str(city or "").strip().lower()
city_meta = (getattr(self, "CITY_REGISTRY", {}) or {}).get(normalized) or {}
if not city_meta and icao:
icao_upper = str(icao or "").strip().upper()
for candidate in (getattr(self, "CITY_REGISTRY", {}) or {}).values():
if str(candidate.get("icao") or "").strip().upper() == icao_upper:
city_meta = candidate
break
raw_ttl = city_meta.get("metar_cache_ttl_sec")
try:
ttl = int(raw_ttl)
except (TypeError, ValueError):
ttl = 0
if ttl > 0:
return ttl
if city_meta.get("fast_metar_refresh"):
return max(1, int(getattr(self, "metar_fast_cache_ttl_sec", 60)))
return max(1, int(getattr(self, "metar_cache_ttl_sec", 600)))
def get_icao_code(self, city: str) -> Optional[str]:
"""根据城市名获取对应的 ICAO 机场代码"""
normalized = city.lower().strip()
if normalized in self.CITY_TO_ICAO:
return self.CITY_TO_ICAO[normalized]
for key, icao in self.CITY_TO_ICAO.items():
if key in normalized or normalized in key:
return icao
return None
def fetch_metar(
self, city: str, use_fahrenheit: bool = False, utc_offset: int = 0
) -> Optional[Dict]:
"""从 NOAA Aviation Weather Center 获取 METAR 航空气象数据。"""
started = time.perf_counter()
icao = self.get_icao_code(city)
if not icao:
logger.warning(f"未找到城市 {city} 对应的 ICAO 代码")
record_source_call("metar", "current", "missing_icao", (time.perf_counter() - started) * 1000.0)
return None
cache_key = f"{icao}:{utc_offset}:{use_fahrenheit}"
now_ts = time.time()
cache_ttl_sec = self._metar_cache_ttl_for_city(city, icao)
with self._metar_cache_lock:
cached = self._metar_cache.get(cache_key)
if cached and now_ts - cached["t"] < cache_ttl_sec:
logger.debug(f"METAR cache hit {icao} age={int(now_ts - cached['t'])}s")
record_source_call("metar", "current", "cache_hit", (time.perf_counter() - started) * 1000.0)
return cached["d"]
try:
url = "https://aviationweather.gov/api/data/metar"
def _request_metar_records(hours: int, timeout: float) -> list:
response = self._http_get(
url,
params={
"ids": icao,
"format": "json",
"hours": hours,
},
timeout=timeout,
)
response.raise_for_status()
payload = response.json()
return payload if isinstance(payload, list) else []
data = []
history_hours = 24
first_exc: Optional[httpx.HTTPError] = None
fallback_hours = [24, 12, 6, 2]
for index, hours in enumerate(fallback_hours):
timeout = (
getattr(self, "metar_timeout_sec", self.timeout)
if index == 0
else getattr(self, "metar_latest_timeout_sec", 2.5)
)
try:
data = _request_metar_records(hours, timeout)
history_hours = hours
if index > 0:
logger.warning(
"METAR {} {}h fallback returned {} records",
icao,
hours,
len(data),
)
break
except httpx.HTTPError as exc:
if first_exc is None:
first_exc = exc
logger.warning(
"METAR {} {}h 请求失败,尝试下一档 fallback: {}",
icao,
hours,
exc,
)
else:
if first_exc is not None:
raise first_exc
if not data:
return None
latest = data[0]
temp_c = latest.get("temp")
if temp_c is not None and not self._is_plausible_metar_temp_c(temp_c, city, icao):
temp_c = None
dewp_c = latest.get("dewp")
def _parse_rawob_time(obs):
raw = obs.get("rawOb", "")
match = re.search(r"\b(\d{2})(\d{2})(\d{2})Z\b", raw)
if match:
_day, hour, minute = (
int(match.group(1)),
int(match.group(2)),
int(match.group(3)),
)
fallback = obs.get("reportTime", "")
try:
clean = fallback.replace(" ", "T")
if not clean.endswith("Z"):
clean += "Z"
base_dt = datetime.fromisoformat(clean.replace("Z", "+00:00"))
result = base_dt.replace(hour=hour, minute=minute, second=0)
if result > base_dt + timedelta(hours=2):
result -= timedelta(days=1)
return result
except Exception:
pass
fallback = obs.get("reportTime", "")
try:
clean = fallback.replace(" ", "T")
if not clean.endswith("Z"):
clean += "Z"
return datetime.fromisoformat(clean.replace("Z", "+00:00"))
except Exception:
return None
obs_dt = _parse_rawob_time(latest)
obs_time = (
obs_dt.strftime("%Y-%m-%dT%H:%M:%S.000Z")
if obs_dt
else latest.get("reportTime", "")
)
now_utc = datetime.now(timezone.utc)
local_now = now_utc + timedelta(seconds=utc_offset)
current_local_date = local_now.date().isoformat()
observation_local_date = None
if obs_dt:
observation_local_date = (
obs_dt + timedelta(seconds=utc_offset)
).date().isoformat()
stale_for_today = bool(
observation_local_date
and observation_local_date != current_local_date
)
local_midnight = local_now.replace(hour=0, minute=0, second=0, microsecond=0)
utc_midnight = local_midnight - timedelta(seconds=utc_offset)
max_so_far_c = -999
max_temp_time = None
for obs in data:
obs_dt_iter = _parse_rawob_time(obs)
if obs_dt_iter is None:
continue
try:
if obs_dt_iter >= utc_midnight:
temp_value = obs.get("temp")
if (
temp_value is not None
and self._is_plausible_metar_temp_c(temp_value, city, icao)
and temp_value > max_so_far_c
):
max_so_far_c = temp_value
local_report = obs_dt_iter + timedelta(seconds=utc_offset)
max_temp_time = local_report.strftime("%H:%M")
except Exception:
continue
recent_temps_raw = []
recent_obs_raw = []
today_obs_raw = []
cloud_rank_map = {
"CLR": 0,
"SKC": 0,
"FEW": 1,
"SCT": 2,
"BKN": 3,
"OVC": 4,
}
for index, obs in enumerate(data):
obs_temp = obs.get("temp")
obs_dt_iter = _parse_rawob_time(obs)
if (
obs_temp is not None
and obs_dt_iter
and self._is_plausible_metar_temp_c(obs_temp, city, icao)
):
local_rt = obs_dt_iter + timedelta(seconds=utc_offset)
time_str = local_rt.strftime("%H:%M")
if obs_dt_iter >= utc_midnight:
today_obs_raw.append((time_str, obs_temp))
if index < 4:
recent_temps_raw.append((time_str, obs_temp))
clouds = obs.get("clouds", [])
max_cloud_rank = 0
for cloud in clouds:
rank = cloud_rank_map.get(cloud.get("cover", ""), 0)
if rank > max_cloud_rank:
max_cloud_rank = rank
recent_obs_raw.append(
{
"time": time_str,
"temp": obs_temp,
"wdir": obs.get("wdir"),
"wspd": obs.get("wspd"),
"cloud_rank": max_cloud_rank,
"altim": obs.get("altim"),
}
)
if use_fahrenheit:
temp = temp_c * 9 / 5 + 32 if temp_c is not None else None
max_so_far = max_so_far_c * 9 / 5 + 32 if max_so_far_c > -900 else None
dewp = dewp_c * 9 / 5 + 32 if dewp_c is not None else None
unit = "fahrenheit"
recent_temps = [(t, round(v * 9 / 5 + 32, 1)) for t, v in recent_temps_raw]
today_obs = [(t, round(v * 9 / 5 + 32, 1)) for t, v in today_obs_raw]
else:
temp = temp_c
max_so_far = max_so_far_c if max_so_far_c > -900 else None
dewp = dewp_c
unit = "celsius"
recent_temps = [(t, v) for t, v in recent_temps_raw]
today_obs = [(t, v) for t, v in today_obs_raw]
result = {
"source": "metar",
"icao": icao,
"station_name": latest.get("name", icao),
"timestamp": datetime.utcnow().isoformat(),
"observation_time": obs_time,
"observation_local_date": observation_local_date,
"current_local_date": current_local_date,
"stale_for_today": stale_for_today,
"report_time": latest.get("reportTime"),
"receipt_time": latest.get("receiptTime"),
"obs_time_epoch": latest.get("obsTime"),
"current": {
"temp": round(temp, 1) if temp is not None else None,
"max_temp_so_far": round(max_so_far, 1) if max_so_far is not None else None,
"max_temp_time": max_temp_time,
"dewpoint": round(dewp, 1) if dewp is not None else None,
"humidity": latest.get("rh"),
"wind_speed_kt": latest.get("wspd"),
"wind_dir": latest.get("wdir"),
"visibility_mi": latest.get("visib"),
"wx_desc": latest.get("wxString"),
"altimeter": latest.get("altim"),
"raw_metar": latest.get("rawOb"),
"clouds": latest.get("clouds", []),
},
"recent_temps": recent_temps,
"today_obs": today_obs,
"recent_obs": recent_obs_raw,
"unit": unit,
"history_hours": history_hours,
}
if temp is not None:
logger.info(
f"✈️ METAR {icao}: {temp:.1f}°{'F' if use_fahrenheit else 'C'} (obs: {obs_time})"
)
else:
logger.info(f"✈️ METAR {icao}: temperature unavailable (obs: {obs_time})")
with self._metar_cache_lock:
self._metar_cache[cache_key] = {"d": result, "t": now_ts}
record_source_call("metar", "current", "success", (time.perf_counter() - started) * 1000.0)
return result
except httpx.HTTPError as exc:
logger.error(f"METAR 请求失败 ({icao}): {exc}")
with self._metar_cache_lock:
stale = self._metar_cache.get(cache_key)
if stale:
logger.warning(f"METAR {icao} 请求失败,使用缓存回退")
record_source_call("metar", "current", "stale_cache", (time.perf_counter() - started) * 1000.0)
return stale["d"]
record_source_call("metar", "current", "error", (time.perf_counter() - started) * 1000.0)
return None
except (KeyError, IndexError, TypeError) as exc:
logger.error(f"METAR 数据解析失败 ({icao}): {exc}")
record_source_call("metar", "current", "parse_error", (time.perf_counter() - started) * 1000.0)
return None
def fetch_taf(self, city: str, utc_offset: int = 0) -> Optional[Dict]:
"""从 NOAA Aviation Weather Center 获取 TAF 机场终端区预报原文。"""
started = time.perf_counter()
icao = self.get_icao_code(city)
if not icao:
record_source_call("taf", "current", "missing_icao", (time.perf_counter() - started) * 1000.0)
return None
cache_key = f"{icao}:{utc_offset}"
now_ts = time.time()
with self._taf_cache_lock:
cached = self._taf_cache.get(cache_key)
if cached and now_ts - cached["t"] < self.taf_cache_ttl_sec:
record_source_call("taf", "current", "cache_hit", (time.perf_counter() - started) * 1000.0)
return cached["d"]
try:
url = "https://aviationweather.gov/api/data/taf"
params = {
"ids": icao,
"format": "json",
"hours": 24,
}
response = self._http_get(
url,
params=params,
timeout=getattr(self, "metar_timeout_sec", self.timeout),
)
response.raise_for_status()
data = response.json()
if not data:
return None
latest = data[0]
result = {
"source": "taf",
"icao": icao,
"station_name": latest.get("name", icao),
"timestamp": datetime.utcnow().isoformat(),
"issue_time": latest.get("issueTime"),
"valid_time_from": latest.get("validTimeFrom"),
"valid_time_to": latest.get("validTimeTo"),
"raw_taf": latest.get("rawTAF") or latest.get("rawTaf") or latest.get("raw_text") or "",
}
with self._taf_cache_lock:
self._taf_cache[cache_key] = {"d": result, "t": now_ts}
record_source_call("taf", "current", "success", (time.perf_counter() - started) * 1000.0)
return result
except httpx.HTTPError as exc:
logger.error(f"TAF 请求失败 ({icao}): {exc}")
with self._taf_cache_lock:
stale = self._taf_cache.get(cache_key)
if stale:
record_source_call("taf", "current", "stale_cache", (time.perf_counter() - started) * 1000.0)
return stale["d"]
record_source_call("taf", "current", "error", (time.perf_counter() - started) * 1000.0)
return None
except (KeyError, IndexError, TypeError, ValueError) as exc:
logger.error(f"TAF 数据解析失败 ({icao}): {exc}")
record_source_call("taf", "current", "parse_error", (time.perf_counter() - started) * 1000.0)
return None
def fetch_metar_nearby_cluster(self, icaos: List[str], use_fahrenheit: bool = False) -> list:
"""批量获取一组 ICAO 站点的 METAR 数据,用于地图周边显示。"""
if not icaos:
return []
results = []
try:
ids_str = ",".join(icaos)
url = f"https://aviationweather.gov/api/data/metar?ids={ids_str}&format=json"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
}
resp = self.session.get(
url,
headers=headers,
timeout=getattr(self, "metar_cluster_timeout_sec", self.timeout),
)
if resp.status_code != 200:
logger.warning(f"METAR cluster fetch HTTP {resp.status_code} for {icaos}")
return []
data = resp.json()
if not isinstance(data, list):
return []
for obs in data:
icao = obs.get("icaoId")
lat = obs.get("lat")
lon = obs.get("lon")
temp_c = obs.get("temp")
if icao and lat and lon and temp_c is not None:
display_temp = (temp_c * 9 / 5) + 32 if use_fahrenheit else temp_c
name = obs.get("name") or icao
name = name.split(" Airport")[0].split(" Intl")[0].split(" International")[0].split(" Arpt")[0].split(",")[0].strip()
results.append(
{
"name": name,
"lat": lat,
"lon": lon,
"temp": round(display_temp, 1),
"temp_c": temp_c,
"istNo": icao,
"icao": icao,
"wind_dir": obs.get("wdir"),
"wind_speed": obs.get("wspd"),
"wind_speed_kt": obs.get("wspd"),
"obs_time": obs.get("obsTime") or obs.get("reportTime"),
"obs_time_epoch": obs.get("obsTime"),
"raw_metar": obs.get("rawOb"),
}
)
if results:
logger.info(f"📍 METAR 集群: 成功抓取 {len(results)} 个参考站数据")
return results
except Exception as exc:
logger.error(f"Failed to fetch METAR cluster {icaos}: {exc}")
return []