feat: Add a multi-source weather data collection module supporting OpenWeatherMap, Visual Crossing, and METAR.

This commit is contained in:
AmandaloveYang
2026-02-21 10:37:37 +08:00
parent 070712228e
commit 6f0eaad6a8
2 changed files with 204 additions and 0 deletions
+116
View File
@@ -57,6 +57,73 @@ def analyze_weather_trend(weather_data, temp_symbol):
local_date_str = datetime.now().strftime("%Y-%m-%d")
local_hour = datetime.now().hour
# === 模型共识评分 ===
labeled_forecasts = []
om_today = daily.get("temperature_2m_max", [None])[0]
if om_today is not None:
labeled_forecasts.append(("OM", om_today))
if mb.get("today_high") is not None:
labeled_forecasts.append(("MB", mb["today_high"]))
if nws.get("today_high") is not None:
labeled_forecasts.append(("NWS", nws["today_high"]))
if mgm.get("today_high") is not None:
labeled_forecasts.append(("MGM", mgm["today_high"]))
# 集合预报中位数 (如果有)
ensemble = weather_data.get("ensemble", {})
ens_median = ensemble.get("median")
if ens_median is not None:
labeled_forecasts.append(("ENS", ens_median))
consensus_level = "unknown"
consensus_spread = None
if len(labeled_forecasts) >= 2:
f_values = [v for _, v in labeled_forecasts]
f_max = max(f_values)
f_min = min(f_values)
consensus_spread = f_max - f_min
f_avg = sum(f_values) / len(f_values)
# 动态阈值:华氏度场景用更大的容差
is_f = (temp_symbol == "°F")
tight_threshold = 1.5 if is_f else 0.8 # 高共识
mid_threshold = 3.0 if is_f else 1.5 # 中共识
parts = " | ".join([f"{name} {val}{temp_symbol}" for name, val in labeled_forecasts])
if consensus_spread <= tight_threshold:
consensus_level = "high"
insights.append(
f"🎯 <b>模型共识:高 ({len(labeled_forecasts)}/{len(labeled_forecasts)})</b> — "
f"{parts},极差仅 {consensus_spread:.1f}°,预报高度一致。"
)
elif consensus_spread <= mid_threshold:
consensus_level = "medium"
insights.append(
f"⚖️ <b>模型共识:中 ({len(labeled_forecasts)}源)</b> — "
f"{parts},极差 {consensus_spread:.1f}°,有轻微分歧。"
)
else:
consensus_level = "low"
# 找出最高和最低的源
highest = max(labeled_forecasts, key=lambda x: x[1])
lowest = min(labeled_forecasts, key=lambda x: x[1])
insights.append(
f"⚠️ <b>模型共识:低 ({len(labeled_forecasts)}源)</b> — "
f"{parts},极差 {consensus_spread:.1f}°!"
f"{highest[0]} 最高 ({highest[1]}{temp_symbol}) vs {lowest[0]} 最低 ({lowest[1]}{temp_symbol}),不确定性大。"
)
# 集合预报区间 (如果有)
ens_p10 = ensemble.get("p10")
ens_p90 = ensemble.get("p90")
if ens_p10 is not None and ens_p90 is not None and ens_median is not None:
ens_range = ens_p90 - ens_p10
insights.append(
f"📊 <b>集合预报</b>:中位数 {ens_median}{temp_symbol}"
f"90% 区间 [{ens_p10}{temp_symbol} - {ens_p90}{temp_symbol}]"
f"波动幅度 {ens_range:.1f}°。"
)
# === 核心判断:实测是否已超预报 ===
is_breakthrough = False
if max_so_far is not None and forecast_high is not None:
@@ -341,6 +408,55 @@ def analyze_weather_trend(weather_data, temp_symbol):
except (ValueError, IndexError):
pass
# 11. 入场时机信号
hours_to_peak = first_peak_h - local_hour if local_hour < first_peak_h else 0
# 综合评分:距离峰值越近 + 共识越高 + 实测越接近预报 → 越适合入场
timing_score = 0
timing_factors = []
if is_peak_passed:
timing_score += 3
timing_factors.append("最热已过")
elif hours_to_peak <= 2:
timing_score += 2
timing_factors.append(f"距峰值{hours_to_peak}h")
elif hours_to_peak <= 4:
timing_score += 1
timing_factors.append(f"距峰值{hours_to_peak}h")
else:
timing_factors.append(f"距峰值{hours_to_peak}h")
if consensus_level == "high":
timing_score += 2
timing_factors.append("模型一致")
elif consensus_level == "medium":
timing_score += 1
timing_factors.append("模型小分歧")
else:
timing_factors.append("模型分歧大")
if max_so_far is not None and forecast_high is not None:
gap = abs(max_so_far - forecast_high)
if gap <= 0.5:
timing_score += 2
timing_factors.append("实测≈预报")
elif gap <= 1.5:
timing_score += 1
timing_factors.append(f"{gap:.1f}°")
else:
timing_factors.append(f"{gap:.1f}°")
factors_str = "".join(timing_factors)
if timing_score >= 5:
insights.append(f"⏰ <b>入场时机:理想</b> — {factors_str}。不确定性低,适合下注。")
elif timing_score >= 3:
insights.append(f"⏰ <b>入场时机:较好</b> — {factors_str}。可以考虑小仓位入场。")
elif timing_score >= 2:
insights.append(f"⏰ <b>入场时机:谨慎</b> — {factors_str}。建议继续观察。")
else:
insights.append(f"⏰ <b>入场时机:不建议</b> — {factors_str}。不确定性大,等更多数据。")
if not insights:
return ""
+88
View File
@@ -535,6 +535,89 @@ class WeatherDataCollector:
logger.error(f"Open-Meteo forecast failed: {e}")
return None
def fetch_ensemble(
self,
lat: float,
lon: float,
use_fahrenheit: bool = False,
) -> Optional[Dict]:
"""
从 Open-Meteo Ensemble API 获取 51 成员集合预报
用于计算预报不确定性范围(散度)
"""
try:
url = "https://ensemble-api.open-meteo.com/v1/ensemble"
params = {
"latitude": lat,
"longitude": lon,
"daily": "temperature_2m_max",
"timezone": "auto",
"forecast_days": 3,
"_t": int(time.time()),
}
if use_fahrenheit:
params["temperature_unit"] = "fahrenheit"
else:
params["temperature_unit"] = "celsius"
response = self.session.get(
url,
params=params,
headers={"Cache-Control": "no-cache"},
timeout=self.timeout,
)
response.raise_for_status()
data = response.json()
daily = data.get("daily", {})
# 每个成员都会返回一组 temperature_2m_max
# 格式: {"time": [...], "temperature_2m_max_member01": [...], ...}
today_highs = []
for key, values in daily.items():
if key.startswith("temperature_2m_max") and key != "temperature_2m_max":
if values and values[0] is not None:
today_highs.append(values[0])
# 也检查非成员键(有些返回格式不同)
if not today_highs:
raw_max = daily.get("temperature_2m_max", [])
if isinstance(raw_max, list) and raw_max:
if isinstance(raw_max[0], list):
# 嵌套列表格式: [[member1_day1, member1_day2], [member2_day1, ...]]
today_highs = [m[0] for m in raw_max if m and m[0] is not None]
elif raw_max[0] is not None:
today_highs = [raw_max[0]]
if len(today_highs) < 3:
logger.warning(f"Ensemble 数据不足: 仅获取 {len(today_highs)} 个成员")
return None
today_highs.sort()
n = len(today_highs)
median = today_highs[n // 2]
p10 = today_highs[max(0, int(n * 0.1))]
p90 = today_highs[min(n - 1, int(n * 0.9))]
result = {
"source": "ensemble",
"members": n,
"median": round(median, 1),
"p10": round(p10, 1),
"p90": round(p90, 1),
"min": round(today_highs[0], 1),
"max": round(today_highs[-1], 1),
"unit": "fahrenheit" if use_fahrenheit else "celsius",
}
logger.info(
f"📊 Ensemble ({n} members): median={median:.1f}, "
f"p10={p10:.1f}, p90={p90:.1f}"
)
return result
except Exception as e:
logger.warning(f"Ensemble API 请求失败: {e}")
return None
def fetch_from_meteoblue(
self,
lat: float,
@@ -807,6 +890,11 @@ class WeatherDataCollector:
nws_data = self.fetch_nws(lat, lon)
if nws_data:
results["nws"] = nws_data
# 集合预报 (所有城市通用,用于不确定性分析)
ens_data = self.fetch_ensemble(lat, lon, use_fahrenheit=use_fahrenheit)
if ens_data:
results["ensemble"] = ens_data
else:
# Open-Meteo 失败时,仍然尝试获取 METAR 和 NWS
metar_data = self.fetch_metar(city, use_fahrenheit=use_fahrenheit)