feat: Implement the PolyWeather dashboard including frontend components, data collection, analysis, and API endpoints.

This commit is contained in:
2569718930@qq.com
2026-03-10 04:45:40 +08:00
parent 020c62676e
commit aab4477ab3
24 changed files with 2835 additions and 524 deletions
+40 -20
View File
@@ -1,6 +1,7 @@
import os
import json
from datetime import datetime, timedelta
from src.analysis.settlement_rounding import wu_round
# Cross-platform file locking
import sys
@@ -99,27 +100,39 @@ def update_daily_record(
if date_str not in data[city_name]:
data[city_name][date_str] = {}
# 避免无意义的频繁磁盘写入
old_actual = data[city_name][date_str].get("actual_high")
if (
old_actual == actual_high
and data[city_name][date_str].get("forecasts") == forecasts
):
return
data[city_name][date_str]["forecasts"] = forecasts
data[city_name][date_str]["actual_high"] = actual_high
if deb_prediction is not None:
data[city_name][date_str]["deb_prediction"] = deb_prediction
if mu is not None:
data[city_name][date_str]["mu"] = round(mu, 2)
compact_probs = None
if probabilities is not None:
# Store compact: [{"v": 25, "p": 0.8}, ...]
data[city_name][date_str]["prob_snapshot"] = [
compact_probs = [
{"v": p["value"], "p": p["probability"]}
for p in probabilities[:4]
]
# 避免无意义的频繁磁盘写入
existing = data[city_name][date_str]
old_actual = existing.get("actual_high")
old_deb = existing.get("deb_prediction")
old_mu = existing.get("mu")
old_probs = existing.get("prob_snapshot")
next_mu = round(mu, 2) if mu is not None else None
if (
old_actual == actual_high
and existing.get("forecasts") == forecasts
and (deb_prediction is None or old_deb == deb_prediction)
and (mu is None or old_mu == next_mu)
and (compact_probs is None or old_probs == compact_probs)
):
return
existing["forecasts"] = forecasts
existing["actual_high"] = actual_high
if deb_prediction is not None:
existing["deb_prediction"] = deb_prediction
if mu is not None:
existing["mu"] = next_mu
if probabilities is not None:
existing["prob_snapshot"] = compact_probs
# 自动清理:只保留最近 14 天的记录(DEB 只用 7 天,14 天留足余量)
cutoff = (datetime.now() - timedelta(days=14)).strftime("%Y-%m-%d")
for city in list(data.keys()):
@@ -173,7 +186,12 @@ def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7):
for model in current_forecasts.keys():
if model in past_forecasts and past_forecasts[model] is not None:
errors[model].append(abs(past_forecasts[model] - actual))
try:
pv = float(past_forecasts[model])
av = float(actual)
except (TypeError, ValueError):
continue
errors[model].append(abs(pv - av))
days_used += 1
if days_used >= lookback_days:
@@ -182,6 +200,8 @@ def calculate_dynamic_weights(city_name, current_forecasts, lookback_days=7):
# 如果有效历史天数 < 2 天,还是使用等权
if days_used < 2:
valid_vals = [v for v in current_forecasts.values() if v is not None]
if not valid_vals:
return None, f"暂无有效模型数据(由于仅{days_used}天历史)"
avg = sum(valid_vals) / len(valid_vals)
return round(avg, 1), f"等权平均(由于仅{days_used}天历史)"
@@ -263,8 +283,8 @@ def get_deb_accuracy(city_name):
continue
total += 1
deb_wu = round(deb_pred)
actual_wu = round(actual)
deb_wu = wu_round(deb_pred)
actual_wu = wu_round(actual)
if deb_wu == actual_wu:
hits += 1
errors.append(abs(deb_pred - actual))
@@ -328,13 +348,13 @@ def get_mu_accuracy(city_name):
total += 1
mu_errors.append(abs(mu_val - actual))
if round(mu_val) == round(actual):
if wu_round(mu_val) == wu_round(actual):
mu_hits += 1
# Brier Score from probability snapshot
prob_snap = record.get("prob_snapshot", [])
if prob_snap:
actual_wu = round(actual)
actual_wu = wu_round(actual)
bs = 0.0
for entry in prob_snap:
predicted_p = entry.get("p", 0)
+20
View File
@@ -0,0 +1,20 @@
import math
from typing import Optional, Union
Number = Union[int, float]
def wu_round(value: Optional[Number]) -> Optional[int]:
"""
WU 结算口径四舍五入(0.5 一律进位):
- 正数: floor(x + 0.5)
- 负数: ceil(x - 0.5)
"""
if value is None:
return None
x = float(value)
if x >= 0:
return int(math.floor(x + 0.5))
return int(math.ceil(x - 0.5))
+8 -7
View File
@@ -13,6 +13,7 @@ from src.analysis.deb_algorithm import (
get_deb_accuracy,
update_daily_record,
)
from src.analysis.settlement_rounding import wu_round
from src.data_collection.city_risk_profiles import get_city_risk_profile
@@ -348,7 +349,7 @@ def analyze_weather_trend(
ai_features.append(f"🎲 数学概率分布:{prob_str}")
elif is_dead_market:
settled_wu = round(max_so_far) if max_so_far is not None else 0
settled_wu = wu_round(max_so_far) if max_so_far is not None else 0
dead_msg = f"🎲 <b>结算预测</b>:已锁定 {settled_wu}{temp_symbol} (死盘确认)"
insights.append(dead_msg)
ai_features.append("🎲 状态: 确认死盘,结算已无悬念。")
@@ -375,7 +376,7 @@ def analyze_weather_trend(
# === Settlement boundary ===
if max_so_far is not None:
settled = round(max_so_far)
settled = wu_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:
@@ -437,7 +438,7 @@ def analyze_weather_trend(
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})。"
f"🏔️ 今日实测最高温: {max_so_far}{temp_symbol} (WU结算={wu_round(max_so_far)}{temp_symbol})。"
)
if city_name:
_profile = get_city_risk_profile(city_name)
@@ -486,7 +487,7 @@ def analyze_weather_trend(
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}]
_prob_list = [{"value": wu_round(max_so_far), "probability": 1.0}]
update_daily_record(
city_name,
@@ -524,7 +525,7 @@ def analyze_weather_trend(
"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,
"wu_settle": wu_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
@@ -543,12 +544,12 @@ def calculate_prob_distribution(
# 0.5 * (1 + erf( (x-m)/(s*sqrt(2)) ))
return 0.5 * (1 + math.erf((x - m) / (sigma * math.sqrt(2))))
min_possible_wu = round(max_so_far) if max_so_far is not None else -999
min_possible_wu = wu_round(max_so_far) if max_so_far is not None else -999
probs = {}
# Range: mu +/- 3 sigma or at least +/- 2 degrees
search_range = max(2, int(sigma * 2.5))
target_mu = round(mu)
target_mu = wu_round(mu)
for n in range(target_mu - search_range, target_mu + search_range + 1):
if n < min_possible_wu:
File diff suppressed because it is too large Load Diff
+22 -8
View File
@@ -40,6 +40,19 @@ class WeatherDataCollector:
"munich": ["EDDM", "EDMO", "EDJA"],
}
# Meteoblue 仅在增益最大的城市启用(减少配额消耗与冗余请求)
METEOBLUE_PRIORITY_CITIES = {
"london",
"paris",
"seoul",
"toronto",
"buenos aires",
"wellington",
"lucknow",
"sao paulo",
"munich",
}
def __init__(self, config: dict):
self.config = config
weather_cfg = config.get("weather", {})
@@ -1503,14 +1516,15 @@ class WeatherDataCollector:
# 获取时区偏移以过滤 METAR
utc_offset = open_meteo.get("utc_offset", 0)
mb_data = self.fetch_from_meteoblue(
lat,
lon,
timezone_name=open_meteo.get("timezone", "UTC"),
use_fahrenheit=use_fahrenheit,
)
if mb_data:
results["meteoblue"] = mb_data
if city_lower in self.METEOBLUE_PRIORITY_CITIES:
mb_data = self.fetch_from_meteoblue(
lat,
lon,
timezone_name=open_meteo.get("timezone", "UTC"),
use_fahrenheit=use_fahrenheit,
)
if mb_data:
results["meteoblue"] = mb_data
# 对美国城市,额外获取 NWS 高精预报
if use_fahrenheit: