diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 0cf8226f..d8f8e5ea 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -1726,7 +1726,7 @@ export function AccountCenter() { const planAmount = Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0 ? parsedPlanAmount - : 5; + : 10; const pointsCfg = paymentConfig?.points_redemption || {}; const pointsEnabled = pointsCfg.enabled !== false; diff --git a/frontend/components/dashboard/ProFeaturePaywall.tsx b/frontend/components/dashboard/ProFeaturePaywall.tsx index aa4e0405..bea00779 100644 --- a/frontend/components/dashboard/ProFeaturePaywall.tsx +++ b/frontend/components/dashboard/ProFeaturePaywall.tsx @@ -31,7 +31,7 @@ export function ProFeaturePaywall({ const isAuthenticated = proAccess.authenticated; const pointsAvailable = Number(proAccess.points || 0); - const PRO_PRICE_USD = 5; + const PRO_PRICE_USD = 10; const POINTS_PER_USD = 500; const MAX_DISCOUNT_USD = 3; diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index 14734939..bd3c7140 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -477,44 +477,7 @@ function ScanTerminalScreen() { userLocalTime={userLocalTime} /> - {!isPro ? ( -
-
- {isEn ? "PolyWeather Pro" : "PolyWeather Pro"} - - {isEn - ? "Weather intelligence for temperature settlement markets." - : "面向温度结算市场的气象情报系统。"} - -

- {isEn - ? "Turn weather data into trading decisions. Compare multi-model forecasts against live observations, identify mispriced Polymarket contracts, and back your trades with calibrated probability distributions." - : "将气象数据转化为交易决策。多模型预报对照实时观测,识别 Polymarket 错价合约,用校准概率分布支撑每一笔交易。"} -

-
- - {proAccess.authenticated ? ( - - ) : ( - - {isEn ? "Sign in for Pro" : "登录查看 Pro"} - - )} -
- ) : null} +
diff --git a/frontend/components/dashboard/scan-terminal/AirportEvidencePanel.tsx b/frontend/components/dashboard/scan-terminal/AirportEvidencePanel.tsx index c519d053..b61798d6 100644 --- a/frontend/components/dashboard/scan-terminal/AirportEvidencePanel.tsx +++ b/frontend/components/dashboard/scan-terminal/AirportEvidencePanel.tsx @@ -4,13 +4,14 @@ import type { CityDetail } from "@/lib/dashboard-types"; import { getDisplayAirportPrimary } from "@/lib/airport-observation-display"; import { formatTemperatureValue } from "@/lib/temperature-utils"; -const FOCUS_RUNWAY_PAIRS: Record> = { - chongqing: [["02L", "20R"]], +// Settlement runway mapping — matches Polymarket settlement anchors +const SETTLEMENT_RUNWAY_PAIRS: Record> = { shanghai: [["17L", "35R"]], - wuhan: [["04", "22"]], beijing: [["01", "19"]], guangzhou: [["02L", "20R"]], chengdu: [["02L", "20R"]], + chongqing: [["02L", "20R"]], + wuhan: [["04", "22"]], seoul: [["15R", "33L"]], }; @@ -45,12 +46,11 @@ function formatObsTime(value: unknown) { return raw.length >= 16 && raw[10] === " " ? raw.slice(11, 16) : raw; } -function buildFocusedRunwayEvidence(detail: CityDetail | null) { +function buildRunwayEvidence(detail: CityDetail | null) { if (!detail) return null; const cityKey = normalizeCityKey(detail.name) || normalizeCityKey(detail.display_name); - const focusPairs = FOCUS_RUNWAY_PAIRS[cityKey]; - if (!focusPairs?.length) return null; - const focusKeys = new Set(focusPairs.map(pairKey)); + const settlementPairs = SETTLEMENT_RUNWAY_PAIRS[cityKey] || []; + const settlementKeys = new Set(settlementPairs.map(pairKey)); const runwayObs = detail.amos?.runway_obs || {}; const runwayPairs = runwayObs.runway_pairs || []; const runwayTemps = runwayObs.temperatures || []; @@ -59,12 +59,13 @@ function buildFocusedRunwayEvidence(detail: CityDetail | null) { label: string; maxTemp: number; values: number[]; + isSettlement: boolean; }> = []; runwayPairs.forEach((rawPair, index) => { const pair = rawPair as [string, string]; if (!Array.isArray(pair) || pair.length < 2) return; - if (!focusKeys.has(pairKey(pair))) return; + const isSettlement = settlementKeys.has(pairKey(pair)); const values = [ ...(Array.isArray(runwayTemps[index]) ? runwayTemps[index] : []), toFiniteNumber(pointTemps[index]?.tdz_temp), @@ -76,6 +77,7 @@ function buildFocusedRunwayEvidence(detail: CityDetail | null) { label: `${normalizeRunwayLabel(pair[0])}/${normalizeRunwayLabel(pair[1])}`, maxTemp: Math.max(...values), values, + isSettlement, }); }); @@ -99,7 +101,7 @@ export function AirportEvidencePanel({ const airportPrimary = getDisplayAirportPrimary(detail); const airportCurrent = detail?.airport_current; const station = airportPrimary || airportCurrent || null; - const runwayEvidence = buildFocusedRunwayEvidence(detail); + const runwayEvidence = buildRunwayEvidence(detail); const tempSymbol = detail?.temp_symbol || "°C"; if (!station && !runwayEvidence) return null; @@ -110,7 +112,7 @@ export function AirportEvidencePanel({ {isEn ? "Airport live evidence" : "机场实时证据"} -

{isEn ? "Airport / focused runway" : "机场主站 / 重点跑道"}

+

{isEn ? "Airport / runway observations" : "机场 / 跑道观测"}

@@ -130,8 +132,15 @@ export function AirportEvidencePanel({
) : null} {runwayEvidence?.rows.map((row) => ( -
- {isEn ? "Focused runway" : "重点跑道"} +
+ + {row.isSettlement + ? isEn ? "★ Settlement runway" : "★ 结算跑道" + : isEn ? "Runway" : "跑道"} + {formatTemperatureValue(row.maxTemp, tempSymbol, { digits: 1 })} {[row.label, runwayEvidence.sourceLabel, runwayEvidence.observedAt] diff --git a/src/data_collection/amsc_awos_sources.py b/src/data_collection/amsc_awos_sources.py index e3164ce6..d190056d 100644 --- a/src/data_collection/amsc_awos_sources.py +++ b/src/data_collection/amsc_awos_sources.py @@ -62,6 +62,25 @@ def _amsc_split_runway_pair(label: str) -> tuple[str, str]: return runway, runway +def _amsc_wind_dir(*candidates: Any) -> Optional[int]: + """Parse wind direction from AMSC fields (degrees).""" + for value in candidates: + parsed = _amsc_safe_float(value) + if parsed is not None and 0 <= parsed <= 360: + return int(round(parsed)) + return None + + +def _amsc_parse_rvr(value: Any) -> Optional[int]: + """Parse RVR field like 'P2000' → 2000 (meters).""" + text = str(value or "").strip().upper() + if not text or text in {"--", "-", "NULL", "NONE"}: + return None + text = text.lstrip("PM") + parsed = _amsc_safe_float(text) + return int(round(parsed)) if parsed is not None else None + + def _amsc_parse_utc_time(value: Any) -> tuple[Optional[str], Optional[str]]: text = str(value or "").strip() if not text: @@ -124,12 +143,44 @@ def _amsc_parse_wind_plate_payload( best_temp = tdz if tdz is not None else max(points) runway_temps.append((best_temp, None)) valid_values.extend(points) + + # Wind: prefer TDZ point, fallback to END + wind_dir = _amsc_wind_dir( + raw_row.get("TDZ_WIND_D10"), + raw_row.get("END_WIND_D10"), + ) + wind_speed = _amsc_safe_float( + raw_row.get("TDZ_WIND_F10") or raw_row.get("END_WIND_F10") + ) + # RVR / MOR: prefer 1-min average + raw_rvr = ( + raw_row.get("TDZ_RVR_1A") or raw_row.get("END_RVR_1A") + or raw_row.get("TDZ_RVR_10A") or raw_row.get("END_RVR_10A") + ) + rvr = _amsc_parse_rvr(raw_rvr) + raw_mor = ( + raw_row.get("TDZ_MOR_1A") or raw_row.get("END_MOR_1A") + or raw_row.get("TDZ_MOR_10A") or raw_row.get("END_MOR_10A") + ) + mor = _amsc_safe_float(raw_mor) + # Humidity: prefer TDZ, fallback to END, then MID + humidity = _amsc_safe_float( + raw_row.get("TDZ_HUMID") or raw_row.get("END_HUMID") or raw_row.get("MID_HUMID") + ) + + target_max = max(points) point_temperatures.append( { "runway": runway_label, "tdz_temp": tdz, "mid_temp": mid, "end_temp": end, + "target_runway_max": target_max, + "wind_dir": wind_dir, + "wind_speed": wind_speed, + "rvr": rvr, + "mor": mor, + "humidity": humidity, } ) @@ -155,6 +206,8 @@ def _amsc_parse_wind_plate_payload( "station_label": airport_meta.get("label") or icao, "raw_metar": raw_metar, "raw_taf": None, + "wind_dir": point_temperatures[0].get("wind_dir") if point_temperatures else None, + "wind_speed": point_temperatures[0].get("wind_speed") if point_temperatures else None, "runway_obs": { "runway_pairs": runway_pairs, "temperatures": runway_temps, diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py index 6c6b206c..dfbef895 100644 --- a/src/data_collection/weather_sources.py +++ b/src/data_collection/weather_sources.py @@ -1090,26 +1090,47 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour # Reuse the existing `amos` detail shape consumed by dashboard runway panels. results["amos"] = amsc_data try: + icao = amsc_data.get("icao") or "" + obs_time = amsc_data.get("observation_time") or datetime.now().isoformat() DBManager().append_airport_obs( - icao=amsc_data.get("icao") or "", + icao=icao, city=city_lower, temp_c=amsc_data.get("temp_c"), - wind_kt=amsc_data.get("wind_kt"), + wind_kt=amsc_data.get("wind_speed"), pressure_hpa=amsc_data.get("pressure_hpa"), - obs_time=amsc_data.get("observation_time") or datetime.now().isoformat(), + obs_time=obs_time, ) runway_obs = amsc_data.get("runway_obs") or {} rw_pairs = runway_obs.get("runway_pairs") or [] rw_temps = runway_obs.get("temperatures") or [] + point_temps = runway_obs.get("point_temperatures") or [] for i, (pair, temp_pair) in enumerate(zip(rw_pairs, rw_temps)): t = temp_pair[0] if temp_pair else None if t is not None and i < 6: pair_label = "/".join(pair) if isinstance(pair, (list, tuple)) else str(pair) + # Legacy airport_obs_log (keep backward compat) DBManager().append_airport_obs( - icao=f"{amsc_data.get('icao', '')}_RWY_{i}", + icao=f"{icao}_RWY_{i}", city=city_lower, temp_c=t, - obs_time=amsc_data.get("observation_time") or datetime.now().isoformat(), + obs_time=obs_time, + ) + # New runway_obs_log with full detail + pt = point_temps[i] if i < len(point_temps) else {} + DBManager().append_runway_obs( + icao=icao, + city=city_lower, + runway=pair_label, + tdz_temp=pt.get("tdz_temp"), + mid_temp=pt.get("mid_temp"), + end_temp=pt.get("end_temp"), + target_runway_max=pt.get("target_runway_max"), + wind_dir=pt.get("wind_dir"), + wind_speed=pt.get("wind_speed"), + rvr=pt.get("rvr"), + mor=pt.get("mor"), + humidity=pt.get("humidity"), + otime_utc=obs_time, ) logger.debug("AMSC AWOS stored runway row city={} runway={} temp={}", city_lower, pair_label, t) except Exception: diff --git a/src/database/db_manager.py b/src/database/db_manager.py index 8c301cb5..88b84d9d 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -403,6 +403,35 @@ class DBManager: conn.execute( "CREATE INDEX IF NOT EXISTS idx_airport_obs_log_icao_time ON airport_obs_log(icao, created_at DESC)" ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS runway_obs_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + icao TEXT NOT NULL, + city TEXT NOT NULL, + runway TEXT NOT NULL, + tdz_temp REAL, + mid_temp REAL, + end_temp REAL, + target_runway_max REAL, + wind_dir INTEGER, + wind_speed REAL, + rvr INTEGER, + mor REAL, + humidity REAL, + otime_utc TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_runway_obs_log_icao_otime " + "ON runway_obs_log(icao, otime_utc DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_runway_obs_log_city_time " + "ON runway_obs_log(city, created_at DESC)" + ) self._ensure_column(conn, "users", "daily_points", "INTEGER DEFAULT 0") self._ensure_column(conn, "users", "daily_points_date", "TEXT") self._ensure_column(conn, "users", "weekly_points", "INTEGER DEFAULT 0") @@ -2202,3 +2231,71 @@ class DBManager: (str(icao).strip().upper(), str(-int(minutes))), ).fetchall() return [dict(r) for r in rows] + + def append_runway_obs( + self, + icao: str, + city: str, + runway: str, + tdz_temp: Optional[float] = None, + mid_temp: Optional[float] = None, + end_temp: Optional[float] = None, + target_runway_max: Optional[float] = None, + wind_dir: Optional[int] = None, + wind_speed: Optional[float] = None, + rvr: Optional[int] = None, + mor: Optional[float] = None, + humidity: Optional[float] = None, + otime_utc: str = "", + ) -> None: + """Insert one runway observation row (deduplicated by icao+runway+otime_utc).""" + safe_icao = str(icao or "").strip().upper() + safe_city = str(city or "").strip().lower() + safe_runway = str(runway or "").strip().upper() + safe_otime = str(otime_utc or "").strip() + if not safe_icao or not safe_runway or not safe_otime: + return + with self._get_connection() as conn: + existing = conn.execute( + "SELECT id FROM runway_obs_log WHERE icao=? AND runway=? AND otime_utc=? LIMIT 1", + (safe_icao, safe_runway, safe_otime), + ).fetchone() + if existing: + return + conn.execute( + """ + INSERT INTO runway_obs_log ( + icao, city, runway, + tdz_temp, mid_temp, end_temp, target_runway_max, + wind_dir, wind_speed, rvr, mor, humidity, + otime_utc + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + safe_icao, safe_city, safe_runway, + tdz_temp, mid_temp, end_temp, target_runway_max, + wind_dir, wind_speed, rvr, mor, humidity, + safe_otime, + ), + ) + conn.commit() + + def get_runway_obs_recent( + self, icao: str, minutes: int = 60 + ) -> List[Dict[str, Any]]: + """Get recent runway observations for trend analysis.""" + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute( + """ + SELECT icao, city, runway, + tdz_temp, mid_temp, end_temp, target_runway_max, + wind_dir, wind_speed, rvr, mor, humidity, + otime_utc, created_at + FROM runway_obs_log + WHERE icao = ? AND created_at >= datetime('now', ? || ' minutes') + ORDER BY created_at ASC + """, + (str(icao).strip().upper(), str(-int(minutes))), + ).fetchall() + return [dict(r) for r in rows] diff --git a/src/utils/telegram_push.py b/src/utils/telegram_push.py index 3c9b8e5b..efd5d2db 100644 --- a/src/utils/telegram_push.py +++ b/src/utils/telegram_push.py @@ -10,6 +10,7 @@ from typing import Any, Dict, List, Optional, Set, Tuple from loguru import logger +from src.database.db_manager import DBManager from src.database.runtime_state import ( STATE_STORAGE_SQLITE, TelegramAlertStateRepository, @@ -545,16 +546,43 @@ HIGH_FREQ_AIRPORT_ICAO = { "san francisco": "KSFO", "houston": "KHOU", "dallas": "KDAL", "austin": "KAUS", "seattle": "KSEA", } -FOCUS_RUNWAY_PAIRS = { - "chongqing": {("02L", "20R")}, +# Settlement runway mapping — matches Polymarket settlement anchor stations. +# Format: (low_number, high_number) order-independent; stored sorted for lookup. +SETTLEMENT_RUNWAY_PAIRS: Dict[str, Set[Tuple[str, str]]] = { "shanghai": {("17L", "35R")}, - "wuhan": {("04", "22")}, "beijing": {("01", "19")}, "guangzhou": {("02L", "20R")}, "chengdu": {("02L", "20R")}, + "chongqing": {("02L", "20R")}, + "wuhan": {("04", "22")}, "seoul": {("15R", "33L")}, } +# All cities with active runway observation data (AMSC AWOS / AMOS). +RUNWAY_OBSERVATION_CITIES = { + "shanghai", "beijing", "guangzhou", "shenzhen", + "chengdu", "chongqing", "wuhan", "qingdao", + "seoul", "busan", +} + +# Wind regime sectors per airport (approximate, based on runway orientation + coastline). +# Values: {sea_breeze: (from_deg, to_deg), warm_advection: (from_deg, to_deg)} +WIND_REGIME: Dict[str, Dict[str, Tuple[int, int]]] = { + "shanghai": {"sea_breeze": (45, 140), "warm_advection": (180, 260)}, + "seoul": {"sea_breeze": (270, 350), "warm_advection": (150, 230)}, + "busan": {"sea_breeze": (120, 200), "warm_advection": (250, 340)}, + "qingdao": {"sea_breeze": (90, 180), "warm_advection": (200, 300)}, + "beijing": {"sea_breeze": (120, 200), "warm_advection": (220, 320)}, + "guangzhou": {"sea_breeze": (120, 200), "warm_advection": (200, 300)}, + "shenzhen": {"sea_breeze": (120, 200), "warm_advection": (200, 300)}, + "chengdu": {"sea_breeze": (0, 0), "warm_advection": (0, 0)}, + "chongqing": {"sea_breeze": (0, 0), "warm_advection": (0, 0)}, + "wuhan": {"sea_breeze": (0, 0), "warm_advection": (0, 0)}, +} + +# Legacy alias for backward compat with existing _select_focus_runway_obs / _focus_runway_pairs_for_city +FOCUS_RUNWAY_PAIRS = SETTLEMENT_RUNWAY_PAIRS + _FUNCTION_HASHTAGS = { "runway": "#跑道观测", "airport": "#机场观测", @@ -664,6 +692,69 @@ def _select_focus_runway_obs( return runway_pairs, runway_temps, points +def _settlement_runway_for_city(city: str) -> Optional[Tuple[str, str]]: + """Return the settlement runway pair for a city, if configured.""" + pairs = SETTLEMENT_RUNWAY_PAIRS.get(str(city or "").strip().lower(), set()) + return next(iter(pairs)) if pairs else None + + +def _is_settlement_runway(city: str, r1: str, r2: str) -> bool: + """Check if a runway pair is the settlement anchor for this city.""" + pair_set = SETTLEMENT_RUNWAY_PAIRS.get(str(city or "").strip().lower(), set()) + return _runway_pair_key(r1, r2) in pair_set + + +def _wind_regime_label(city: str, wind_dir: Optional[int]) -> Optional[str]: + """Classify wind direction into a thermal regime label.""" + if wind_dir is None: + return None + regimes = WIND_REGIME.get(str(city or "").strip().lower(), {}) + sea = regimes.get("sea_breeze") + warm = regimes.get("warm_advection") + if sea and sea[0] != sea[1] and sea[0] <= wind_dir <= sea[1]: + return "海风降温" + if warm and warm[0] != warm[1] and warm[0] <= wind_dir <= warm[1]: + return "暖平流增强" + return None + + +def _compute_slope_15m(icao: str, current_temp: float) -> Optional[float]: + """Estimate 15-minute temperature trend from runway_obs_log.""" + try: + db = DBManager() + rows = db.get_runway_obs_recent(icao, minutes=20) + temps = [] + for r in rows: + t = r.get("target_runway_max") or r.get("tdz_temp") + if t is not None: + temps.append(float(t)) + if len(temps) >= 2: + # Compare latest vs earliest in ~15 min window + return round(current_temp - temps[0], 1) + except Exception: + pass + return None + + +def _runway_heat_signal( + current_temp: float, + slope_15m: Optional[float], + wind_dir: Optional[int], + city: str, +) -> str: + """Compute a simple runway heat signal label.""" + if slope_15m is None: + return "" + regime = _wind_regime_label(city, wind_dir) + if slope_15m >= 1.0: + return "🚀 冲顶增强" if regime == "暖平流增强" else "🔥 升温中" + if slope_15m >= 0.5: + return "🔥 升温中" + if slope_15m >= -0.2: + return "⚠️ 高位观察" if regime == "海风降温" else "⏸️ 高位横盘" + return "🧊 过峰风险" + + def _focused_runway_max(city: str, city_weather: Dict[str, Any]) -> Optional[float]: amos = city_weather.get("amos") or {} runway_obs = (amos.get("runway_obs") or {}) if isinstance(amos, dict) else {} @@ -825,114 +916,131 @@ def _build_airport_status_message( en_name = city.title() ap_name = _AIRPORT_EN.get(city, "") time_suffix = f" · {local_time}" if local_time else "" - header = f"{en_name} / {ap_name}{time_suffix}" if ap_name else f"{en_name}{time_suffix}" amos = city_weather.get("amos") or {} runway_data = (amos.get("runway_obs") or {}) if amos else {} runway_pairs = runway_data.get("runway_pairs") or [] runway_temps = runway_data.get("temperatures") or [] point_temps = runway_data.get("point_temperatures") or [] - runway_pairs, runway_temps, point_temps = _select_focus_runway_obs( - city, runway_pairs, runway_temps, point_temps - ) - mgm_nearby = city_weather.get("mgm_nearby") or [] - airport_icao = HIGH_FREQ_AIRPORT_ICAO.get(city, "") - airport_row = None - for row in mgm_nearby: - if str(row.get("istNo") or "") == airport_icao or str(row.get("icao") or "") == airport_icao: - airport_row = row - break - if not airport_row: - airport_row = mgm_nearby[0] if mgm_nearby else {} - station_temp = airport_row.get("temp") if airport_row else None - current = city_weather.get("current") or {} - if station_temp is None: - station_temp = current.get("temp") + is_amsc = amos.get("source") == "amsc_awos" + amos_icao = amos.get("icao") or HIGH_FREQ_AIRPORT_ICAO.get(city, "") + settlement_pair = _settlement_runway_for_city(city) - # Current temp from runway max if available - display_temp = station_temp - if runway_temps: - valid = [t for (t, _d) in runway_temps if t is not None] - if valid: - display_temp = max(valid) + # ── Display temp: settlement runway max first, then airport temp ── + settlement_temp: Optional[float] = None + display_temp: Optional[float] = None + if point_temps: + for pt in point_temps: + rw = str(pt.get("runway") or "") + rw_parts = [p.strip() for p in str(rw).split("/") if p.strip()] + if settlement_pair and len(rw_parts) >= 2 and _runway_pair_key(rw_parts[0], rw_parts[1]) == _runway_pair_key(*settlement_pair): + tmax = pt.get("target_runway_max") + if tmax is not None: + settlement_temp = float(tmax) + break + if settlement_temp is not None: + display_temp = settlement_temp + if display_temp is None: + if point_temps: + valid_tmax = [float(p.get("target_runway_max")) for p in point_temps if p.get("target_runway_max") is not None] + display_temp = max(valid_tmax) if valid_tmax else None + if display_temp is None: + station_temp = None + mgm_nearby = city_weather.get("mgm_nearby") or [] + airport_icao = HIGH_FREQ_AIRPORT_ICAO.get(city, "") + for row in mgm_nearby: + if str(row.get("istNo") or "") == airport_icao or str(row.get("icao") or "") == airport_icao: + station_temp = row.get("temp") + break + if station_temp is None and mgm_nearby: + station_temp = mgm_nearby[0].get("temp") + if station_temp is None: + station_temp = (city_weather.get("current") or {}).get("temp") + display_temp = station_temp + + # ── Heat model ── + wind_dir = amos.get("wind_dir") if is_amsc else None + slope_15m = _compute_slope_15m(amos_icao, display_temp) if is_amsc and display_temp is not None else None + heat_signal = _runway_heat_signal(display_temp or 0, slope_15m, wind_dir, city) if is_amsc else "" + wind_label = _wind_regime_label(city, wind_dir) if is_amsc and wind_dir is not None else None max_so_far, max_temp_time = _get_airport_daily_high(city_weather) - new_high = (display_temp is not None and max_so_far is not None - and display_temp - max_so_far >= 0.3) + has_runway = bool(is_amsc and point_temps) - is_amsc = amos.get("source") == "amsc_awos" - has_runway = bool(runway_pairs and runway_temps and len(runway_pairs) == len(runway_temps)) + # ── Build message ── + lines: List[str] = [] + + # Header hashtag_line = _build_telegram_hashtag_line( "runway" if has_runway else "airport", city=city, ) + icao_display = f"{amos_icao} · " if amos_icao else "" + settlement_str = f" · ★{settlement_pair[0]}/{settlement_pair[1]}" if settlement_pair else "" + header = f"{icao_display}{en_name} / {ap_name}{settlement_str}{time_suffix}" if ap_name else f"{icao_display}{en_name}{settlement_str}{time_suffix}" + lines.append(hashtag_line) + lines.append("") + lines.append(header) - # ── Build lines ── - lines: List[str] = [hashtag_line, header] - if new_high: - lines.append("\U0001f536 今日新高") - if state: - lines.append(state) + # Heat signal + if heat_signal: + lines.append("") + lines.append(heat_signal) + if state: + lines.append(state) - # Runway detail block - if is_amsc and runway_pairs and runway_temps and len(runway_pairs) == len(runway_temps): + # All runway detail block + if has_runway: lines.append("") for i, ((r1, r2), (t, _d)) in enumerate(zip(runway_pairs, runway_temps)): - if t is not None: - pts = point_temps[i] if i < len(point_temps) else {} - tdz = pts.get("tdz_temp") - mid = pts.get("mid_temp") - end = pts.get("end_temp") - if tdz is not None or mid is not None or end is not None: - lines.append( - f"{r1}/{r2} TDZ:{_fmt(tdz)} MID:{_fmt(mid)} END:{_fmt(end)}" - ) - else: - lines.append(f"{r1}/{r2} {t:.1f}°C") - elif has_runway: - lines.append("") - for (r1, r2), (t, _d) in zip(runway_pairs, runway_temps): - if t is not None: - lines.append(f"{r1}/{r2} {t:.1f}°C") + if t is None: + continue + pts = point_temps[i] if i < len(point_temps) else {} + tdz = pts.get("tdz_temp") + mid = pts.get("mid_temp") + end = pts.get("end_temp") + is_settlement = _is_settlement_runway(city, r1, r2) + marker = " ★结算" if is_settlement else "" + tmax = pts.get("target_runway_max") + if tdz is not None or mid is not None or end is not None: + line = f"{r1}/{r2}{marker} TDZ:{_fmt(tdz)} MID:{_fmt(mid)} END:{_fmt(end)}" + if tmax is not None: + line += f" max:{tmax:.1f}" + lines.append(line) + else: + lines.append(f"{r1}/{r2}{marker} {t:.1f}°C") - # ── 第一层:当前 / 日高 / DEB ── + # Summary stats lines.append("") temp_symbol = str(city_weather.get("temp_symbol") or "°C").strip() cur_str = f"{display_temp:.1f}{temp_symbol}" if display_temp is not None else "--" - lines.append(f"当前:{cur_str}") + lines.append(f"结算跑道当前:{cur_str}") if max_so_far is not None: time_str = f"({max_temp_time})" if max_temp_time else "" - lines.append(f"日高:{max_so_far:.1f}{temp_symbol}{time_str}") + lines.append(f"今日跑道高点:{max_so_far:.1f}{temp_symbol}{time_str}") + if slope_15m is not None: + sign = "+" if slope_15m >= 0 else "" + lines.append(f"15分钟趋势:{sign}{slope_15m:.1f}°C") + if wind_dir is not None: + wind_str = f"风向:{wind_dir}°" + if wind_label: + wind_str += f" {wind_label}" + lines.append(wind_str) if deb_pred is not None: if display_temp is not None and display_temp > deb_pred: lines.append(f"DEB:{deb_pred:.1f}{temp_symbol}(已突破 +{display_temp - deb_pred:.1f}°)") else: lines.append(f"DEB:{deb_pred:.1f}{temp_symbol}") - # ── 第二层:模型结构 ── + # Model summary (compact) models = city_weather.get("multi_model") or {} if isinstance(models, dict) and len(models) >= 2: - lines.append("") vals = sorted([(v, k) for k, v in models.items() if isinstance(v, (int, float))]) if len(vals) >= 2: lo, hi = vals[0][0], vals[-1][0] spread = hi - lo spread_label = "低分歧" if spread <= 2.0 else ("中等分歧" if spread <= 4.0 else "高分歧") - lines.append(f"模型区间:{lo:.1f}~{hi:.1f}{temp_symbol} 分歧:{spread:.1f}°({spread_label})") - hot = [k for v, k in reversed(vals[-3:])] - cold = [k for v, k in vals[:3]] - if hot: - lines.append(f"热模型:{' / '.join(hot)}") - if cold: - lines.append(f"冷模型:{' / '.join(cold)}") - - # ── 第三层:市场解释 ── - narrative = _build_narrative( - display_temp, max_so_far, deb_pred, models, city_weather, - ) - if narrative: - lines.append("") - lines.append(narrative) + lines.append(f"模型区间:{lo:.1f}~{hi:.1f}{temp_symbol} {spread_label}") return "\n".join(lines) diff --git a/tests/test_amsc_awos_sources.py b/tests/test_amsc_awos_sources.py index 440aa60c..1f64a638 100644 --- a/tests/test_amsc_awos_sources.py +++ b/tests/test_amsc_awos_sources.py @@ -56,12 +56,17 @@ def test_parse_wind_plate_payload_normalizes_runway_point_temperatures(): runway_obs = parsed["runway_obs"] assert runway_obs["runway_pairs"] == [("18R", "36L"), ("18L", "36R"), ("19", "01")] assert runway_obs["temperatures"] == [(20.8, None), (21.0, None), (20.2, None)] - assert runway_obs["point_temperatures"][0] == { - "runway": "18R/36L", - "tdz_temp": 20.8, - "mid_temp": None, - "end_temp": 20.8, - } + pt0 = runway_obs["point_temperatures"][0] + assert pt0["runway"] == "18R/36L" + assert pt0["tdz_temp"] == 20.8 + assert pt0["mid_temp"] is None + assert pt0["end_temp"] == 20.8 + assert pt0["target_runway_max"] == 20.8 + assert pt0["wind_dir"] is None + assert pt0["wind_speed"] is None + assert pt0["rvr"] is None + assert pt0["mor"] is None + assert pt0["humidity"] == 67.0 def test_parse_wind_plate_payload_rejects_unauthorized_or_empty_payloads(): diff --git a/tests/test_telegram_hashtags.py b/tests/test_telegram_hashtags.py index 213ac6d1..932d893c 100644 --- a/tests/test_telegram_hashtags.py +++ b/tests/test_telegram_hashtags.py @@ -21,8 +21,8 @@ def test_airport_status_message_starts_with_runway_city_and_station_hashtags(): "runway_pairs": [("17", "35"), ("16", "34")], "temperatures": [(23.0, None), (23.2, None)], "point_temperatures": [ - {"tdz_temp": 23.0, "mid_temp": None, "end_temp": 23.1}, - {"tdz_temp": 23.2, "mid_temp": None, "end_temp": 23.3}, + {"runway": "17/35", "tdz_temp": 23.0, "mid_temp": None, "end_temp": 23.1, "target_runway_max": 23.1, "wind_dir": None, "wind_speed": None, "rvr": None, "mor": None, "humidity": None}, + {"runway": "16/34", "tdz_temp": 23.2, "mid_temp": None, "end_temp": 23.3, "target_runway_max": 23.3, "wind_dir": None, "wind_speed": None, "rvr": None, "mor": None, "humidity": None}, ], }, }, @@ -50,8 +50,8 @@ def test_airport_status_hides_non_focus_runways_for_key_airports(): "runway_pairs": [("02L", "20R"), ("02R", "20L")], "temperatures": [(31.1, None), (34.9, None)], "point_temperatures": [ - {"tdz_temp": 31.0, "mid_temp": 31.1, "end_temp": 31.2}, - {"tdz_temp": 34.8, "mid_temp": 34.9, "end_temp": 35.0}, + {"runway": "02L/20R", "tdz_temp": 31.0, "mid_temp": 31.1, "end_temp": 31.2, "target_runway_max": 31.2, "wind_dir": None, "wind_speed": None, "rvr": None, "mor": None, "humidity": None}, + {"runway": "02R/20L", "tdz_temp": 34.8, "mid_temp": 34.9, "end_temp": 35.0, "target_runway_max": 35.0, "wind_dir": None, "wind_speed": None, "rvr": None, "mor": None, "humidity": None}, ], }, }, @@ -61,8 +61,8 @@ def test_airport_status_hides_non_focus_runways_for_key_airports(): ) assert "02L/20R" in text - assert "02R/20L" not in text - assert "当前:31.1°C" in text + assert "02R/20L" in text + assert "结算跑道当前:31.2°C" in text def test_singapore_is_in_telegram_push_city_lists():