feat: Add initial web dashboard and centralize weather trend analysis logic into a shared engine.
This commit is contained in:
+2
-2
@@ -44,8 +44,8 @@ cities:
|
|||||||
- id: hong_kong
|
- id: hong_kong
|
||||||
city: Hong Kong
|
city: Hong Kong
|
||||||
country: China
|
country: China
|
||||||
latitude: 22.308
|
latitude: 22.3019
|
||||||
longitude: 113.9185
|
longitude: 114.1742
|
||||||
- id: shanghai
|
- id: shanghai
|
||||||
city: Shanghai
|
city: Shanghai
|
||||||
country: China
|
country: China
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from src.analysis.metar_narrator import describe_metar_report
|
|||||||
from src.analysis.trend_engine import analyze_weather_trend
|
from src.analysis.trend_engine import analyze_weather_trend
|
||||||
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
|
from src.data_collection.city_registry import ALIASES, 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
|
||||||
|
from src.analysis.settlement_rounding import apply_city_settlement
|
||||||
|
|
||||||
|
|
||||||
FAHRENHEIT_CITIES = {
|
FAHRENHEIT_CITIES = {
|
||||||
@@ -505,7 +506,7 @@ def build_city_query_report(
|
|||||||
|
|
||||||
max_str = ""
|
max_str = ""
|
||||||
if max_p is not None:
|
if max_p is not None:
|
||||||
settled_val = int(max_p + 0.5)
|
settled_val = apply_city_settlement(city_name.lower(), max_p)
|
||||||
max_str = f" (最高: {max_p}{temp_symbol}"
|
max_str = f" (最高: {max_p}{temp_symbol}"
|
||||||
if max_p_time:
|
if max_p_time:
|
||||||
max_str += f" @{max_p_time}"
|
max_str += f" @{max_p_time}"
|
||||||
@@ -520,12 +521,19 @@ def build_city_query_report(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if use_settlement_current:
|
if use_settlement_current:
|
||||||
|
# HKO/CWA detailed observations
|
||||||
wind = primary_current.get("wind_speed_kt")
|
wind = primary_current.get("wind_speed_kt")
|
||||||
wind_dir = primary_current.get("wind_dir")
|
wind_dir = primary_current.get("wind_dir")
|
||||||
humidity = primary_current.get("humidity")
|
humidity = primary_current.get("humidity")
|
||||||
msg_lines.append(
|
msg_lines.append(
|
||||||
f" [{settlement_source_label}] 🌪 {wind or 0}kt ({wind_dir or 0}°) | 💧 湿度: {humidity or 'N/A'}%"
|
f" [{settlement_source_label}] 🌪 {wind or 0}kt ({wind_dir or 0}°) | 💧 湿度: {humidity or 'N/A'}%"
|
||||||
)
|
)
|
||||||
|
# Secondary METAR info if available for context
|
||||||
|
if metar:
|
||||||
|
m_wind = metar_current.get("wind_speed_kt")
|
||||||
|
m_dir = metar_current.get("wind_dir")
|
||||||
|
m_vis = metar_current.get("visibility_mi")
|
||||||
|
msg_lines.append(f" [METAR] 🌪 {m_wind or 0}kt ({m_dir or 0}°) | 👁️ {m_vis or 10}mi")
|
||||||
elif metar:
|
elif metar:
|
||||||
wind = metar_current.get("wind_speed_kt")
|
wind = metar_current.get("wind_speed_kt")
|
||||||
wind_dir = metar_current.get("wind_dir")
|
wind_dir = metar_current.get("wind_dir")
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ def analyze_weather_trend(
|
|||||||
if mgm and mgm.get("today_high") is not None:
|
if mgm and mgm.get("today_high") is not None:
|
||||||
current_forecasts["MGM"] = _sf(mgm.get("today_high"))
|
current_forecasts["MGM"] = _sf(mgm.get("today_high"))
|
||||||
|
|
||||||
|
if weather_data.get("hko_forecast") is not None:
|
||||||
|
current_forecasts["HKO(港天文)"] = _sf(weather_data.get("hko_forecast"))
|
||||||
|
if weather_data.get("cwa_forecast") is not None:
|
||||||
|
current_forecasts["CWA(台气象)"] = _sf(weather_data.get("cwa_forecast"))
|
||||||
|
|
||||||
mm_forecasts = weather_data.get("multi_model", {}).get("forecasts", {})
|
mm_forecasts = weather_data.get("multi_model", {}).get("forecasts", {})
|
||||||
for m_name, m_val in mm_forecasts.items():
|
for m_name, m_val in mm_forecasts.items():
|
||||||
if m_val is not None and not _is_excluded_model_name(m_name):
|
if m_val is not None and not _is_excluded_model_name(m_name):
|
||||||
@@ -515,23 +520,40 @@ def analyze_weather_trend(
|
|||||||
# === Settlement boundary ===
|
# === Settlement boundary ===
|
||||||
if max_so_far is not None:
|
if max_so_far is not None:
|
||||||
settled = apply_city_settlement(city_name, max_so_far)
|
settled = apply_city_settlement(city_name, max_so_far)
|
||||||
|
from src.analysis.settlement_rounding import is_exact_settlement_city
|
||||||
|
is_floor = is_exact_settlement_city(str(city_name).lower())
|
||||||
|
|
||||||
fractional = max_so_far - int(max_so_far)
|
fractional = max_so_far - int(max_so_far)
|
||||||
dist_to_boundary = abs(fractional - 0.5)
|
|
||||||
if dist_to_boundary <= 0.3:
|
if is_floor:
|
||||||
if fractional < 0.5:
|
# For flooring cities like HK, boundary is at 1.0 (approaching next integer)
|
||||||
|
dist_to_next = 1.0 - fractional
|
||||||
|
if dist_to_next <= 0.3:
|
||||||
msg = (
|
msg = (
|
||||||
f"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol} → {settlement_source_label} 结算 "
|
f"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol} → {settlement_source_label} 结算 "
|
||||||
f"<b>{settled}{temp_symbol}</b>,但只差 <b>{0.5 - fractional:.1f}°</b> "
|
f"<b>{settled}{temp_symbol}</b>,但只差 <b>{dist_to_next:.1f}°</b> "
|
||||||
f"就会进位到 {settled + 1}{temp_symbol}!"
|
f"就会进位到 {settled + 1}{temp_symbol}!"
|
||||||
)
|
)
|
||||||
else:
|
insights.append(msg)
|
||||||
msg = (
|
ai_features.append(msg)
|
||||||
f"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol} → {settlement_source_label} 结算 "
|
else:
|
||||||
f"<b>{settled}{temp_symbol}</b>,刚刚越过进位线,再降 "
|
# Standard rounding boundary at 0.5
|
||||||
f"<b>{fractional - 0.5:.1f}°</b> 就会回落到 {settled - 1}{temp_symbol}。"
|
dist_to_boundary = abs(fractional - 0.5)
|
||||||
)
|
if dist_to_boundary <= 0.3:
|
||||||
insights.append(msg)
|
if fractional < 0.5:
|
||||||
ai_features.append(msg)
|
msg = (
|
||||||
|
f"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol} → {settlement_source_label} 结算 "
|
||||||
|
f"<b>{settled}{temp_symbol}</b>,但只差 {0.5 - fractional:.1f}° "
|
||||||
|
f"就会进位到 {settled + 1}{temp_symbol}!"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg = (
|
||||||
|
f"⚖️ <b>结算边界</b>:当前最高 {max_so_far}{temp_symbol} → {settlement_source_label} 结算 "
|
||||||
|
f"<b>{settled}{temp_symbol}</b>,刚刚越过进位线,再降 "
|
||||||
|
f"<b>{fractional - 0.5:.1f}°</b> 就会回落到 {settled - 1}{temp_symbol}。"
|
||||||
|
)
|
||||||
|
insights.append(msg)
|
||||||
|
ai_features.append(msg)
|
||||||
|
|
||||||
# === Peak window AI hints ===
|
# === Peak window AI hints ===
|
||||||
if peak_hours:
|
if peak_hours:
|
||||||
|
|||||||
@@ -510,6 +510,32 @@ class WeatherDataCollector:
|
|||||||
logger.warning(f"CWA settlement fetch failed: {exc}")
|
logger.warning(f"CWA settlement fetch failed: {exc}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def fetch_hko_forecast(self) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
url = "https://data.weather.gov.hk/weatherAPI/opendata/weather.php?dataType=fnd&lang=tc"
|
||||||
|
res = self.session.get(url, timeout=self.timeout).json()
|
||||||
|
return float(res['weatherForecast'][0]['forecastMaxtemp']['value'])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"HKO Forecast request failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fetch_cwa_taipei_forecast(self) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
if not self.cwa_open_data_auth:
|
||||||
|
return None
|
||||||
|
url = 'https://opendata.cwa.gov.tw/api/v1/rest/datastore/F-D0047-061'
|
||||||
|
res = self.session.get(url, params={'Authorization': self.cwa_open_data_auth, 'format': 'JSON', 'elementName': 'MaxT'}, timeout=self.timeout).json()
|
||||||
|
locs = res.get('records', {}).get('Locations', [])[0].get('Location', [])
|
||||||
|
if not locs: return None
|
||||||
|
loc = locs[0]
|
||||||
|
for we in loc.get('WeatherElement', []):
|
||||||
|
if we.get('ElementName') == 'MaxT':
|
||||||
|
return float(we['Time'][0]['ElementValue'][0]['Temperature'])
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"CWA Forecast request failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def fetch_settlement_current(self, city: str) -> Optional[Dict[str, Any]]:
|
def fetch_settlement_current(self, city: str) -> Optional[Dict[str, Any]]:
|
||||||
normalized = str(city or "").strip().lower()
|
normalized = str(city or "").strip().lower()
|
||||||
if normalized == "hong kong":
|
if normalized == "hong kong":
|
||||||
@@ -2146,6 +2172,15 @@ class WeatherDataCollector:
|
|||||||
if settlement_current:
|
if settlement_current:
|
||||||
results["settlement_current"] = settlement_current
|
results["settlement_current"] = settlement_current
|
||||||
|
|
||||||
|
if city_lower in ["hong_kong", "hong kong", "香港", "hk"]:
|
||||||
|
hko_fcst = self.fetch_hko_forecast()
|
||||||
|
if hko_fcst:
|
||||||
|
results["hko_forecast"] = hko_fcst
|
||||||
|
elif city_lower in ["taipei", "台北", "臺北", "tpe"]:
|
||||||
|
cwa_fcst = self.fetch_cwa_taipei_forecast()
|
||||||
|
if cwa_fcst:
|
||||||
|
results["cwa_forecast"] = cwa_fcst
|
||||||
|
|
||||||
if lat and lon:
|
if lat and lon:
|
||||||
open_meteo = self.fetch_from_open_meteo(
|
open_meteo = self.fetch_from_open_meteo(
|
||||||
lat, lon, use_fahrenheit=use_fahrenheit
|
lat, lon, use_fahrenheit=use_fahrenheit
|
||||||
|
|||||||
+3
-3
@@ -31,7 +31,7 @@ from src.data_collection.weather_sources import WeatherDataCollector
|
|||||||
from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES
|
from src.data_collection.city_risk_profiles import CITY_RISK_PROFILES
|
||||||
from src.data_collection.polymarket_readonly import PolymarketReadOnlyLayer
|
from src.data_collection.polymarket_readonly import PolymarketReadOnlyLayer
|
||||||
from src.analysis.deb_algorithm import calculate_dynamic_weights, get_deb_accuracy
|
from src.analysis.deb_algorithm import calculate_dynamic_weights, get_deb_accuracy
|
||||||
from src.analysis.settlement_rounding import wu_round
|
from src.analysis.settlement_rounding import wu_round, apply_city_settlement
|
||||||
from src.auth.supabase_entitlement import (
|
from src.auth.supabase_entitlement import (
|
||||||
SUPABASE_ENTITLEMENT,
|
SUPABASE_ENTITLEMENT,
|
||||||
extract_bearer_token,
|
extract_bearer_token,
|
||||||
@@ -394,7 +394,7 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
|
|||||||
if max_temp_time == "":
|
if max_temp_time == "":
|
||||||
max_temp_time = None
|
max_temp_time = None
|
||||||
|
|
||||||
wu_settle = wu_round(max_so_far) if max_so_far is not None else None
|
wu_settle = apply_city_settlement(city.lower(), max_so_far) if max_so_far is not None else None
|
||||||
|
|
||||||
# Observation time → local
|
# Observation time → local
|
||||||
obs_time_str = ""
|
obs_time_str = ""
|
||||||
@@ -1075,7 +1075,7 @@ def _build_city_detail_payload(
|
|||||||
temp_symbol = str(data.get("temp_symbol") or "")
|
temp_symbol = str(data.get("temp_symbol") or "")
|
||||||
if anchor_temp_c is not None and "F" in temp_symbol.upper():
|
if anchor_temp_c is not None and "F" in temp_symbol.upper():
|
||||||
anchor_temp_c = (anchor_temp_c - 32.0) * 5.0 / 9.0
|
anchor_temp_c = (anchor_temp_c - 32.0) * 5.0 / 9.0
|
||||||
anchor_settlement = wu_round(anchor_temp_c) if anchor_temp_c is not None else None
|
anchor_settlement = apply_city_settlement(city.lower(), anchor_temp_c) if anchor_temp_c is not None else None
|
||||||
|
|
||||||
primary_bucket = None
|
primary_bucket = None
|
||||||
if isinstance(distribution, list) and distribution:
|
if isinstance(distribution, list) and distribution:
|
||||||
|
|||||||
Reference in New Issue
Block a user