diff --git a/bot_listener.py b/bot_listener.py index 4e9ba88c..85375064 100644 --- a/bot_listener.py +++ b/bot_listener.py @@ -118,33 +118,37 @@ def start_bot(): ) bot.send_message(message.chat.id, rank_text, parse_mode="HTML") - @bot.message_handler(commands=["deb"]) def deb_accuracy(message): - """查询 DEB 融合预测的历史准确率""" + """查询 DEB 融合预测的近 7 天准确率。""" try: parts = message.text.split(maxsplit=1) if len(parts) < 2: bot.reply_to( - message, "❓ 用法: /deb ankara", parse_mode="HTML" + message, + "❌ 用法: /deb ankara", + parse_mode="HTML", ) return - from src.data_collection.city_registry import ALIASES, CITY_REGISTRY + from datetime import datetime as _dt, timedelta as _td + import os as _os + + from src.analysis.deb_algorithm import load_history + from src.data_collection.city_registry import ALIASES + city_input = parts[1].strip().lower() city_name = ALIASES.get(city_input, city_input) - from src.analysis.deb_algorithm import load_history - import os as _os - - # 获取详细历史数据 project_root = _os.path.dirname(_os.path.abspath(__file__)) history_file = _os.path.join(project_root, "data", "daily_records.json") data = load_history(history_file) if city_name not in data or not data[city_name]: bot.reply_to( - message, f"❌ 暂无 {city_name} 的历史数据", parse_mode="HTML" + message, + f"❌ 暂无 {city_name} 的历史数据。", + parse_mode="HTML", ) return @@ -152,28 +156,40 @@ def start_bot(): return city_data = data[city_name] - from datetime import datetime as _dt + today = _dt.now().date() + today_str = today.strftime("%Y-%m-%d") + cutoff_date = today - _td(days=6) - today_str = _dt.now().strftime("%Y-%m-%d") + recent_items = [] + for date_str, record in city_data.items(): + try: + row_date = _dt.strptime(date_str, "%Y-%m-%d").date() + except Exception: + continue + if row_date >= cutoff_date: + recent_items.append((date_str, record, row_date)) - lines = [f"📊 DEB 准确率报告 - {city_name.title()}\n"] + recent_items.sort(key=lambda item: item[0]) - # 逐日明细 - lines.append("📅 逐日记录:") + lines = [ + f"📊 DEB 准确率报告 - {city_name.title()}", + "", + "📅 近7日记录:", + ] total_days = 0 hits = 0 deb_errors = [] - signed_errors = [] # 有正负的误差 (DEB - 实测) + signed_errors = [] model_errors = {} - for date_str in sorted(city_data.keys()): - record = city_data[date_str] + for date_str, record, _row_date in recent_items: actual = record.get("actual_high") deb_pred = record.get("deb_prediction") - forecasts = record.get("forecasts", {}) + forecasts = record.get("forecasts", {}) or {} if actual is None: continue + try: actual = float(actual) if deb_pred is not None: @@ -181,18 +197,16 @@ def start_bot(): except Exception: continue - # 如果没有存 DEB 预测值,用当天各模型平均值回算 if deb_pred is None and forecasts: - valid_preds = [ - float(v) for v in forecasts.values() if v is not None - ] + valid_preds = [float(v) for v in forecasts.values() if v is not None] if valid_preds: deb_pred = round(sum(valid_preds) / len(valid_preds), 1) actual_wu = round(actual) - # DEB 命中判断 - if deb_pred is not None and date_str != today_str: + if date_str == today_str: + lines.append(f" {date_str}: 📍 今天进行中 (实测暂 {actual:.1f})") + elif deb_pred is not None: total_days += 1 deb_wu = round(deb_pred) hit = deb_wu == actual_wu @@ -201,111 +215,97 @@ def start_bot(): err = deb_pred - actual deb_errors.append(abs(err)) signed_errors.append(err) - icon = "✅" if hit else "❌" - retro = "≈" if "deb_prediction" not in record else "" - # 错误类型标签 - if not hit: - err_label = ( - f" 低估{abs(err):.1f}°" - if err < 0 - else f" 高估{abs(err):.1f}°" - ) - else: - err_label = f" 偏差{abs(err):.1f}°" - mu_val = record.get("mu") - mu_str = f" | μ: {mu_val}" if mu_val is not None else "" - lines.append( - f" {date_str}: DEB {retro}{deb_pred}→{deb_wu} vs 实测 {actual}→{actual_wu} {icon}{err_label}{mu_str}" - ) - elif date_str == today_str: - lines.append(f" {date_str}: 📍 今天进行中 (实测暂 {actual})") - # 各模型误差统计 + if hit: + result_icon = "✅" + err_text = f"偏差{abs(err):.1f}°" + elif err < 0: + result_icon = "❌" + err_text = f"低估{abs(err):.1f}°" + else: + result_icon = "❌" + err_text = f"高估{abs(err):.1f}°" + + retro = "≈" if "deb_prediction" not in record else "" + lines.append( + f" {date_str}: DEB {retro}{deb_pred:.1f}→{deb_wu} vs 实测 {actual:.1f}→{actual_wu} " + f"{result_icon} {err_text}" + ) + if date_str != today_str and actual is not None: for model, pred in forecasts.items(): - if pred is not None: - if model not in model_errors: - model_errors[model] = [] - model_errors[model].append(abs(float(pred) - actual)) + if pred is None: + continue + try: + model_errors.setdefault(model, []).append(abs(float(pred) - actual)) + except Exception: + continue - # 汇总 if total_days > 0: hit_rate = hits / total_days * 100 deb_mae = sum(deb_errors) / len(deb_errors) + lines.append("") lines.append( - f"\n🎯 DEB 总战绩:WU命中 {hits}/{total_days} ({hit_rate:.0f}%) | MAE: {deb_mae:.1f}°" + f"🎯 DEB 总战绩:WU命中 {hits}/{total_days} ({hit_rate:.0f}%) | MAE: {deb_mae:.1f}°" ) - # --- 概率引擎 μ 的战绩 --- - from src.analysis.deb_algorithm import get_mu_accuracy - mu_acc = get_mu_accuracy(city_name) - if mu_acc: - mu_mae, mu_hr, avg_brier, mu_total, _ = mu_acc - lines.append( - f"🎲 概率引擎 (μ):WU命中 {mu_hr:.0f}% | MAE: {mu_mae:.1f}°" - ) - if avg_brier is not None: - # Brier Score 范围是 0 (完美) 到 2 (全错) - bs_eval = "极佳" if avg_brier < 0.2 else ("良好" if avg_brier < 0.4 else "需校准") - lines.append(f" ▪ Brier评分: {avg_brier:.3f} ({bs_eval})") - - - # 和各模型 MAE 对比 if model_errors: - lines.append("\n📈 模型 MAE 对比:") - model_maes = { - m: sum(e) / len(e) for m, e in model_errors.items() if e - } - sorted_models = sorted(model_maes.items(), key=lambda x: x[1]) - for m, mae in sorted_models: + lines.append("") + lines.append("📈 模型 MAE 对比:") + model_maes = {m: sum(e) / len(e) for m, e in model_errors.items() if e} + sorted_models = sorted(model_maes.items(), key=lambda item: item[1]) + for model, mae in sorted_models: tag = " ⭐" if mae <= deb_mae else "" - lines.append(f" {m}: {mae:.1f}°{tag}") + lines.append(f" {model}: {mae:.1f}°{tag}") lines.append(f" DEB融合: {deb_mae:.1f}°") - # 偏差模式分析 mean_bias = sum(signed_errors) / len(signed_errors) underest = sum(1 for e in signed_errors if e < -0.3) overest = sum(1 for e in signed_errors if e > 0.3) + accurate = total_days - underest - overest - lines.append("\n🔍 偏差分析:") + lines.append("") + lines.append("🔍 偏差分析:") if abs(mean_bias) > 0.3: - bias_dir = "低估" if mean_bias < 0 else "高估" - lines.append(f" ⚠️ 系统性{bias_dir}:平均偏差 {mean_bias:+.1f}°") + bias_label = "系统性低估" if mean_bias < 0 else "系统性高估" + lines.append(f" ⚠️ {bias_label}:平均偏差 {mean_bias:+.1f}°") else: - lines.append(f" ✅ 无明显系统偏差(平均 {mean_bias:+.1f}°)") - lines.append( - f" 低估 {underest} 次 | 高估 {overest} 次 | 准确 {total_days - underest - overest} 次" - ) + lines.append(f" ✅ 整体无明显系统偏差:平均偏差 {mean_bias:+.1f}°") + lines.append(f" 低估 {underest} 次 | 高估 {overest} 次 | 准确 {accurate} 次") - # 可操作建议 - lines.append("\n💡 建议:") + lines.append("") + lines.append("💡 建议:") if underest > overest and abs(mean_bias) > 0.5: lines.append( - f" 该城市模型集体低估趋势明显({mean_bias:+.1f}°),实际最高温可能比 DEB 融合值高 {abs(mean_bias):.0f}-{abs(mean_bias) + 0.5:.0f}°。交易时建议适当看高。" + f" 该城市模型集体低估趋势明显({mean_bias:+.1f}°),实际最高温可能比 DEB 融合值高 " + f"{abs(mean_bias):.0f}-{abs(mean_bias) + 0.5:.0f}°。交易时建议适当看高。" ) elif overest > underest and abs(mean_bias) > 0.5: lines.append( - f" 该城市模型集体高估趋势明显({mean_bias:+.1f}°),实际最高温可能比 DEB 融合值低。交易时建议适当看低。" + f" 该城市模型集体高估趋势明显({mean_bias:+.1f}°),实际最高温可能低于 DEB 融合值。交易时注意追高风险。" ) elif deb_mae > 1.5: - lines.append( - f" 该城市预报波动大 (MAE {deb_mae:.1f}°),建议观望或轻仓。" - ) + lines.append(f" 近期模型波动较大(MAE {deb_mae:.1f}°),建议降低对单一日预测的信任度。") elif hit_rate >= 60: - lines.append(" DEB 表现良好,可作为主要参考。") + lines.append(" DEB 近期表现稳定,可继续作为主要参考。") else: - lines.append(" 数据积累中,建议结合 AI 分析综合判断。") + lines.append(" 近期准确率一般,建议结合主站实测与周边站点共同判断。") - lines.append("\n📝 MAE = 平均绝对误差,越小越准。⭐ = 优于 DEB 融合。") + lines.append("") + lines.append("📝 MAE = 平均绝对误差,越小越准。⭐ = 优于 DEB 融合。") + lines.append("🗓 统计窗口:近7天滚动样本。") else: - lines.append("\n⏳ 尚无完整的 DEB 预测记录,明天起开始统计。") + lines.append("") + lines.append("⏳ 近7天尚无完整的 DEB 预测记录。") - lines.append(f"\n💳 本次消耗 {DEB_QUERY_COST} 积分。") + lines.append("") + lines.append(f"💳 本次消耗 {DEB_QUERY_COST} 积分。") bot.reply_to(message, "\n".join(lines), parse_mode="HTML") except Exception as e: bot.reply_to(message, f"❌ 查询失败: {e}") @bot.message_handler(commands=["city"]) + def get_city_info(message): """查询指定城市的天气详情""" try: diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 52c8295d..6e1481cb 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -123,3 +123,132 @@ } } } + + +@layer components { + .nearby-marker { + display: flex; + align-items: center; + gap: 6px; + background: rgba(15, 23, 42, 0.9); + color: rgba(226, 232, 240, 0.92); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 4px 10px; + font-size: 11px; + font-weight: 600; + box-shadow: 0 10px 28px rgba(2, 6, 23, 0.45); + backdrop-filter: blur(10px); + white-space: nowrap; + } + + .nearby-name { + color: rgba(148, 163, 184, 0.95); + } + + .nearby-temp { + color: #fff; + font-weight: 800; + } + + .nearby-unit { + color: rgba(148, 163, 184, 0.85); + font-size: 9px; + } + + .wind-info { + display: flex; + align-items: center; + gap: 4px; + margin-left: 4px; + padding-left: 6px; + border-left: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(34, 211, 238, 0.9); + font-size: 10px; + } + + .wind-arrow { + display: inline-block; + transform-origin: center; + } + + .city-marker { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + cursor: pointer; + } + + .marker-bubble { + position: relative; + min-width: 46px; + padding: 5px 10px; + border-radius: 12px; + font-size: 13px; + font-weight: 800; + text-align: center; + color: white; + border: 1px solid transparent; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + } + + .marker-bubble::after { + content: ""; + position: absolute; + bottom: -6px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid transparent; + } + + .marker-bubble.risk-high { + background: linear-gradient(135deg, #dc2626, #ef4444); + border-color: rgba(239, 68, 68, 0.5); + } + + .marker-bubble.risk-high::after { border-top-color: #ef4444; } + + .marker-bubble.risk-medium { + background: linear-gradient(135deg, #d97706, #f59e0b); + border-color: rgba(245, 158, 11, 0.5); + } + + .marker-bubble.risk-medium::after { border-top-color: #f59e0b; } + + .marker-bubble.risk-low { + background: linear-gradient(135deg, #059669, #10b981); + border-color: rgba(16, 185, 129, 0.5); + } + + .marker-bubble.risk-low::after { border-top-color: #10b981; } + + .marker-name { + margin-top: 8px; + font-size: 10px; + font-weight: 700; + color: rgba(255, 255, 255, 0.88); + text-shadow: 0 1px 4px rgba(0,0,0,0.8); + } + + .city-marker.selected .marker-bubble { + animation: markerGlow 2s ease-in-out infinite; + } + + .custom-scrollbar::-webkit-scrollbar { + width: 8px; + } + .custom-scrollbar::-webkit-scrollbar-thumb { + background: rgba(51, 65, 85, 0.75); + border-radius: 9999px; + } +} + +@keyframes markerGlow { + 0%, 100% { box-shadow: 0 6px 18px rgba(0,0,0,0.4); } + 50% { box-shadow: 0 10px 26px rgba(34,211,238,0.35); } +} diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index e2528d45..d3dbb834 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -238,6 +238,10 @@ export interface CityAnalysis { clouds_raw: Array<{ cover: string; base: number | null }>; visibility_mi: number | null; wx_desc: string | null; + raw_metar?: string | null; + report_time?: string | null; + receipt_time?: string | null; + obs_time_epoch?: number | null; }; mgm?: { temp?: number | null; @@ -317,6 +321,7 @@ export interface CityDetail { weather_gov: WeatherGovData; mgm: any; mgm_nearby: any[]; + nearby_source?: string; }; timeseries: { metar_recent_obs: any[]; diff --git a/frontend/public/legacy/index.html b/frontend/public/legacy/index.html index e46673a7..8ac00933 100644 --- a/frontend/public/legacy/index.html +++ b/frontend/public/legacy/index.html @@ -193,7 +193,7 @@