feat: introduce PolyWeather web API and frontend for interactive weather data display, centralizing data collection and analysis.
This commit is contained in:
+91
-91
@@ -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, "❓ 用法: <code>/deb ankara</code>", parse_mode="HTML"
|
||||
message,
|
||||
"❌ 用法: <code>/deb ankara</code>",
|
||||
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"📊 <b>DEB 准确率报告 - {city_name.title()}</b>\n"]
|
||||
recent_items.sort(key=lambda item: item[0])
|
||||
|
||||
# 逐日明细
|
||||
lines.append("<b>📅 逐日记录:</b>")
|
||||
lines = [
|
||||
f"📊 <b>DEB 准确率报告 - {city_name.title()}</b>",
|
||||
"",
|
||||
"📅 <b>近7日记录:</b>",
|
||||
]
|
||||
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}→<b>{deb_wu}</b> vs 实测 {actual}→<b>{actual_wu}</b> {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🎯 <b>DEB 总战绩</b>:WU命中 {hits}/{total_days} (<b>{hit_rate:.0f}%</b>) | MAE: {deb_mae:.1f}°"
|
||||
f"🎯 <b>DEB 总战绩:</b>WU命中 {hits}/{total_days} (<b>{hit_rate:.0f}%</b>) | 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"🎲 <b>概率引擎 (μ)</b>:WU命中 <b>{mu_hr:.0f}%</b> | 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📈 <b>模型 MAE 对比</b>:")
|
||||
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("📈 <b>模型 MAE 对比:</b>")
|
||||
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" <b>DEB融合: {deb_mae:.1f}°</b>")
|
||||
|
||||
# 偏差模式分析
|
||||
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🔍 <b>偏差分析</b>:")
|
||||
lines.append("")
|
||||
lines.append("🔍 <b>偏差分析:</b>")
|
||||
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💡 <b>建议</b>:")
|
||||
lines.append("")
|
||||
lines.append("💡 <b>建议:</b>")
|
||||
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💳 本次消耗 <code>{DEB_QUERY_COST}</code> 积分。")
|
||||
lines.append("")
|
||||
lines.append(f"💳 本次消耗 <code>{DEB_QUERY_COST}</code> 积分。")
|
||||
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:
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -193,7 +193,7 @@
|
||||
<div id="historyModal" class="modal-overlay hidden">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>📊 <span id="historyModalTitle">历史准确率</span></h2>
|
||||
<h2>📊 <span id="historyModalTitle">历史准确率</span></h2>
|
||||
<button class="modal-close" id="historyModalClose">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
+174
-91
@@ -40,6 +40,10 @@ let tempChart = null;
|
||||
const AUTO_REFRESH_MS = 60 * 60 * 1000; // 1 hour
|
||||
let selectedForecastDate = null;
|
||||
let nearbyLayerGroup = null;
|
||||
let autoNearbyCity = null;
|
||||
let autoNearbyLoading = false;
|
||||
const AUTO_NEARBY_MIN_ZOOM = 8;
|
||||
const AUTO_NEARBY_MAX_DISTANCE_M = 120000;
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Map Setup
|
||||
@@ -74,6 +78,7 @@ function initMap() {
|
||||
|
||||
// Handle zoom-based visibility for local stations and minor cities
|
||||
map.on("zoomend", updateMapVisibility);
|
||||
map.on("moveend", maybeAutoShowNearbyStations);
|
||||
}
|
||||
|
||||
function updateMapVisibility() {
|
||||
@@ -93,6 +98,73 @@ function updateMapVisibility() {
|
||||
Object.values(markers).forEach(({ marker }) => {
|
||||
if (!map.hasLayer(marker)) map.addLayer(marker);
|
||||
});
|
||||
|
||||
maybeAutoShowNearbyStations();
|
||||
}
|
||||
|
||||
function getNearestCityForCenter() {
|
||||
if (!map) return null;
|
||||
const center = map.getCenter();
|
||||
let best = null;
|
||||
|
||||
Object.entries(markers).forEach(([cityName, entry]) => {
|
||||
const city = entry.city;
|
||||
if (city?.lat == null || city?.lon == null) return;
|
||||
const distance = map.distance(center, L.latLng(city.lat, city.lon));
|
||||
if (distance > AUTO_NEARBY_MAX_DISTANCE_M) return;
|
||||
if (!best || distance < best.distance) {
|
||||
best = { cityName, distance };
|
||||
}
|
||||
});
|
||||
|
||||
return best?.cityName || null;
|
||||
}
|
||||
|
||||
async function maybeAutoShowNearbyStations() {
|
||||
if (!map || !nearbyLayerGroup) return;
|
||||
if (map.getZoom() < AUTO_NEARBY_MIN_ZOOM) {
|
||||
autoNearbyCity = null;
|
||||
nearbyLayerGroup.clearLayers();
|
||||
return;
|
||||
}
|
||||
|
||||
const targetCity = getNearestCityForCenter();
|
||||
if (!targetCity) {
|
||||
autoNearbyCity = null;
|
||||
nearbyLayerGroup.clearLayers();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
autoNearbyCity === targetCity &&
|
||||
nearbyLayerGroup.getLayers().length > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoNearbyCity = targetCity;
|
||||
|
||||
if (cityDataCache[targetCity]) {
|
||||
renderNearbyStations(cityDataCache[targetCity], true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoNearbyLoading) return;
|
||||
autoNearbyLoading = true;
|
||||
try {
|
||||
const data = await fetchCityDetail(targetCity, false);
|
||||
cityDataCache[targetCity] = data;
|
||||
saveCache();
|
||||
renderNearbyStations(data, true);
|
||||
if (data.current?.temp != null) {
|
||||
updateMarkerTemp(targetCity, data.current.temp);
|
||||
updateCityListInfo(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Auto nearby load failed for ${targetCity}:`, e);
|
||||
} finally {
|
||||
autoNearbyLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -258,13 +330,31 @@ async function fetchCityDetail(cityName, force = false) {
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Nearby Map Stations Rendering
|
||||
// ──────────────────────────────────────────────────────────
|
||||
function renderNearbyStations(data) {
|
||||
function renderNearbyStations(data, preserveView = false) {
|
||||
if (!nearbyLayerGroup) return;
|
||||
nearbyLayerGroup.clearLayers();
|
||||
|
||||
if (!data.mgm_nearby || data.mgm_nearby.length === 0) {
|
||||
const allNearby = Array.isArray(data.mgm_nearby) ? data.mgm_nearby : [];
|
||||
const isAnkara = String(data.name || "").toLowerCase() === "ankara";
|
||||
const ankaraPriority = [
|
||||
"Airport (MGM/17128)",
|
||||
"Ankara (Bölge/Center)",
|
||||
"Etimesgut",
|
||||
"Keçiören",
|
||||
"Pursaklar",
|
||||
"Çubuk",
|
||||
"Kalecik",
|
||||
];
|
||||
const ankaraNearby = isAnkara
|
||||
? ankaraPriority
|
||||
.map((name) => allNearby.find((st) => st?.name === name))
|
||||
.filter(Boolean)
|
||||
: allNearby;
|
||||
const nearbyStations = ankaraNearby.length > 0 ? ankaraNearby : allNearby;
|
||||
|
||||
if (nearbyStations.length === 0) {
|
||||
// Regular city zoom-in
|
||||
if (data.lat != null && data.lon != null) {
|
||||
if (!preserveView && data.lat != null && data.lon != null) {
|
||||
map.flyTo([data.lat, data.lon], 10, {
|
||||
animate: true,
|
||||
duration: 1.5,
|
||||
@@ -281,7 +371,7 @@ function renderNearbyStations(data) {
|
||||
latLngs.push([data.lat, data.lon]);
|
||||
}
|
||||
|
||||
data.mgm_nearby.forEach((st) => {
|
||||
nearbyStations.forEach((st) => {
|
||||
// Filter out stations too far if needed, but il handles grouping nicely.
|
||||
// Skip if it is the exact same marker as main (though coordinates might slightly differ)
|
||||
const sym = data.temp_symbol || "°C";
|
||||
@@ -289,7 +379,7 @@ function renderNearbyStations(data) {
|
||||
let windHtml = "";
|
||||
if (st.wind_dir != null) {
|
||||
const rot = (parseFloat(st.wind_dir) + 180) % 360;
|
||||
const speedRaw = parseFloat(st.wind_speed);
|
||||
const speedRaw = parseFloat(st.wind_speed ?? st.wind_speed_kt);
|
||||
const speed = !isNaN(speedRaw) ? `${speedRaw.toFixed(1)}k` : "";
|
||||
windHtml = `
|
||||
<div class="wind-info">
|
||||
@@ -317,6 +407,10 @@ function renderNearbyStations(data) {
|
||||
latLngs.push([st.lat, st.lon]);
|
||||
});
|
||||
|
||||
if (preserveView) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (latLngs.length > 1) {
|
||||
const bounds = L.latLngBounds(latLngs);
|
||||
map.flyToBounds(bounds, {
|
||||
@@ -363,12 +457,7 @@ async function loadCityDetail(cityName, force = false) {
|
||||
|
||||
// Update marker and list
|
||||
if (data.current?.temp != null) {
|
||||
const displayTemp =
|
||||
data.current.max_so_far != null &&
|
||||
data.current.max_so_far >= data.current.temp
|
||||
? data.current.max_so_far
|
||||
: data.current.temp;
|
||||
updateMarkerTemp(cityName, displayTemp);
|
||||
updateMarkerTemp(cityName, data.current.temp);
|
||||
updateCityListInfo(data);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -879,7 +968,7 @@ function renderProbabilities(data) {
|
||||
|
||||
let html = "";
|
||||
if (mu != null) {
|
||||
html += `<div style="font-size:11px;color:var(--text-muted);margin-bottom:6px;">期望值 μ = ${mu}${data.temp_symbol}</div>`;
|
||||
html += `<div style="font-size:11px;color:var(--text-muted);margin-bottom:6px;">动态分布中心= ${mu}${data.temp_symbol}</div>`;
|
||||
}
|
||||
|
||||
probs.forEach((p, i) => {
|
||||
@@ -895,7 +984,6 @@ function renderProbabilities(data) {
|
||||
});
|
||||
container.innerHTML = html;
|
||||
|
||||
// Animate bars
|
||||
requestAnimationFrame(() => {
|
||||
container.querySelectorAll(".prob-bar-fill").forEach((bar, i) => {
|
||||
const pct = Math.round(probs[i].probability * 100);
|
||||
@@ -922,7 +1010,9 @@ function formatCents(price) {
|
||||
const n = Number(price);
|
||||
if (!Number.isFinite(n)) return "--";
|
||||
const cents = Math.round(n * 1000) / 10;
|
||||
return Number.isInteger(cents) ? `${cents.toFixed(0)}c` : `${cents.toFixed(1)}c`;
|
||||
return Number.isInteger(cents)
|
||||
? `${cents.toFixed(0)}c`
|
||||
: `${cents.toFixed(1)}c`;
|
||||
}
|
||||
|
||||
function renderModels(data) {
|
||||
@@ -1080,6 +1170,7 @@ function closePanel() {
|
||||
document
|
||||
.querySelectorAll(".city-item")
|
||||
.forEach((el) => el.classList.remove("active"));
|
||||
maybeAutoShowNearbyStations();
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -1095,12 +1186,7 @@ function startAutoRefresh() {
|
||||
cityDataCache[selectedCity] = data;
|
||||
renderPanel(data);
|
||||
if (data.current?.temp != null) {
|
||||
const displayTemp =
|
||||
data.current.max_so_far != null &&
|
||||
data.current.max_so_far >= data.current.temp
|
||||
? data.current.max_so_far
|
||||
: data.current.temp;
|
||||
updateMarkerTemp(selectedCity, displayTemp);
|
||||
updateMarkerTemp(selectedCity, data.current.temp);
|
||||
updateCityListInfo(data);
|
||||
}
|
||||
flashLiveBadge();
|
||||
@@ -1177,66 +1263,110 @@ async function openHistoryModal() {
|
||||
modal.classList.remove("hidden");
|
||||
title.textContent = `历史准确率对账 - ${selectedCity.toUpperCase()}`;
|
||||
statsDiv.innerHTML =
|
||||
'<span style="color:var(--text-muted)">正在获取底层数据库...</span>';
|
||||
'<span style="color:var(--text-muted)">正在获取历史数据...</span>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/history/${encodeURIComponent(selectedCity)}`);
|
||||
const json = await res.json();
|
||||
const data = json.history || [];
|
||||
const cutoff = new Date();
|
||||
cutoff.setHours(0, 0, 0, 0);
|
||||
cutoff.setDate(cutoff.getDate() - 14);
|
||||
const recentData = data.filter((row) => {
|
||||
if (!row?.date) return false;
|
||||
const rowDate = new Date(`${row.date}T00:00:00`);
|
||||
return !Number.isNaN(rowDate.getTime()) && rowDate >= cutoff;
|
||||
});
|
||||
|
||||
if (data.length === 0) {
|
||||
if (recentData.length === 0) {
|
||||
statsDiv.innerHTML =
|
||||
'<span style="color:var(--text-muted)">暂无该城市历史数据</span>';
|
||||
'<span style="color:var(--text-muted)">\u8fd115\u5929\u6682\u65e0\u8be5\u57ce\u5e02\u5386\u53f2\u6570\u636e</span>';
|
||||
if (historyChartInst) historyChartInst.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute stats
|
||||
let hits = 0;
|
||||
let debErrors = [];
|
||||
let muErrors = [];
|
||||
|
||||
const debErrors = [];
|
||||
const dates = [];
|
||||
const actuals = [];
|
||||
const debs = [];
|
||||
const mus = [];
|
||||
const mgms = [];
|
||||
const cityLocalDate = cityDataCache?.[selectedCity]?.local_date || null;
|
||||
const settledData = recentData.filter((row) => {
|
||||
if (!row?.date) return false;
|
||||
return cityLocalDate
|
||||
? row.date < cityLocalDate
|
||||
: row.date < new Date().toISOString().slice(0, 10);
|
||||
});
|
||||
|
||||
data.forEach((row) => {
|
||||
recentData.forEach((row) => {
|
||||
dates.push(row.date);
|
||||
actuals.push(row.actual);
|
||||
debs.push(row.deb);
|
||||
mus.push(row.mu);
|
||||
mgms.push(row.mgm);
|
||||
});
|
||||
|
||||
settledData.forEach((row) => {
|
||||
if (row.actual != null && row.deb != null) {
|
||||
debErrors.push(Math.abs(row.actual - row.deb));
|
||||
if (Math.round(row.actual) === Math.round(row.deb)) {
|
||||
hits++;
|
||||
}
|
||||
}
|
||||
if (row.actual != null && row.mu != null) {
|
||||
muErrors.push(Math.abs(row.actual - row.mu));
|
||||
}
|
||||
});
|
||||
|
||||
const hitRate = debErrors.length
|
||||
? ((hits / debErrors.length) * 100).toFixed(0)
|
||||
: 0;
|
||||
: "--";
|
||||
const debMae = debErrors.length
|
||||
? (debErrors.reduce((a, b) => a + b, 0) / debErrors.length).toFixed(1)
|
||||
: "-";
|
||||
const muMae = muErrors.length
|
||||
? (muErrors.reduce((a, b) => a + b, 0) / muErrors.length).toFixed(1)
|
||||
: "-";
|
||||
: "--";
|
||||
const hasMgm =
|
||||
selectedCity === "ankara" && mgms.some((value) => value != null);
|
||||
|
||||
statsDiv.innerHTML = `
|
||||
<div class="h-stat-card"><span class="label">DEB 结算胜率 (WU)</span><span class="val">${hitRate}%</span></div>
|
||||
<div class="h-stat-card"><span class="label">DEB MAE</span><span class="val">${debMae}°</span></div>
|
||||
<div class="h-stat-card"><span class="label">μ (概率) MAE</span><span class="val">${muMae}°</span></div>
|
||||
<div class="h-stat-card"><span class="label">有效样本数</span><span class="val">${data.length}天</span></div>
|
||||
<div class="h-stat-card"><span class="label">DEB ???? (WU)</span><span class="val">${hitRate === "--" ? "--" : `${hitRate}%`}</span></div>
|
||||
<div class="h-stat-card"><span class="label">DEB MAE</span><span class="val">${debMae}?</span></div>
|
||||
<div class="h-stat-card"><span class="label">?15??????</span><span class="val">${settledData.length}?</span></div>
|
||||
`;
|
||||
|
||||
const datasets = [
|
||||
{
|
||||
label: "实测最高温",
|
||||
data: actuals,
|
||||
borderColor: "#f87171",
|
||||
backgroundColor: "rgba(248, 113, 113, 0.1)",
|
||||
borderWidth: 2,
|
||||
tension: 0.2,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: "#f87171",
|
||||
pointBorderColor: "#fff",
|
||||
zIndex: 10,
|
||||
},
|
||||
{
|
||||
label: "DEB 融合",
|
||||
data: debs,
|
||||
borderColor: "#34d399",
|
||||
backgroundColor: "transparent",
|
||||
borderWidth: 2,
|
||||
borderDash: [5, 4],
|
||||
tension: 0.2,
|
||||
pointRadius: 3,
|
||||
},
|
||||
];
|
||||
|
||||
if (hasMgm) {
|
||||
datasets.push({
|
||||
label: "MGM 官方预报",
|
||||
data: mgms,
|
||||
borderColor: "#fb923c",
|
||||
backgroundColor: "transparent",
|
||||
borderWidth: 2,
|
||||
tension: 0.2,
|
||||
pointRadius: 3,
|
||||
});
|
||||
}
|
||||
|
||||
if (historyChartInst) historyChartInst.destroy();
|
||||
const ctx = document.getElementById("historyChart").getContext("2d");
|
||||
|
||||
@@ -1244,50 +1374,7 @@ async function openHistoryModal() {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: dates,
|
||||
datasets: [
|
||||
{
|
||||
label: "实测最高温",
|
||||
data: actuals,
|
||||
borderColor: "#f87171", // red
|
||||
backgroundColor: "rgba(248, 113, 113, 0.1)",
|
||||
borderWidth: 2,
|
||||
tension: 0.2,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: "#f87171",
|
||||
pointBorderColor: "#fff",
|
||||
zIndex: 10,
|
||||
},
|
||||
{
|
||||
label: "DEB 融合",
|
||||
data: debs,
|
||||
borderColor: "#34d399", // emerald
|
||||
backgroundColor: "transparent",
|
||||
borderWidth: 2,
|
||||
borderDash: [5, 4],
|
||||
tension: 0.2,
|
||||
pointRadius: 3,
|
||||
},
|
||||
{
|
||||
label: "μ (概率锚定)",
|
||||
data: mus,
|
||||
borderColor: "#a78bfa", // purple
|
||||
backgroundColor: "transparent",
|
||||
borderWidth: 2,
|
||||
borderDash: [2, 2],
|
||||
tension: 0.2,
|
||||
pointRadius: 3,
|
||||
},
|
||||
{
|
||||
label: "MGM 官方预报",
|
||||
data: mgms,
|
||||
borderColor: "#fb923c", // orange
|
||||
backgroundColor: "transparent",
|
||||
borderWidth: 2,
|
||||
tension: 0.2,
|
||||
pointRadius: 3,
|
||||
hidden: false, // Show by default for Ankara
|
||||
},
|
||||
],
|
||||
datasets,
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
@@ -1387,11 +1474,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
cities.forEach((c) => {
|
||||
const cached = cityDataCache[c.name];
|
||||
if (cached && cached.current?.temp != null) {
|
||||
c._temp =
|
||||
cached.current.max_so_far != null &&
|
||||
cached.current.max_so_far >= cached.current.temp
|
||||
? cached.current.max_so_far
|
||||
: cached.current.temp;
|
||||
c._temp = cached.current.temp;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -759,7 +759,12 @@ class WeatherDataCollector:
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"temp": round(display_temp, 1),
|
||||
"istNo": icao # 用 ICAO ID 作为标识
|
||||
"istNo": icao, # 用 ICAO ID 作为标识
|
||||
"icao": icao,
|
||||
"wind_dir": obs.get("wdir"),
|
||||
"wind_speed": obs.get("wspd"),
|
||||
"wind_speed_kt": obs.get("wspd"),
|
||||
"raw_metar": obs.get("rawOb"),
|
||||
})
|
||||
|
||||
if results:
|
||||
|
||||
+118
@@ -591,6 +591,110 @@ def _normalize_city_or_404(name: str) -> str:
|
||||
return city
|
||||
|
||||
|
||||
def _build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": data.get("name"),
|
||||
"display_name": data.get("display_name"),
|
||||
"icao": data.get("risk", {}).get("icao"),
|
||||
"local_time": data.get("local_time"),
|
||||
"temp_symbol": data.get("temp_symbol"),
|
||||
"current": {
|
||||
"temp": data.get("current", {}).get("temp"),
|
||||
"obs_time": data.get("current", {}).get("obs_time"),
|
||||
},
|
||||
"deb": {
|
||||
"prediction": data.get("deb", {}).get("prediction"),
|
||||
},
|
||||
"risk": {
|
||||
"level": data.get("risk", {}).get("level"),
|
||||
"warning": data.get("risk", {}).get("warning"),
|
||||
},
|
||||
"updated_at": data.get("updated_at"),
|
||||
}
|
||||
|
||||
|
||||
def _build_city_detail_payload(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
distribution = data.get("probabilities", {}).get("distribution", []) or []
|
||||
primary_bucket = distribution[0] if distribution else None
|
||||
return {
|
||||
"city": data.get("name"),
|
||||
"fetched_at": data.get("updated_at"),
|
||||
"overview": {
|
||||
"name": data.get("name"),
|
||||
"display_name": data.get("display_name"),
|
||||
"icao": data.get("risk", {}).get("icao"),
|
||||
"airport": data.get("risk", {}).get("airport"),
|
||||
"lat": data.get("lat"),
|
||||
"lon": data.get("lon"),
|
||||
"local_time": data.get("local_time"),
|
||||
"local_date": data.get("local_date"),
|
||||
"temp_symbol": data.get("temp_symbol"),
|
||||
"current_temp": data.get("current", {}).get("temp"),
|
||||
"deb_prediction": data.get("deb", {}).get("prediction"),
|
||||
"risk_level": data.get("risk", {}).get("level"),
|
||||
"risk_warning": data.get("risk", {}).get("warning"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
},
|
||||
"official": {
|
||||
"available": bool(data.get("current", {}).get("temp") is not None),
|
||||
"metar": {
|
||||
"observation_time": data.get("current", {}).get("obs_time"),
|
||||
"obs_age_min": data.get("current", {}).get("obs_age_min"),
|
||||
"report_time": data.get("current", {}).get("report_time"),
|
||||
"receipt_time": data.get("current", {}).get("receipt_time"),
|
||||
"raw_metar": data.get("current", {}).get("raw_metar"),
|
||||
"current": data.get("current"),
|
||||
},
|
||||
"weather_gov": {},
|
||||
"mgm": data.get("mgm") or {},
|
||||
"mgm_nearby": data.get("mgm_nearby") or [],
|
||||
"nearby_source": "mgm" if data.get("name") == "ankara" else "metar_cluster",
|
||||
},
|
||||
"timeseries": {
|
||||
"metar_recent_obs": data.get("metar_recent_obs") or [],
|
||||
"metar_today_obs": data.get("metar_today_obs") or [],
|
||||
"hourly": data.get("hourly") or {},
|
||||
"mgm_hourly": (data.get("mgm") or {}).get("hourly", []),
|
||||
"forecast_daily": (data.get("forecast") or {}).get("daily", []),
|
||||
},
|
||||
"models": data.get("multi_model") or {},
|
||||
"probabilities": data.get("probabilities") or {"mu": None, "distribution": []},
|
||||
"market_scan": {
|
||||
"available": False,
|
||||
"reason": "Market layer is not available on the current backend build.",
|
||||
"primary_market": None,
|
||||
"selected_date": data.get("local_date"),
|
||||
"selected_condition_id": None,
|
||||
"selected_slug": None,
|
||||
"temperature_bucket": primary_bucket,
|
||||
"model_probability": (
|
||||
(primary_bucket.get("probability") / 100.0)
|
||||
if isinstance(primary_bucket, dict) and primary_bucket.get("probability") is not None
|
||||
else None
|
||||
),
|
||||
"market_price": None,
|
||||
"edge_percent": None,
|
||||
"signal_label": "MONITOR",
|
||||
"confidence": "low",
|
||||
"yes_token": None,
|
||||
"no_token": None,
|
||||
"yes_buy": None,
|
||||
"yes_sell": None,
|
||||
"no_buy": None,
|
||||
"no_sell": None,
|
||||
"last_trade_price": None,
|
||||
"liquidity": None,
|
||||
"volume": None,
|
||||
"sparkline": [p.get("probability", 0) for p in distribution[:8] if isinstance(p, dict)],
|
||||
"recent_trades": [],
|
||||
"websocket": {},
|
||||
},
|
||||
"risk": data.get("risk"),
|
||||
"ai_analysis": data.get("ai_analysis") or "",
|
||||
"errors": {},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/history/{name}")
|
||||
async def city_history(name: str):
|
||||
"""Return historical accuracy data (DEB, mu, actuals) for a city."""
|
||||
@@ -625,6 +729,20 @@ async def city_history(name: str):
|
||||
})
|
||||
return {"history": out}
|
||||
|
||||
|
||||
@app.get("/api/city/{name}/summary")
|
||||
async def city_summary(name: str, force_refresh: bool = False):
|
||||
city = _normalize_city_or_404(name)
|
||||
data = _analyze(city, force_refresh=force_refresh)
|
||||
return _build_city_summary_payload(data)
|
||||
|
||||
|
||||
@app.get("/api/city/{name}/detail")
|
||||
async def city_detail_aggregate(name: str, force_refresh: bool = False):
|
||||
city = _normalize_city_or_404(name)
|
||||
data = _analyze(city, force_refresh=force_refresh)
|
||||
return _build_city_detail_payload(data)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Entrypoint
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user