feat: Add a multi-source weather data collection module supporting OpenWeatherMap, Visual Crossing, and METAR.
This commit is contained in:
+116
@@ -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 ""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user