feat: Implement multi-source weather data collection with caching, rate limiting, and disk persistence.

This commit is contained in:
2569718930@qq.com
2026-03-20 13:35:34 +08:00
parent da9b0b36f7
commit dbf10253a8
7 changed files with 239 additions and 91 deletions
+93 -72
View File
@@ -1,76 +1,97 @@
# Weather API Configuration
weather: weather:
timeout: 30 timeout: 30
# Target Cities
cities: cities:
- id: "london" - id: london
city: "London" city: London
country: "UK" country: UK
latitude: 51.5074 latitude: 51.5074
longitude: -0.1278 longitude: -0.1278
- id: "paris" - id: paris
city: "Paris" city: Paris
country: "France" country: France
latitude: 48.8566 latitude: 48.8566
longitude: 2.3522 longitude: 2.3522
- id: "ankara" - id: ankara
city: "Ankara" city: Ankara
country: "Turkey" country: Turkey
latitude: 39.9334 latitude: 39.9334
longitude: 32.8597 longitude: 32.8597
- id: "new_york" - id: new_york
city: "New York" city: New York
country: "USA" country: USA
latitude: 40.7128 latitude: 40.7128
longitude: -74.0060 longitude: -74.006
- id: "chicago" - id: chicago
city: "Chicago" city: Chicago
country: "USA" country: USA
latitude: 41.8781 latitude: 41.8781
longitude: -87.6298 longitude: -87.6298
- id: "lucknow" - id: lucknow
city: "Lucknow" city: Lucknow
country: "India" country: India
latitude: 26.7606 latitude: 26.7606
longitude: 80.8893 longitude: 80.8893
- id: "sao paulo" - id: sao paulo
city: "São Paulo" city: São Paulo
country: "Brazil" country: Brazil
latitude: -23.4356 latitude: -23.4356
longitude: -46.4731 longitude: -46.4731
- id: "munich" - id: munich
city: "Munich" city: Munich
country: "Germany" country: Germany
latitude: 48.3538 latitude: 48.3538
longitude: 11.7861 longitude: 11.7861
- id: "hong_kong" - id: hong_kong
city: "Hong Kong" city: Hong Kong
country: "China" country: China
latitude: 22.3080 latitude: 22.308
longitude: 113.9185 longitude: 113.9185
- id: "shanghai" - id: shanghai
city: "Shanghai" city: Shanghai
country: "China" country: China
latitude: 31.1434 latitude: 31.1434
longitude: 121.8052 longitude: 121.8052
- id: "singapore" - id: singapore
city: "Singapore" city: Singapore
country: "Singapore" country: Singapore
latitude: 1.3644 latitude: 1.3644
longitude: 103.9915 longitude: 103.9915
- id: "tokyo" - id: tokyo
city: "Tokyo" city: Tokyo
country: "Japan" country: Japan
latitude: 35.5523 latitude: 35.5523
longitude: 139.7798 longitude: 139.7798
- id: "tel_aviv" - id: tel_aviv
city: "Tel Aviv" city: Tel Aviv
country: "Israel" country: Israel
latitude: 32.0114 latitude: 32.0114
longitude: 34.8867 longitude: 34.8867
# Logging - id: chengdu
city: Chengdu
country: China
latitude: 30.5785
longitude: 103.9471
- id: chongqing
city: Chongqing
country: China
latitude: 29.7196
longitude: 106.6416
- id: shenzhen
city: Shenzhen
country: China
latitude: 22.6393
longitude: 113.8107
- id: beijing
city: Beijing
country: China
latitude: 40.0801
longitude: 116.5846
- id: wuhan
city: Wuhan
country: China
latitude: 30.7838
longitude: 114.2081
logging: logging:
level: "INFO" level: INFO
rotation: "10 MB" rotation: 10 MB
retention: "10 days" retention: 10 days
+5 -5
View File
@@ -2,7 +2,7 @@ import os
import json import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
import requests import requests
from src.analysis.settlement_rounding import wu_round from src.analysis.settlement_rounding import wu_round, apply_city_settlement, is_exact_settlement_city
# Cross-platform file locking # Cross-platform file locking
import sys import sys
@@ -442,8 +442,8 @@ def get_deb_accuracy(city_name):
continue continue
total += 1 total += 1
deb_wu = wu_round(deb_pred) deb_wu = apply_city_settlement(city_name, deb_pred)
actual_wu = wu_round(actual) actual_wu = apply_city_settlement(city_name, actual)
if deb_wu == actual_wu: if deb_wu == actual_wu:
hits += 1 hits += 1
errors.append(abs(deb_pred - actual)) errors.append(abs(deb_pred - actual))
@@ -507,13 +507,13 @@ def get_mu_accuracy(city_name):
total += 1 total += 1
mu_errors.append(abs(mu_val - actual)) mu_errors.append(abs(mu_val - actual))
if wu_round(mu_val) == wu_round(actual): if apply_city_settlement(city_name, mu_val) == apply_city_settlement(city_name, actual):
mu_hits += 1 mu_hits += 1
# Brier Score from probability snapshot # Brier Score from probability snapshot
prob_snap = record.get("prob_snapshot", []) prob_snap = record.get("prob_snapshot", [])
if prob_snap: if prob_snap:
actual_wu = wu_round(actual) actual_wu = apply_city_settlement(city_name, actual)
bs = 0.0 bs = 0.0
for entry in prob_snap: for entry in prob_snap:
predicted_p = entry.get("p", 0) predicted_p = entry.get("p", 0)
+2 -2
View File
@@ -9,7 +9,7 @@ import re
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from src.analysis.settlement_rounding import wu_round from src.analysis.settlement_rounding import wu_round, apply_city_settlement, is_exact_settlement_city
def _sf(v: Any) -> Optional[float]: def _sf(v: Any) -> Optional[float]:
@@ -716,7 +716,7 @@ def _extract_market_snapshot(city_weather: Dict[str, Any]) -> Dict[str, Any]:
market_url = f"https://polymarket.com/market/{slug}" market_url = f"https://polymarket.com/market/{slug}"
anchor_today_high_c, anchor_model = _extract_multi_model_anchor_high_c(city_weather) anchor_today_high_c, anchor_model = _extract_multi_model_anchor_high_c(city_weather)
anchor_settlement = wu_round(anchor_today_high_c) anchor_settlement = apply_city_settlement(city, anchor_today_high_c)
forecast_bucket = _pick_bucket_for_forecast( forecast_bucket = _pick_bucket_for_forecast(
rows=all_bucket_rows, rows=all_bucket_rows,
forecast_settlement=anchor_settlement, forecast_settlement=anchor_settlement,
+21
View File
@@ -18,3 +18,24 @@ def wu_round(value: Optional[Number]) -> Optional[int]:
return int(math.floor(x + 0.5)) return int(math.floor(x + 0.5))
return int(math.ceil(x - 0.5)) return int(math.ceil(x - 0.5))
def is_exact_settlement_city(city: str) -> bool:
"""是否为不四舍五入的精确结算城市"""
if not city:
return False
c = str(city).lower().strip()
return c in ["hong kong", "hk", "taipei", "tpe", "臺北", "台北", "香港"]
def apply_city_settlement(city: str, value: Optional[Number]) -> Optional[int]:
"""
根据城市返回最终的结算值
- 香港/台北: 向下取整 (e.g. 28.9 -> 28)
- 其他: WU 规则四舍五入
"""
if value is None:
return None
if is_exact_settlement_city(city):
return int(math.floor(float(value)))
return wu_round(value)
+22 -12
View File
@@ -15,7 +15,7 @@ from src.analysis.deb_algorithm import (
update_daily_record, update_daily_record,
_is_excluded_model_name, _is_excluded_model_name,
) )
from src.analysis.settlement_rounding import wu_round from src.analysis.settlement_rounding import wu_round, apply_city_settlement, is_exact_settlement_city
from src.data_collection.city_registry import CITY_REGISTRY from src.data_collection.city_registry import CITY_REGISTRY
from src.data_collection.city_risk_profiles import get_city_risk_profile from src.data_collection.city_risk_profiles import get_city_risk_profile
@@ -429,7 +429,7 @@ def analyze_weather_trend(
forecast_miss_deg = 0.0 forecast_miss_deg = 0.0
if is_dead_market: if is_dead_market:
settled_wu = wu_round(max_so_far) if max_so_far is not None else 0 settled_wu = apply_city_settlement(city_name, max_so_far) if max_so_far is not None else 0
dead_msg = ( dead_msg = (
f"🎲 <b>结算预测</b>:已锁定 {settled_wu}{temp_symbol} " f"🎲 <b>结算预测</b>:已锁定 {settled_wu}{temp_symbol} "
f"({settlement_source_label} 死盘确认)" f"({settlement_source_label} 死盘确认)"
@@ -481,7 +481,7 @@ def analyze_weather_trend(
# Probability Engine # Probability Engine
probs_result = calculate_prob_distribution( probs_result = calculate_prob_distribution(
mu, sigma, max_so_far, temp_symbol mu, sigma, max_so_far, temp_symbol, city_name
) )
mu = probs_result.get("mu", mu) mu = probs_result.get("mu", mu)
probabilities = probs_result.get("probabilities", []) probabilities = probs_result.get("probabilities", [])
@@ -514,7 +514,7 @@ def analyze_weather_trend(
# === Settlement boundary === # === Settlement boundary ===
if max_so_far is not None: if max_so_far is not None:
settled = wu_round(max_so_far) settled = apply_city_settlement(city_name, max_so_far)
fractional = max_so_far - int(max_so_far) fractional = max_so_far - int(max_so_far)
dist_to_boundary = abs(fractional - 0.5) dist_to_boundary = abs(fractional - 0.5)
if dist_to_boundary <= 0.3: if dist_to_boundary <= 0.3:
@@ -585,7 +585,7 @@ def analyze_weather_trend(
if max_so_far is not None: if max_so_far is not None:
ai_features.append( ai_features.append(
f"🏔️ 今日实测最高温: {max_so_far}{temp_symbol} " f"🏔️ 今日实测最高温: {max_so_far}{temp_symbol} "
f"({settlement_source_label}结算={wu_round(max_so_far)}{temp_symbol})。" f"({settlement_source_label}结算={apply_city_settlement(city_name, max_so_far)}{temp_symbol})。"
) )
if city_name: if city_name:
_profile = get_city_risk_profile(city_name) _profile = get_city_risk_profile(city_name)
@@ -634,7 +634,7 @@ def analyze_weather_trend(
for t, p in sorted_probs[:4] for t, p in sorted_probs[:4]
] ]
elif is_dead_market and max_so_far is not None: elif is_dead_market and max_so_far is not None:
_prob_list = [{"value": wu_round(max_so_far), "probability": 1.0}] _prob_list = [{"value": apply_city_settlement(city_name, max_so_far), "probability": 1.0}]
update_daily_record( update_daily_record(
city_name, city_name,
@@ -672,14 +672,14 @@ def analyze_weather_trend(
"forecast_miss_deg": forecast_miss_deg, "forecast_miss_deg": forecast_miss_deg,
"max_so_far": max_so_far, "max_so_far": max_so_far,
"cur_temp": cur_temp, "cur_temp": cur_temp,
"wu_settle": wu_round(max_so_far) if max_so_far is not None else None, "wu_settle": apply_city_settlement(city_name, max_so_far) if max_so_far is not None else None,
} }
display_str = "\n".join(insights) if insights else "" display_str = "\n".join(insights) if insights else ""
return display_str, "\n".join(ai_features), structured return display_str, "\n".join(ai_features), structured
def calculate_prob_distribution( def calculate_prob_distribution(
mu: float, sigma: float, max_so_far: Optional[float], temp_symbol: str mu: float, sigma: float, max_so_far: Optional[float], temp_symbol: str, city_name: str = ""
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Generalized Gaussian probability distribution calculation. Generalized Gaussian probability distribution calculation.
@@ -691,17 +691,26 @@ def calculate_prob_distribution(
# 0.5 * (1 + erf( (x-m)/(s*sqrt(2)) )) # 0.5 * (1 + erf( (x-m)/(s*sqrt(2)) ))
return 0.5 * (1 + math.erf((x - m) / (sigma * math.sqrt(2)))) return 0.5 * (1 + math.erf((x - m) / (sigma * math.sqrt(2))))
min_possible_wu = wu_round(max_so_far) if max_so_far is not None else -999 min_possible_wu = apply_city_settlement(city_name, max_so_far) if max_so_far is not None else -999
probs = {} probs = {}
# Range: mu +/- 3 sigma or at least +/- 2 degrees # Range: mu +/- 3 sigma or at least +/- 2 degrees
search_range = max(2, int(sigma * 2.5)) search_range = max(2, int(sigma * 2.5))
target_mu = wu_round(mu) is_exact = is_exact_settlement_city(city_name)
target_mu = apply_city_settlement(city_name, mu)
if is_exact:
target_mu = int(math.floor(mu))
for n in range(target_mu - search_range, target_mu + search_range + 1): for n in range(target_mu - search_range, target_mu + search_range + 1):
if n < min_possible_wu: if n < min_possible_wu:
continue continue
p = _norm_cdf(n + 0.5, mu, sigma) - _norm_cdf(n - 0.5, mu, sigma) if is_exact:
# 向下取整的概率区间为 [n, n + 1)
p = _norm_cdf(n + 1.0, mu, sigma) - _norm_cdf(n, mu, sigma)
else:
# 常规四舍五入的概率区间为 [n - 0.5, n + 0.5)
p = _norm_cdf(n + 0.5, mu, sigma) - _norm_cdf(n - 0.5, mu, sigma)
if p > 0.01: if p > 0.01:
probs[n] = p probs[n] = p
@@ -713,9 +722,10 @@ def calculate_prob_distribution(
norm_probs = {k: v / total_p for k, v in probs.items()} norm_probs = {k: v / total_p for k, v in probs.items()}
sorted_probs = sorted(norm_probs.items(), key=lambda x: x[1], reverse=True) sorted_probs = sorted(norm_probs.items(), key=lambda x: x[1], reverse=True)
for t, p in sorted_probs[:4]: for t, p in sorted_probs[:4]:
rng_str = f"[{t}.0~{t+1}.0)" if is_exact else f"[{t-0.5}~{t+0.5})"
probabilities.append({ probabilities.append({
"value": int(t), "value": int(t),
"range": f"[{t-0.5}~{t+0.5})", "range": rng_str,
"probability": round(p, 3) "probability": round(p, 3)
}) })
+81
View File
@@ -357,6 +357,77 @@ CITY_REGISTRY = {
"distance_km": 13.0, "distance_km": 13.0,
"warning": "机场位于东北侧开阔区,午后混合层增强时与核心城区体感温度可能出现偏差。", "warning": "机场位于东北侧开阔区,午后混合层增强时与核心城区体感温度可能出现偏差。",
}, },
"chengdu": {
"name": "Chengdu",
"lat": 30.5785,
"lon": 103.9471,
"icao": "ZUUU",
"tz_offset": 28800,
"use_fahrenheit": False,
"is_major": True,
"risk_level": "medium",
"risk_emoji": "🟡",
"airport_name": "成都双流国际机场",
"distance_km": 16.0,
"warning": "盆地地形,夜间降温较慢,多云雾影响日照升温。",
},
"chongqing": {
"name": "Chongqing",
"lat": 29.7196,
"lon": 106.6416,
"icao": "ZUCK",
"tz_offset": 28800,
"use_fahrenheit": False,
"is_major": True,
"risk_level": "high",
"risk_emoji": "🔴",
"airport_name": "重庆江北国际机场",
"distance_km": 19.0,
"warning": "四大火炉之一,夏季持续高温,夜间温度偏高,湿度大。",
},
"shenzhen": {
"name": "Shenzhen",
"lat": 22.6393,
"lon": 113.8107,
"icao": "ZGSZ",
"tz_offset": 28800,
"use_fahrenheit": False,
"is_major": True,
"risk_level": "medium",
"risk_emoji": "🟡",
"airport_name": "深圳宝安国际机场",
"distance_km": 32.0,
"warning": "近海受海洋调节,热岛效应强,日夜温差较小。",
},
"beijing": {
"name": "Beijing",
"lat": 40.0801,
"lon": 116.5846,
"icao": "ZBAA",
"tz_offset": 28800,
"use_fahrenheit": False,
"is_major": True,
"risk_level": "medium",
"risk_emoji": "🟡",
"airport_name": "北京首都国际机场",
"distance_km": 25.0,
"warning": "北方内陆,夏季干热,冬季寒冷,春秋易受风沙影响。",
},
"wuhan": {
"name": "Wuhan",
"lat": 30.7838,
"lon": 114.2081,
"icao": "ZHHH",
"tz_offset": 28800,
"use_fahrenheit": False,
"is_major": True,
"risk_level": "high",
"risk_emoji": "🔴",
"airport_name": "武汉天河国际机场",
"distance_km": 26.0,
"warning": "江汉平原,水汽充足,夏季常现长时闷热高温。",
},
} }
ALIASES = { ALIASES = {
@@ -406,4 +477,14 @@ ALIASES = {
"華沙": "warsaw", "華沙": "warsaw",
"马德里": "madrid", "马德里": "madrid",
"馬德里": "madrid", "馬德里": "madrid",
"ctu": "chengdu", "cd": "chengdu",
"ckg": "chongqing", "cq": "chongqing",
"szx": "shenzhen", "sz": "shenzhen",
"pek": "beijing", "bjs": "beijing", "bj": "beijing",
"wuh": "wuhan", "wh": "wuhan",
"成都": "chengdu",
"重庆": "chongqing",
"深圳": "shenzhen",
"北京": "beijing",
"武汉": "wuhan",
} }
+15
View File
@@ -34,6 +34,11 @@ class WeatherDataCollector:
"seoul": ["RKSI", "RKSS"], "seoul": ["RKSI", "RKSS"],
"hong kong": ["VHHH", "VMMC", "ZGSZ"], "hong kong": ["VHHH", "VMMC", "ZGSZ"],
"taipei": ["RCSS", "RCTP"], "taipei": ["RCSS", "RCTP"],
"chengdu": ["ZUUU", "ZUTF"],
"chongqing": ["ZUCK", "ZUPS"],
"shenzhen": ["ZGSZ", "ZGGG"],
"beijing": ["ZBAA", "ZBAD"],
"wuhan": ["ZHHH", "ZHES"],
"shanghai": ["ZSPD", "ZSSS", "ZSNB", "ZSHC"], "shanghai": ["ZSPD", "ZSSS", "ZSNB", "ZSHC"],
"singapore": ["WSSS", "WSAP", "WMKK"], "singapore": ["WSSS", "WSAP", "WMKK"],
"tokyo": ["RJTT", "RJAA", "RJAH", "RJTJ"], "tokyo": ["RJTT", "RJAA", "RJAH", "RJTJ"],
@@ -1960,6 +1965,16 @@ class WeatherDataCollector:
"taipei": "Taipei", "taipei": "Taipei",
"台北": "Taipei", "台北": "Taipei",
"臺北": "Taipei", "臺北": "Taipei",
"chengdu": "Chengdu",
"成都": "Chengdu",
"chongqing": "Chongqing",
"重庆": "Chongqing",
"shenzhen": "Shenzhen",
"深圳": "Shenzhen",
"beijing": "Beijing",
"北京": "Beijing",
"wuhan": "Wuhan",
"武汉": "Wuhan",
"shanghai": "Shanghai", "shanghai": "Shanghai",
"上海": "Shanghai", "上海": "Shanghai",
"singapore": "Singapore", "singapore": "Singapore",