机场推送重构:观测缓存分离 + 全城市覆盖 + 四路并发
- 新增 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:
@@ -727,24 +727,35 @@ 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"
|
||||||
open_meteo_key = f"{base}:14:{unit}"
|
if not keep_model_caches:
|
||||||
ensemble_key = f"{base}:{unit}"
|
open_meteo_key = f"{base}:14:{unit}"
|
||||||
cache_city = str(city or "").strip().lower()
|
ensemble_key = f"{base}:{unit}"
|
||||||
multi_model_key = (
|
cache_city = str(city or "").strip().lower()
|
||||||
f"{base}:{cache_city}:{unit}:{self.multi_model_cache_version}"
|
multi_model_key = (
|
||||||
)
|
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:
|
||||||
self._ensemble_cache.pop(ensemble_key, None)
|
self._ensemble_cache.pop(ensemble_key, None)
|
||||||
with self._multi_model_cache_lock:
|
with self._multi_model_cache_lock:
|
||||||
self._multi_model_cache.pop(multi_model_key, None)
|
self._multi_model_cache.pop(multi_model_key, None)
|
||||||
|
|
||||||
icao = self.get_icao_code(city)
|
icao = self.get_icao_code(city)
|
||||||
if icao:
|
if icao:
|
||||||
@@ -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)
|
||||||
|
|||||||
+202
-189
@@ -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,6 +1077,145 @@ def _check_rising_trend(icao: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _process_airport_city(
|
||||||
|
city: str,
|
||||||
|
now_ts: int,
|
||||||
|
last_city: dict,
|
||||||
|
chat_ids: List[str],
|
||||||
|
bot: Any,
|
||||||
|
) -> tuple | None:
|
||||||
|
"""Process one airport city and return (city, new_state_entry) or None.
|
||||||
|
|
||||||
|
This is the per-city unit used by the concurrent thread pool in
|
||||||
|
``_run_high_freq_airport_cycle``.
|
||||||
|
"""
|
||||||
|
last_city_ts = int(last_city.get("ts") or 0)
|
||||||
|
last_obs_time = str(last_city.get("obs_time") or "")
|
||||||
|
city_interval = _AIRPORT_PUSH_INTERVAL.get(city, 600)
|
||||||
|
if now_ts - last_city_ts < city_interval:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from web.app import _analyze # lazy import — only the bot process needs it
|
||||||
|
|
||||||
|
city_weather: Dict[str, Any] = {}
|
||||||
|
deb_pred: Optional[float] = None
|
||||||
|
try:
|
||||||
|
city_weather = _analyze(city, force_refresh_observations_only=True)
|
||||||
|
deb_raw = (city_weather.get("deb") or {}).get("prediction")
|
||||||
|
if deb_raw is not None:
|
||||||
|
deb_pred = float(deb_raw)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("airport analyze failed for city={}", city)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract airport-level temperature
|
||||||
|
amos = city_weather.get("amos") or {}
|
||||||
|
mgm_nearby = city_weather.get("mgm_nearby") or []
|
||||||
|
airport_icao = HIGH_FREQ_AIRPORT_ICAO.get(city, "")
|
||||||
|
airport_row = None
|
||||||
|
for row in mgm_nearby:
|
||||||
|
if str(row.get("istNo") or "") == airport_icao or str(row.get("icao") or "") == airport_icao:
|
||||||
|
airport_row = row
|
||||||
|
break
|
||||||
|
if not airport_row:
|
||||||
|
airport_row = mgm_nearby[0] if mgm_nearby else {}
|
||||||
|
station_temp = airport_row.get("temp") if airport_row else None
|
||||||
|
current_obs_time = str(airport_row.get("obs_time") or "")
|
||||||
|
|
||||||
|
runway_obs = (amos.get("runway_obs") or {})
|
||||||
|
runway_pairs = runway_obs.get("runway_pairs") or []
|
||||||
|
runway_temps = runway_obs.get("temperatures") or []
|
||||||
|
runway_pairs, runway_temps, _point_temps = _select_focus_runway_obs(
|
||||||
|
city, runway_pairs, runway_temps,
|
||||||
|
runway_obs.get("point_temperatures") or [],
|
||||||
|
)
|
||||||
|
if runway_temps:
|
||||||
|
valid_temps = [t for (t, _d) in runway_temps if t is not None]
|
||||||
|
if valid_temps:
|
||||||
|
station_temp = max(valid_temps)
|
||||||
|
amos_obs_time = amos.get("observation_time") or ""
|
||||||
|
if amos_obs_time:
|
||||||
|
current_obs_time = amos_obs_time
|
||||||
|
|
||||||
|
current_temp = station_temp
|
||||||
|
if current_temp is None:
|
||||||
|
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":
|
||||||
|
arome_temp = _fetch_arome_temp()
|
||||||
|
if arome_temp is not None:
|
||||||
|
current_temp = arome_temp
|
||||||
|
city_weather.setdefault("current", {})["temp"] = arome_temp
|
||||||
|
if not current_obs_time:
|
||||||
|
current_obs_time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||||
|
if current_temp is None or deb_pred is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Dedup: same observation → skip (with delayed retry for HK / LFS)
|
||||||
|
_CITIES_WITH_DELAYED_API = {"hong kong", "lau fau shan"}
|
||||||
|
if (current_obs_time and last_obs_time and current_obs_time == last_obs_time
|
||||||
|
and city in _CITIES_WITH_DELAYED_API
|
||||||
|
and now_ts - last_city_ts > 540):
|
||||||
|
time.sleep(4)
|
||||||
|
try:
|
||||||
|
city_weather = _analyze(city, force_refresh_observations_only=True)
|
||||||
|
deb_raw2 = (city_weather.get("deb") or {}).get("prediction")
|
||||||
|
if deb_raw2 is not None:
|
||||||
|
deb_pred = float(deb_raw2)
|
||||||
|
mgm_nearby2 = city_weather.get("mgm_nearby") or []
|
||||||
|
row2 = None
|
||||||
|
for r in mgm_nearby2:
|
||||||
|
if str(r.get("istNo") or "") == airport_icao or str(r.get("icao") or "") == airport_icao:
|
||||||
|
row2 = r
|
||||||
|
break
|
||||||
|
if not row2 and mgm_nearby2:
|
||||||
|
row2 = mgm_nearby2[0]
|
||||||
|
retry_obs = str(row2.get("obs_time") or "") if row2 else ""
|
||||||
|
if retry_obs and retry_obs != last_obs_time:
|
||||||
|
current_obs_time = retry_obs
|
||||||
|
station_temp = row2.get("temp") if row2 else None
|
||||||
|
current_temp = station_temp or (city_weather.get("current") or {}).get("temp")
|
||||||
|
if current_temp is None or deb_pred is None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
elif current_obs_time and last_obs_time and current_obs_time == last_obs_time:
|
||||||
|
return None
|
||||||
|
|
||||||
|
obs_local = (
|
||||||
|
((city_weather.get("amos") or {}).get("observation_time_local") or "")[11:16]
|
||||||
|
if len(str((city_weather.get("amos") or {}).get("observation_time_local") or "")) >= 16
|
||||||
|
else (city_weather.get("airport_current") or {}).get("obs_time")
|
||||||
|
or city_weather.get("local_time")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
message = _build_airport_status_message(city, city_weather, deb_pred, obs_local, state="")
|
||||||
|
|
||||||
|
# Send to all target chats
|
||||||
|
sent = False
|
||||||
|
for chat_id in chat_ids:
|
||||||
|
try:
|
||||||
|
kwargs = {}
|
||||||
|
thread_id = _resolve_thread_id(chat_id, city)
|
||||||
|
if thread_id:
|
||||||
|
kwargs["message_thread_id"] = thread_id
|
||||||
|
bot.send_message(chat_id, message, **kwargs)
|
||||||
|
sent = True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("airport push failed city={} chat_id={}: {}", city, chat_id, exc)
|
||||||
|
|
||||||
|
if sent:
|
||||||
|
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(
|
def _run_high_freq_airport_cycle(
|
||||||
bot: Any,
|
bot: Any,
|
||||||
config: Dict[str, Any],
|
config: Dict[str, Any],
|
||||||
@@ -1077,170 +1225,32 @@ def _run_high_freq_airport_cycle(
|
|||||||
state_dirty = False
|
state_dirty = False
|
||||||
now_ts = int(time.time())
|
now_ts = int(time.time())
|
||||||
last_by_city = state.setdefault("last_by_city", {})
|
last_by_city = state.setdefault("last_by_city", {})
|
||||||
|
logger.info("airport cycle tick cities={}", len(HIGH_FREQ_AIRPORT_CITIES))
|
||||||
|
|
||||||
for city in sorted(HIGH_FREQ_AIRPORT_CITIES):
|
cities = sorted(HIGH_FREQ_AIRPORT_CITIES)
|
||||||
try:
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||||
last_city = last_by_city.get(city) or {}
|
futures = {
|
||||||
last_city_ts = int(last_city.get("ts") or 0)
|
pool.submit(
|
||||||
last_obs_time = str(last_city.get("obs_time") or "")
|
_process_airport_city,
|
||||||
city_interval = _AIRPORT_PUSH_INTERVAL.get(city, 600)
|
|
||||||
if now_ts - last_city_ts < city_interval:
|
|
||||||
continue
|
|
||||||
|
|
||||||
city_weather: Dict[str, Any] = {}
|
|
||||||
deb_pred: Optional[float] = None
|
|
||||||
try:
|
|
||||||
from web.app import _analyze
|
|
||||||
city_weather = _analyze(city, force_refresh=True)
|
|
||||||
deb_raw = (city_weather.get("deb") or {}).get("prediction")
|
|
||||||
if deb_raw is not None:
|
|
||||||
deb_pred = float(deb_raw)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Extract airport-level temperature
|
|
||||||
amos = city_weather.get("amos") or {}
|
|
||||||
mgm_nearby = city_weather.get("mgm_nearby") or []
|
|
||||||
airport_icao = HIGH_FREQ_AIRPORT_ICAO.get(city, "")
|
|
||||||
airport_row = None
|
|
||||||
for row in mgm_nearby:
|
|
||||||
if str(row.get("istNo") or "") == airport_icao or str(row.get("icao") or "") == airport_icao:
|
|
||||||
airport_row = row
|
|
||||||
break
|
|
||||||
if not airport_row:
|
|
||||||
airport_row = mgm_nearby[0] if mgm_nearby else {}
|
|
||||||
station_temp = airport_row.get("temp") if airport_row else None
|
|
||||||
current_obs_time = str(airport_row.get("obs_time") or "")
|
|
||||||
|
|
||||||
runway_obs = (amos.get("runway_obs") or {})
|
|
||||||
runway_pairs = runway_obs.get("runway_pairs") or []
|
|
||||||
runway_temps = runway_obs.get("temperatures") or []
|
|
||||||
runway_pairs, runway_temps, _point_temps = _select_focus_runway_obs(
|
|
||||||
city,
|
city,
|
||||||
runway_pairs,
|
now_ts,
|
||||||
runway_temps,
|
last_by_city.get(city) or {},
|
||||||
runway_obs.get("point_temperatures") or [],
|
chat_ids,
|
||||||
)
|
bot,
|
||||||
if runway_temps:
|
): city
|
||||||
valid_temps = [t for (t, _d) in runway_temps if t is not None]
|
for city in cities
|
||||||
if valid_temps:
|
}
|
||||||
station_temp = max(valid_temps)
|
for future in as_completed(futures):
|
||||||
amos_obs_time = amos.get("observation_time") or ""
|
|
||||||
if amos_obs_time:
|
|
||||||
current_obs_time = amos_obs_time
|
|
||||||
|
|
||||||
current_temp = station_temp
|
|
||||||
if current_temp is None:
|
|
||||||
current_temp = (city_weather.get("current") or {}).get("temp")
|
|
||||||
if city == "paris":
|
|
||||||
arome_temp = _fetch_arome_temp()
|
|
||||||
if arome_temp is not None:
|
|
||||||
current_temp = arome_temp
|
|
||||||
city_weather.setdefault("current", {})["temp"] = arome_temp
|
|
||||||
if not current_obs_time:
|
|
||||||
current_obs_time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
|
||||||
if current_temp is None or deb_pred is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 基于原始观测数据时间的去重:同一条观测不重复推送
|
|
||||||
# HK/LFS 数据在 x7 分发布,API 可能有 3-5s 延迟,
|
|
||||||
# obs_time 未变但距上次推送已超 9min → 等 4s 重拉一次
|
|
||||||
_CITIES_WITH_DELAYED_API = {"hong kong", "lau fau shan"}
|
|
||||||
if (current_obs_time and last_obs_time and current_obs_time == last_obs_time
|
|
||||||
and city in _CITIES_WITH_DELAYED_API
|
|
||||||
and now_ts - last_city_ts > 540):
|
|
||||||
time.sleep(4)
|
|
||||||
try:
|
|
||||||
city_weather = _analyze(city, force_refresh=True)
|
|
||||||
deb_raw2 = (city_weather.get("deb") or {}).get("prediction")
|
|
||||||
if deb_raw2 is not None:
|
|
||||||
deb_pred = float(deb_raw2)
|
|
||||||
mgm_nearby2 = city_weather.get("mgm_nearby") or []
|
|
||||||
row2 = None
|
|
||||||
for r in mgm_nearby2:
|
|
||||||
if str(r.get("istNo") or "") == airport_icao or str(r.get("icao") or "") == airport_icao:
|
|
||||||
row2 = r
|
|
||||||
break
|
|
||||||
if not row2 and mgm_nearby2:
|
|
||||||
row2 = mgm_nearby2[0]
|
|
||||||
retry_obs = str(row2.get("obs_time") or "") if row2 else ""
|
|
||||||
if retry_obs and retry_obs != last_obs_time:
|
|
||||||
current_obs_time = retry_obs
|
|
||||||
station_temp = row2.get("temp") if row2 else None
|
|
||||||
current_temp = station_temp or (city_weather.get("current") or {}).get("temp")
|
|
||||||
if current_temp is None or deb_pred is None:
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
elif current_obs_time and last_obs_time and current_obs_time == last_obs_time:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# ── 热度状态机 ──
|
|
||||||
daily_high, max_time = _get_airport_daily_high(city_weather)
|
|
||||||
gap = (daily_high - current_temp) if daily_high is not None and current_temp is not None else None
|
|
||||||
try:
|
try:
|
||||||
h = int(str(city_weather.get("local_time") or "00")[:2])
|
result = future.result()
|
||||||
except ValueError:
|
except Exception:
|
||||||
h = 0
|
logger.exception("airport city task crashed city={}", futures[future])
|
||||||
rising = _check_rising_trend(airport_icao) if city != "paris" else True
|
|
||||||
|
|
||||||
# 夜间 (20:00–06:00):🌙 无有效升温窗口,跳过
|
|
||||||
if h >= 20 or h <= 5:
|
|
||||||
continue
|
continue
|
||||||
|
if result is None:
|
||||||
# 早晨 (06:00–09: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)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 判定状态
|
|
||||||
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
|
continue
|
||||||
|
city, entry = result
|
||||||
# 用观测数据时间而非当前本地时间
|
last_by_city[city] = entry
|
||||||
airport_cur = city_weather.get("airport_current") or {}
|
state_dirty = True
|
||||||
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
|
|
||||||
for chat_id in chat_ids:
|
|
||||||
try:
|
|
||||||
kwargs = {}
|
|
||||||
thread_id = _resolve_thread_id(chat_id, city)
|
|
||||||
if thread_id:
|
|
||||||
kwargs["message_thread_id"] = thread_id
|
|
||||||
bot.send_message(chat_id, message, **kwargs)
|
|
||||||
sent = True
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("airport push failed city={} chat_id={}: {}", city, chat_id, exc)
|
|
||||||
|
|
||||||
if sent:
|
|
||||||
last_by_city[city] = {"ts": now_ts, "active": True, "obs_time": current_obs_time}
|
|
||||||
state_dirty = True
|
|
||||||
logger.info("airport status pushed city={} temp={} deb={} obs_time={}", city, current_temp, deb_pred, current_obs_time)
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
logger.exception("airport cycle failed for city={}", city)
|
|
||||||
|
|
||||||
return state_dirty
|
return state_dirty
|
||||||
|
|
||||||
@@ -1265,14 +1275,17 @@ 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()
|
||||||
state = _load_airport_state()
|
try:
|
||||||
if _run_high_freq_airport_cycle(
|
state = _load_airport_state()
|
||||||
bot=bot,
|
if _run_high_freq_airport_cycle(
|
||||||
config=config,
|
bot=bot,
|
||||||
chat_ids=chat_ids,
|
config=config,
|
||||||
state=state,
|
chat_ids=chat_ids,
|
||||||
):
|
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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user