refactor: extract trend_engine, unify probability engine across bot and web, add unit tests

This commit is contained in:
2569718930@qq.com
2026-03-04 02:52:31 +08:00
parent 3c2749544f
commit 7329dd728e
4 changed files with 833 additions and 621 deletions
+4 -486
View File
@@ -16,492 +16,10 @@ from src.analysis.deb_algorithm import calculate_dynamic_weights, update_daily_r
def analyze_weather_trend(weather_data, temp_symbol, city_name=None):
"""根据实测与预测分析气温态势,增加峰值时刻预测"""
insights: List[str] = []
ai_features: List[str] = []
mu = None
sorted_probs = []
_deb_to_save = None
metar = weather_data.get("metar", {})
open_meteo = weather_data.get("open-meteo", {})
mb = weather_data.get("meteoblue", {})
nws = weather_data.get("nws", {})
weather_data.get("mgm", {})
if not metar or not open_meteo:
return "", ""
# 数值归一化:防止 JSON 反序列化后的 str 类型炸数学运算
def _sf(v):
"""safe float"""
if v is None:
return None
try:
return float(v)
except Exception:
return None
_sf(metar.get("current", {}).get("temp"))
max_so_far = _sf(metar.get("current", {}).get("max_temp_so_far"))
daily = open_meteo.get("daily", {})
hourly = open_meteo.get("hourly", {})
times = hourly.get("time", [])
temps = hourly.get("temperature_2m", [])
# === 新核心:动态集合权重预报 (DEB) ===
# 抽取各个确定性预报值构成的字典
current_forecasts = {}
if daily.get("temperature_2m_max"):
current_forecasts["Open-Meteo"] = _sf(daily.get("temperature_2m_max")[0])
if mb.get("today_high") is not None:
current_forecasts["Meteoblue"] = _sf(mb.get("today_high"))
if nws.get("today_high") is not None:
current_forecasts["NWS"] = _sf(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] = _sf(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_highs) if forecast_highs else forecast_high
forecast_median = (
sorted(forecast_highs)[len(forecast_highs) // 2] if forecast_highs else None
)
wind_speed = metar.get("current", {}).get("wind_speed_kt", 0)
# 获取当地时间小时和分钟
local_time_full = open_meteo.get("current", {}).get("local_time", "")
try:
local_date_str = local_time_full.split(" ")[0]
time_parts = local_time_full.split(" ")[1].split(":")
local_hour = int(time_parts[0])
local_minute = int(time_parts[1]) if len(time_parts) > 1 else 0
except Exception:
from datetime import datetime
local_date_str = datetime.now().strftime("%Y-%m-%d")
local_hour = datetime.now().hour
local_minute = datetime.now().minute
local_hour_frac = local_hour + local_minute / 60 # 含分钟的精确小时
# === 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}"
)
# daily_record 将在函数末尾统一保存(含 μ 和概率快照)
_deb_to_save = blended_high
# === METAR 趋势分析 (移到前部判断降温) ===
recent_temps = metar.get("recent_temps", [])
trend_desc = ""
if len(recent_temps) >= 2:
temps_only = [t for _, t in recent_temps]
latest_val = temps_only[0]
prev_val = temps_only[1]
diff = latest_val - prev_val
if len(temps_only) >= 3:
all_same = all(t == latest_val for t in temps_only[:3])
all_rising = all(
temps_only[i] >= temps_only[i + 1]
for i in range(min(3, len(temps_only)) - 1)
)
all_falling = all(
temps_only[i] <= temps_only[i + 1]
for i in range(min(3, len(temps_only)) - 1)
)
trend_display = "".join(
[f"{t}{temp_symbol}@{tm}" for tm, t in recent_temps[:3]]
)
if all_same:
trend_desc = f"📉 温度已停滞({trend_display}),大概率到顶。"
elif all_rising and diff > 0:
trend_desc = f"📈 仍在升温({trend_display})。"
elif all_falling and diff < 0:
trend_desc = f"📉 已开始降温({trend_display})。"
else:
trend_desc = f"📊 温度波动中({trend_display})。"
elif diff == 0:
trend_desc = f"📉 温度持平(最近两条都是 {latest_val}{temp_symbol})。"
elif diff > 0:
trend_desc = f"📈 仍在升温({prev_val}{latest_val}{temp_symbol})。"
else:
trend_desc = f"📉 已开始降温({prev_val}{latest_val}{temp_symbol})。"
is_cooling = "降温" in trend_desc
om_today = daily.get("temperature_2m_max", [None])[0]
# === 峰值时刻预测(提前计算,供概率引擎使用)===
# 只在白天时段 (8:00-19:00) 内搜索,避免夜间温差小时把凌晨误判为峰值
peak_hours = []
if times and temps and om_today is not None:
for t_str, temp in zip(times, temps):
if t_str.startswith(local_date_str) and abs(temp - om_today) <= 0.2:
hour = int(t_str.split("T")[1][:2])
if 8 <= hour <= 19: # 只考虑白天
peak_hours.append(t_str.split("T")[1][:5])
if peak_hours:
first_peak_h = int(peak_hours[0].split(":")[0])
last_peak_h = int(peak_hours[-1].split(":")[0])
else:
first_peak_h, last_peak_h = 13, 15
# === 集合预报区间 ===
ensemble = weather_data.get("ensemble", {})
ens_p10 = _sf(ensemble.get("p10"))
ens_p90 = _sf(ensemble.get("p90"))
ens_median = _sf(ensemble.get("median"))
if ens_p10 is not None and ens_p90 is not None and ens_median is not None:
msg1 = f"📊 <b>集合预报</b>:中位数 {ens_median}{temp_symbol}90% 区间 [{ens_p10}{temp_symbol} - {ens_p90}{temp_symbol}]。"
if not is_cooling:
insights.append(msg1)
ai_features.append(msg1)
if om_today is not None:
if om_today > ens_p90 and (
max_so_far is None or max_so_far < om_today - 0.5
):
msg2 = f"⚡ 预报偏高:确定性预报 {om_today}{temp_symbol} 超集合90%上限,更可能接近 {ens_median}{temp_symbol}"
ai_features.append(msg2)
elif om_today < ens_p10 and (max_so_far is None or max_so_far < ens_median):
msg2 = f"⚡ 预报偏低:确定性预报 {om_today}{temp_symbol} 低于集合90%下限,更可能接近 {ens_median}{temp_symbol}"
ai_features.append(msg2)
# === 数学概率计算(基于集合预报正态分布拟合)===
import math as _math
# 用 P10/P90 反推标准差: P10 = median - 1.28*sigma, P90 = median + 1.28*sigma
sigma = (ens_p90 - ens_p10) / 2.56
if sigma < 0.1:
sigma = 0.1 # 防止除以零
# 用 DEB 历史 MAE 作为 σ 的下限
# 如果模型过去的平均误差远大于集合预报的 σ,说明集合低估了真实不确定性
if city_name:
from src.analysis.deb_algorithm import get_deb_accuracy
acc = get_deb_accuracy(city_name)
if acc:
_, hist_mae, _, _ = acc
if hist_mae > sigma:
sigma = hist_mae
# === Shock Score: 气象突变软评分 (0~1) ===
# 用近 4 条 METAR 的风向/云量/气压变化评估环境稳定性
# 越高 = 越不稳定 = σ 放宽
shock_score = 0.0
recent_obs = metar.get("recent_obs", [])
if len(recent_obs) >= 2:
oldest = recent_obs[-1] # 最早
newest = recent_obs[0] # 最新
# ① 风向变化项 (0~0.4)
# 角度差 × 风速放大系数(风速 > 10kt 时权重高,弱风忽略噪声)
wdir_old = _sf(oldest.get("wdir"))
wdir_new = _sf(newest.get("wdir"))
wspd_new = _sf(newest.get("wspd")) or 0
if wdir_old is not None and wdir_new is not None:
angle_diff = abs(wdir_new - wdir_old)
if angle_diff > 180:
angle_diff = 360 - angle_diff
wind_weight = min(wspd_new / 15.0, 1.0)
wind_shock = min(angle_diff / 90.0, 1.0) * wind_weight * 0.4
shock_score += wind_shock
# ② 云量阶跃项 (0~0.35)
# CLR=0, FEW=1, SCT=2, BKN=3, OVC=4
cloud_old = oldest.get("cloud_rank", 0)
cloud_new = newest.get("cloud_rank", 0)
cloud_jump = abs(cloud_new - cloud_old)
cloud_shock = min(cloud_jump / 3.0, 1.0) * 0.35
shock_score += cloud_shock
# ③ 气压变化项 (0~0.25)
# 2h 内气压变化超过 2hPa 视为异常
altim_old = _sf(oldest.get("altim"))
altim_new = _sf(newest.get("altim"))
if altim_old is not None and altim_new is not None:
press_diff = abs(altim_new - altim_old)
press_shock = min(press_diff / 4.0, 1.0) * 0.25
shock_score += press_shock
# 应用 shock_score: 放宽 σ
if shock_score > 0.05:
sigma *= 1 + 0.5 * shock_score
# 时间修正:根据当前时间距峰值的位置调整 σ
# 峰值前:σ 不变(不确定性最大)
# 峰值窗口内:σ 缩小 30%(正在定型)
# 峰值后:σ 缩小 70%(基本确定)
if local_hour_frac > last_peak_h:
sigma *= 0.3 # 峰值已过,结果基本锁定
elif first_peak_h <= local_hour_frac <= last_peak_h:
sigma *= 0.7 # 正在峰值窗口
# === 判定是否为“死盘” (Dead Market) ===
# 逻辑:深夜且气温大幅回落,或者已过峰值时段且明显降温
is_dead_market = False
current_temp = _sf(metar.get("current", {}).get("temp"))
if max_so_far is not None and current_temp is not None:
# 深夜死盘:21:00 后,回落超过 3°C
if local_hour >= 21 and max_so_far - current_temp >= 3.0:
is_dead_market = True
# 峰值后死盘:已过最热窗口,回落超过 1.5°C
elif local_hour > last_peak_h and max_so_far - current_temp >= 1.5:
is_dead_market = True
if ens_p10 is not None and ens_p90 is not None and not is_dead_market:
# --- Reality-anchored μ ---
# Determine peak status
if local_hour_frac > last_peak_h:
_peak_status = "past"
elif first_peak_h <= local_hour_frac <= last_peak_h:
_peak_status = "in_window"
else:
_peak_status = "before"
# Compute forecast miss magnitude
forecast_miss_deg = 0
if max_so_far is not None and forecast_median is not None:
forecast_miss_deg = round(forecast_median - max_so_far, 1)
# If peak is past/in_window AND actual max is significantly below
# forecasts, anchor μ on actual max, not on failed predictions
if (
max_so_far is not None
and forecast_median is not None
and _peak_status in ("past", "in_window")
and max_so_far < forecast_median - 2.0
):
if is_cooling or _peak_status == "past":
mu = max_so_far
else:
mu = max_so_far + 0.5
else:
mu = (
forecast_median * 0.7 + ens_median * 0.3
if forecast_median is not None
else ens_median
)
if max_so_far is not None and max_so_far > mu:
mu = max_so_far + (0.3 if not is_cooling else 0.0)
# Inject forecast miss severity for AI
if forecast_miss_deg > 2.0 and _peak_status in ("past", "in_window"):
if forecast_miss_deg > 5.0:
severity = ""
elif forecast_miss_deg > 3.0:
severity = ""
else:
severity = ""
min_fc = min(
(v for v in forecast_highs if v is not None), default=None
)
_trend_dir = "降温" if is_cooling else ("停滞" if "停滞" in trend_desc else "升温")
ai_features.append(
f"🚨 预报崩盘 [{severity}级失准]: 最低预报 {min_fc}{temp_symbol} vs 实测最高 {max_so_far}{temp_symbol}"
f"偏差 {forecast_miss_deg}°。当前趋势: {_trend_dir}"
)
def _norm_cdf(x, m, s):
return 0.5 * (1 + _math.erf((x - m) / (s * _math.sqrt(2))))
min_possible_wu = round(max_so_far) if max_so_far is not None else -999
probs = {}
for n in range(round(mu) - 2, round(mu) + 3):
if n < min_possible_wu:
continue
p = _norm_cdf(n + 0.5, mu, sigma) - _norm_cdf(n - 0.5, mu, sigma)
if p > 0.01:
probs[n] = p
total_p = sum(probs.values())
if total_p > 0:
probs = {k: v / total_p for k, v in probs.items()}
sorted_probs = sorted(probs.items(), key=lambda x: x[1], reverse=True)
prob_parts = [
f"{int(t)}{temp_symbol} [{t - 0.5}~{t + 0.5}) {p * 100:.0f}%"
for t, p in sorted_probs[:4]
]
if prob_parts:
prob_str = " | ".join(prob_parts)
insights.append(f"🎲 <b>结算概率</b> (μ={mu:.1f}){prob_str}")
ai_features.append(f"🎲 数学概率分布:{prob_str}")
elif is_dead_market:
settled_wu = round(max_so_far) if max_so_far is not None else "N/A"
dead_msg = f"🎲 <b>结算预测</b>:已锁定 {settled_wu}{temp_symbol} (死盘确认)"
insights.append(dead_msg)
ai_features.append("🎲 状态: 确认死盘,结算已无悬念。")
# === 实测已超预报 & 趋势输出 ===
if max_so_far is not None and forecast_high is not None:
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}°)。"
insights.append(bt_msg)
ai_features.append(
f"🚨 异常: 实测已冲破所有预报上限 ({max_so_far}{temp_symbol} vs {forecast_high}{temp_symbol})。"
)
if trend_desc:
ai_features.append(trend_desc)
else:
if trend_desc:
ai_features.append(trend_desc)
elif trend_desc:
ai_features.append(trend_desc)
# === 结算取整分析 ===
if max_so_far is not None:
settled = round(max_so_far)
fractional = max_so_far - int(max_so_far)
dist_to_boundary = abs(fractional - 0.5)
if dist_to_boundary <= 0.3:
if fractional < 0.5:
msg = f"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol} → WU 结算 <b>{settled}{temp_symbol}</b>,但只差 <b>{0.5 - fractional:.1f}°</b> 就会进位到 {settled + 1}{temp_symbol}"
else:
msg = f"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol} → WU 结算 <b>{settled}{temp_symbol}</b>,刚刚越过进位线,再降 <b>{fractional - 0.5:.1f}°</b> 就会回落到 {settled - 1}{temp_symbol}"
insights.append(msg)
ai_features.append(msg)
# === 峰值时刻 AI 提示 ===
if peak_hours:
window = (
f"{peak_hours[0]} - {peak_hours[-1]}"
if len(peak_hours) > 1
else peak_hours[0]
)
if local_hour <= last_peak_h:
if last_peak_h < 6:
ai_features.append(
"⚠️ <b>提示</b>:预测最热在凌晨,后续气温可能一路走低。"
)
elif local_hour < first_peak_h and (
max_so_far is None or max_so_far < forecast_high
):
target_temp = om_today if om_today is not None else forecast_high
ai_features.append(
f"🎯 <b>关注重点</b>:看看那个时段能否涨到 {target_temp}{temp_symbol}"
)
# 写给AI(使用精确到分钟的时间)
remain_hrs = first_peak_h - local_hour_frac
if local_hour_frac > last_peak_h:
ai_features.append(f"⏱️ 状态: 预报峰值时段已过 ({window})。")
elif first_peak_h <= local_hour_frac <= last_peak_h:
remain_in_window = last_peak_h - local_hour_frac
if remain_in_window < 1:
ai_features.append(
f"⏱️ 状态: 正处于预报最热窗口 ({window})内,距窗口结束约 {int(remain_in_window * 60)} 分钟。"
)
else:
ai_features.append(
f"⏱️ 状态: 正处于预报最热窗口 ({window})内,距窗口结束约 {remain_in_window:.1f}h。"
)
elif remain_hrs < 1:
ai_features.append(
f"⏱️ 状态: 距最热时段开始还有约 {int(remain_hrs * 60)} 分钟 ({window}),尚未进入峰值窗口。"
)
else:
ai_features.append(
f"⏱️ 状态: 距最热时段开始还有约 {remain_hrs:.1f}h ({window})。"
)
# === 其他 AI 专供的事实特征 ===
# 明确告知 AI 当前实测温度和今日最高温,避免 AI 从趋势数据中误读
current_temp = metar.get("current", {}).get("temp")
if current_temp is not None:
ai_features.append(f"🌡️ 当前实测温度: {current_temp}{temp_symbol}")
if max_so_far is not None:
ai_features.append(
f"🏔️ 今日实测最高温: {max_so_far}{temp_symbol} (WU结算={round(max_so_far)}{temp_symbol})。"
)
# 传递城市的 METAR 取整特性给 AI
from src.data_collection.city_risk_profiles import get_city_risk_profile
if city_name:
_profile = get_city_risk_profile(city_name)
if _profile and _profile.get("metar_rounding"):
ai_features.append(f"⚠️ METAR特性: {_profile['metar_rounding']}")
if wind_speed:
wind_dir = metar.get("current", {}).get("wind_dir", "未知")
ai_features.append(f"🌬️ 当下风况: 约 {wind_speed}kt (方向 {wind_dir}°)。")
humidity = metar.get("current", {}).get("humidity")
if humidity and humidity > 80:
ai_features.append(f"💦 湿度极高 ({humidity}%)。")
clouds = metar.get("current", {}).get("clouds", [])
if clouds:
cover = clouds[-1].get("cover", "")
c_desc = {"OVC": "全阴", "BKN": "多云", "SCT": "散云", "FEW": "少云"}.get(
cover, cover
)
ai_features.append(f"☁️ 天空状况: {c_desc}")
wx_desc = metar.get("current", {}).get("wx_desc")
if wx_desc:
ai_features.append(f"🌧️ 天气现象: {wx_desc}")
max_temp_time_str = metar.get("current", {}).get("max_temp_time", "")
if max_so_far is not None and max_temp_time_str:
try:
max_h = int(max_temp_time_str.split(":")[0])
max_temp_rad = 0.0
hourly_rad = hourly.get("shortwave_radiation", [])
for t_str, rad in zip(times, hourly_rad):
if (
t_str.startswith(local_date_str)
and int(t_str.split("T")[1][:2]) == max_h
):
max_temp_rad = rad if rad is not None else 0.0
break
if max_temp_rad < 50:
ai_features.append(
f"🌙 动力事实: 最高温出现在低辐射时段 ({max_temp_time_str}, 辐射{max_temp_rad:.0f}W/m²)。"
)
except Exception:
pass
# === 统一保存今日记录(含 μ 和概率快照)===
try:
_prob_list = None
if sorted_probs:
_prob_list = [
{"value": int(t), "probability": round(p, 3)}
for t, p in sorted_probs[:4]
]
elif is_dead_market and max_so_far is not None:
_prob_list = [{"value": round(max_so_far), "probability": 1.0}]
update_daily_record(
city_name,
local_date_str,
current_forecasts,
max_so_far,
deb_prediction=_deb_to_save,
mu=mu,
probabilities=_prob_list,
)
except Exception:
pass
display_str = "\n".join(insights) if insights else ""
return display_str, "\n".join(ai_features)
"""Thin wrapper — delegates to shared trend_engine module."""
from src.analysis.trend_engine import analyze_weather_trend as _analyze
display_str, ai_context, _structured = _analyze(weather_data, temp_symbol, city_name)
return display_str, ai_context
def start_bot():
+538
View File
@@ -0,0 +1,538 @@
"""
Trend Engine — Shared weather analysis module
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Extracted from bot_listener.py to provide a single source of truth
for both Telegram bot and web dashboard.
"""
import math
from typing import List, Optional, Tuple, Dict, Any
from src.analysis.deb_algorithm import (
calculate_dynamic_weights,
get_deb_accuracy,
update_daily_record,
)
from src.data_collection.city_risk_profiles import get_city_risk_profile
def _sf(v):
"""Safe float conversion — prevents JSON str types from breaking math."""
if v is None:
return None
try:
return float(v)
except Exception:
return None
def analyze_weather_trend(
weather_data: dict,
temp_symbol: str,
city_name: Optional[str] = None,
) -> Tuple[str, str, Dict[str, Any]]:
"""
Analyze weather trend from multi-source data.
Returns:
(display_str, ai_context, structured_data)
display_str: HTML-formatted insights for Telegram display
ai_context: plain-text context for AI analysis
structured_data: dict with computed values for direct use:
- mu: probability center
- probabilities: [{value, range, probability}, ...]
- trend_info: {direction, recent, is_cooling, is_dead_market}
- peak_status: "before" / "in_window" / "past"
- peak_hours: list of peak hour strings
- deb_prediction: DEB blended value
- current_forecasts: {model: temp, ...}
- forecast_miss_deg: float
- max_so_far: float
- cur_temp: float
- wu_settle: int
"""
insights: List[str] = []
ai_features: List[str] = []
mu = None
sorted_probs = []
_deb_to_save = None
metar = weather_data.get("metar", {})
open_meteo = weather_data.get("open-meteo", {})
mb = weather_data.get("meteoblue", {})
nws = weather_data.get("nws", {})
empty_result = ("", "", {})
if not metar or not open_meteo:
return empty_result
max_so_far = _sf(metar.get("current", {}).get("max_temp_so_far"))
cur_temp = _sf(metar.get("current", {}).get("temp"))
daily = open_meteo.get("daily", {})
hourly = open_meteo.get("hourly", {})
times = hourly.get("time", [])
temps = hourly.get("temperature_2m", [])
# === Forecasts ===
current_forecasts: Dict[str, Optional[float]] = {}
if daily.get("temperature_2m_max"):
current_forecasts["Open-Meteo"] = _sf(daily.get("temperature_2m_max")[0])
if mb.get("today_high") is not None:
current_forecasts["Meteoblue"] = _sf(mb.get("today_high"))
if nws.get("today_high") is not None:
current_forecasts["NWS"] = _sf(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] = _sf(m_val)
forecast_highs = [h for h in current_forecasts.values() if h is not None]
forecast_high = max(forecast_highs) if forecast_highs else None
forecast_median = (
sorted(forecast_highs)[len(forecast_highs) // 2] if forecast_highs else None
)
wind_speed = metar.get("current", {}).get("wind_speed_kt", 0)
# === Local time ===
local_time_full = open_meteo.get("current", {}).get("local_time", "")
try:
local_date_str = local_time_full.split(" ")[0]
time_parts = local_time_full.split(" ")[1].split(":")
local_hour = int(time_parts[0])
local_minute = int(time_parts[1]) if len(time_parts) > 1 else 0
except Exception:
from datetime import datetime
local_date_str = datetime.now().strftime("%Y-%m-%d")
local_hour = datetime.now().hour
local_minute = datetime.now().minute
local_hour_frac = local_hour + local_minute / 60
# === DEB ===
deb_prediction = None
deb_weights = ""
if city_name and current_forecasts:
blended_high, weight_info = calculate_dynamic_weights(
city_name, current_forecasts
)
if blended_high is not None:
deb_prediction = blended_high
deb_weights = weight_info
insights.insert(
0,
f"🧬 <b>DEB 融合预测</b><b>{blended_high}{temp_symbol}</b> ({weight_info})",
)
ai_features.append(
f"🧬 DEB系统已通过历史偏差矫正算出期待点是: {blended_high}{temp_symbol}"
)
_deb_to_save = blended_high
# === METAR trend ===
recent_temps = metar.get("recent_temps", [])
trend_desc = ""
trend_direction = "unknown"
if len(recent_temps) >= 2:
temps_only = [t for _, t in recent_temps]
latest_val = temps_only[0]
prev_val = temps_only[1]
diff = latest_val - prev_val
if len(temps_only) >= 3:
all_same = all(t == latest_val for t in temps_only[:3])
all_rising = all(
temps_only[i] >= temps_only[i + 1]
for i in range(min(3, len(temps_only)) - 1)
)
all_falling = all(
temps_only[i] <= temps_only[i + 1]
for i in range(min(3, len(temps_only)) - 1)
)
trend_display = "".join(
[f"{t}{temp_symbol}@{tm}" for tm, t in recent_temps[:3]]
)
if all_same:
trend_desc = f"📉 温度已停滞({trend_display}),大概率到顶。"
trend_direction = "stagnant"
elif all_rising and diff > 0:
trend_desc = f"📈 仍在升温({trend_display})。"
trend_direction = "rising"
elif all_falling and diff < 0:
trend_desc = f"📉 已开始降温({trend_display})。"
trend_direction = "falling"
else:
trend_desc = f"📊 温度波动中({trend_display})。"
trend_direction = "mixed"
elif diff == 0:
trend_desc = f"📉 温度持平(最近两条都是 {latest_val}{temp_symbol})。"
trend_direction = "stagnant"
elif diff > 0:
trend_desc = f"📈 仍在升温({prev_val}{latest_val}{temp_symbol})。"
trend_direction = "rising"
else:
trend_desc = f"📉 已开始降温({prev_val}{latest_val}{temp_symbol})。"
trend_direction = "falling"
is_cooling = "降温" in trend_desc
om_today = daily.get("temperature_2m_max", [None])[0]
# === Peak hours ===
peak_hours = []
if times and temps and om_today is not None:
for t_str, temp in zip(times, temps):
if t_str.startswith(local_date_str) and abs(temp - om_today) <= 0.2:
hour = int(t_str.split("T")[1][:2])
if 8 <= hour <= 19:
peak_hours.append(t_str.split("T")[1][:5])
if peak_hours:
first_peak_h = int(peak_hours[0].split(":")[0])
last_peak_h = int(peak_hours[-1].split(":")[0])
else:
first_peak_h, last_peak_h = 13, 15
# Peak status
if local_hour_frac > last_peak_h:
peak_status = "past"
elif first_peak_h <= local_hour_frac <= last_peak_h:
peak_status = "in_window"
else:
peak_status = "before"
# === Ensemble ===
ensemble = weather_data.get("ensemble", {})
ens_p10 = _sf(ensemble.get("p10"))
ens_p90 = _sf(ensemble.get("p90"))
ens_median = _sf(ensemble.get("median"))
ens_data = {"p10": ens_p10, "p90": ens_p90, "median": ens_median}
sigma = None
if ens_p10 is not None and ens_p90 is not None and ens_median is not None:
msg1 = (
f"📊 <b>集合预报</b>:中位数 {ens_median}{temp_symbol}"
f"90% 区间 [{ens_p10}{temp_symbol} - {ens_p90}{temp_symbol}]。"
)
if not is_cooling:
insights.append(msg1)
ai_features.append(msg1)
if om_today is not None:
if om_today > ens_p90 and (
max_so_far is None or max_so_far < om_today - 0.5
):
ai_features.append(
f"⚡ 预报偏高:确定性预报 {om_today}{temp_symbol} 超集合90%上限,"
f"更可能接近 {ens_median}{temp_symbol}"
)
elif om_today < ens_p10 and (
max_so_far is None or max_so_far < ens_median
):
ai_features.append(
f"⚡ 预报偏低:确定性预报 {om_today}{temp_symbol} 低于集合90%下限,"
f"更可能接近 {ens_median}{temp_symbol}"
)
# === Sigma calculation ===
sigma = (ens_p90 - ens_p10) / 2.56
if sigma < 0.1:
sigma = 0.1
# MAE floor
if city_name:
acc = get_deb_accuracy(city_name)
if acc:
_, hist_mae, _, _ = acc
if hist_mae > sigma:
sigma = hist_mae
# Shock Score
shock_score = 0.0
recent_obs = metar.get("recent_obs", [])
if len(recent_obs) >= 2:
oldest = recent_obs[-1]
newest = recent_obs[0]
wdir_old = _sf(oldest.get("wdir"))
wdir_new = _sf(newest.get("wdir"))
wspd_new = _sf(newest.get("wspd")) or 0
if wdir_old is not None and wdir_new is not None:
angle_diff = abs(wdir_new - wdir_old)
if angle_diff > 180:
angle_diff = 360 - angle_diff
wind_weight = min(wspd_new / 15.0, 1.0)
shock_score += min(angle_diff / 90.0, 1.0) * wind_weight * 0.4
cloud_old = oldest.get("cloud_rank", 0)
cloud_new = newest.get("cloud_rank", 0)
shock_score += min(abs(cloud_new - cloud_old) / 3.0, 1.0) * 0.35
altim_old = _sf(oldest.get("altim"))
altim_new = _sf(newest.get("altim"))
if altim_old is not None and altim_new is not None:
shock_score += min(abs(altim_new - altim_old) / 4.0, 1.0) * 0.25
if shock_score > 0.05:
sigma *= 1 + 0.5 * shock_score
# Time decay
if local_hour_frac > last_peak_h:
sigma *= 0.3
elif first_peak_h <= local_hour_frac <= last_peak_h:
sigma *= 0.7
# === Dead Market ===
is_dead_market = False
if max_so_far is not None and cur_temp is not None:
if local_hour >= 21 and max_so_far - cur_temp >= 3.0:
is_dead_market = True
elif local_hour > last_peak_h and max_so_far - cur_temp >= 1.5:
is_dead_market = True
# === Probability Engine ===
probabilities: List[Dict[str, Any]] = []
forecast_miss_deg = 0.0
if ens_p10 is not None and ens_p90 is not None and not is_dead_market:
# Forecast miss magnitude
if max_so_far is not None and forecast_median is not None:
forecast_miss_deg = round(forecast_median - max_so_far, 1)
# Reality-anchored μ
if (
max_so_far is not None
and forecast_median is not None
and peak_status in ("past", "in_window")
and max_so_far < forecast_median - 2.0
):
if is_cooling or peak_status == "past":
mu = max_so_far
else:
mu = max_so_far + 0.5
else:
mu = (
forecast_median * 0.7 + ens_median * 0.3
if forecast_median is not None
else ens_median
)
if max_so_far is not None and max_so_far > mu:
mu = max_so_far + (0.3 if not is_cooling else 0.0)
# Forecast miss severity for AI
if forecast_miss_deg > 2.0 and peak_status in ("past", "in_window"):
severity = "" if forecast_miss_deg > 5.0 else ("" if forecast_miss_deg > 3.0 else "")
min_fc = min((v for v in forecast_highs if v is not None), default=None)
_trend_dir = "降温" if is_cooling else ("停滞" if "停滞" in trend_desc else "升温")
ai_features.append(
f"🚨 预报崩盘 [{severity}级失准]: 最低预报 {min_fc}{temp_symbol} vs "
f"实测最高 {max_so_far}{temp_symbol},偏差 {forecast_miss_deg}°。当前趋势: {_trend_dir}"
)
# Gaussian CDF
def _norm_cdf(x, m, s):
return 0.5 * (1 + math.erf((x - m) / (s * math.sqrt(2))))
min_possible_wu = round(max_so_far) if max_so_far is not None else -999
probs = {}
for n in range(round(mu) - 2, round(mu) + 3):
if n < min_possible_wu:
continue
p = _norm_cdf(n + 0.5, mu, sigma) - _norm_cdf(n - 0.5, mu, sigma)
if p > 0.01:
probs[n] = p
total_p = sum(probs.values())
if total_p > 0:
probs = {k: v / total_p for k, v in probs.items()}
sorted_probs = sorted(probs.items(), key=lambda x: x[1], reverse=True)
prob_parts = [
f"{int(t)}{temp_symbol} [{t - 0.5}~{t + 0.5}) {p * 100:.0f}%"
for t, p in sorted_probs[:4]
]
if prob_parts:
prob_str = " | ".join(prob_parts)
insights.append(f"🎲 <b>结算概率</b> (μ={mu:.1f}){prob_str}")
ai_features.append(f"🎲 数学概率分布:{prob_str}")
for t, p in sorted_probs[:4]:
probabilities.append(
{"value": int(t), "range": f"[{t-0.5}~{t+0.5})", "probability": round(p, 3)}
)
elif is_dead_market:
settled_wu = round(max_so_far) if max_so_far is not None else 0
dead_msg = f"🎲 <b>结算预测</b>:已锁定 {settled_wu}{temp_symbol} (死盘确认)"
insights.append(dead_msg)
ai_features.append("🎲 状态: 确认死盘,结算已无悬念。")
if max_so_far is not None:
mu = max_so_far
probabilities = [
{"value": settled_wu, "range": f"[{settled_wu-0.5}~{settled_wu+0.5})", "probability": 1.0}
]
# === Actual exceeds forecast ===
if max_so_far is not None and forecast_high is not None:
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} 超过上限 "
f"{forecast_high}{temp_symbol}+{exceed_by:.1f}°)。"
)
insights.append(bt_msg)
ai_features.append(
f"🚨 异常: 实测已冲破所有预报上限 ({max_so_far}{temp_symbol} vs {forecast_high}{temp_symbol})。"
)
if trend_desc:
ai_features.append(trend_desc)
# === Settlement boundary ===
if max_so_far is not None:
settled = round(max_so_far)
fractional = max_so_far - int(max_so_far)
dist_to_boundary = abs(fractional - 0.5)
if dist_to_boundary <= 0.3:
if fractional < 0.5:
msg = (
f"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol} → WU 结算 "
f"<b>{settled}{temp_symbol}</b>,但只差 <b>{0.5 - fractional:.1f}°</b> "
f"就会进位到 {settled + 1}{temp_symbol}"
)
else:
msg = (
f"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol} → WU 结算 "
f"<b>{settled}{temp_symbol}</b>,刚刚越过进位线,再降 "
f"<b>{fractional - 0.5:.1f}°</b> 就会回落到 {settled - 1}{temp_symbol}"
)
insights.append(msg)
ai_features.append(msg)
# === Peak window AI hints ===
if peak_hours:
window = (
f"{peak_hours[0]} - {peak_hours[-1]}"
if len(peak_hours) > 1
else peak_hours[0]
)
if local_hour <= last_peak_h:
if last_peak_h < 6:
ai_features.append("⚠️ <b>提示</b>:预测最热在凌晨,后续气温可能一路走低。")
elif local_hour < first_peak_h and (
max_so_far is None or max_so_far < forecast_high
):
target_temp = om_today if om_today is not None else forecast_high
ai_features.append(
f"🎯 <b>关注重点</b>:看看那个时段能否涨到 {target_temp}{temp_symbol}"
)
remain_hrs = first_peak_h - local_hour_frac
if local_hour_frac > last_peak_h:
ai_features.append(f"⏱️ 状态: 预报峰值时段已过 ({window})。")
elif first_peak_h <= local_hour_frac <= last_peak_h:
remain_in_window = last_peak_h - local_hour_frac
if remain_in_window < 1:
ai_features.append(
f"⏱️ 状态: 正处于预报最热窗口 ({window})内,距窗口结束约 {int(remain_in_window * 60)} 分钟。"
)
else:
ai_features.append(
f"⏱️ 状态: 正处于预报最热窗口 ({window})内,距窗口结束约 {remain_in_window:.1f}h。"
)
elif remain_hrs < 1:
ai_features.append(
f"⏱️ 状态: 距最热时段开始还有约 {int(remain_hrs * 60)} 分钟 ({window}),尚未进入峰值窗口。"
)
else:
ai_features.append(f"⏱️ 状态: 距最热时段开始还有约 {remain_hrs:.1f}h ({window})。")
# === AI fact features ===
if cur_temp is not None:
ai_features.append(f"🌡️ 当前实测温度: {cur_temp}{temp_symbol}")
if max_so_far is not None:
ai_features.append(
f"🏔️ 今日实测最高温: {max_so_far}{temp_symbol} (WU结算={round(max_so_far)}{temp_symbol})。"
)
if city_name:
_profile = get_city_risk_profile(city_name)
if _profile and _profile.get("metar_rounding"):
ai_features.append(f"⚠️ METAR特性: {_profile['metar_rounding']}")
if wind_speed:
wind_dir = metar.get("current", {}).get("wind_dir", "未知")
ai_features.append(f"🌬️ 当下风况: 约 {wind_speed}kt (方向 {wind_dir}°)。")
humidity = metar.get("current", {}).get("humidity")
if humidity and humidity > 80:
ai_features.append(f"💦 湿度极高 ({humidity}%)。")
clouds = metar.get("current", {}).get("clouds", [])
if clouds:
cover = clouds[-1].get("cover", "")
c_desc = {"OVC": "全阴", "BKN": "多云", "SCT": "散云", "FEW": "少云"}.get(cover, cover)
ai_features.append(f"☁️ 天空状况: {c_desc}")
wx_desc = metar.get("current", {}).get("wx_desc")
if wx_desc:
ai_features.append(f"🌧️ 天气现象: {wx_desc}")
max_temp_time_str = metar.get("current", {}).get("max_temp_time", "")
if max_so_far is not None and max_temp_time_str:
try:
max_h = int(max_temp_time_str.split(":")[0])
max_temp_rad = 0.0
hourly_rad = hourly.get("shortwave_radiation", [])
for t_str, rad in zip(times, hourly_rad):
if t_str.startswith(local_date_str) and int(t_str.split("T")[1][:2]) == max_h:
max_temp_rad = rad if rad is not None else 0.0
break
if max_temp_rad < 50:
ai_features.append(
f"🌙 动力事实: 最高温出现在低辐射时段 ({max_temp_time_str}, 辐射{max_temp_rad:.0f}W/m²)。"
)
except Exception:
pass
# === Save daily record (with μ + prob snapshot) ===
try:
_prob_list = None
if sorted_probs:
_prob_list = [
{"value": int(t), "probability": round(p, 3)}
for t, p in sorted_probs[:4]
]
elif is_dead_market and max_so_far is not None:
_prob_list = [{"value": round(max_so_far), "probability": 1.0}]
update_daily_record(
city_name,
local_date_str,
current_forecasts,
max_so_far,
deb_prediction=_deb_to_save,
mu=mu,
probabilities=_prob_list,
)
except Exception:
pass
# === Build recent list for trend_info ===
recent_list = []
for tm, t in recent_temps[:4]:
recent_list.append({"time": tm, "temp": t})
# === Structured result ===
structured = {
"mu": mu,
"probabilities": probabilities,
"trend_info": {
"direction": trend_direction if 'trend_direction' in dir() else "unknown",
"recent": recent_list,
"is_cooling": is_cooling,
"is_dead_market": is_dead_market,
},
"peak_status": peak_status,
"peak_hours": peak_hours,
"deb_prediction": deb_prediction,
"deb_weights": deb_weights,
"current_forecasts": current_forecasts,
"ens_data": ens_data,
"forecast_miss_deg": forecast_miss_deg,
"max_so_far": max_so_far,
"cur_temp": cur_temp,
"wu_settle": round(max_so_far) if max_so_far is not None else None,
}
display_str = "\n".join(insights) if insights else ""
return display_str, "\n".join(ai_features), structured
+262
View File
@@ -0,0 +1,262 @@
"""
Unit tests for trend_engine core logic.
Tests: μ/σ calculation, dead market detection, forecast bust grading.
"""
import sys
import os
# Ensure project root is in path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
from unittest.mock import patch
from src.analysis.trend_engine import analyze_weather_trend, _sf
# ─── Helpers ───
def _make_weather_data(
cur_temp=25.0,
max_so_far=28.0,
om_today_high=30.0,
ens_median=29.0,
ens_p10=27.0,
ens_p90=31.0,
local_time="2026-03-04 14:30",
recent_temps=None,
multi_model=None,
recent_obs=None,
):
"""Build a minimal weather_data dict for testing."""
data = {
"metar": {
"current": {
"temp": cur_temp,
"max_temp_so_far": max_so_far,
"max_temp_time": "14:00",
"wind_speed_kt": 5,
"wind_dir": 180,
"humidity": 50,
"clouds": [{"cover": "SCT", "base": 5000}],
},
"recent_temps": recent_temps or [("14:00", 27.0), ("13:00", 26.0), ("12:00", 25.0)],
"recent_obs": recent_obs or [],
},
"open-meteo": {
"current": {"local_time": local_time},
"daily": {
"temperature_2m_max": [om_today_high],
"sunrise": ["06:30"],
"sunset": ["18:30"],
},
"hourly": {
"time": [f"2026-03-04T{h:02d}:00" for h in range(24)],
"temperature_2m": [15 + (h - 6) * 1.5 if 6 <= h <= 14 else 20 - (h - 14) * 0.5 for h in range(24)],
"shortwave_radiation": [0 if h < 6 or h > 18 else 200 + h * 20 for h in range(24)],
},
},
"ensemble": {
"median": ens_median,
"p10": ens_p10,
"p90": ens_p90,
},
"multi_model": {"forecasts": multi_model or {}},
"meteoblue": {},
"nws": {},
}
return data
# ─── Tests: _sf ───
class TestSafeFloat:
def test_none(self):
assert _sf(None) is None
def test_int(self):
assert _sf(5) == 5.0
def test_str_number(self):
assert _sf("3.14") == 3.14
def test_invalid_str(self):
assert _sf("abc") is None
# ─── Tests: μ Calculation ───
class TestMuCalculation:
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_normal_mu_blends_forecast_and_ensemble(self, _udr, _deb_acc, _dw):
"""Normal case: μ = forecast_median * 0.7 + ens_median * 0.3"""
data = _make_weather_data(
cur_temp=25.0, max_so_far=26.0,
om_today_high=30.0, ens_median=29.0,
local_time="2026-03-04 10:00" # Before peak window to prevent early bust override
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
mu = sd["mu"]
# forecast_median = 30.0, ens_median = 29.0 → 30*0.7 + 29*0.3 = 29.7
assert mu is not None
assert 29.0 <= mu <= 31.0 # Reasonable range
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_reality_anchored_mu_on_forecast_bust(self, _udr, _deb_acc, _dw):
"""When past peak and actual << forecasts, μ anchors on actual max."""
data = _make_weather_data(
cur_temp=22.0, max_so_far=23.0,
om_today_high=30.0, ens_median=29.0,
local_time="2026-03-04 17:00", # Past peak
recent_temps=[("17:00", 22.0), ("16:00", 23.0), ("15:00", 23.0)],
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
mu = sd["mu"]
# max_so_far=23 vs forecast_median=30 → bust → μ ≈ 23
assert mu is not None
assert mu <= 24.0, f"μ should anchor on actual max (23°C), got {mu}"
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_mu_rises_when_actual_exceeds_forecast(self, _udr, _deb_acc, _dw):
"""When actual max exceeds μ, μ adjusts upward."""
data = _make_weather_data(
cur_temp=32.0, max_so_far=33.0,
om_today_high=30.0, ens_median=29.0,
local_time="2026-03-04 14:00",
recent_temps=[("14:00", 32.0), ("13:00", 31.0), ("12:00", 30.0)],
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
mu = sd["mu"]
assert mu is not None
assert mu >= 33.0, f"μ should be >= actual max (33°C), got {mu}"
# ─── Tests: Dead Market ───
class TestDeadMarket:
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_dead_market_after_peak_with_cooling(self, _udr, _deb_acc, _dw):
"""Past peak + 1.5°C drop → dead market."""
data = _make_weather_data(
cur_temp=26.0, max_so_far=28.0,
local_time="2026-03-04 17:00",
recent_temps=[("17:00", 26.0), ("16:00", 27.0), ("15:00", 28.0)],
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
ti = sd["trend_info"]
assert ti["is_dead_market"] is True
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_not_dead_market_during_peak_warming(self, _udr, _deb_acc, _dw):
"""During peak window while still warming → NOT dead market."""
data = _make_weather_data(
cur_temp=28.0, max_so_far=28.0,
local_time="2026-03-04 14:00",
recent_temps=[("14:00", 28.0), ("13:00", 27.0), ("12:00", 26.0)],
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
ti = sd["trend_info"]
assert ti["is_dead_market"] is False
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_dead_market_probability_is_100_percent(self, _udr, _deb_acc, _dw):
"""When dead market, probabilities collapse to 100% at settled value."""
data = _make_weather_data(
cur_temp=25.0, max_so_far=28.0,
local_time="2026-03-04 22:00", # Late night
recent_temps=[("22:00", 25.0), ("21:00", 26.0), ("20:00", 27.0)],
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
assert sd["trend_info"]["is_dead_market"] is True
probs = sd["probabilities"]
assert len(probs) == 1
assert probs[0]["value"] == 28 # round(28.0)
assert probs[0]["probability"] == 1.0
# ─── Tests: Forecast Bust Detection ───
class TestForecastBust:
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_heavy_forecast_bust_detected(self, _udr, _deb_acc, _dw):
"""Heavy bust: forecast_median - max_so_far > 5.0"""
data = _make_weather_data(
cur_temp=22.0, max_so_far=23.0,
om_today_high=30.0, ens_median=29.0,
local_time="2026-03-04 16:00",
recent_temps=[("16:00", 22.0), ("15:00", 23.0), ("14:00", 23.0)],
)
_, ai_context, sd = analyze_weather_trend(data, "°C", "test_city")
# forecast_median=30, max_so_far=23 → miss = 7°C → heavy
assert "预报崩盘" in ai_context
assert "" in ai_context or "级失准" in ai_context
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_no_bust_when_on_track(self, _udr, _deb_acc, _dw):
"""No bust when actual is close to forecast."""
data = _make_weather_data(
cur_temp=29.0, max_so_far=29.5,
om_today_high=30.0, ens_median=29.5,
local_time="2026-03-04 14:00",
recent_temps=[("14:00", 29.0), ("13:00", 28.5), ("12:00", 28.0)],
)
_, ai_context, _ = analyze_weather_trend(data, "°C", "test_city")
assert "预报崩盘" not in ai_context
# ─── Tests: Trend Direction ───
class TestTrendDirection:
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_rising_trend(self, _udr, _deb_acc, _dw):
data = _make_weather_data(
recent_temps=[("14:00", 28.0), ("13:00", 27.0), ("12:00", 26.0)],
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
assert sd["trend_info"]["direction"] == "rising"
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_falling_trend(self, _udr, _deb_acc, _dw):
data = _make_weather_data(
recent_temps=[("16:00", 25.0), ("15:00", 26.0), ("14:00", 27.0)],
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
assert sd["trend_info"]["direction"] == "falling"
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_stagnant_trend(self, _udr, _deb_acc, _dw):
data = _make_weather_data(
recent_temps=[("14:00", 27.0), ("13:00", 27.0), ("12:00", 27.0)],
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
assert sd["trend_info"]["direction"] == "stagnant"
+29 -135
View File
@@ -268,126 +268,42 @@ def _analyze(city: str) -> Dict[str, Any]:
else:
peak_status = "before"
# ── 10. Probability distribution ──
# ── 10. Shared analysis (probability, trend, AI) via trend_engine ──
# This single call replaces the duplicate probability engine, dead market
# detection, forecast bust grading, and AI context building.
from src.analysis.trend_engine import analyze_weather_trend as _trend_analyze
from src.analysis.ai_analyzer import get_ai_analysis
probabilities = []
mu = None
forecast_miss_deg = 0 # How far actual is below forecasts
if (
ens_data["p10"] is not None
and ens_data["p90"] is not None
and ens_data["median"] is not None
):
sigma = (ens_data["p90"] - ens_data["p10"]) / 2.56
if sigma < 0.1:
sigma = 0.1
ai_text = ""
try:
_, ai_context, sd = _trend_analyze(raw, sym, city)
# Historical MAE floor
acc = get_deb_accuracy(city)
if acc:
_, hist_mae, _, _ = acc
if hist_mae > sigma:
sigma = hist_mae
# Use structured data from shared engine
mu = sd.get("mu")
probabilities = sd.get("probabilities", [])
trend_info["is_dead_market"] = sd.get("trend_info", {}).get("is_dead_market", False)
trend_info["direction"] = sd.get("trend_info", {}).get("direction", trend_info.get("direction", "unknown"))
trend_info["is_cooling"] = sd.get("trend_info", {}).get("is_cooling", False)
peak_status = sd.get("peak_status", peak_status)
# Shock score
recent_obs = metar.get("recent_obs", []) if metar else []
shock = 0.0
if len(recent_obs) >= 2:
o_obs, n_obs = recent_obs[-1], recent_obs[0]
wd_o, wd_n = _sf(o_obs.get("wdir")), _sf(n_obs.get("wdir"))
ws_n = _sf(n_obs.get("wspd")) or 0
if wd_o is not None and wd_n is not None:
ad = abs(wd_n - wd_o)
if ad > 180:
ad = 360 - ad
shock += min(ad / 90, 1) * min(ws_n / 15, 1) * 0.4
cr_o = o_obs.get("cloud_rank", 0)
cr_n = n_obs.get("cloud_rank", 0)
shock += min(abs(cr_n - cr_o) / 3, 1) * 0.35
ap_o, ap_n = _sf(o_obs.get("altim")), _sf(n_obs.get("altim"))
if ap_o is not None and ap_n is not None:
shock += min(abs(ap_n - ap_o) / 4, 1) * 0.25
if shock > 0.05:
sigma *= 1 + 0.5 * shock
# Use shared DEB if not already set
if deb_val is None and sd.get("deb_prediction") is not None:
deb_val = sd["deb_prediction"]
deb_weights = sd.get("deb_weights", "")
# Time-based sigma adjustment
if local_hour_frac > last_peak_h:
sigma *= 0.3
elif first_peak_h <= local_hour_frac <= last_peak_h:
sigma *= 0.7
# Mu calculation — reality-anchored
forecast_highs = [h for h in current_forecasts.values() if h is not None]
forecast_median = (
sorted(forecast_highs)[len(forecast_highs) // 2]
if forecast_highs
else ens_data["median"]
)
# Compute forecast miss magnitude
if max_so_far is not None and forecast_median is not None:
forecast_miss_deg = round(forecast_median - max_so_far, 1)
# --- Key fix: Reality-anchored μ ---
# If we are past or in the peak window AND actual max is significantly
# below forecasts, anchor μ on max_so_far, not on forecast_median.
if (
max_so_far is not None
and forecast_median is not None
and peak_status in ("past", "in_window")
and max_so_far < forecast_median - 2.0
):
# Forecast bust: μ anchors on actual max, not failed predictions
# Allow small upward margin only if still warming
if trend_info["is_cooling"] or peak_status == "past":
mu = max_so_far
else:
# Still in window and warming — small margin
mu = max_so_far + 0.5
else:
# Normal case: blend forecast and ensemble
mu = (
forecast_median * 0.7 + ens_data["median"] * 0.3
if forecast_median is not None
else ens_data["median"]
# Append multi-model divergence for AI
if current_forecasts and ai_context:
mm_str = " | ".join(
[f"{k}:{v}{sym}" for k, v in current_forecasts.items() if v]
)
if max_so_far is not None and max_so_far > mu:
mu = max_so_far + (0.3 if not trend_info["is_cooling"] else 0.0)
ai_context += f"\n模型分歧: {mm_str}"
def _norm_cdf(x, m, s):
return 0.5 * (1 + math.erf((x - m) / (s * math.sqrt(2))))
min_wu = round(max_so_far) if max_so_far is not None else -999
probs = {}
for n in range(round(mu) - 2, round(mu) + 3):
if n < min_wu:
continue
p = _norm_cdf(n + 0.5, mu, sigma) - _norm_cdf(n - 0.5, mu, sigma)
if p > 0.01:
probs[n] = p
total = sum(probs.values())
if total > 0:
probs = {k: v / total for k, v in probs.items()}
for t, p in sorted(probs.items(), key=lambda x: x[1], reverse=True)[:4]:
probabilities.append(
{"value": t, "range": f"[{t-0.5}~{t+0.5})", "probability": round(p, 3)}
)
# ── 11. Dead market detection ──
is_dead = False
if max_so_far is not None and cur_temp is not None:
if local_hour >= 21 and max_so_far - cur_temp >= 3.0:
is_dead = True
elif local_hour > last_peak_h and max_so_far - cur_temp >= 1.5:
is_dead = True
trend_info["is_dead_market"] = is_dead
# Override probabilities when dead market confirmed
if is_dead and max_so_far is not None:
settled = round(max_so_far)
mu = max_so_far
probabilities = [
{"value": settled, "range": f"[{settled-0.5}~{settled+0.5})", "probability": 1.0}
]
if ai_context:
ai_text = get_ai_analysis(ai_context, city, sym)
except Exception as e:
logger.warning(f"Analysis/AI skipped for {city}: {e}")
# ── 12. Hourly data (today only, for chart) ──
today_hourly: Dict[str, list] = {"times": [], "temps": [], "radiation": []}
@@ -442,28 +358,6 @@ def _analyze(city: str) -> Dict[str, Any]:
"rain_24h": _sf(mgc.get("rain_24h")),
}
# ── 15. AI Analysis (reuse bot_listener's analyze_weather_trend) ──
ai_text = ""
try:
from src.analysis.ai_analyzer import get_ai_analysis
from bot_listener import analyze_weather_trend
# analyze_weather_trend returns (display_str, ai_context)
# It already handles: DEB, probability, dead market, forecast bust,
# METAR trend, peak window, wind, cloud, humidity, etc.
_, ai_context = analyze_weather_trend(raw, sym, city)
# Append multi-model divergence if not already included
if current_forecasts and ai_context:
mm_str = " | ".join(
[f"{k}:{v}{sym}" for k, v in current_forecasts.items() if v]
)
ai_context += f"\n模型分歧: {mm_str}"
if ai_context:
ai_text = get_ai_analysis(ai_context, city, sym)
except Exception as e:
logger.warning(f"AI analysis skipped for {city}: {e}")
# ── Assemble result ──
result = {