From 0417387c0be6e88b2e807a3c59b5b070dc26d690 Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Sun, 8 Mar 2026 12:59:45 +0800
Subject: [PATCH] feat: introduce PolyWeather web API and frontend for
interactive weather data display, centralizing data collection and analysis.
---
bot_listener.py | 182 ++++++++---------
frontend/app/globals.css | 129 ++++++++++++
frontend/lib/types.ts | 5 +
frontend/public/legacy/index.html | 2 +-
frontend/public/static/app.js | 265 ++++++++++++++++---------
src/data_collection/weather_sources.py | 7 +-
web/app.py | 118 +++++++++++
7 files changed, 524 insertions(+), 184 deletions(-)
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 @@