From f60f5601d9d2d51c2161e6e722e0a954253b39b3 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Fri, 27 Feb 2026 01:43:17 +0800
Subject: [PATCH] feat(ai): integrate Dynamic Ensemble Blending & simplify user
output
---
bot_listener.py | 48 ++++++++----
src/analysis/ai_analyzer.py | 2 +-
src/analysis/deb_algorithm.py | 123 +++++++++++++++++++++++++++++++
src/data_mining/fetch_history.py | 85 +++++++++++++++++++++
4 files changed, 242 insertions(+), 16 deletions(-)
create mode 100644 src/analysis/deb_algorithm.py
create mode 100644 src/data_mining/fetch_history.py
diff --git a/bot_listener.py b/bot_listener.py
index 1324200e..e422eeb4 100644
--- a/bot_listener.py
+++ b/bot_listener.py
@@ -13,8 +13,9 @@ if project_root not in sys.path:
from src.utils.config_loader import load_config # type: ignore
from src.data_collection.weather_sources import WeatherDataCollector # type: ignore
from src.data_collection.city_risk_profiles import get_city_risk_profile, format_risk_warning # type: ignore
+from src.analysis.deb_algorithm import calculate_dynamic_weights, update_daily_record
-def analyze_weather_trend(weather_data, temp_symbol):
+def analyze_weather_trend(weather_data, temp_symbol, city_name=None):
'''根据实测与预测分析气温态势,增加峰值时刻预测'''
insights: List[str] = []
ai_features: List[str] = []
@@ -27,6 +28,7 @@ def analyze_weather_trend(weather_data, temp_symbol):
if not metar or not open_meteo:
return "", ""
+
curr_temp = metar.get("current", {}).get("temp")
max_so_far = metar.get("current", {}).get("max_temp_so_far") # 今日实测最高
@@ -35,17 +37,23 @@ def analyze_weather_trend(weather_data, temp_symbol):
times = hourly.get("time", [])
temps = hourly.get("temperature_2m", [])
- # === 核心:整合多源预报最高温 ===
- forecast_highs = [daily.get("temperature_2m_max", [None])[0]]
+ # === 新核心:动态集合权重预报 (DEB) ===
+ # 抽取各个确定性预报值构成的字典
+ current_forecasts = {}
+ if daily.get("temperature_2m_max"):
+ current_forecasts["Open-Meteo"] = daily.get("temperature_2m_max")[0]
if mb.get("today_high") is not None:
- forecast_highs.append(mb["today_high"])
+ current_forecasts["Meteoblue"] = mb.get("today_high")
if nws.get("today_high") is not None:
- forecast_highs.append(nws["today_high"])
- for mv in weather_data.get("multi_model", {}).get("forecasts", {}).values():
- if mv is not None:
- forecast_highs.append(mv)
-
- forecast_highs = [h for h in forecast_highs if h is not None]
+ current_forecasts["NWS"] = nws.get("today_high")
+
+ mm_forecasts = weather_data.get("multi_model", {}).get("forecasts", {})
+ for m_name, m_val in mm_forecasts.items():
+ if m_val is not None:
+ current_forecasts[m_name] = m_val
+
+ # 从 URL/入参里我们暂时拿不到城名,为了 DEB 追溯我们在后方的总控那里提取。这里的 analyze_weather_trend 主要计算最高预留。
+ forecast_highs = [h for h in current_forecasts.values() if h is not None]
forecast_high = max(forecast_highs) if forecast_highs else None
min_forecast_high = min(forecast_highs) if forecast_highs else forecast_high
forecast_median = sorted(forecast_highs)[len(forecast_highs) // 2] if forecast_highs else None
@@ -62,6 +70,19 @@ def analyze_weather_trend(weather_data, temp_symbol):
local_date_str = datetime.now().strftime("%Y-%m-%d")
local_hour = datetime.now().hour
+ # === DEB 融合渲染 ===
+ if city_name and current_forecasts:
+ blended_high, weight_info = calculate_dynamic_weights(city_name, current_forecasts)
+ if blended_high is not None:
+ insights.insert(0, f"🧬 DEB 融合预测:{blended_high}{temp_symbol} ({weight_info})")
+ ai_features.append(f"🧬 DEB系统已通过历史偏差矫正算出期待点是: {blended_high}{temp_symbol}。")
+
+ # 顺便把今天的预测记录下来供之后回测用
+ try:
+ update_daily_record(city_name, local_date_str, current_forecasts, max_so_far)
+ except:
+ pass
+
# === METAR 趋势分析 (移到前部判断降温) ===
recent_temps = metar.get("recent_temps", [])
trend_desc = ""
@@ -160,16 +181,13 @@ def analyze_weather_trend(weather_data, temp_symbol):
if max_so_far > forecast_high + 0.5:
exceed_by = max_so_far - forecast_high
bt_msg = f"🚨 实测已超预报:{max_so_far}{temp_symbol} 超过上限 {forecast_high}{temp_symbol}(+{exceed_by:.1f}°)。"
- if trend_desc: bt_msg += f"\n{trend_desc}"
insights.append(bt_msg)
ai_features.append(f"🚨 异常: 实测已冲破所有预报上限 ({max_so_far}{temp_symbol} vs {forecast_high}{temp_symbol})。")
- ai_features.append(trend_desc)
+ if trend_desc: ai_features.append(trend_desc)
else:
if trend_desc:
- insights.append(trend_desc)
ai_features.append(trend_desc)
elif trend_desc:
- insights.append(trend_desc)
ai_features.append(trend_desc)
# === 结算取整分析 ===
@@ -588,7 +606,7 @@ def start_bot():
msg_lines.append(f" {prefix} {cloud_desc} | 👁️ {vis or 10}mi | 💨 {wind or 0}kt")
# --- 5. 态势特征提取 ---
- feature_str, ai_context = analyze_weather_trend(weather_data, temp_symbol)
+ feature_str, ai_context = analyze_weather_trend(weather_data, temp_symbol, city_name)
if feature_str:
# 仅将最核心的信息展示给用户作为"态势分析"
# 但后面会把更全的数据传给 AI
diff --git a/src/analysis/ai_analyzer.py b/src/analysis/ai_analyzer.py
index d96777a8..3c5074f7 100644
--- a/src/analysis/ai_analyzer.py
+++ b/src/analysis/ai_analyzer.py
@@ -38,7 +38,7 @@ def get_ai_analysis(weather_insights: str, city_name: str, temp_symbol: str) ->
2. 严格按照以下 HTML 格式输出:
🤖 Groq AI 决策
-- 🎲 盘口: [一句话指出结算在哪里博弈,以及目前是否到了最热时段。例如:距最热时段还有3小时,目前预计在27或28之间博弈,有突破可能。]
+- 🎲 盘口: [一句话指出结算在哪里博弈,以及目前是否到了最热时段。如果已经处于明确的降温期,请直接给出死盘结论(如:已过最热点且降温,今日锁定在 X 度结算,悬念终止),不要再说“博弈”。]
- 💡 逻辑: [不要重复模版例子!请使用一句话提炼机场实测(如风速风向、云量、气温变化趋势)及热力动力因子。例如:实测吹强劲西南风(15kt)伴随云量减少,辐射加热强劲,破预报阻力非常小。]
- 🎯 置信度: [1-10]/10
"""
diff --git a/src/analysis/deb_algorithm.py b/src/analysis/deb_algorithm.py
new file mode 100644
index 00000000..2fa3c290
--- /dev/null
+++ b/src/analysis/deb_algorithm.py
@@ -0,0 +1,123 @@
+import os
+import json
+import logging
+from datetime import datetime, timedelta
+
+def load_history(filepath):
+ if os.path.exists(filepath):
+ try:
+ with open(filepath, 'r', encoding='utf-8') as f:
+ return json.load(f)
+ except:
+ return {}
+ return {}
+
+def save_history(filepath, data):
+ with open(filepath, 'w', encoding='utf-8') as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+
+def update_daily_record(city_name, date_str, forecasts, actual_high):
+ """
+ 保存/更新某城市某天的各个模型预报与最终实测值
+ forecasts: dict, 例如 {"ECMWF": 28.5, "GFS": 30.0, ...}
+ actual_high: float, 最终实测最高温
+ """
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ history_file = os.path.join(project_root, 'data', 'daily_records.json')
+
+ data = load_history(history_file)
+ if city_name not in data:
+ data[city_name] = {}
+
+ if date_str not in data[city_name]:
+ data[city_name][date_str] = {}
+
+ data[city_name][date_str]['forecasts'] = forecasts
+ # 只要仍在更新或者已经结束,都记录最新高点
+ data[city_name][date_str]['actual_high'] = actual_high
+
+ save_history(history_file, data)
+
+def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7):
+ """
+ 计算动态权重融合 (Dynamic Ensemble Blending, DEB)
+ 根据过去 N 天各模型的 Mean Absolute Error (MAE) 计算倒数权重
+ 返回: blended_high (融合预报值), weights_info (权重展示字符串)
+ """
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ history_file = os.path.join(project_root, 'data', 'daily_records.json')
+ data = load_history(history_file)
+
+ if city_name not in data or not data[city_name]:
+ # 没有历史数据,返回简单的平均/中位数
+ valid_vals = [v for v in current_forecasts.values() if v is not None]
+ if not valid_vals: return None, "暂无模型数据"
+ avg = sum(valid_vals) / len(valid_vals)
+ return round(avg, 1), "等权平均(历史数据不足)"
+
+ # 获取过去 lookback_days 天的有 actual_high 的记录
+ city_data = data[city_name]
+ sorted_dates = sorted(city_data.keys(), reverse=True)
+
+ # 我们只用真正结清(或者有比较准确最高温)的历史来算误差
+ # 这边简化:凡是有 actual_high 的都算进去
+ errors = {model: [] for model in current_forecasts.keys()}
+
+ days_used = 0
+ for date_str in sorted_dates:
+ # 跳过今天,今天还没出最终结果
+ if date_str == datetime.now().strftime("%Y-%m-%d"):
+ continue
+
+ record = city_data[date_str]
+ actual = record.get('actual_high')
+ past_forecasts = record.get('forecasts', {})
+
+ if actual is None:
+ continue
+
+ for model in current_forecasts.keys():
+ if model in past_forecasts and past_forecasts[model] is not None:
+ errors[model].append(abs(past_forecasts[model] - actual))
+
+ days_used += 1
+ if days_used >= lookback_days:
+ break
+
+ # 如果有效历史天数 < 2 天,还是使用等权
+ if days_used < 2:
+ valid_vals = [v for v in current_forecasts.values() if v is not None]
+ avg = sum(valid_vals) / len(valid_vals)
+ return round(avg, 1), f"等权平均(由于仅{days_used}天历史)"
+
+ # 计算 MAE
+ maes = {}
+ for model, err_list in errors.items():
+ if err_list:
+ maes[model] = sum(err_list) / len(err_list)
+ else:
+ # 如果某个新模型没有历史数据,给它一个平均误差
+ maes[model] = 2.0
+
+ # 计算权重(用 MAE 的倒数,误差越小权重越大;加 0.1 防止除以0)
+ inverse_errors = {m: 1.0 / (mae + 0.1) for m, mae in maes.items() if current_forecasts.get(m) is not None}
+
+ total_inv = sum(inverse_errors.values())
+ if total_inv == 0:
+ return None, "权重计算异常"
+
+ weights = {m: inv / total_inv for m, inv in inverse_errors.items()}
+
+ # 计算加权最高温
+ blended_high = 0.0
+ for m in weights.keys():
+ blended_high += current_forecasts[m] * weights[m]
+
+ # 格式化权重信息,挑选前权重最高的2-3个模型展示
+ sorted_models = sorted(weights.items(), key=lambda x: x[1], reverse=True)
+ weight_str_parts = []
+ for m, w in sorted_models[:3]:
+ weight_str_parts.append(f"{m}({w*100:.0f}%,MAE:{maes[m]:.1f}°)")
+
+ return round(blended_high, 1), " | ".join(weight_str_parts)
+
diff --git a/src/data_mining/fetch_history.py b/src/data_mining/fetch_history.py
new file mode 100644
index 00000000..64e7f0b8
--- /dev/null
+++ b/src/data_mining/fetch_history.py
@@ -0,0 +1,85 @@
+import os
+import sys
+import json
+import logging
+from datetime import datetime
+import pandas as pd
+import requests
+
+# Set up logging
+logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
+
+def fetch_historical_data_for_city(city_info, output_dir):
+ city_name = city_info['city']
+ lat = city_info['latitude']
+ lon = city_info['longitude']
+
+ # We will fetch data from Jan 1, 2023 to yesterday (or to latest available)
+ start_date = "2023-01-01"
+ end_date = "2025-12-31" # API handles dates in the future up to latest available archive usually
+ # For safety let's use a dynamic yesterday end_date
+ import datetime
+ today = datetime.datetime.now()
+ yesterday = (today - datetime.timedelta(days=2)).strftime("%Y-%m-%d")
+
+ url = (
+ f"https://archive-api.open-meteo.com/v1/archive?latitude={lat}&longitude={lon}"
+ f"&start_date={start_date}&end_date={yesterday}"
+ "&hourly=temperature_2m,relative_humidity_2m,wind_speed_10m,wind_direction_10m,"
+ "cloud_cover,shortwave_radiation,precipitation,surface_pressure"
+ "&timezone=auto"
+ )
+
+ logging.info(f"Downloading historical data for {city_name} (Lat: {lat}, Lon: {lon})...")
+
+ try:
+ response = requests.get(url, timeout=60)
+ response.raise_for_status()
+ data = response.json()
+
+ if "hourly" not in data:
+ logging.error(f"Failed to find 'hourly' data for {city_name}.")
+ return
+
+ hourly_data = data["hourly"]
+
+ # Convert to pandas DataFrame
+ df = pd.DataFrame(hourly_data)
+
+ # Save to CSV
+ output_path = os.path.join(output_dir, f"{city_name.replace(' ', '_').lower()}_historical.csv")
+ df.to_csv(output_path, index=False)
+
+ logging.info(f"✅ Successfully saved historical data for {city_name} to {output_path}. Shape: {df.shape}")
+
+ except requests.exceptions.RequestException as e:
+ logging.error(f"❌ Network error while fetching data for {city_name}: {e}")
+ except Exception as e:
+ logging.error(f"❌ Error processing data for {city_name}: {e}")
+
+def main():
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+ config_path = os.path.join(project_root, 'config', 'config.yaml')
+ output_dir = os.path.join(project_root, 'data', 'historical')
+
+ os.makedirs(output_dir, exist_ok=True)
+
+ # Load config
+ try:
+ import yaml
+ with open(config_path, 'r', encoding='utf-8') as f:
+ config = yaml.safe_load(f)
+ except Exception as e:
+ logging.error(f"Failed to load {config_path}: {e}")
+ sys.exit(1)
+
+ cities = config.get('cities', [])
+ if not cities:
+ logging.warning("No cities found in config.yaml")
+ return
+
+ for city_info in cities:
+ fetch_historical_data_for_city(city_info, output_dir)
+
+if __name__ == "__main__":
+ main()