feat(ai): integrate Dynamic Ensemble Blending & simplify user output

This commit is contained in:
2569718930@qq.com
2026-02-27 01:43:17 +08:00
parent ae59fc8278
commit f60f5601d9
4 changed files with 242 additions and 16 deletions
+33 -15
View File
@@ -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"🧬 <b>DEB 融合预测</b><b>{blended_high}{temp_symbol}</b> ({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"🚨 <b>实测已超预报</b>{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
+1 -1
View File
@@ -38,7 +38,7 @@ def get_ai_analysis(weather_insights: str, city_name: str, temp_symbol: str) ->
2. 严格按照以下 HTML 格式输出:
🤖 <b>Groq AI 决策</b>
- 🎲 盘口: [一句话指出结算在哪里博弈,以及目前是否到了最热时段。例如:距最热时段还有3小时,目前预计在27或28之间博弈,有突破可能。]
- 🎲 盘口: [一句话指出结算在哪里博弈,以及目前是否到了最热时段。如果已经处于明确的降温期,请直接给出死盘结论(如:已过最热点且降温,今日锁定在 X 度结算,悬念终止),不要再说“博弈”。]
- 💡 逻辑: [不要重复模版例子!请使用一句话提炼机场实测(如风速风向、云量、气温变化趋势)及热力动力因子。例如:实测吹强劲西南风(15kt)伴随云量减少,辐射加热强劲,破预报阻力非常小。]
- 🎯 置信度: [1-10]/10
"""
+123
View File
@@ -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)
+85
View File
@@ -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()