机场推送重构:观测缓存分离 + 全城市覆盖 + 四路并发

- 新增 force_refresh_observations_only 模式:机场推送仅刷新 METAR/AMOS
  观测缓存,多模型预报缓存保留 15 分钟,杜绝 Open-Meteo 429 限流后
  DEB 回退到实测温度的 bug
- 砍掉夜间静默和温度状态机,每条新观测无条件推送
- HIGH_FREQ_AIRPORT_CITIES 从 19 城扩到 30 城(新增 11 个美国城市)
- 美国城市走 airport_primary (MADIS) 优先取温
- 机场周期从串行改为 ThreadPoolExecutor(max_workers=4),周期时间从
  ~120s 压缩到 ~33s

Constraint: 单核 VPS 安全并发上限
Tested: docker compose up -d --build polyweather 重建后推送正常
This commit is contained in:
2569718930@qq.com
2026-05-17 18:04:05 +08:00
parent 58e6557c66
commit d39534dff4
3 changed files with 242 additions and 208 deletions
+16 -3
View File
@@ -727,18 +727,29 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
lat: Optional[float], lat: Optional[float],
lon: Optional[float], lon: Optional[float],
use_fahrenheit: bool, use_fahrenheit: bool,
*,
keep_model_caches: bool = False,
) -> None: ) -> None:
"""Drop in-memory caches for one city before a force-refresh query.""" """Drop in-memory caches for one city before a force-refresh query.
When *keep_model_caches* is True (used by high-frequency observation
loops such as airport runway pushes), only observation-level caches
(METAR, AMOS, country networks, settlement) are evicted while the
longer-lived multi-model / ensemble / single-model forecast caches
are left intact so the DEB blending does not fall back to the
current observed temperature during an Open-Meteo rate-limit
cooldown.
"""
if lat is not None and lon is not None: if lat is not None and lon is not None:
base = f"{round(float(lat), 4)}:{round(float(lon), 4)}" base = f"{round(float(lat), 4)}:{round(float(lon), 4)}"
unit = "f" if use_fahrenheit else "c" unit = "f" if use_fahrenheit else "c"
if not keep_model_caches:
open_meteo_key = f"{base}:14:{unit}" open_meteo_key = f"{base}:14:{unit}"
ensemble_key = f"{base}:{unit}" ensemble_key = f"{base}:{unit}"
cache_city = str(city or "").strip().lower() cache_city = str(city or "").strip().lower()
multi_model_key = ( multi_model_key = (
f"{base}:{cache_city}:{unit}:{self.multi_model_cache_version}" f"{base}:{cache_city}:{unit}:{self.multi_model_cache_version}"
) )
with self._open_meteo_cache_lock: with self._open_meteo_cache_lock:
self._open_meteo_cache.pop(open_meteo_key, None) self._open_meteo_cache.pop(open_meteo_key, None)
with self._ensemble_cache_lock: with self._ensemble_cache_lock:
@@ -1284,6 +1295,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
lon: float = None, lon: float = None,
country: str = None, country: str = None,
force_refresh: bool = False, force_refresh: bool = False,
force_refresh_observations_only: bool = False,
include_taf: bool = True, include_taf: bool = True,
include_nearby: bool = True, include_nearby: bool = True,
include_ensemble: bool = True, include_ensemble: bool = True,
@@ -1298,12 +1310,13 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
use_fahrenheit = self._uses_fahrenheit(city_lower) use_fahrenheit = self._uses_fahrenheit(city_lower)
supports_aviationweather = self._supports_aviationweather(city_lower) supports_aviationweather = self._supports_aviationweather(city_lower)
if force_refresh: if force_refresh or force_refresh_observations_only:
self._evict_city_caches( self._evict_city_caches(
city=city, city=city,
lat=lat, lat=lat,
lon=lon, lon=lon,
use_fahrenheit=use_fahrenheit, use_fahrenheit=use_fahrenheit,
keep_model_caches=force_refresh_observations_only,
) )
self._log_temperature_unit(city, use_fahrenheit) self._log_temperature_unit(city, use_fahrenheit)
self._attach_settlement_sources(results, city_lower) self._attach_settlement_sources(results, city_lower)
+110 -97
View File
@@ -528,8 +528,24 @@ def _alert_signature(alert_payload: Dict[str, Any]) -> str:
# ── high-freq airport push loop ── # ── high-freq airport push loop ──
HIGH_FREQ_AIRPORT_CITIES = {"seoul", "singapore", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "istanbul", "paris", "hong kong", "lau fau shan", "taipei", "beijing", "shanghai", "guangzhou", "qingdao", "chengdu", "chongqing", "wuhan"} HIGH_FREQ_AIRPORT_CITIES = {
HIGH_FREQ_AIRPORT_ICAO = {"seoul": "RKSI", "singapore": "WSSS", "busan": "RKPK", "tokyo": "44166", "ankara": "17128", "helsinki": "EFHK", "amsterdam": "EHAM", "istanbul": "17058", "paris": "LFPB", "hong kong": "HKO", "lau fau shan": "LFS", "taipei": "466920", "beijing": "ZBAA", "shanghai": "ZSPD", "guangzhou": "ZGGG", "qingdao": "ZSQD", "chengdu": "ZUUU", "chongqing": "ZUCK", "wuhan": "ZHHH"} "seoul", "singapore", "busan", "tokyo", "ankara", "helsinki", "amsterdam",
"istanbul", "paris", "hong kong", "lau fau shan", "taipei",
"beijing", "shanghai", "guangzhou", "qingdao", "chengdu", "chongqing", "wuhan",
"new york", "los angeles", "chicago", "denver", "atlanta",
"miami", "san francisco", "houston", "dallas", "austin", "seattle",
}
HIGH_FREQ_AIRPORT_ICAO = {
"seoul": "RKSI", "singapore": "WSSS", "busan": "RKPK", "tokyo": "44166",
"ankara": "17128", "helsinki": "EFHK", "amsterdam": "EHAM", "istanbul": "17058",
"paris": "LFPB", "hong kong": "HKO", "lau fau shan": "LFS", "taipei": "466920",
"beijing": "ZBAA", "shanghai": "ZSPD", "guangzhou": "ZGGG", "qingdao": "ZSQD",
"chengdu": "ZUUU", "chongqing": "ZUCK", "wuhan": "ZHHH",
"new york": "KLGA", "los angeles": "KLAX", "chicago": "KORD",
"denver": "KBKF", "atlanta": "KATL", "miami": "KMIA",
"san francisco": "KSFO", "houston": "KHOU", "dallas": "KDAL",
"austin": "KAUS", "seattle": "KSEA",
}
FOCUS_RUNWAY_PAIRS = { FOCUS_RUNWAY_PAIRS = {
"chongqing": {("02L", "20R")}, "chongqing": {("02L", "20R")},
"shanghai": {("17L", "35R")}, "shanghai": {("17L", "35R")},
@@ -853,7 +869,11 @@ def _build_airport_status_message(
"hong kong": "Observatory", "lau fau shan": "Lau Fau Shan", "hong kong": "Observatory", "lau fau shan": "Lau Fau Shan",
"taipei": "Songshan", "beijing": "Capital", "shanghai": "Pudong", "taipei": "Songshan", "beijing": "Capital", "shanghai": "Pudong",
"guangzhou": "Baiyun", "shenzhen": "Bao'an", "qingdao": "Jiaodong", "guangzhou": "Baiyun", "shenzhen": "Bao'an", "qingdao": "Jiaodong",
"chengdu": "Shuangliu", "chongqing": "Jiangbei", "wuhan": "Tianhe"} "chengdu": "Shuangliu", "chongqing": "Jiangbei", "wuhan": "Tianhe",
"new york": "LaGuardia", "los angeles": "LAX", "chicago": "O'Hare",
"denver": "Buckley", "atlanta": "Hartsfield", "miami": "Intl",
"san francisco": "SFO", "houston": "Hobby", "dallas": "Love Field",
"austin": "Bergstrom", "seattle": "Sea-Tac"}
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 ""
@@ -984,25 +1004,14 @@ def _get_airport_daily_high(city_weather: Dict[str, Any]):
# Per-city push interval — unified to 60s, obs_time dedup prevents spam # Per-city push interval — unified to 60s, obs_time dedup prevents spam
_AIRPORT_PUSH_INTERVAL = { _AIRPORT_PUSH_INTERVAL = {
"seoul": 60, "seoul": 60, "busan": 60, "tokyo": 60, "ankara": 60,
"busan": 60, "helsinki": 60, "amsterdam": 60, "istanbul": 60, "paris": 60,
"tokyo": 60, "hong kong": 60, "lau fau shan": 60, "singapore": 60, "taipei": 60,
"ankara": 60, "beijing": 60, "shanghai": 60, "guangzhou": 60, "qingdao": 60,
"helsinki": 60, "chengdu": 60, "chongqing": 60, "wuhan": 60,
"amsterdam": 60, "new york": 60, "los angeles": 60, "chicago": 60, "denver": 60,
"istanbul": 60, "atlanta": 60, "miami": 60, "san francisco": 60, "houston": 60,
"paris": 60, "dallas": 60, "austin": 60, "seattle": 60,
"hong kong": 60,
"lau fau shan": 60,
"singapore": 60,
"taipei": 60,
"beijing": 60,
"shanghai": 60,
"guangzhou": 60,
"qingdao": 60,
"chengdu": 60,
"chongqing": 60,
"wuhan": 60,
} }
# Per-city temperature window threshold (°C below DEB predicted high) # Per-city temperature window threshold (°C below DEB predicted high)
# Continental airports: wider window (temp rises steadily over land) # Continental airports: wider window (temp rises steadily over land)
@@ -1068,35 +1077,36 @@ def _check_rising_trend(icao: str) -> bool:
return False return False
def _run_high_freq_airport_cycle( def _process_airport_city(
bot: Any, city: str,
config: Dict[str, Any], now_ts: int,
last_city: dict,
chat_ids: List[str], chat_ids: List[str],
state: Dict[str, Any], bot: Any,
) -> bool: ) -> tuple | None:
state_dirty = False """Process one airport city and return (city, new_state_entry) or None.
now_ts = int(time.time())
last_by_city = state.setdefault("last_by_city", {})
for city in sorted(HIGH_FREQ_AIRPORT_CITIES): This is the per-city unit used by the concurrent thread pool in
try: ``_run_high_freq_airport_cycle``.
last_city = last_by_city.get(city) or {} """
last_city_ts = int(last_city.get("ts") or 0) last_city_ts = int(last_city.get("ts") or 0)
last_obs_time = str(last_city.get("obs_time") or "") last_obs_time = str(last_city.get("obs_time") or "")
city_interval = _AIRPORT_PUSH_INTERVAL.get(city, 600) city_interval = _AIRPORT_PUSH_INTERVAL.get(city, 600)
if now_ts - last_city_ts < city_interval: if now_ts - last_city_ts < city_interval:
continue return None
from web.app import _analyze # lazy import — only the bot process needs it
city_weather: Dict[str, Any] = {} city_weather: Dict[str, Any] = {}
deb_pred: Optional[float] = None deb_pred: Optional[float] = None
try: try:
from web.app import _analyze city_weather = _analyze(city, force_refresh_observations_only=True)
city_weather = _analyze(city, force_refresh=True)
deb_raw = (city_weather.get("deb") or {}).get("prediction") deb_raw = (city_weather.get("deb") or {}).get("prediction")
if deb_raw is not None: if deb_raw is not None:
deb_pred = float(deb_raw) deb_pred = float(deb_raw)
except Exception: except Exception:
pass logger.exception("airport analyze failed for city={}", city)
return None
# Extract airport-level temperature # Extract airport-level temperature
amos = city_weather.get("amos") or {} amos = city_weather.get("amos") or {}
@@ -1116,9 +1126,7 @@ def _run_high_freq_airport_cycle(
runway_pairs = runway_obs.get("runway_pairs") or [] runway_pairs = runway_obs.get("runway_pairs") or []
runway_temps = runway_obs.get("temperatures") or [] runway_temps = runway_obs.get("temperatures") or []
runway_pairs, runway_temps, _point_temps = _select_focus_runway_obs( runway_pairs, runway_temps, _point_temps = _select_focus_runway_obs(
city, city, runway_pairs, runway_temps,
runway_pairs,
runway_temps,
runway_obs.get("point_temperatures") or [], runway_obs.get("point_temperatures") or [],
) )
if runway_temps: if runway_temps:
@@ -1131,7 +1139,10 @@ def _run_high_freq_airport_cycle(
current_temp = station_temp current_temp = station_temp
if current_temp is None: if current_temp is None:
current_temp = (city_weather.get("current") or {}).get("temp") airport_primary = city_weather.get("airport_primary") or {}
current_temp = airport_primary.get("temp") or (city_weather.get("current") or {}).get("temp")
if not current_obs_time:
current_obs_time = str(airport_primary.get("obs_time") or "")
if city == "paris": if city == "paris":
arome_temp = _fetch_arome_temp() arome_temp = _fetch_arome_temp()
if arome_temp is not None: if arome_temp is not None:
@@ -1140,18 +1151,16 @@ def _run_high_freq_airport_cycle(
if not current_obs_time: if not current_obs_time:
current_obs_time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S") current_obs_time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
if current_temp is None or deb_pred is None: if current_temp is None or deb_pred is None:
continue return None
# 基于原始观测数据时间的去重:同一条观测不重复推送 # Dedup: same observation → skip (with delayed retry for HK / LFS)
# HK/LFS 数据在 x7 分发布,API 可能有 3-5s 延迟,
# obs_time 未变但距上次推送已超 9min → 等 4s 重拉一次
_CITIES_WITH_DELAYED_API = {"hong kong", "lau fau shan"} _CITIES_WITH_DELAYED_API = {"hong kong", "lau fau shan"}
if (current_obs_time and last_obs_time and current_obs_time == last_obs_time if (current_obs_time and last_obs_time and current_obs_time == last_obs_time
and city in _CITIES_WITH_DELAYED_API and city in _CITIES_WITH_DELAYED_API
and now_ts - last_city_ts > 540): and now_ts - last_city_ts > 540):
time.sleep(4) time.sleep(4)
try: try:
city_weather = _analyze(city, force_refresh=True) city_weather = _analyze(city, force_refresh_observations_only=True)
deb_raw2 = (city_weather.get("deb") or {}).get("prediction") deb_raw2 = (city_weather.get("deb") or {}).get("prediction")
if deb_raw2 is not None: if deb_raw2 is not None:
deb_pred = float(deb_raw2) deb_pred = float(deb_raw2)
@@ -1169,59 +1178,24 @@ def _run_high_freq_airport_cycle(
station_temp = row2.get("temp") if row2 else None station_temp = row2.get("temp") if row2 else None
current_temp = station_temp or (city_weather.get("current") or {}).get("temp") current_temp = station_temp or (city_weather.get("current") or {}).get("temp")
if current_temp is None or deb_pred is None: if current_temp is None or deb_pred is None:
continue return None
else: else:
continue return None
except Exception: except Exception:
continue return None
elif current_obs_time and last_obs_time and current_obs_time == last_obs_time: elif current_obs_time and last_obs_time and current_obs_time == last_obs_time:
continue return None
# ── 热度状态机 ── obs_local = (
daily_high, max_time = _get_airport_daily_high(city_weather) ((city_weather.get("amos") or {}).get("observation_time_local") or "")[11:16]
gap = (daily_high - current_temp) if daily_high is not None and current_temp is not None else None if len(str((city_weather.get("amos") or {}).get("observation_time_local") or "")) >= 16
try: else (city_weather.get("airport_current") or {}).get("obs_time")
h = int(str(city_weather.get("local_time") or "00")[:2]) or city_weather.get("local_time")
except ValueError: or ""
h = 0
rising = _check_rising_trend(airport_icao) if city != "paris" else True
# 夜间 (20:00–06:00):🌙 无有效升温窗口,跳过
if h >= 20 or h <= 5:
continue
# 早晨 (06:0009:00)"今日最高"可能只是凌晨刚形成的首个观测值
# 只有日高已稳定存在一段时间,或当前已明显高于凌晨基线,才有意义
high_formed = (
max_time is not None
and daily_high is not None
and current_temp is not None
and (h >= 9 or current_temp > daily_high + 0.5)
) )
message = _build_airport_status_message(city, city_weather, deb_pred, obs_local, state="")
# 判定状态 # Send to all target chats
if current_temp is not None and daily_high is not None and current_temp > daily_high + 0.3:
if deb_pred is not None and current_temp > deb_pred:
state = "\U0001f680 超预期"
else:
state = "\U0001f525 冲高中"
elif high_formed and gap is not None and gap <= 1.0:
state = "⚠️ 冲顶观察"
elif high_formed and gap is not None and gap <= 2.0 and rising:
state = "\U0001f525 升温中"
elif gap is not None and gap <= 3.0:
state = "❄️ 降温中"
else:
continue
# 用观测数据时间而非当前本地时间
airport_cur = city_weather.get("airport_current") or {}
amos_obs = (city_weather.get("amos") or {}).get("observation_time_local") or ""
if amos_obs and len(str(amos_obs)) >= 16:
amos_obs = str(amos_obs)[11:16] # "2026-05-15 17:32:00" → "17:32"
obs_local = amos_obs or airport_cur.get("obs_time") or city_weather.get("local_time") or ""
message = _build_airport_status_message(city, city_weather, deb_pred, obs_local, state=state)
sent = False sent = False
for chat_id in chat_ids: for chat_id in chat_ids:
try: try:
@@ -1235,12 +1209,48 @@ def _run_high_freq_airport_cycle(
logger.warning("airport push failed city={} chat_id={}: {}", city, chat_id, exc) logger.warning("airport push failed city={} chat_id={}: {}", city, chat_id, exc)
if sent: if sent:
last_by_city[city] = {"ts": now_ts, "active": True, "obs_time": current_obs_time} logger.info("airport status pushed city={} temp={} deb={} obs_time={}",
state_dirty = True city, current_temp, deb_pred, current_obs_time)
logger.info("airport status pushed city={} temp={} deb={} obs_time={}", city, current_temp, deb_pred, current_obs_time) return (city, {"ts": now_ts, "active": True, "obs_time": current_obs_time})
return None
def _run_high_freq_airport_cycle(
bot: Any,
config: Dict[str, Any],
chat_ids: List[str],
state: Dict[str, Any],
) -> bool:
state_dirty = False
now_ts = int(time.time())
last_by_city = state.setdefault("last_by_city", {})
logger.info("airport cycle tick cities={}", len(HIGH_FREQ_AIRPORT_CITIES))
cities = sorted(HIGH_FREQ_AIRPORT_CITIES)
with ThreadPoolExecutor(max_workers=4) as pool:
futures = {
pool.submit(
_process_airport_city,
city,
now_ts,
last_by_city.get(city) or {},
chat_ids,
bot,
): city
for city in cities
}
for future in as_completed(futures):
try:
result = future.result()
except Exception: except Exception:
logger.exception("airport cycle failed for city={}", city) logger.exception("airport city task crashed city={}", futures[future])
continue
if result is None:
continue
city, entry = result
last_by_city[city] = entry
state_dirty = True
return state_dirty return state_dirty
@@ -1265,6 +1275,7 @@ def start_high_freq_airport_push_loop(bot: Any, config: Dict[str, Any]) -> Optio
) )
while True: while True:
cycle_started = time.time() cycle_started = time.time()
try:
state = _load_airport_state() state = _load_airport_state()
if _run_high_freq_airport_cycle( if _run_high_freq_airport_cycle(
bot=bot, bot=bot,
@@ -1273,6 +1284,8 @@ def start_high_freq_airport_push_loop(bot: Any, config: Dict[str, Any]) -> Optio
state=state, state=state,
): ):
_save_airport_state(state) _save_airport_state(state)
except Exception:
logger.exception("airport push cycle crashed")
elapsed = time.time() - cycle_started elapsed = time.time() - cycle_started
sleep_sec = max(5, interval_sec - int(elapsed)) sleep_sec = max(5, interval_sec - int(elapsed))
+11 -3
View File
@@ -1656,11 +1656,18 @@ def _archive_intraday_path_snapshot(city: str, result: Dict[str, Any]) -> None:
def _analyze( def _analyze(
city: str, city: str,
force_refresh: bool = False, force_refresh: bool = False,
force_refresh_observations_only: bool = False,
include_llm_commentary: bool = False, include_llm_commentary: bool = False,
detail_mode: str = "full", detail_mode: str = "full",
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Fetch, analyse, and return structured weather data for one city.""" """Fetch, analyse, and return structured weather data for one city.
# Check cache
Set *force_refresh_observations_only* to True for high-frequency
observation loops that need fresh METAR/AMOS/runway data but should
keep the longer-lived multi-model forecast caches intact so the DEB
blending does not fall back to the current observed temperature.
"""
# Check cache skip when explicitly refreshing observations
ttl = _analysis_ttl_for_city(city) ttl = _analysis_ttl_for_city(city)
normalized_detail_mode_raw = str(detail_mode or "full").strip().lower() normalized_detail_mode_raw = str(detail_mode or "full").strip().lower()
if normalized_detail_mode_raw == "panel": if normalized_detail_mode_raw == "panel":
@@ -1673,7 +1680,7 @@ def _analyze(
normalized_detail_mode = "full" normalized_detail_mode = "full"
cache_key = _analysis_cache_key(city, normalized_detail_mode) cache_key = _analysis_cache_key(city, normalized_detail_mode)
if not force_refresh: if not force_refresh and not force_refresh_observations_only:
cached = _cache.get(cache_key) cached = _cache.get(cache_key)
if cached and _time.time() - cached["t"] < ttl: if cached and _time.time() - cached["t"] < ttl:
if include_llm_commentary: if include_llm_commentary:
@@ -1707,6 +1714,7 @@ def _analyze(
lat=lat, lat=lat,
lon=lon, lon=lon,
force_refresh=force_refresh, force_refresh=force_refresh,
force_refresh_observations_only=force_refresh_observations_only,
include_taf=not is_panel_mode and not is_nearby_mode and not is_market_mode, include_taf=not is_panel_mode and not is_nearby_mode and not is_market_mode,
include_nearby=not is_panel_mode and not is_market_mode, include_nearby=not is_panel_mode and not is_market_mode,
include_ensemble=not is_panel_mode and not is_nearby_mode and not is_market_mode, include_ensemble=not is_panel_mode and not is_nearby_mode and not is_market_mode,