feat: Implement core PolyWeather application with city weather query service, trend analysis, data collection, and dashboard UI.

This commit is contained in:
2569718930@qq.com
2026-03-11 10:49:35 +08:00
parent d2a40462c5
commit 1958b2764b
17 changed files with 24 additions and 279 deletions
+2 -29
View File
@@ -175,7 +175,6 @@ def _append_future_forecast_lines(
mgm_daily[date_key] = day_high
mm_raw = weather_data.get("multi_model") or {}
mm_daily = mm_raw.get("daily_forecasts", {}) if isinstance(mm_raw, dict) else {}
mb_daily = (weather_data.get("meteoblue") or {}).get("daily_highs", []) or []
nws_periods = (weather_data.get("nws") or {}).get("forecast_periods", []) or []
if len(dates) > 1:
@@ -231,18 +230,6 @@ def _append_future_forecast_lines(
lines.append("📅 " + " | ".join(future))
return
if isinstance(mb_daily, list) and len(mb_daily) > 1:
future = []
for idx in range(1, min(3, len(mb_daily))):
day_temp = _sf(mb_daily[idx])
if day_temp is None:
continue
day = (local_now + timedelta(days=idx)).strftime("%m-%d")
future.append(f"{day}: MB {day_temp:.1f}{temp_symbol}")
if future:
lines.append("📅 " + " | ".join(future))
return
if isinstance(nws_periods, list) and nws_periods:
future = []
seen_days = set()
@@ -358,14 +345,13 @@ def build_city_query_report(
nws_high = _sf((weather_data.get("nws") or {}).get("today_high"))
mgm_high = _sf((mgm.get("today_high") if isinstance(mgm, dict) else None))
mb_high = _sf((weather_data.get("meteoblue") or {}).get("today_high"))
metar_max_so_far = _sf((metar.get("current") or {}).get("max_temp_so_far"))
today_t = _sf(max_temps[0]) if max_temps else None
fallback_source = None
metar_only_fallback = False
if today_t is None:
for source_name, candidate in (("MB", mb_high), ("NWS", nws_high), ("MGM", mgm_high)):
for source_name, candidate in (("NWS", nws_high), ("MGM", mgm_high)):
if candidate is not None:
today_t = candidate
fallback_source = source_name
@@ -378,11 +364,6 @@ def build_city_query_report(
sources = ["Open-Meteo"] if max_temps else []
comp_parts: List[str] = []
if mb_high is not None:
if "MB" not in sources:
sources.append("MB")
if fallback_source != "MB":
comp_parts.append(f"MB: {mb_high:.1f}{temp_symbol}")
if nws_high is not None:
if "NWS" not in sources:
sources.append("NWS")
@@ -402,18 +383,10 @@ def build_city_query_report(
if not sources:
sources = ["N/A"]
divergence_warning = ""
base_for_divergence = _sf(max_temps[0]) if max_temps else today_t
if mb_high is not None and base_for_divergence is not None:
diff = abs(mb_high - base_for_divergence)
threshold = 5.0 if city_is_fahrenheit else 2.5
if diff > threshold:
divergence_warning = f" ⚠️ <b>模型显著分歧 ({diff:.1f}{temp_symbol})</b>"
comp_str = f" ({' | '.join(comp_parts)})" if comp_parts else ""
msg_lines.append(f"\n📊 <b>预报 ({' | '.join(sources)})</b>")
msg_lines.append(
f"👉 <b>今天: {today_t_display}{temp_symbol}{comp_str}</b>{divergence_warning}"
f"👉 <b>今天: {today_t_display}{temp_symbol}{comp_str}</b>"
)
_append_future_forecast_lines(
-3
View File
@@ -62,7 +62,6 @@ def analyze_weather_trend(
metar = weather_data.get("metar", {})
open_meteo = weather_data.get("open-meteo", {})
mgm = weather_data.get("mgm") or {}
mb = weather_data.get("meteoblue", {})
nws = weather_data.get("nws", {})
empty_result = ("", "", {})
@@ -81,8 +80,6 @@ def analyze_weather_trend(
current_forecasts: Dict[str, Optional[float]] = {}
if daily.get("temperature_2m_max"):
current_forecasts["Open-Meteo"] = _sf(daily.get("temperature_2m_max")[0])
if mb.get("today_high") is not None:
current_forecasts["Meteoblue"] = _sf(mb.get("today_high"))
if nws.get("today_high") is not None:
current_forecasts["NWS"] = _sf(nws.get("today_high"))
+12 -1
View File
@@ -1263,6 +1263,7 @@ class PolymarketReadOnlyLayer:
ranked: List[
Tuple[
float,
float,
float,
Dict[str, Any],
@@ -1315,6 +1316,7 @@ class PolymarketReadOnlyLayer:
(
market_prob,
volume,
bucket_temp,
market,
yes_token,
no_token,
@@ -1331,7 +1333,16 @@ class PolymarketReadOnlyLayer:
max_items = max(1, int(limit or 4))
primary_slug = str(primary_market.get("slug") or "").strip().lower()
for market_prob, _volume, market, yes_token, no_token, yes_prices, no_prices in ranked[
for (
market_prob,
_volume,
bucket_temp,
market,
yes_token,
no_token,
yes_prices,
no_prices,
) in ranked[
:max_items
]:
yes_buy = _extract_price(yes_prices.get("buy"))
-141
View File
@@ -42,25 +42,10 @@ class WeatherDataCollector:
"munich": ["EDDM", "EDMO", "EDJA"],
}
# Meteoblue 仅在增益最大的城市启用(减少配额消耗与冗余请求)
METEOBLUE_PRIORITY_CITIES = {
"ankara",
"london",
"paris",
"seoul",
"toronto",
"buenos aires",
"wellington",
"lucknow",
"sao paulo",
"munich",
}
def __init__(self, config: dict):
self.config = config
weather_cfg = config.get("weather", {})
self.wunderground_key = weather_cfg.get("wunderground_api_key")
self.meteoblue_key = weather_cfg.get("meteoblue_api_key")
self.timeout = 30 # 增加超时以支持高延迟 VPS
self.session = requests.Session()
@@ -91,11 +76,6 @@ class WeatherDataCollector:
)
self._open_meteo_last_call_ts: float = 0.0
self._open_meteo_call_lock = threading.Lock()
self.meteoblue_cache_ttl_sec = int(
os.getenv("METEOBLUE_CACHE_TTL_SEC", "7200")
)
self._meteoblue_cache: Dict[str, Dict] = {}
self._meteoblue_cache_lock = threading.Lock()
self.metar_cache_ttl_sec = int(
os.getenv("METAR_CACHE_TTL_SEC", "600") # 默认 10 分钟
)
@@ -1558,105 +1538,6 @@ class WeatherDataCollector:
return fallback
return None
def fetch_from_meteoblue(
self,
lat: float,
lon: float,
timezone_name: str = "UTC",
use_fahrenheit: bool = False,
) -> Optional[Dict]:
"""
通过 Meteoblue 官方 API 获取高精度预测数据
带本地缓存,避免频繁请求触发 429。
"""
if not self.meteoblue_key:
logger.warning("Meteoblue API Key 未配置,跳过抓取。")
return None
cache_key = f"{round(float(lat), 4)}:{round(float(lon), 4)}:{'f' if use_fahrenheit else 'c'}"
now_ts = time.time()
with self._meteoblue_cache_lock:
cached = self._meteoblue_cache.get(cache_key)
if (
cached
and now_ts - float(cached.get("t", 0)) < self.meteoblue_cache_ttl_sec
):
cached_data = cached.get("data")
if isinstance(cached_data, dict):
return dict(cached_data)
try:
# 1. 调用官方 API (使用 basic-day 包,它是多模型 ML 融合结果)
# 格式: https://my.meteoblue.com/packages/basic-day?apikey=KEY&lat=LAT&lon=LON&format=json
url = "https://my.meteoblue.com/packages/basic-day"
params = {
"apikey": self.meteoblue_key,
"lat": lat,
"lon": lon,
"format": "json",
"as_daylight": "true",
}
response = self.session.get(url, params=params, timeout=self.timeout)
response.raise_for_status()
data = response.json()
day_data = data.get("data_day", {})
max_temps = day_data.get("temperature_max", [])
if not max_temps:
logger.warning(
f"Meteoblue API 返回数据中找不到最高温 (坐标: {lat},{lon})"
)
return None
# 2. 转换单位
def c_to_f(c):
return round((c * 9 / 5) + 32, 1)
result = {
"source": "meteoblue",
"today_high": None,
"daily_highs": [],
"unit": "fahrenheit" if use_fahrenheit else "celsius",
"url": f"https://www.meteoblue.com/en/weather/week/{lat}N{lon}E", # 仅供参考
}
# 提取今日最高
mb_today_c = max_temps[0]
result["today_high"] = c_to_f(mb_today_c) if use_fahrenheit else mb_today_c
# 提取接下来几天的最高温
if use_fahrenheit:
result["daily_highs"] = [c_to_f(t) for t in max_temps]
else:
result["daily_highs"] = max_temps
with self._meteoblue_cache_lock:
self._meteoblue_cache[cache_key] = {
"t": now_ts,
"data": dict(result),
}
logger.info(
f"✅ Meteoblue API 获取成功 ({lat},{lon}): 今天 {result['today_high']}{result['unit']}"
)
return result
except Exception as e:
status_code = getattr(getattr(e, "response", None), "status_code", None)
if status_code == 429:
logger.warning("Meteoblue API 限流(429),尝试使用本地缓存回退。")
else:
logger.error(f"Meteoblue API fetch failed: {e}")
with self._meteoblue_cache_lock:
stale = self._meteoblue_cache.get(cache_key)
if stale and isinstance(stale.get("data"), dict):
fallback = dict(stale["data"])
fallback["stale_cache"] = True
return fallback
return None
def extract_date_from_title(self, title: str) -> Optional[str]:
"""
从标题中提取日期并标准化为 YYYY-MM-DD
@@ -1836,7 +1717,6 @@ class WeatherDataCollector:
unit = "f" if use_fahrenheit else "c"
open_meteo_key = f"{base}:14:{unit}"
ensemble_key = f"{base}:{unit}"
meteoblue_key = ensemble_key
multi_model_key = ensemble_key
with self._open_meteo_cache_lock:
@@ -1845,8 +1725,6 @@ class WeatherDataCollector:
self._ensemble_cache.pop(ensemble_key, None)
with self._multi_model_cache_lock:
self._multi_model_cache.pop(multi_model_key, None)
with self._meteoblue_cache_lock:
self._meteoblue_cache.pop(meteoblue_key, None)
icao = self.get_icao_code(city)
if icao:
@@ -1962,16 +1840,6 @@ class WeatherDataCollector:
# 获取时区偏移以过滤 METAR
utc_offset = open_meteo.get("utc_offset", 0)
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:
nws_data = self.fetch_nws(lat, lon)
@@ -2023,15 +1891,6 @@ class WeatherDataCollector:
if cluster_data:
results["mgm_nearby"] = cluster_data
if city_lower in self.METEOBLUE_PRIORITY_CITIES:
mb_data = self.fetch_from_meteoblue(
lat,
lon,
timezone_name="UTC",
use_fahrenheit=use_fahrenheit,
)
if mb_data:
results["meteoblue"] = mb_data
if use_fahrenheit:
nws_data = self.fetch_nws(lat, lon)
if nws_data:
-1
View File
@@ -19,7 +19,6 @@ def load_config():
"openweather_api_key": get_env_or_none("OPENWEATHER_API_KEY"),
"wunderground_api_key": get_env_or_none("WUNDERGROUND_API_KEY"),
"visualcrossing_api_key": get_env_or_none("VISUALCROSSING_API_KEY"),
"meteoblue_api_key": get_env_or_none("METEOBLUE_API_KEY"),
"proxy": os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY"),
},
"telegram": {