From ffb88ae61d05936c0d64e15522f77bfc16c2b565 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Wed, 11 Mar 2026 03:40:25 +0800
Subject: [PATCH] feat: implement multi-source weather data collection
including OpenWeatherMap, Visual Crossing, and METAR with caching.
---
bot_listener.py | 34 +++++-
src/data_collection/weather_sources.py | 138 ++++++++++++++++++++++---
2 files changed, 157 insertions(+), 15 deletions(-)
diff --git a/bot_listener.py b/bot_listener.py
index 0577c9be..88852708 100644
--- a/bot_listener.py
+++ b/bot_listener.py
@@ -392,6 +392,14 @@ def start_bot():
# --- 1. 紧凑 Header (城市 + 时间 + 风险状态) ---
local_time = open_meteo.get("current", {}).get("local_time", "")
time_str = local_time.split(" ")[1][:5] if " " in local_time else "N/A"
+ if time_str == "N/A":
+ metar_obs = metar.get("observation_time", "") if metar else ""
+ if "T" in metar_obs:
+ time_str = metar_obs.split("T")[1][:5]
+ elif " " in metar_obs:
+ time_str = metar_obs.split(" ")[1][:5]
+ elif metar_obs:
+ time_str = str(metar_obs)[:5]
risk_profile = get_city_risk_profile(city_name)
risk_emoji = risk_profile.get("risk_level", "⚠️") if risk_profile else "⚠️"
@@ -414,11 +422,27 @@ def start_bot():
nws_high = _sf(weather_data.get("nws", {}).get("today_high"))
mgm_high = _sf(mgm.get("today_high"))
mb_high = _sf(weather_data.get("meteoblue", {}).get("today_high"))
+ metar_max_so_far = _sf(metar.get("current", {}).get("max_temp_so_far")) if metar else None
# 今天对比
- today_t = max_temps[0] if max_temps else "N/A"
+ today_t = _sf(max_temps[0]) if max_temps else None
+ fallback_source = None
+ if today_t is None:
+ for source_name, candidate in (
+ ("MB", mb_high),
+ ("NWS", nws_high),
+ ("MGM", mgm_high),
+ ("METAR", metar_max_so_far),
+ ):
+ if candidate is not None:
+ today_t = candidate
+ fallback_source = source_name
+ break
+ today_t_display = (
+ f"{today_t:.1f}" if isinstance(today_t, (int, float)) else "N/A"
+ )
comp_parts = []
- sources = ["Open-Meteo"]
+ sources = ["Open-Meteo"] if max_temps else []
if mb_high is not None:
sources.append("MB")
@@ -441,6 +465,10 @@ def start_bot():
if isinstance(mgm_high, (int, float))
else f"🇺🇸 MGM: {mgm_high}"
)
+ if fallback_source and fallback_source not in sources:
+ sources.append(fallback_source)
+ if not sources:
+ sources = ["N/A"]
# 检查是否有显著分歧 (超过 5°F 或 2.5°C)
divergence_warning = ""
@@ -457,7 +485,7 @@ def start_bot():
msg_lines.append(f"\n📊 预报 ({sources_str})")
msg_lines.append(
- f"👉 今天: {today_t}{temp_symbol}{comp_str}{divergence_warning}"
+ f"👉 今天: {today_t_display}{temp_symbol}{comp_str}{divergence_warning}"
)
# 明后天
diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py
index 74969853..5ead8b02 100644
--- a/src/data_collection/weather_sources.py
+++ b/src/data_collection/weather_sources.py
@@ -64,6 +64,21 @@ class WeatherDataCollector:
self.timeout = 30 # 增加超时以支持高延迟 VPS
self.session = requests.Session()
+ self.open_meteo_cache_ttl_sec = int(
+ os.getenv("OPEN_METEO_CACHE_TTL_SEC", "900")
+ )
+ self.open_meteo_ensemble_cache_ttl_sec = int(
+ os.getenv("OPEN_METEO_ENSEMBLE_CACHE_TTL_SEC", "900")
+ )
+ self.open_meteo_multi_model_cache_ttl_sec = int(
+ os.getenv("OPEN_METEO_MULTI_MODEL_CACHE_TTL_SEC", "900")
+ )
+ self._open_meteo_cache: Dict[str, Dict] = {}
+ self._ensemble_cache: Dict[str, Dict] = {}
+ self._multi_model_cache: Dict[str, Dict] = {}
+ self._open_meteo_cache_lock = threading.Lock()
+ self._ensemble_cache_lock = threading.Lock()
+ self._multi_model_cache_lock = threading.Lock()
self.meteoblue_cache_ttl_sec = int(
os.getenv("METEOBLUE_CACHE_TTL_SEC", "1800")
)
@@ -265,7 +280,6 @@ class WeatherDataCollector:
response = self.session.get(
url,
params=params,
- headers={"Cache-Control": "no-cache", "Pragma": "no-cache"},
timeout=self.timeout,
)
response.raise_for_status()
@@ -931,6 +945,20 @@ class WeatherDataCollector:
forecast_days: Number of forecast days to fetch (default 14 to cover all market dates)
use_fahrenheit: Whether to return temperatures in Fahrenheit (for US markets)
"""
+ cache_key = (
+ f"{round(float(lat), 4)}:{round(float(lon), 4)}:"
+ f"{forecast_days}:{'f' if use_fahrenheit else 'c'}"
+ )
+ now_ts = time.time()
+ with self._open_meteo_cache_lock:
+ cached = self._open_meteo_cache.get(cache_key)
+ if (
+ cached
+ and now_ts - float(cached.get("t", 0)) < self.open_meteo_cache_ttl_sec
+ ):
+ cached_data = cached.get("data")
+ if isinstance(cached_data, dict):
+ return dict(cached_data)
try:
url = "https://api.open-meteo.com/v1/forecast"
params = {
@@ -941,7 +969,6 @@ class WeatherDataCollector:
"daily": "temperature_2m_max,apparent_temperature_max,sunrise,sunset,sunshine_duration",
"timezone": "auto",
"forecast_days": forecast_days,
- "_t": int(time.time()), # 禁用缓存,强制刷新
}
# 显式指定单位,防止 API 默认行为漂移
@@ -953,7 +980,6 @@ class WeatherDataCollector:
response = self.session.get(
url,
params=params,
- headers={"Cache-Control": "no-cache", "Pragma": "no-cache"},
timeout=self.timeout,
)
response.raise_for_status()
@@ -1003,7 +1029,7 @@ class WeatherDataCollector:
local_now = now_utc + timedelta(seconds=utc_offset)
local_time_str = local_now.strftime("%Y-%m-%d %H:%M")
- return {
+ result = {
"source": "open-meteo",
"timestamp": now_utc.isoformat(),
"timezone": timezone_name,
@@ -1016,8 +1042,26 @@ class WeatherDataCollector:
"daily": daily_data,
"unit": "fahrenheit" if use_fahrenheit else "celsius",
}
+ with self._open_meteo_cache_lock:
+ self._open_meteo_cache[cache_key] = {
+ "t": time.time(),
+ "data": dict(result),
+ }
+ return result
except Exception as e:
- logger.error(f"Open-Meteo forecast failed: {e}")
+ status_code = getattr(getattr(e, "response", None), "status_code", None)
+ if status_code == 429:
+ logger.warning(
+ f"Open-Meteo rate limited (429), fallback to cache if available: lat={lat}, lon={lon}"
+ )
+ else:
+ logger.error(f"Open-Meteo forecast failed: {e}")
+ with self._open_meteo_cache_lock:
+ stale = self._open_meteo_cache.get(cache_key)
+ if stale and isinstance(stale.get("data"), dict):
+ fallback = dict(stale["data"])
+ fallback["stale_cache"] = True
+ return fallback
return None
def fetch_ensemble(
@@ -1030,6 +1074,21 @@ class WeatherDataCollector:
从 Open-Meteo Ensemble API 获取 51 成员集合预报
用于计算预报不确定性范围(散度)
"""
+ cache_key = (
+ f"{round(float(lat), 4)}:{round(float(lon), 4)}:"
+ f"{'f' if use_fahrenheit else 'c'}"
+ )
+ now_ts = time.time()
+ with self._ensemble_cache_lock:
+ cached = self._ensemble_cache.get(cache_key)
+ if (
+ cached
+ and now_ts - float(cached.get("t", 0))
+ < self.open_meteo_ensemble_cache_ttl_sec
+ ):
+ cached_data = cached.get("data")
+ if isinstance(cached_data, dict):
+ return dict(cached_data)
try:
url = "https://ensemble-api.open-meteo.com/v1/ensemble"
params = {
@@ -1038,7 +1097,6 @@ class WeatherDataCollector:
"daily": "temperature_2m_max",
"timezone": "auto",
"forecast_days": 3,
- "_t": int(time.time()),
}
if use_fahrenheit:
params["temperature_unit"] = "fahrenheit"
@@ -1048,7 +1106,6 @@ class WeatherDataCollector:
response = self.session.get(
url,
params=params,
- headers={"Cache-Control": "no-cache"},
timeout=self.timeout,
)
response.raise_for_status()
@@ -1098,9 +1155,26 @@ class WeatherDataCollector:
f"📊 Ensemble ({n} members): median={median:.1f}, "
f"p10={p10:.1f}, p90={p90:.1f}"
)
+ with self._ensemble_cache_lock:
+ self._ensemble_cache[cache_key] = {
+ "t": time.time(),
+ "data": dict(result),
+ }
return result
except Exception as e:
- logger.warning(f"Ensemble API 请求失败: {e}")
+ status_code = getattr(getattr(e, "response", None), "status_code", None)
+ if status_code == 429:
+ logger.warning(
+ f"Ensemble API rate limited (429), fallback to cache if available: lat={lat}, lon={lon}"
+ )
+ else:
+ logger.warning(f"Ensemble API 请求失败: {e}")
+ with self._ensemble_cache_lock:
+ stale = self._ensemble_cache.get(cache_key)
+ if stale and isinstance(stale.get("data"), dict):
+ fallback = dict(stale["data"])
+ fallback["stale_cache"] = True
+ return fallback
return None
def fetch_multi_model(
@@ -1122,6 +1196,21 @@ class WeatherDataCollector:
返回 3 天的预报数据,支持今日+明日共识分析
"""
+ cache_key = (
+ f"{round(float(lat), 4)}:{round(float(lon), 4)}:"
+ f"{'f' if use_fahrenheit else 'c'}"
+ )
+ now_ts = time.time()
+ with self._multi_model_cache_lock:
+ cached = self._multi_model_cache.get(cache_key)
+ if (
+ cached
+ and now_ts - float(cached.get("t", 0))
+ < self.open_meteo_multi_model_cache_ttl_sec
+ ):
+ cached_data = cached.get("data")
+ if isinstance(cached_data, dict):
+ return dict(cached_data)
try:
url = "https://api.open-meteo.com/v1/forecast"
models = "ecmwf_ifs025,gfs_seamless,icon_seamless,gem_seamless,jma_seamless"
@@ -1132,7 +1221,6 @@ class WeatherDataCollector:
"models": models,
"timezone": "auto",
"forecast_days": 3,
- "_t": int(time.time()),
}
if use_fahrenheit:
params["temperature_unit"] = "fahrenheit"
@@ -1140,7 +1228,6 @@ class WeatherDataCollector:
response = self.session.get(
url,
params=params,
- headers={"Cache-Control": "no-cache"},
timeout=self.timeout,
)
response.raise_for_status()
@@ -1182,15 +1269,33 @@ class WeatherDataCollector:
f"🔬 Multi-model ({len(forecasts)}个, {len(daily_forecasts)}天): {labels_str}"
)
- return {
+ result = {
"source": "multi_model",
"forecasts": forecasts, # 今天 {"ECMWF": 12.3, "GFS": 11.8, ...} (向后兼容)
"daily_forecasts": daily_forecasts, # 按天 {"2026-02-23": {...}, "2026-02-24": {...}}
"dates": dates,
"unit": "fahrenheit" if use_fahrenheit else "celsius",
}
+ with self._multi_model_cache_lock:
+ self._multi_model_cache[cache_key] = {
+ "t": time.time(),
+ "data": dict(result),
+ }
+ return result
except Exception as e:
- logger.warning(f"Multi-model API 请求失败: {e}")
+ status_code = getattr(getattr(e, "response", None), "status_code", None)
+ if status_code == 429:
+ logger.warning(
+ f"Multi-model API rate limited (429), fallback to cache if available: lat={lat}, lon={lon}"
+ )
+ else:
+ logger.warning(f"Multi-model API 请求失败: {e}")
+ with self._multi_model_cache_lock:
+ stale = self._multi_model_cache.get(cache_key)
+ if stale and isinstance(stale.get("data"), dict):
+ fallback = dict(stale["data"])
+ fallback["stale_cache"] = True
+ return fallback
return None
def fetch_from_meteoblue(
@@ -1586,6 +1691,15 @@ class WeatherDataCollector:
metar_data = self.fetch_metar(city, use_fahrenheit=use_fahrenheit)
if metar_data:
results["metar"] = metar_data
+ if city_lower in self.METEOBLUE_PRIORITY_CITIES:
+ mb_data = self.fetch_from_meteoblue(
+ lat,
+ lon,
+ timezone_name="UTC",
+ use_fahrenheit=use_fahrenheit,
+ )
+ if mb_data:
+ results["meteoblue"] = mb_data
if use_fahrenheit:
nws_data = self.fetch_nws(lat, lon)
if nws_data: