import hashlib import json import os import re import threading import time from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Set, Tuple import requests as requests_lib from loguru import logger from src.database.db_manager import DBManager from src.database.runtime_state import ( STATE_STORAGE_SQLITE, TelegramAlertStateRepository, get_state_storage_mode, ) from src.data_collection.city_registry import ALIASES from src.data_collection.city_registry import CITY_REGISTRY from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env, parse_telegram_chat_ids from src.utils.telegram_i18n import ( copy_text as _copy, is_bilingual as _is_bilingual, is_zh as _is_zh, normalize_push_language as _normalize_push_language, telegram_push_language as _resolve_telegram_push_language, ) from web.services.canonical_temperature import build_city_weather_from_canonical # Forum topic routing: maps city_key -> message_thread_id for the push forum group. # Created by scripts/create_forum_topics.py, stored in the runtime data dir. _CITY_THREAD_IDS_PATH = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "data", "city_thread_ids.json", ) _DEFAULT_FORUM_CHAT_ID = "-1003927451869" _city_thread_ids: dict = {} _CITY_THREAD_IDS_LOCK = threading.Lock() # Shared HTTP session for AROME and auxiliary queries (connection reuse) _HTTP_SESSION: Optional[requests_lib.Session] = None _HTTP_SESSION_LOCK = threading.Lock() # Bot send_message rate limiter: max N messages per second across all threads _SEND_MSG_LOCK = threading.Lock() _SEND_MSG_LAST_TS: float = 0.0 _SEND_MSG_MIN_INTERVAL_SEC = float(os.getenv("TELEGRAM_SEND_RATE_LIMIT_SEC", "1.1")) def _get_http_session() -> requests_lib.Session: global _HTTP_SESSION if _HTTP_SESSION is None: with _HTTP_SESSION_LOCK: if _HTTP_SESSION is None: _HTTP_SESSION = requests_lib.Session() return _HTTP_SESSION # Reusable executor for airport push cycles (avoids thread pool churn) _AIRPORT_EXECUTOR: Optional[ThreadPoolExecutor] = None _AIRPORT_EXECUTOR_LOCK = threading.Lock() _AIRPORT_EXECUTOR_MAX_WORKERS: int = 0 def _get_airport_executor(max_workers: int) -> ThreadPoolExecutor: global _AIRPORT_EXECUTOR, _AIRPORT_EXECUTOR_MAX_WORKERS if _AIRPORT_EXECUTOR is None or _AIRPORT_EXECUTOR_MAX_WORKERS != max_workers: with _AIRPORT_EXECUTOR_LOCK: if _AIRPORT_EXECUTOR is None or _AIRPORT_EXECUTOR_MAX_WORKERS != max_workers: if _AIRPORT_EXECUTOR is not None: _AIRPORT_EXECUTOR.shutdown(wait=False) _AIRPORT_EXECUTOR = ThreadPoolExecutor(max_workers=max_workers) _AIRPORT_EXECUTOR_MAX_WORKERS = max_workers return _AIRPORT_EXECUTOR def _rate_limited_send(bot: Any, chat_id: str, message: str, **kwargs: Any) -> None: """Throttle bot.send_message calls to avoid hitting Telegram rate limits.""" global _SEND_MSG_LAST_TS with _SEND_MSG_LOCK: now = time.time() wait = _SEND_MSG_MIN_INTERVAL_SEC - (now - _SEND_MSG_LAST_TS) if wait > 0: time.sleep(wait) _SEND_MSG_LAST_TS = time.time() bot.send_message(chat_id, message, **kwargs) def _load_city_thread_ids() -> dict: global _city_thread_ids if _city_thread_ids: return _city_thread_ids paths = [ _CITY_THREAD_IDS_PATH, "/var/lib/polyweather/city_thread_ids.json", "/app/data/city_thread_ids.json", ] for path in paths: if os.path.isfile(path): try: with open(path, "r", encoding="utf-8") as f: _city_thread_ids = json.load(f) logger.info("loaded city_thread_ids from {}: {} cities", path, len(_city_thread_ids)) return _city_thread_ids except Exception as exc: logger.warning("failed to load city_thread_ids from {}: {}", path, exc) return {} def normalize_airport_push_city(raw: str) -> str: city = str(raw or "").strip().lower().replace("-", " ") city = re.sub(r"\s+", " ", city) compact = city.replace(" ", "") city = ALIASES.get(city, ALIASES.get(compact, city)) if city not in HIGH_FREQ_AIRPORT_CITIES: city = { candidate.replace(" ", ""): candidate for candidate in HIGH_FREQ_AIRPORT_CITIES }.get(compact, city) if city in HIGH_FREQ_AIRPORT_CITIES: return city return "" def _city_thread_ids_write_path() -> str: env_path = str(os.getenv("POLYWEATHER_CITY_THREAD_IDS_PATH") or "").strip() if env_path: return env_path for path in ( "/var/lib/polyweather/city_thread_ids.json", "/app/data/city_thread_ids.json", _CITY_THREAD_IDS_PATH, ): if os.path.isfile(path): return path return "/var/lib/polyweather/city_thread_ids.json" def record_city_thread_id(city: str, thread_id: int) -> dict: """Persist a forum topic mapping and update the in-process cache.""" global _city_thread_ids normalized_city = normalize_airport_push_city(city) if not normalized_city: raise ValueError(f"unsupported airport push city: {city}") try: normalized_thread_id = int(thread_id) except Exception as exc: raise ValueError(f"invalid message_thread_id: {thread_id}") from exc if normalized_thread_id <= 0: raise ValueError(f"invalid message_thread_id: {thread_id}") with _CITY_THREAD_IDS_LOCK: path = _city_thread_ids_write_path() mapping = dict(_load_city_thread_ids()) mapping[normalized_city] = normalized_thread_id os.makedirs(os.path.dirname(path), exist_ok=True) tmp_path = f"{path}.tmp" with open(tmp_path, "w", encoding="utf-8") as f: json.dump(mapping, f, ensure_ascii=False, indent=2, sort_keys=True) f.write("\n") os.replace(tmp_path, path) _city_thread_ids = mapping logger.info( "recorded forum topic mapping city={} thread_id={} path={} total={}", normalized_city, normalized_thread_id, path, len(mapping), ) return { "city": normalized_city, "thread_id": normalized_thread_id, "path": path, "total": len(mapping), } def _forum_chat_ids() -> Set[str]: return set( parse_telegram_chat_ids( os.getenv("TELEGRAM_FORUM_CHAT_ID"), os.getenv("POLYWEATHER_TELEGRAM_TOPICS_GROUP_ID"), os.getenv("POLYWEATHER_TELEGRAM_GROUP_ID"), _DEFAULT_FORUM_CHAT_ID, ) ) def _is_forum_chat_id(chat_id: Any) -> bool: chat_key = str(chat_id or "").strip() return bool(chat_key and chat_key in _forum_chat_ids()) def _resolve_thread_id(chat_id: str, city: str) -> int: """Return message_thread_id for a given chat and city, or 0 if not a forum topic.""" if not _is_forum_chat_id(chat_id): return 0 mapping = _load_city_thread_ids() city_key = (city or "").strip().lower() return int(mapping.get(city_key) or 0) SEVERITY_RANK = { "none": 0, "low": 1, "medium": 2, "high": 3, } _telegram_state_repo = TelegramAlertStateRepository() def _env_bool(name: str, default: bool) -> bool: raw = os.getenv(name) if raw is None: return default return raw.strip().lower() in {"1", "true", "yes", "on"} def _env_int(name: str, default: int) -> int: raw = os.getenv(name) if raw is None: return default try: return int(raw) except Exception: return default def _env_float(name: str, default: float) -> float: raw = os.getenv(name) if raw is None: return default try: return float(raw) except Exception: return default def _telegram_push_language() -> str: return _resolve_telegram_push_language( "TELEGRAM_AIRPORT_PUSH_LANGUAGE", "TELEGRAM_PUSH_LANGUAGE", "POLYWEATHER_TELEGRAM_PUSH_LANGUAGE", ) def _norm_prob(v: Any) -> Optional[float]: if v is None: return None try: n = float(v) except Exception: return None if n > 1.0: n = n / 100.0 return max(0.0, min(1.0, n)) def _fmt_cents(value: Any) -> Optional[str]: numeric = _norm_prob(value) if numeric is None: return None cents = numeric * 100.0 rounded = round(cents, 1) text = f"{rounded:.1f}".rstrip("0").rstrip(".") return f"{text}c" def _safe_float(v: Any) -> Optional[float]: if v is None: return None try: return float(v) except Exception: return None def _bucket_value(row: Dict[str, Any]) -> Optional[float]: if not isinstance(row, dict): return None for key in ("value", "temp"): n = _safe_float(row.get(key)) if n is not None: return n label = str(row.get("label") or "").strip() m = re.search(r"(-?\d+(?:\.\d+)?)", label) if not m: return None return _safe_float(m.group(1)) def _bucket_bounds(row: Dict[str, Any]) -> Optional[Tuple[Optional[float], Optional[float]]]: value = _bucket_value(row) if value is None: return None label = str(row.get("label") or "").strip().lower() is_upper_tail = any(key in label for key in ("+", "or higher", "or above", "and above")) is_lower_tail = any(key in label for key in ("<=", "or lower", "or below", "and below")) if is_upper_tail and not is_lower_tail: return value, None if is_lower_tail and not is_upper_tail: return None, value return value, value def _observed_settlement_floor(alert_payload: Dict[str, Any]) -> Optional[float]: evidence = alert_payload.get("evidence") or {} if not isinstance(evidence, dict): evidence = {} inputs = evidence.get("inputs") or {} if not isinstance(inputs, dict): inputs = {} suppression = alert_payload.get("suppression") or {} if not isinstance(suppression, dict): suppression = {} rules = alert_payload.get("rules") or {} if not isinstance(rules, dict): rules = {} breakthrough = rules.get("forecast_breakthrough") or {} if not isinstance(breakthrough, dict): breakthrough = {} floor_candidates: List[float] = [] for raw in ( inputs.get("wu_settle"), suppression.get("max_so_far"), inputs.get("current_temp"), suppression.get("current_temp"), breakthrough.get("current_temp"), ): n = _safe_float(raw) if n is not None: floor_candidates.append(n) if not floor_candidates: return None return max(floor_candidates) def _optional_bool(value: Any) -> Optional[bool]: if value is None: return None if isinstance(value, bool): return value if isinstance(value, (int, float)): return bool(value) if isinstance(value, str): normalized = value.strip().lower() if normalized in {"1", "true", "yes", "on"}: return True if normalized in {"0", "false", "no", "off"}: return False return bool(value) def _parse_iso_datetime_utc(value: Any) -> Optional[datetime]: if not value: return None text = str(value).strip() if not text: return None if "T" not in text: return None try: dt = datetime.fromisoformat(text.replace("Z", "+00:00")) except Exception: return None if dt.tzinfo is None: return dt.replace(tzinfo=timezone.utc) return dt.astimezone(timezone.utc) def _parse_observation_time_epoch(value: Any) -> Optional[int]: if value is None: return None text = str(value).strip() if not text: return None try: numeric = float(text) if numeric >= 1_000_000_000: return int(numeric) except (TypeError, ValueError): pass parsed = _parse_iso_datetime_utc(text) return int(parsed.timestamp()) if parsed is not None else None def _parse_city_list(raw: Optional[str]) -> List[str]: if not raw: return list(CITY_REGISTRY.keys()) out: List[str] = [] for part in raw.split(","): city = part.strip().lower() if city and city in CITY_REGISTRY: out.append(city) return out or list(CITY_REGISTRY.keys()) def _state_file() -> str: root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) return os.path.join(root, "data", "telegram_alert_state.json") def _load_state(path: str) -> Dict[str, Any]: mode = get_state_storage_mode() if mode == STATE_STORAGE_SQLITE: try: return _telegram_state_repo.load_state() except Exception as exc: logger.error(f"failed to load telegram push state from sqlite: {exc}") if not os.path.exists(path): return {"last_by_city": {}, "by_signature": {}} try: with open(path, "r", encoding="utf-8") as fh: data = json.load(fh) if isinstance(data, dict): data.setdefault("last_by_city", {}) data.setdefault("by_signature", {}) return data except Exception as exc: logger.warning(f"failed to load telegram push state: {exc}") return {"last_by_city": {}, "by_signature": {}} def _save_state(path: str, state: Dict[str, Any]) -> None: mode = get_state_storage_mode() if mode == STATE_STORAGE_SQLITE: _telegram_state_repo.save_state(state) if mode == STATE_STORAGE_SQLITE: return os.makedirs(os.path.dirname(path), exist_ok=True) tmp_path = f"{path}.tmp" with open(tmp_path, "w", encoding="utf-8") as fh: json.dump(state, fh, ensure_ascii=False, indent=2) os.replace(tmp_path, path) def _cleanup_state(state: Dict[str, Any], now_ts: int, keep_sec: int = 7 * 86400) -> None: for bucket_name in ("by_signature",): bucket = state.get(bucket_name, {}) if not isinstance(bucket, dict): state[bucket_name] = {} continue stale = [key for key, value in bucket.items() if now_ts - int(value or 0) > keep_sec] for key in stale: bucket.pop(key, None) last_by_city = state.get("last_by_city", {}) if not isinstance(last_by_city, dict): state["last_by_city"] = {} return stale_city = [] for city, row in last_by_city.items(): ts = int((row or {}).get("ts") or 0) if now_ts - ts > keep_sec: stale_city.append(city) for city in stale_city: last_by_city.pop(city, None) def _severity_ok(alert_payload: Dict[str, Any], min_severity: str, min_trigger_count: int) -> bool: triggered_alerts = alert_payload.get("triggered_alerts") or [] if any(alert.get("force_push") for alert in triggered_alerts): return True trigger_count = int(alert_payload.get("trigger_count") or 0) if trigger_count < min_trigger_count: return False severity = str(alert_payload.get("severity") or "none").lower() return SEVERITY_RANK.get(severity, 0) >= SEVERITY_RANK.get(min_severity, 0) def _market_price_cap_ok( alert_payload: Dict[str, Any], ) -> bool: market = alert_payload.get("market_snapshot") or {} if not isinstance(market, dict) or not market.get("available"): return True primary_market = market.get("primary_market") or {} if not isinstance(primary_market, dict): primary_market = {} market_slug = ( str(market.get("selected_slug") or "").strip() or str(primary_market.get("slug") or "").strip() or "--" ) active = market.get("market_active") if active is None: active = primary_market.get("active") active = _optional_bool(active) closed = market.get("market_closed") if closed is None: closed = primary_market.get("closed") closed = _optional_bool(closed) accepting_orders = market.get("market_accepting_orders") if accepting_orders is None: accepting_orders = primary_market.get( "accepting_orders", primary_market.get("acceptingOrders"), ) accepting_orders = _optional_bool(accepting_orders) market_tradable = _optional_bool(market.get("market_tradable")) tradable_reason = str( market.get("market_tradable_reason") or primary_market.get("tradable_reason") or "" ).strip() ended_at = str( market.get("market_ended_at_utc") or primary_market.get("ended_at_utc") or "" ).strip() ended_dt = _parse_iso_datetime_utc(ended_at) is_past_end = ended_dt is not None and ended_dt <= datetime.now(timezone.utc) if ( market_tradable is False or closed is True or active is False or accepting_orders is False or is_past_end ): reason = tradable_reason or ("past_end_time" if is_past_end else "market_not_tradable") logger.info( "trade alert skipped: market not tradable city={} slug={} reason={} active={} closed={} accepting_orders={} ended_at={}".format( alert_payload.get("city"), market_slug, reason, active, closed, accepting_orders, ended_at or "--", ) ) return False # Strict rule: use the bucket mapped from multi-model anchor settlement. forecast_bucket = market.get("forecast_bucket") or {} settle_ref = market.get("anchor_settlement") if settle_ref is None: settle_ref = market.get("open_meteo_settlement") anchor_model = str(market.get("anchor_model") or "").strip() or "--" yes_buy = None bucket_label = None if isinstance(forecast_bucket, dict): yes_buy = _norm_prob(forecast_bucket.get("yes_buy")) bucket_label = str(forecast_bucket.get("label") or "").strip() or None observed_floor = _observed_settlement_floor(alert_payload) bucket_bounds = _bucket_bounds(forecast_bucket) if isinstance(forecast_bucket, dict) else None if observed_floor is not None and bucket_bounds is not None: _lower, upper = bucket_bounds if upper is not None and observed_floor > upper + 1e-9: logger.info( "trade alert skipped: mapped bucket invalidated by observed high city={} bucket={} observed_floor={} upper_bound={} anchor_model={} anchor_settle={}".format( alert_payload.get("city"), bucket_label or "--", round(observed_floor, 2), round(upper, 2), anchor_model, settle_ref, ) ) return False if yes_buy is None or yes_buy <= 0.0: logger.info( "trade alert skipped: no actionable mapped bucket quote city={} bucket={} anchor_model={} anchor_settle={}".format( alert_payload.get("city"), bucket_label or "--", anchor_model, settle_ref, ) ) return False return True def _trigger_type_key(alert_payload: Dict[str, Any]) -> str: trigger_types = sorted( str(alert.get("type") or "").strip() for alert in (alert_payload.get("triggered_alerts") or []) if alert.get("type") ) market = alert_payload.get("market_snapshot") or {} if isinstance(market, dict) and market.get("available"): signal = str(market.get("signal_label") or "").strip() bucket = str(market.get("selected_bucket") or "").strip() if signal: trigger_types.append(f"mkt:{signal}:{bucket}") return "|".join(trigger_types) def _evidence_brief(alert_payload: Dict[str, Any]) -> str: evidence = alert_payload.get("evidence") or {} if not isinstance(evidence, dict): return "--" trigger_summary = evidence.get("trigger_summary") or {} rules = evidence.get("rules") or {} market = evidence.get("market") or {} momentum = rules.get("momentum_spike") or {} advection = rules.get("advection") or {} breakthrough = rules.get("forecast_breakthrough") or {} parts: List[str] = [] trigger_types = trigger_summary.get("trigger_types") if isinstance(trigger_types, list) and trigger_types: parts.append(f"triggers={','.join(str(t) for t in trigger_types)}") slope = momentum.get("slope_30m") if slope is not None: parts.append(f"slope_30m={slope}") lead_delta = advection.get("lead_delta") if lead_delta is not None: parts.append(f"lead_delta={lead_delta}") margin = breakthrough.get("margin") if margin is not None: parts.append(f"break_margin={margin}") edge = market.get("edge_percent") if edge is not None: parts.append(f"edge_pct={edge}") forecast_bucket = market.get("forecast_bucket") or {} if isinstance(forecast_bucket, dict): label = str(forecast_bucket.get("label") or "").strip() yes_buy = forecast_bucket.get("yes_buy") if label: parts.append(f"bucket={label}") if yes_buy is not None: parts.append(f"yes_buy={yes_buy}") if not parts: return "--" return "; ".join(parts) def _alert_signature(alert_payload: Dict[str, Any]) -> str: rules = alert_payload.get("rules") or {} center_deb = rules.get("ankara_center_deb_hit") or {} momentum = rules.get("momentum_spike") or {} breakthrough = rules.get("forecast_breakthrough") or {} advection = rules.get("advection") or {} suppression = alert_payload.get("suppression") or {} market = alert_payload.get("market_snapshot") or {} signature_payload = { "city": alert_payload.get("city"), "target_date": alert_payload.get("target_date"), "severity": alert_payload.get("severity"), "trigger_types": sorted( alert.get("type") for alert in (alert_payload.get("triggered_alerts") or []) if alert.get("type") ), "center_temp": round(float(((center_deb.get("center_station") or {}).get("temp")) or 0.0), 1), "center_deb_prediction": round(float(center_deb.get("deb_prediction") or 0.0), 1), "center_airport_gap": round(float(center_deb.get("center_lead_vs_airport") or 0.0), 1), "momentum_direction": momentum.get("direction"), "momentum_slope_30m": round(float(momentum.get("slope_30m") or 0.0), 1), "breakthrough_margin": round(float(breakthrough.get("margin") or 0.0), 1), "lead_station": (advection.get("lead_station") or {}).get("name"), "lead_delta": round(float(advection.get("lead_delta") or 0.0), 1), "suppressed": bool(suppression.get("suppressed")), "suppression_reason": suppression.get("reason"), "suppression_peak_time": suppression.get("max_temp_time"), "suppression_rollback": round(float(suppression.get("rollback") or 0.0), 1), "market_available": bool(market.get("available")), "market_bucket": market.get("selected_bucket"), "market_top_bucket": market.get("top_bucket"), "market_top_bucket_prob": round(float(market.get("top_bucket_prob") or 0.0), 3), "market_prob": round(float(market.get("market_prob") or 0.0), 3), "model_prob": round(float(market.get("model_prob") or 0.0), 3), "market_yes_buy": round(float(market.get("yes_buy") or 0.0), 3), "market_yes_sell": round(float(market.get("yes_sell") or 0.0), 3), "market_spread": round(float(market.get("spread") or 0.0), 3), "market_edge_percent": round(float(market.get("edge_percent") or 0.0), 2), "market_signal": market.get("signal_label"), "market_confidence": market.get("confidence"), } raw = json.dumps(signature_payload, sort_keys=True, ensure_ascii=True) return hashlib.sha1(raw.encode("utf-8")).hexdigest() # ── high-freq airport push loop ── HIGH_FREQ_AIRPORT_CITIES = { "seoul", "singapore", "busan", "tokyo", "ankara", "helsinki", "amsterdam", "istanbul", "paris", "hong kong", "taipei", "beijing", "shanghai", "guangzhou", "qingdao", "chengdu", "chongqing", "wuhan", "shenzhen", "new york", "los angeles", "chicago", "denver", "atlanta", "miami", "san francisco", "houston", "dallas", "austin", "seattle", "tel aviv", } CHINA_HIGH_FREQ_AIRPORT_CITIES = { "beijing", "shanghai", "guangzhou", "qingdao", "chengdu", "chongqing", "wuhan", } HIGH_FREQ_AIRPORT_ICAO = { "seoul": "RKSI", "singapore": "WSSS", "busan": "RKPK", "tokyo": "44166", "ankara": "17128", "helsinki": "EFHK", "amsterdam": "EHAM", "istanbul": "17058", "paris": "LFPB", "hong kong": "HKO", "taipei": "466920", "beijing": "ZBAA", "shanghai": "ZSPD", "guangzhou": "ZGGG", "qingdao": "ZSQD", "chengdu": "ZUUU", "chongqing": "ZUCK", "wuhan": "ZHHH", "shenzhen": "LFS", "new york": "KLGA", "los angeles": "KLAX", "chicago": "KORD", "denver": "KBKF", "atlanta": "KATL", "miami": "KMIA", "san francisco": "KSFO", "houston": "KHOU", "dallas": "KDAL", "austin": "KAUS", "seattle": "KSEA", "tel aviv": "LLBG", } # Settlement runway mapping — matches 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")}, "beijing": {("01", "19")}, "guangzhou": {("02L", "20R")}, "chengdu": {("02L", "20R")}, "chongqing": {("02L", "20R")}, "wuhan": {("04", "22")}, "qingdao": {("16", "34")}, "seoul": {("15R", "33L")}, } SETTLEMENT_RUNWAY_TARGETS: Dict[str, str] = { "shanghai": "35R", "chengdu": "02L", "chongqing": "02L", "guangzhou": "02L", "wuhan": "04", "beijing": "01", "qingdao": "34", } # All cities with active runway observation data (AMSC AWOS / AMOS). RUNWAY_OBSERVATION_CITIES = { "shanghai", "beijing", "guangzhou", "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)}, "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_ZH = { "runway": "#跑道观测", "airport": "#机场观测", "trade": "#交易机会", } _FUNCTION_HASHTAGS_EN = { "runway": "#RunwayObs", "airport": "#AirportObs", "trade": "#TradeAlert", } def _city_hashtag(city: Optional[str]) -> Optional[str]: text = (city or "").strip() if not text: return None parts = [part for part in re.split(r"[^A-Za-z0-9]+", text.title()) if part] if not parts: return None return "#" + "".join(parts) def _station_hashtag(station: Optional[str]) -> Optional[str]: text = re.sub(r"[^A-Za-z0-9]+", "", (station or "").upper()) return f"#{text}" if text else None def _build_telegram_hashtag_line( kind: str, *, city: Optional[str] = None, station: Optional[str] = None, extra: Optional[List[str]] = None, language: Optional[str] = None, ) -> str: tags: List[str] = [] tag_maps = ( (_FUNCTION_HASHTAGS_EN, _FUNCTION_HASHTAGS_ZH) if _is_bilingual(language) else ((_FUNCTION_HASHTAGS_ZH,) if _is_zh(language) else (_FUNCTION_HASHTAGS_EN,)) ) for tag_map in tag_maps: primary = tag_map.get(kind) if primary and primary not in tags: tags.append(primary) for item in extra or []: added = False for tag_map in tag_maps: tag = tag_map.get(item) if tag and tag not in tags: tags.append(tag) added = True if not added and item and item not in tags: tags.append(item) city_tag = _city_hashtag(city) if city_tag and city_tag not in tags: tags.append(city_tag) station_tag = _station_hashtag(station) if station_tag and station_tag not in tags: tags.append(station_tag) return " ".join(tags) def _fmt(value: Any) -> str: """Format a single temperature reading; returns '--' for missing values.""" if value is None: return "--" try: f = float(value) if not -80.0 < f < 80.0: return "--" return f"{f:.1f}" except (TypeError, ValueError): return "--" def _normalize_runway_label(value: Any) -> str: return re.sub(r"[^0-9A-Z]+", "", str(value or "").strip().upper()) def _runway_pair_key(r1: Any, r2: Any) -> Tuple[str, str]: a = _normalize_runway_label(r1) b = _normalize_runway_label(r2) return tuple(sorted((a, b))) # type: ignore[return-value] def _focus_runway_pairs_for_city(city: str) -> Set[Tuple[str, str]]: return {_runway_pair_key(a, b) for a, b in FOCUS_RUNWAY_PAIRS.get(city, set())} def _settlement_runway_target_for_city(city: str) -> str: city_key = (city or "").strip().lower() return _normalize_runway_label(SETTLEMENT_RUNWAY_TARGETS.get(city_key)) def _runway_pair_from_point(pair: Any, point: Any) -> Tuple[str, str]: if isinstance(point, dict): rw = str(point.get("runway") or "") parts = [_normalize_runway_label(p) for p in rw.split("/") if p.strip()] if len(parts) >= 2: return parts[0], parts[1] try: r1, r2 = pair return _normalize_runway_label(r1), _normalize_runway_label(r2) except Exception: return "", "" def _settlement_endpoint_for_point( city: str, pair: Any, point: Any, ) -> Optional[Dict[str, Any]]: if not isinstance(point, dict): point = {} r1, r2 = _runway_pair_from_point(pair, point) if not r1 or not r2 or not _is_settlement_runway(city, r1, r2): return None target = _settlement_runway_target_for_city(city) if target: direct_temp = _safe_float(point.get("settlement_runway_temp")) direct_runway = _normalize_runway_label(point.get("settlement_runway")) if direct_temp is not None and (not direct_runway or direct_runway == target): return { "temp": direct_temp, "pair": f"{r1}/{r2}", "runway": target, "position": point.get("settlement_runway_position") or "settlement", "label": "settle", } tdz = _safe_float(point.get("tdz_temp")) end = _safe_float(point.get("end_temp")) if target == r1: temp = tdz if tdz is not None else end position = "tdz" if tdz is not None else "end_fallback" elif target == r2: temp = end if end is not None else tdz position = "end" if end is not None else "tdz_fallback" else: return None if temp is None: return None return { "temp": temp, "pair": f"{r1}/{r2}", "runway": target, "position": position, "label": "settle", } tmax = _safe_float(point.get("target_runway_max")) if tmax is None: return None return { "temp": tmax, "pair": f"{r1}/{r2}", "runway": "", "position": "max", "label": "max", } def _settlement_endpoint_from_obs( city: str, runway_pairs: List[Any], point_temps: Optional[List[Any]] = None, ) -> Optional[Dict[str, Any]]: points = point_temps or [] for i, pair in enumerate(runway_pairs or []): point = points[i] if i < len(points) else {} endpoint = _settlement_endpoint_for_point(city, pair, point) if endpoint is not None: return endpoint return None def _select_focus_runway_obs( city: str, runway_pairs: List[Any], runway_temps: List[Any], point_temps: Optional[List[Any]] = None, ) -> Tuple[List[Any], List[Any], List[Any]]: """Return only market-relevant runway pairs when configured for the city. If a configured focus pair is not present in the upstream payload, fall back to the original lists so the push still carries useful airport evidence. """ focus_pairs = _focus_runway_pairs_for_city(city) if not focus_pairs or not runway_pairs or not runway_temps: return runway_pairs, runway_temps, point_temps or [] selected_pairs: List[Any] = [] selected_temps: List[Any] = [] selected_points: List[Any] = [] points = point_temps or [] for i, (pair, temp) in enumerate(zip(runway_pairs, runway_temps)): try: r1, r2 = pair except Exception: continue if _runway_pair_key(r1, r2) not in focus_pairs: continue selected_pairs.append(pair) selected_temps.append(temp) if i < len(points): selected_points.append(points[i]) if selected_pairs: return selected_pairs, selected_temps, selected_points 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((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((city or "").strip().lower(), set()) return _runway_pair_key(r1, r2) in pair_set def _wind_regime_label(city: str, wind_dir: Optional[int], language: Optional[str] = None) -> Optional[str]: """Classify wind direction into a thermal regime label.""" if wind_dir is None: return None regimes = WIND_REGIME.get((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 _copy(language, "sea-breeze cooling", "海风降温") if warm and warm[0] != warm[1] and warm[0] <= wind_dir <= warm[1]: return _copy(language, "warm advection", "暖平流增强") return None def _runway_row_temp_for_city(city: str, row: Dict[str, Any]) -> Optional[float]: endpoint = _settlement_endpoint_for_point(city, row.get("runway"), row) if endpoint is not None: return _safe_float(endpoint.get("temp")) target_runway_max = _safe_float(row.get("target_runway_max")) if target_runway_max is not None: return target_runway_max return _safe_float(row.get("tdz_temp")) def _compute_slope_15m(icao: str, current_temp: float, city: str = "") -> 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 = _runway_row_temp_for_city(city, r) 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, language: Optional[str] = None, ) -> str: """Compute a simple runway heat signal label.""" if slope_15m is None: return "" regime = _wind_regime_label(city, wind_dir, language) warm_regime = _copy(language, "warm advection", "暖平流增强") sea_regime = _copy(language, "sea-breeze cooling", "海风降温") if slope_15m >= 1.0: return _copy(language, "🚀 Peak push strengthening", "🚀 冲顶增强") if regime == warm_regime else _copy(language, "🔥 Warming", "🔥 升温中") if slope_15m >= 0.5: return _copy(language, "🔥 Warming", "🔥 升温中") if slope_15m >= -0.2: return _copy(language, "⚠️ Watch sea-breeze cooling", "⚠️ 高位观察") if regime == sea_regime else _copy(language, "⏸️ Holding near high", "⏸️ 高位横盘") return _copy(language, "🧊 Peak-risk cooling", "🧊 过峰风险") 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 {} runway_pairs = runway_obs.get("runway_pairs") or [] runway_temps = runway_obs.get("temperatures") or [] runway_pairs, runway_temps, _points = _select_focus_runway_obs( city, runway_pairs, runway_temps, runway_obs.get("point_temperatures") or [], ) endpoint = _settlement_endpoint_from_obs(city, runway_pairs, _points) if endpoint is not None: return float(endpoint["temp"]) del runway_pairs valid = [float(t) for (t, _d) in runway_temps if t is not None] return max(valid) if valid else None _AIRPORT_PUSH_STATE_PATH = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "data", "airport_push_state.json", ) def _load_airport_state() -> Dict[str, Any]: path = _AIRPORT_PUSH_STATE_PATH if not os.path.exists(path): return {"last_by_city": {}} try: with open(path, "r", encoding="utf-8") as fh: data = json.load(fh) if isinstance(data, dict): data.setdefault("last_by_city", {}) return data except Exception: pass return {"last_by_city": {}} def _save_airport_state(state: Dict[str, Any]) -> None: path = _AIRPORT_PUSH_STATE_PATH os.makedirs(os.path.dirname(path), exist_ok=True) tmp = f"{path}.tmp" with open(tmp, "w", encoding="utf-8") as fh: json.dump(state, fh, ensure_ascii=False, indent=2) os.replace(tmp, path) _AROME_CACHE: Dict[str, Any] = {} _AROME_CACHE_TTL_SEC = 600 # AROME HD updates every 15 min; cache 10 min def _fetch_arome_temp() -> Optional[float]: """Fetch latest AROME France HD 15-min temperature for LFPB from Open-Meteo. Cached for 10 minutes since the model only updates every 15 minutes. """ now = time.time() cached = _AROME_CACHE.get("value") cached_at = _AROME_CACHE.get("ts", 0) if cached is not None and (now - cached_at) < _AROME_CACHE_TTL_SEC: return cached try: url = ( "https://api.open-meteo.com/v1/forecast?" "latitude=48.9673&longitude=2.4277" "&models=meteofrance_arome_france_hd" "&minutely_15=temperature_2m" "&timezone=Europe/Paris" "&forecast_minutely_15=2" ) resp = _get_http_session().get(url, timeout=8) data = resp.json() temps = (data.get("minutely_15") or {}).get("temperature_2m") or [] result = float(temps[-1]) if temps else None _AROME_CACHE["value"] = result _AROME_CACHE["ts"] = now return result except Exception: return _AROME_CACHE.get("value") _LAST_AEROWEB: Dict[str, Any] = {} # {"temp": 31.0, "max_so_far": 31.0, "max_time": "14:00", "ts": epoch} def _build_narrative( current_temp: Optional[float], max_so_far: Optional[float], deb_pred: Optional[float], models: Dict[str, Any], city_weather: Dict[str, Any], ) -> str: """Generate a market-structure interpretation based on current state.""" if current_temp is None: return "" vals = sorted([v for v in models.values() if isinstance(v, (int, float))]) model_lo = vals[0] if vals else None model_hi = vals[-1] if vals else None h = 12 try: h = int(str(city_weather.get("local_time") or "12")[:2]) except ValueError: pass lines = [] # Delta vs daily high if max_so_far is not None: d = current_temp - max_so_far if d >= 0.3: lines.append(f"🔺 当前已创今日新高(+{d:.1f}°),持续冲高") elif d >= -1.0: lines.append(f"紧贴日高,距峰值仅 {abs(d):.1f}°") else: lines.append(f"低于日高 {abs(d):.1f}°") # DEB relation if deb_pred is not None: if current_temp > deb_pred: lines.append("DEB 已被突破,模型偏保守") elif max_so_far is not None and max_so_far >= deb_pred: pass # already covered by 日高已触及 elif current_temp > deb_pred - 2.0: lines.append("DEB 仍在可达范围") # Model position + time context if model_lo is not None and model_hi is not None: if current_temp < model_lo: if h >= 17: lines.append("低于所有模型,晚间升温窗口有限") else: lines.append("低于主流模型,日间仍有升温空间") elif current_temp <= model_hi: lines.append("位于模型区间内,市场在预期路径上") else: lines.append("已超出最热模型,市场进入超预期定价") elif max_so_far is not None and current_temp < max_so_far - 2.0: if h < 14: lines.append("日间仍可能二次冲高") else: lines.append("已脱离日内峰值") return "\n".join(lines) def _build_airport_status_message( city: str, city_weather: Dict[str, Any], deb_pred: Optional[float], local_time: str = "", state: str = "", source_label: str = "", arome_temp: Optional[float] = None, aeroweb_available: bool = False, language: Optional[str] = None, ) -> str: language = _normalize_push_language(language or _telegram_push_language()) _AIRPORT_EN = {"seoul": "Incheon", "singapore": "Changi", "busan": "Gimhae", "tokyo": "Haneda", "ankara": "Esenboğa", "helsinki": "Vantaa", "amsterdam": "Schiphol", "istanbul": "Airport", "paris": "Le Bourget", "hong kong": "Observatory", "taipei": "Songshan", "beijing": "Capital", "shanghai": "Pudong", "guangzhou": "Baiyun", "qingdao": "Jiaodong", "chengdu": "Shuangliu", "chongqing": "Jiangbei", "wuhan": "Tianhe", "shenzhen": "Lau Fau Shan", "new york": "LaGuardia", "los angeles": "LAX", "chicago": "O'Hare", "denver": "Buckley", "atlanta": "Hartsfield", "miami": "Intl", "san francisco": "SFO", "houston": "Hobby", "dallas": "Love Field", "austin": "Bergstrom", "seattle": "Sea-Tac", "tel aviv": "Ben Gurion"} en_name = city.title() ap_name = _AIRPORT_EN.get(city, "") time_suffix = f" · {local_time}" if local_time else "" amos = city_weather.get("amos") or {} runway_data = amos.get("runway_obs") or {} runway_pairs = runway_data.get("runway_pairs") or [] runway_temps = runway_data.get("temperatures") or [] point_temps = runway_data.get("point_temperatures") or [] is_amsc = amos.get("source") in ("amsc_awos", "amos") has_runway = bool(runway_pairs and (runway_temps or point_temps)) amos_icao = amos.get("icao") or HIGH_FREQ_AIRPORT_ICAO.get(city, "") settlement_pair = _settlement_runway_for_city(city) settlement_endpoint = _settlement_endpoint_from_obs(city, runway_pairs, point_temps) # ── Display temp: settlement endpoint first, then airport temp ── display_temp: Optional[float] = None if settlement_endpoint is not None: display_temp = float(settlement_endpoint["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: logger.warning( "airport message fallback city={}: station {} not found in mgm_nearby, falling back to current.temp", city, airport_icao, ) 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, city) 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, language) if is_amsc else "" wind_label = _wind_regime_label(city, wind_dir, language) if is_amsc and wind_dir is not None else None max_so_far, max_temp_time = _get_airport_daily_high(city_weather) # ── Build message ── lines: List[str] = [] # Header hashtag_line = _build_telegram_hashtag_line( "runway" if has_runway else "airport", city=city, language=language, ) icao_display = f"{amos_icao} · " if amos_icao else "" settlement_pair_label = ( str(settlement_endpoint.get("pair")) if settlement_endpoint is not None and settlement_endpoint.get("pair") else (f"{settlement_pair[0]}/{settlement_pair[1]}" if settlement_pair else "") ) settlement_str = f" · ★{settlement_pair_label}" if settlement_pair_label 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) # Heat signal if heat_signal: lines.append("") lines.append(heat_signal) if state: lines.append(state) # 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 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 = f" {_copy(language, '★Settlement', '★结算')}" if is_settlement else "" 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)}" settlement_line_endpoint = _settlement_endpoint_for_point(city, (r1, r2), pts) if is_settlement else None if settlement_line_endpoint is not None: line += f" settle:{float(settlement_line_endpoint['temp']):.1f}" lines.append(line) else: temp_symbol = str(city_weather.get("temp_symbol") or "°C").strip() lines.append(f"{r1}/{r2}{marker} {t:.1f}{temp_symbol}") # 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 "--" if city == "paris": if aeroweb_available and display_temp is not None: lines.append(f"{_copy(language, 'Current observation', '当前实况')}: {cur_str}") else: lines.append(_copy(language, "Current observation: unavailable", "当前实况:暂无")) else: if has_runway: current_label = ( _copy(language, "Settlement runway now", "结算跑道当前") if settlement_pair else _copy(language, "Runway now", "跑道当前") ) lines.append(f"{current_label}: {cur_str}") else: lines.append(f"{_copy(language, 'Current', '当前')}: {cur_str}") if city == "paris": if aeroweb_available and max_so_far is not None: time_str = f" ({max_temp_time})" if max_temp_time and not _is_zh(language) else (f"({max_temp_time})" if max_temp_time else "") lines.append(f"{_copy(language, 'Observed high', '日高实况')}: {max_so_far:.1f}{temp_symbol}{time_str}") elif not aeroweb_available: last_temp = _LAST_AEROWEB.get("temp") if last_temp is not None: last_time = _LAST_AEROWEB.get("max_time", "") time_str = f" ({last_time})" if last_time and not _is_zh(language) else (f"({last_time})" if last_time else "") lines.append(f"{_copy(language, 'Latest observation', '最近实况')}: {last_temp:.1f}{temp_symbol}{time_str}") elif max_so_far is not None: time_str = f" ({max_temp_time})" if max_temp_time and not _is_zh(language) else (f"({max_temp_time})" if max_temp_time else "") if has_runway: high_label = _copy(language, "Today's runway high", "今日跑道高点") lines.append(f"{high_label}: {max_so_far:.1f}{temp_symbol}{time_str}") else: high_label = _copy(language, "Today's high", "日高") lines.append(f"{high_label}: {max_so_far:.1f}{temp_symbol}{time_str}") if slope_15m is not None: sign = "+" if slope_15m >= 0 else "" lines.append(f"{_copy(language, '15m trend', '15分钟趋势')}: {sign}{slope_15m:.1f}°") if wind_dir is not None: wind_str = f"{_copy(language, 'Wind dir', '风向')}: {wind_dir}°" if wind_label: wind_str += f" {wind_label}" lines.append(wind_str) # --- AMSC METAR temp + time for Chinese cities (Beijing time) --- if is_amsc: raw_metar = amos.get("raw_metar") or "" if raw_metar: parts = raw_metar.split() # Extract temp/dew: "20/17" → 20 metar_temp = None for p in parts: m = re.match(r"^(M?\d{2})/(M?\d{2})$", p) if m: t = m.group(1) metar_temp = str(int(t.replace("M", "-"))) break # Extract time: "211930Z" → Beijing time (UTC+8) metar_time = None for p in parts: m = re.match(r"^(\d{2})(\d{2})(\d{2})Z$", p) if m: _day, hh, mm = int(m.group(1)), int(m.group(2)), m.group(3) bj_h = hh + 8 if bj_h >= 24: bj_h -= 24 metar_time = f"{_copy(language, 'Beijing time', '北京时')} {bj_h:02d}:{mm}" break if metar_temp or metar_time: bits = [] if metar_temp: bits.append(f"{metar_temp}{temp_symbol}") if metar_time: bits.append(metar_time) lines.append(f"{_copy(language, 'METAR', '报文')}: {' '.join(bits)}") if deb_pred is not None: if city == "paris": if aeroweb_available and display_temp is not None: diff = display_temp - deb_pred sign = "+" if diff >= 0 else "" suffix = _copy(language, f" (vs obs {sign}{diff:.1f}°)", f"(距实况 {sign}{diff:.1f}°)") lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}{suffix}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}{suffix}") elif not aeroweb_available: last_temp = _LAST_AEROWEB.get("temp") if last_temp is not None: diff = last_temp - deb_pred sign = "+" if diff >= 0 else "" suffix = _copy(language, f" (vs latest obs {sign}{diff:.1f}°)", f"(距最近实况 {sign}{diff:.1f}°)") lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}{suffix}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}{suffix}") else: lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}") else: lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}") elif display_temp is not None and display_temp > deb_pred: suffix = _copy(language, f" (already above +{display_temp - deb_pred:.1f}°)", f"(已突破 +{display_temp - deb_pred:.1f}°)") lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}{suffix}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}{suffix}") else: lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}") if city == "paris": lines.append("") if aeroweb_available and display_temp is not None: label = _copy(language, "AEROWEB airport obs", "AEROWEB 机场实况") lines.append(f"📡 {label}: {display_temp:.1f}{temp_symbol} · Météo-France") else: lines.append(_copy(language, "📡 AEROWEB airport obs: unavailable", "📡 AEROWEB 机场实况:暂不可用")) if arome_temp is not None: if aeroweb_available and display_temp is not None: d = arome_temp - display_temp sign = "+" if d >= 0 else "" label = _copy(language, "AROME HD 15m nowcast", "AROME HD 15分钟临近预报") suffix = _copy(language, f" (vs obs {sign}{d:.1f}°)", f"(较实况 {sign}{d:.1f}°)") lines.append(f"🕐 {label}: {arome_temp:.1f}{temp_symbol}{suffix}") else: label = _copy(language, "AROME HD 15m nowcast", "AROME HD 15分钟临近预报") lines.append(f"🕐 {label}: {arome_temp:.1f}{temp_symbol}") else: label = _copy(language, "AROME HD 15m nowcast", "AROME HD 15分钟临近预报") lines.append(f"🕐 {label}: --") if not aeroweb_available: lines.append("") lines.append(_copy(language, "Note: showing model reference only; observed high is not updating.", "提示:当前仅显示模型参考,不更新实况日高。")) elif source_label: lines.append("") lines.append(f"📡 {source_label}") # Model summary (compact) models = city_weather.get("multi_model") or {} if isinstance(models, dict) and len(models) >= 2: 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 if spread <= 2.0: spread_label = _copy(language, "low dispersion", "低分歧") elif spread <= 4.0: spread_label = _copy(language, "moderate dispersion", "中等分歧") else: spread_label = _copy(language, "high dispersion", "高分歧") range_label = _copy(language, "Model range", "模型区间") lines.append(f"{range_label}: {lo:.1f}~{hi:.1f}{temp_symbol} {spread_label}") return "\n".join(lines) def _get_airport_daily_high(city_weather: Dict[str, Any]): """Get today's observed high from METAR/AMOS airport history.""" airport = city_weather.get("airport_current") or {} max_so_far = airport.get("max_so_far") max_time = airport.get("max_temp_time") if max_so_far is not None: try: max_so_far = round(float(max_so_far), 1) except Exception: max_so_far = None return max_so_far, max_time # Per-city push interval. The loop wakes every minute, but Telegram should # read recent city cache instead of acting as an upstream observation producer. _AIRPORT_PUSH_INTERVAL = { city: 600 for city in HIGH_FREQ_AIRPORT_CITIES } _AIRPORT_PUSH_INTERVAL.update({ "seoul": 60, "busan": 60, **{city: 60 for city in CHINA_HIGH_FREQ_AIRPORT_CITIES}, }) def _airport_push_cache_max_age_sec(city: str) -> int: interval = int(_AIRPORT_PUSH_INTERVAL.get((city or "").strip().lower(), 600) or 600) return max(90, interval * 2) def _cached_payload_observation_epoch(payload: Dict[str, Any]) -> Optional[int]: amos = payload.get("amos") or {} airport_primary = payload.get("airport_primary") or {} airport_current = payload.get("airport_current") or {} current = payload.get("current") or {} candidates = [ (payload.get("canonical_temperature") or {}).get("observed_at"), amos.get("observation_time"), airport_primary.get("obs_time"), airport_current.get("obs_time"), current.get("observed_at"), current.get("obs_time"), ] parsed = [ timestamp for timestamp in (_parse_observation_time_epoch(value) for value in candidates) if timestamp is not None ] return max(parsed) if parsed else None def _read_cached_airport_city_weather(city: str, max_age_sec: Optional[int] = None) -> Optional[Dict[str, Any]]: """Read web city cache for Telegram without triggering collection.""" normalized_city = (city or "").strip().lower() if not normalized_city: return None max_age = int(max_age_sec if max_age_sec is not None else _airport_push_cache_max_age_sec(normalized_city)) now_ts = time.time() candidates: List[Tuple[int, int, float, str, Dict[str, Any]]] = [] try: db = DBManager() for kind in ("full", "panel"): entry = db.get_city_cache(kind, normalized_city) if not isinstance(entry, dict): continue updated_at_ts = float(entry.get("updated_at_ts") or 0.0) payload = entry.get("payload") if updated_at_ts <= 0 or not isinstance(payload, dict): continue age_sec = now_ts - updated_at_ts is_fresh = age_sec <= max_age if age_sec > max_age: logger.debug( "airport push cache stale city={} kind={} age_sec={} max_age_sec={}", normalized_city, kind, round(age_sec, 1), max_age, ) observation_ts = _cached_payload_observation_epoch(payload) candidates.append( ( int(observation_ts or 0), 1 if is_fresh else 0, updated_at_ts, kind, dict(payload), ) ) except Exception as exc: logger.debug("airport push city cache read failed city={}: {}", normalized_city, exc) if candidates: observation_ts, is_fresh, updated_at_ts, kind, payload = max( candidates, key=lambda item: (item[0], item[1], item[2]), ) logger.debug( "airport push cache selected city={} kind={} age_sec={} observation_ts={} fresh={}", normalized_city, kind, round(max(0.0, now_ts - updated_at_ts), 1), observation_ts or None, bool(is_fresh), ) return payload return None def _attach_latest_raw_observation_payload( db: Any, city: str, payload: Dict[str, Any], ) -> Dict[str, Any]: canonical = payload.get("canonical_temperature") or {} source = str(canonical.get("source") or "").strip().lower() if source != "amsc_awos": return payload getter = getattr(db, "get_latest_raw_observation", None) if not callable(getter): return payload try: row = getter("amsc_awos", city) except Exception as exc: logger.debug("airport push latest raw observation read failed city={}: {}", city, exc) return payload raw_payload = row.get("payload") if isinstance(row, dict) else None if isinstance(raw_payload, dict) and raw_payload: payload = dict(payload) payload["amos"] = dict(raw_payload) return payload def _read_canonical_airport_city_weather(city: str, db: Optional[Any] = None) -> Optional[Dict[str, Any]]: normalized_city = (city or "").strip().lower() if not normalized_city: return None try: db = db or DBManager() getter = getattr(db, "get_canonical_temperature", None) if not callable(getter): return None row = getter(normalized_city) except Exception as exc: logger.debug("airport push canonical latest read failed city={}: {}", normalized_city, exc) return None if not isinstance(row, dict): return None canonical = row.get("payload") or row if not isinstance(canonical, dict): return None payload = build_city_weather_from_canonical(normalized_city, canonical) if not isinstance(payload, dict): return None return _attach_latest_raw_observation_payload(db, normalized_city, payload) def _merge_airport_push_context( latest_payload: Dict[str, Any], cached_payload: Optional[Dict[str, Any]], ) -> Dict[str, Any]: if not isinstance(cached_payload, dict) or not cached_payload: return latest_payload merged = dict(latest_payload) def has_useful_context(value: Any) -> bool: if isinstance(value, dict): return any(item not in (None, "", [], {}) for item in value.values()) if isinstance(value, list): return bool(value) return value is not None for key in ( "deb", "multi_model", "multi_model_daily", "forecast", "probabilities", "peak", "local_time", "local_date", "temp_symbol", "risk", ): value = cached_payload.get(key) if not has_useful_context(value): continue if key not in merged or not has_useful_context(merged.get(key)): merged[key] = value return merged def _load_airport_city_weather_for_push(city: str) -> Dict[str, Any]: cached = _read_cached_airport_city_weather(city) canonical = _read_canonical_airport_city_weather(city) if canonical is not None: cached_ts = _cached_payload_observation_epoch(cached or {}) canonical_ts = _cached_payload_observation_epoch(canonical) if cached is None or (canonical_ts or 0) > (cached_ts or 0): return _merge_airport_push_context(canonical, cached) if cached is not None: return cached raise RuntimeError(f"no cached city weather for airport push city={city}") # Per-city temperature window threshold (°C below DEB predicted high) # Continental airports: wider window (temp rises steadily over land) # Maritime airports: narrower (sea breeze moderates temp) # Strong sea breeze: tightest (marine air suppresses peak) _AIRPORT_HEAT_THRESHOLD = { "seoul": 3.0, "ankara": 3.0, "istanbul": 3.0, "paris": 3.0, "busan": 2.0, "tokyo": 2.0, "amsterdam": 2.0, "helsinki": 2.0, "hong kong": 1.5, "taipei": 1.5, } # 部分城市 Open-Meteo 算出的 peak 窗口偏窄,用 fallback 拓宽 # (例如沿海城市受海风影响,高温窗口被压缩) _AIRPORT_PEAK_FALLBACK = { "busan": (12, 16), } def _in_peak_time_window(city: str, city_weather: Dict[str, Any]) -> bool: """Check if current local time is within the expected peak temperature window.""" peak = city_weather.get("peak") or {} first_h = peak.get("first_h") last_h = peak.get("last_h") fallback = _AIRPORT_PEAK_FALLBACK.get(city) if fallback and ((first_h is None) or (last_h is not None and last_h - first_h < 3)): first_h, last_h = fallback local_time = city_weather.get("local_time") or "" if first_h is None or last_h is None or not local_time: return False try: current_h, current_m = int(local_time[:2]), int(local_time[3:5]) current_minutes = current_h * 60 + current_m # Window: first_h - 4h to last_h + 2h start_min = max(0, (first_h - 4) * 60) end_min = min(24 * 60 - 1, (last_h + 2) * 60) return start_min <= current_minutes <= end_min except Exception: return False def _check_rising_trend(icao: str) -> bool: """Check if temperature has been rising over the last 30-60 minutes.""" try: from src.database.db_manager import DBManager db = DBManager() obs = db.get_airport_obs_recent(icao, minutes=60) if not obs: return False temps = [r.get("temp_c") for r in obs if r.get("temp_c") is not None] if len(temps) < 4: return False # Check: last 3 readings are increasing recent = temps[-3:] if recent[2] > recent[1] > recent[0]: return True # Or: current > 30 min ago if len(temps) >= 4: mid = len(temps) // 2 if temps[-1] > temps[mid]: return True return False except Exception: return False def _process_airport_city( city: str, now_ts: int, last_city: dict, chat_ids: List[str], bot: Any, ) -> Optional[Tuple[str, dict]]: """Process one airport city and return (city, new_state_entry) or None. This is the per-city unit used by the concurrent thread pool in ``_run_high_freq_airport_cycle``. """ last_city_ts = int(last_city.get("ts") or 0) last_obs_time = str(last_city.get("obs_time") or "") last_obs_ts = ( _parse_observation_time_epoch(last_city.get("obs_ts")) or _parse_observation_time_epoch(last_obs_time) ) city_interval = _AIRPORT_PUSH_INTERVAL.get(city, 600) if now_ts - last_city_ts < city_interval: return None city_weather: Dict[str, Any] = {} deb_pred: Optional[float] = None try: city_weather = _load_airport_city_weather_for_push(city) deb_raw = (city_weather.get("deb") or {}).get("prediction") if deb_raw is not None: deb_pred = float(deb_raw) except Exception: logger.exception("airport analyze failed for city={}", city) return None # Extract airport-level temperature amos = city_weather.get("amos") or {} 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_primary = city_weather.get("airport_primary") or {} if airport_primary.get("temp") is not None: airport_row = airport_primary else: current_fallback = city_weather.get("current") or {} if current_fallback.get("temp") is not None: airport_row = current_fallback else: logger.warning( "airport push skipped city={}: station {} not found in mgm_nearby, " "airport_primary, or current (mgm={} rows)", city, airport_icao, len(mgm_nearby), ) return None station_temp = airport_row.get("temp") if airport_row else None current_obs_time = str(airport_row.get("obs_time") or "") runway_obs = (amos.get("runway_obs") or {}) runway_pairs = runway_obs.get("runway_pairs") or [] runway_temps = runway_obs.get("temperatures") or [] runway_pairs, runway_temps, point_temps = _select_focus_runway_obs( city, runway_pairs, runway_temps, runway_obs.get("point_temperatures") or [], ) if runway_pairs and (runway_temps or point_temps): endpoint = _settlement_endpoint_from_obs(city, runway_pairs, point_temps) if endpoint is not None: station_temp = float(endpoint["temp"]) else: valid_temps = [t for (t, _d) in runway_temps if t is not None] if valid_temps: station_temp = max(valid_temps) amos_obs_time = amos.get("observation_time") or "" if amos_obs_time: current_obs_time = amos_obs_time current_temp = station_temp if current_temp is None: airport_primary = city_weather.get("airport_primary") or {} current_temp = airport_primary.get("temp") or (city_weather.get("current") or {}).get("temp") if not current_obs_time: current_obs_time = str(airport_primary.get("obs_time") or "") current_obs_ts = _parse_observation_time_epoch(current_obs_time) source_label = "" # human-readable data source for Paris messages arome_temp_val = None # AROME HD temperature for display (always fetched for comparison) aeroweb_available = False if city == "paris": arome_temp_val = _fetch_arome_temp() airport_primary = city_weather.get("airport_primary") or {} # Detect AEROWEB availability if airport_primary.get("source_code") == "aeroweb" and airport_primary.get("temp") is not None: aeroweb_available = True source_label = "AEROWEB 机场实况 · Météo-France" # Update last known AEROWEB cache _LAST_AEROWEB["temp"] = float(airport_primary["temp"]) _LAST_AEROWEB["ts"] = time.time() # Get daily high from airport data aero_max = (city_weather.get("airport_current") or {}).get("max_so_far") aero_max_time = (city_weather.get("airport_current") or {}).get("max_temp_time") if aero_max is not None: _LAST_AEROWEB["max_so_far"] = float(aero_max) _LAST_AEROWEB["max_time"] = str(aero_max_time or "")[11:16] if aero_max_time else "" # Allow Paris pushes even when AEROWEB is down (show cached/last-known data) if city == "paris" and not aeroweb_available and deb_pred is not None: pass elif current_temp is None or deb_pred is None: return None # Dedup: same observation → skip (with delayed retry for HKO) _CITIES_WITH_DELAYED_API = {"hong kong"} if (current_obs_time and last_obs_time and current_obs_time == last_obs_time and city in _CITIES_WITH_DELAYED_API and now_ts - last_city_ts > 540): time.sleep(4) try: city_weather = _load_airport_city_weather_for_push(city) deb_raw2 = (city_weather.get("deb") or {}).get("prediction") if deb_raw2 is not None: deb_pred = float(deb_raw2) mgm_nearby2 = city_weather.get("mgm_nearby") or [] row2 = None for r in mgm_nearby2: if str(r.get("istNo") or "") == airport_icao or str(r.get("icao") or "") == airport_icao: row2 = r break if not row2 and mgm_nearby2: row2 = mgm_nearby2[0] retry_obs = str(row2.get("obs_time") or "") if row2 else "" if retry_obs and retry_obs != last_obs_time: current_obs_time = retry_obs station_temp = row2.get("temp") if row2 else None current_temp = station_temp or (city_weather.get("current") or {}).get("temp") if current_temp is None or deb_pred is None: return None else: return None except Exception: return None current_obs_ts = _parse_observation_time_epoch(current_obs_time) if ( current_obs_ts is not None and last_obs_ts is not None and current_obs_ts <= last_obs_ts ): logger.debug( "airport push skipped stale observation city={} current_obs_time={} last_obs_time={}", city, current_obs_time, last_obs_time, ) return None if current_obs_time and last_obs_time and current_obs_time == last_obs_time: return None obs_local = ( ((city_weather.get("amos") or {}).get("observation_time_local") or "")[11:16] if len(str((city_weather.get("amos") or {}).get("observation_time_local") or "")) >= 16 else (city_weather.get("airport_current") or {}).get("obs_time") or city_weather.get("local_time") or "" ) message = _build_airport_status_message(city, city_weather, deb_pred, obs_local, state="", source_label=source_label, arome_temp=arome_temp_val, aeroweb_available=aeroweb_available) # Send to all target chats sent = False for chat_id in chat_ids: thread_id = 0 try: kwargs = {} thread_id = _resolve_thread_id(chat_id, city) if thread_id: kwargs["message_thread_id"] = thread_id elif _is_forum_chat_id(chat_id): logger.warning( "airport push skipped missing forum thread mapping city={} chat_id={} mapping_cities={}", city, chat_id, len(_load_city_thread_ids()), ) continue _rate_limited_send(bot, chat_id, message, **kwargs) sent = True except Exception as exc: if thread_id and "message thread not found" in str(exc).lower(): logger.warning( "airport push skipped missing forum thread city={} chat_id={} thread_id={}", city, chat_id, thread_id, ) continue logger.warning("airport push failed city={} chat_id={}: {}", city, chat_id, exc) if sent: logger.info("airport status pushed city={} temp={} deb={} obs_time={}", city, current_temp, deb_pred, current_obs_time) return ( city, { "ts": now_ts, "active": True, "obs_time": current_obs_time, "obs_ts": current_obs_ts, }, ) return None def _due_airport_cities( cities: Set[str], now_ts: int, last_by_city: Dict[str, Any], ) -> List[str]: due: List[str] = [] for city in sorted( cities, key=lambda item: (0 if item in CHINA_HIGH_FREQ_AIRPORT_CITIES else 1, item), ): last_city = last_by_city.get(city) or {} last_city_ts = int(last_city.get("ts") or 0) city_interval = _AIRPORT_PUSH_INTERVAL.get(city, 600) if now_ts - last_city_ts >= city_interval: due.append(city) return due def _run_high_freq_airport_cycle( bot: Any, config: Dict[str, Any], chat_ids: List[str], state: Dict[str, Any], ) -> bool: state_dirty = False now_ts = int(time.time()) last_by_city = state.setdefault("last_by_city", {}) max_workers = max(1, min(4, _env_int("TELEGRAM_AIRPORT_PUSH_MAX_WORKERS", 1))) cities = _due_airport_cities(HIGH_FREQ_AIRPORT_CITIES, now_ts, last_by_city) logger.info( "airport cycle tick cities={} due={} max_workers={}", len(HIGH_FREQ_AIRPORT_CITIES), len(cities), max_workers, ) if not cities: return False pool = _get_airport_executor(max_workers) futures = { pool.submit( _process_airport_city, city, now_ts, last_by_city.get(city) or {}, chat_ids, bot, ): city for city in cities } for future in as_completed(futures): try: result = future.result() except Exception: logger.exception("airport city task crashed city={}", futures[future]) continue if result is None: continue city, entry = result last_by_city[city] = entry state_dirty = True return state_dirty def start_high_freq_airport_push_loop(bot: Any, config: Dict[str, Any]) -> Optional[threading.Thread]: enabled = _env_bool("TELEGRAM_AIRPORT_PUSH_ENABLED", True) chat_ids = get_telegram_chat_ids_from_env() if not enabled: logger.info("airport high-freq push loop disabled") return None if not chat_ids: logger.warning("airport high-freq push loop skipped: TELEGRAM_CHAT_IDS is not set") return None interval_sec = max(30, _env_int("TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC", 60)) def _runner() -> None: state = _load_airport_state() logger.info( "airport high-freq push loop started cities={} interval={}s chat_targets={}", len(HIGH_FREQ_AIRPORT_CITIES), interval_sec, len(chat_ids), ) while True: cycle_started = time.time() try: state = _load_airport_state() if _run_high_freq_airport_cycle( bot=bot, config=config, chat_ids=chat_ids, state=state, ): _save_airport_state(state) except Exception: logger.exception("airport push cycle crashed") elapsed = time.time() - cycle_started sleep_sec = max(5, interval_sec - int(elapsed)) time.sleep(sleep_sec) thread = threading.Thread( target=_runner, name="airport-high-freq-pusher", daemon=True, ) thread.start() logger.info("airport high-freq push loop thread started") return thread