diff --git a/bot_listener.py b/bot_listener.py index 974d2fa6..721229c9 100644 --- a/bot_listener.py +++ b/bot_listener.py @@ -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"🧬 DEB 融合预测{blended_high}{temp_symbol} ({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"📊 集合预报:中位数 {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"🎲 结算概率 (μ={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"🎲 结算预测:已锁定 {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"🚨 实测已超预报:{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"⚖️ 结算边界:当前最高 {max_so_far}{temp_symbol} → WU 结算 {settled}{temp_symbol},但只差 {0.5 - fractional:.1f}° 就会进位到 {settled + 1}{temp_symbol}!" - else: - msg = f"⚖️ 结算边界:当前最高 {max_so_far}{temp_symbol} → WU 结算 {settled}{temp_symbol},刚刚越过进位线,再降 {fractional - 0.5:.1f}° 就会回落到 {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( - "⚠️ 提示:预测最热在凌晨,后续气温可能一路走低。" - ) - 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"🎯 关注重点:看看那个时段能否涨到 {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(): diff --git a/src/analysis/trend_engine.py b/src/analysis/trend_engine.py new file mode 100644 index 00000000..51c98326 --- /dev/null +++ b/src/analysis/trend_engine.py @@ -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"🧬 DEB 融合预测{blended_high}{temp_symbol} ({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"📊 集合预报:中位数 {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"🎲 结算概率 (μ={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"🎲 结算预测:已锁定 {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"🚨 实测已超预报:{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"⚖️ 结算边界:当前最高 {max_so_far}{temp_symbol} → WU 结算 " + f"{settled}{temp_symbol},但只差 {0.5 - fractional:.1f}° " + f"就会进位到 {settled + 1}{temp_symbol}!" + ) + else: + msg = ( + f"⚖️ 结算边界:当前最高 {max_so_far}{temp_symbol} → WU 结算 " + f"{settled}{temp_symbol},刚刚越过进位线,再降 " + f"{fractional - 0.5:.1f}° 就会回落到 {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("⚠️ 提示:预测最热在凌晨,后续气温可能一路走低。") + 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"🎯 关注重点:看看那个时段能否涨到 {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 diff --git a/tests/test_trend_engine.py b/tests/test_trend_engine.py new file mode 100644 index 00000000..c2fecc4e --- /dev/null +++ b/tests/test_trend_engine.py @@ -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" diff --git a/web/app.py b/web/app.py index 8b4f920c..e6abad17 100644 --- a/web/app.py +++ b/web/app.py @@ -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 = {