refactor: extract trend_engine, unify probability engine across bot and web, add unit tests
This commit is contained in:
+4
-486
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user