2026-03-06 10:09:49 +08:00
|
|
|
|
import hashlib
|
|
|
|
|
|
import json
|
|
|
|
|
|
import os
|
2026-03-13 13:01:57 +08:00
|
|
|
|
import re
|
2026-03-06 10:09:49 +08:00
|
|
|
|
import threading
|
|
|
|
|
|
import time
|
2026-05-15 16:38:02 +08:00
|
|
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
2026-04-01 18:24:50 +08:00
|
|
|
|
from datetime import datetime, timezone
|
2026-05-17 13:37:51 +08:00
|
|
|
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
2026-03-06 10:09:49 +08:00
|
|
|
|
|
2026-05-25 02:20:15 +08:00
|
|
|
|
import requests as requests_lib
|
2026-03-06 10:09:49 +08:00
|
|
|
|
from loguru import logger
|
|
|
|
|
|
|
2026-05-20 18:16:27 +08:00
|
|
|
|
from src.database.db_manager import DBManager
|
2026-03-20 23:00:07 +08:00
|
|
|
|
from src.database.runtime_state import (
|
|
|
|
|
|
STATE_STORAGE_SQLITE,
|
|
|
|
|
|
TelegramAlertStateRepository,
|
|
|
|
|
|
get_state_storage_mode,
|
|
|
|
|
|
)
|
2026-06-14 04:48:58 +08:00
|
|
|
|
from src.data_collection.city_registry import ALIASES
|
2026-03-06 10:09:49 +08:00
|
|
|
|
from src.data_collection.city_registry import CITY_REGISTRY
|
2026-06-14 03:49:18 +08:00
|
|
|
|
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env, parse_telegram_chat_ids
|
2026-05-27 07:50:05 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-06-14 17:53:24 +08:00
|
|
|
|
from web.services.canonical_temperature import build_city_weather_from_canonical
|
2026-03-06 10:09:49 +08:00
|
|
|
|
|
2026-05-17 17:05:47 +08:00
|
|
|
|
# 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",
|
|
|
|
|
|
)
|
2026-06-14 03:49:18 +08:00
|
|
|
|
_DEFAULT_FORUM_CHAT_ID = "-1003927451869"
|
2026-05-17 17:05:47 +08:00
|
|
|
|
_city_thread_ids: dict = {}
|
2026-06-14 04:48:58 +08:00
|
|
|
|
_CITY_THREAD_IDS_LOCK = threading.Lock()
|
2026-05-17 17:05:47 +08:00
|
|
|
|
|
2026-05-25 02:20:15 +08:00
|
|
|
|
# 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
|
2026-06-14 05:45:07 +08:00
|
|
|
|
_SEND_MSG_MIN_INTERVAL_SEC = float(os.getenv("TELEGRAM_SEND_RATE_LIMIT_SEC", "1.1"))
|
2026-05-25 02:20:15 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-05-17 17:05:47 +08:00
|
|
|
|
|
|
|
|
|
|
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 {}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 04:48:58 +08:00
|
|
|
|
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),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 03:49:18 +08:00
|
|
|
|
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())
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-17 17:05:47 +08:00
|
|
|
|
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."""
|
2026-06-14 03:49:18 +08:00
|
|
|
|
if not _is_forum_chat_id(chat_id):
|
2026-05-17 17:05:47 +08:00
|
|
|
|
return 0
|
|
|
|
|
|
mapping = _load_city_thread_ids()
|
2026-05-27 08:42:08 +08:00
|
|
|
|
city_key = (city or "").strip().lower()
|
2026-05-17 17:05:47 +08:00
|
|
|
|
return int(mapping.get(city_key) or 0)
|
|
|
|
|
|
|
2026-03-06 10:09:49 +08:00
|
|
|
|
|
|
|
|
|
|
SEVERITY_RANK = {
|
|
|
|
|
|
"none": 0,
|
|
|
|
|
|
"low": 1,
|
|
|
|
|
|
"medium": 2,
|
|
|
|
|
|
"high": 3,
|
|
|
|
|
|
}
|
2026-03-20 23:00:07 +08:00
|
|
|
|
_telegram_state_repo = TelegramAlertStateRepository()
|
2026-03-06 10:09:49 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-11 01:56:22 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-27 07:50:05 +08:00
|
|
|
|
def _telegram_push_language() -> str:
|
|
|
|
|
|
return _resolve_telegram_push_language(
|
|
|
|
|
|
"TELEGRAM_AIRPORT_PUSH_LANGUAGE",
|
|
|
|
|
|
"TELEGRAM_PUSH_LANGUAGE",
|
|
|
|
|
|
"POLYWEATHER_TELEGRAM_PUSH_LANGUAGE",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-11 01:56:22 +08:00
|
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-01 17:53:31 +08:00
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:01:57 +08:00
|
|
|
|
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
|
2026-05-27 08:53:36 +08:00
|
|
|
|
label = str(row.get("label") or "").strip()
|
2026-03-13 13:01:57 +08:00
|
|
|
|
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
|
2026-05-27 08:53:36 +08:00
|
|
|
|
label = str(row.get("label") or "").strip().lower()
|
2026-03-13 13:01:57 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-12 10:06:09 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 15:38:05 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-06 10:09:49 +08:00
|
|
|
|
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]:
|
2026-03-20 23:00:07 +08:00
|
|
|
|
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}")
|
2026-03-06 10:09:49 +08:00
|
|
|
|
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:
|
2026-03-20 23:00:07 +08:00
|
|
|
|
mode = get_state_storage_mode()
|
2026-04-16 00:52:55 +08:00
|
|
|
|
if mode == STATE_STORAGE_SQLITE:
|
2026-03-20 23:00:07 +08:00
|
|
|
|
_telegram_state_repo.save_state(state)
|
|
|
|
|
|
if mode == STATE_STORAGE_SQLITE:
|
|
|
|
|
|
return
|
2026-03-06 10:09:49 +08:00
|
|
|
|
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:
|
2026-05-07 21:42:06 +08:00
|
|
|
|
for bucket_name in ("by_signature",):
|
2026-03-06 10:09:49 +08:00
|
|
|
|
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:
|
2026-03-06 10:51:06 +08:00
|
|
|
|
triggered_alerts = alert_payload.get("triggered_alerts") or []
|
|
|
|
|
|
if any(alert.get("force_push") for alert in triggered_alerts):
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2026-03-06 10:09:49 +08:00
|
|
|
|
trigger_count = int(alert_payload.get("trigger_count") or 0)
|
|
|
|
|
|
if trigger_count < min_trigger_count:
|
|
|
|
|
|
return False
|
2026-05-27 08:53:36 +08:00
|
|
|
|
severity = str(alert_payload.get("severity") or "none").lower()
|
2026-03-06 10:09:49 +08:00
|
|
|
|
return SEVERITY_RANK.get(severity, 0) >= SEVERITY_RANK.get(min_severity, 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-11 10:23:33 +08:00
|
|
|
|
def _market_price_cap_ok(
|
|
|
|
|
|
alert_payload: Dict[str, Any],
|
|
|
|
|
|
) -> bool:
|
2026-03-11 01:56:22 +08:00
|
|
|
|
market = alert_payload.get("market_snapshot") or {}
|
|
|
|
|
|
if not isinstance(market, dict) or not market.get("available"):
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
2026-03-12 10:06:09 +08:00
|
|
|
|
primary_market = market.get("primary_market") or {}
|
|
|
|
|
|
if not isinstance(primary_market, dict):
|
|
|
|
|
|
primary_market = {}
|
|
|
|
|
|
market_slug = (
|
2026-05-27 08:53:36 +08:00
|
|
|
|
str(market.get("selected_slug") or "").strip()
|
|
|
|
|
|
or str(primary_market.get("slug") or "").strip()
|
2026-03-12 10:06:09 +08:00
|
|
|
|
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"))
|
2026-05-27 08:53:36 +08:00
|
|
|
|
tradable_reason = str(
|
2026-03-12 10:06:09 +08:00
|
|
|
|
market.get("market_tradable_reason")
|
|
|
|
|
|
or primary_market.get("tradable_reason")
|
|
|
|
|
|
or ""
|
|
|
|
|
|
).strip()
|
2026-05-27 08:53:36 +08:00
|
|
|
|
ended_at = str(
|
2026-03-12 10:06:09 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-03-11 12:15:09 +08:00
|
|
|
|
# Strict rule: use the bucket mapped from multi-model anchor settlement.
|
2026-03-11 01:56:22 +08:00
|
|
|
|
forecast_bucket = market.get("forecast_bucket") or {}
|
2026-03-11 12:15:09 +08:00
|
|
|
|
settle_ref = market.get("anchor_settlement")
|
|
|
|
|
|
if settle_ref is None:
|
|
|
|
|
|
settle_ref = market.get("open_meteo_settlement")
|
2026-05-27 08:53:36 +08:00
|
|
|
|
anchor_model = str(market.get("anchor_model") or "").strip() or "--"
|
2026-03-11 01:56:22 +08:00
|
|
|
|
yes_buy = None
|
|
|
|
|
|
bucket_label = None
|
|
|
|
|
|
if isinstance(forecast_bucket, dict):
|
|
|
|
|
|
yes_buy = _norm_prob(forecast_bucket.get("yes_buy"))
|
2026-05-27 08:53:36 +08:00
|
|
|
|
bucket_label = str(forecast_bucket.get("label") or "").strip() or None
|
2026-03-11 01:56:22 +08:00
|
|
|
|
|
2026-03-13 13:01:57 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-03-11 10:23:33 +08:00
|
|
|
|
if yes_buy is None or yes_buy <= 0.0:
|
2026-03-11 02:13:38 +08:00
|
|
|
|
logger.info(
|
2026-03-11 12:15:09 +08:00
|
|
|
|
"trade alert skipped: no actionable mapped bucket quote city={} bucket={} anchor_model={} anchor_settle={}".format(
|
2026-03-11 02:13:38 +08:00
|
|
|
|
alert_payload.get("city"),
|
2026-03-11 10:23:33 +08:00
|
|
|
|
bucket_label or "--",
|
2026-03-11 12:15:09 +08:00
|
|
|
|
anchor_model,
|
|
|
|
|
|
settle_ref,
|
2026-03-11 02:13:38 +08:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
return False
|
2026-03-11 01:56:22 +08:00
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-06 18:50:11 +08:00
|
|
|
|
def _trigger_type_key(alert_payload: Dict[str, Any]) -> str:
|
|
|
|
|
|
trigger_types = sorted(
|
2026-05-27 08:53:36 +08:00
|
|
|
|
str(alert.get("type") or "").strip()
|
2026-03-06 18:50:11 +08:00
|
|
|
|
for alert in (alert_payload.get("triggered_alerts") or [])
|
|
|
|
|
|
if alert.get("type")
|
|
|
|
|
|
)
|
2026-03-10 09:02:56 +08:00
|
|
|
|
market = alert_payload.get("market_snapshot") or {}
|
|
|
|
|
|
if isinstance(market, dict) and market.get("available"):
|
2026-05-27 08:53:36 +08:00
|
|
|
|
signal = str(market.get("signal_label") or "").strip()
|
|
|
|
|
|
bucket = str(market.get("selected_bucket") or "").strip()
|
2026-03-10 09:02:56 +08:00
|
|
|
|
if signal:
|
|
|
|
|
|
trigger_types.append(f"mkt:{signal}:{bucket}")
|
2026-03-06 18:50:11 +08:00
|
|
|
|
return "|".join(trigger_types)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-11 08:46:32 +08:00
|
|
|
|
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):
|
2026-05-27 08:53:36 +08:00
|
|
|
|
label = str(forecast_bucket.get("label") or "").strip()
|
2026-03-11 08:46:32 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-06 10:09:49 +08:00
|
|
|
|
def _alert_signature(alert_payload: Dict[str, Any]) -> str:
|
|
|
|
|
|
rules = alert_payload.get("rules") or {}
|
2026-03-06 10:51:06 +08:00
|
|
|
|
center_deb = rules.get("ankara_center_deb_hit") or {}
|
2026-03-06 10:09:49 +08:00
|
|
|
|
momentum = rules.get("momentum_spike") or {}
|
|
|
|
|
|
breakthrough = rules.get("forecast_breakthrough") or {}
|
|
|
|
|
|
advection = rules.get("advection") or {}
|
2026-03-06 12:31:15 +08:00
|
|
|
|
suppression = alert_payload.get("suppression") or {}
|
2026-03-10 09:02:56 +08:00
|
|
|
|
market = alert_payload.get("market_snapshot") or {}
|
2026-03-06 10:09:49 +08:00
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
),
|
2026-03-06 10:51:06 +08:00
|
|
|
|
"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),
|
2026-03-06 10:09:49 +08:00
|
|
|
|
"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),
|
2026-03-06 12:31:15 +08:00
|
|
|
|
"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),
|
2026-03-10 09:02:56 +08:00
|
|
|
|
"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"),
|
2026-03-06 10:09:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
raw = json.dumps(signature_payload, sort_keys=True, ensure_ascii=True)
|
|
|
|
|
|
return hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
|
|
|
|
|
|
2026-05-12 17:04:17 +08:00
|
|
|
|
# ── high-freq airport push loop ──
|
|
|
|
|
|
|
2026-05-17 18:04:05 +08:00
|
|
|
|
HIGH_FREQ_AIRPORT_CITIES = {
|
|
|
|
|
|
"seoul", "singapore", "busan", "tokyo", "ankara", "helsinki", "amsterdam",
|
2026-06-11 16:08:33 +08:00
|
|
|
|
"istanbul", "paris", "hong kong", "taipei",
|
2026-05-17 18:04:05 +08:00
|
|
|
|
"beijing", "shanghai", "guangzhou", "qingdao", "chengdu", "chongqing", "wuhan",
|
2026-06-14 04:48:58 +08:00
|
|
|
|
"shenzhen",
|
2026-05-26 00:25:35 +08:00
|
|
|
|
"new york", "los angeles", "chicago", "denver", "atlanta",
|
|
|
|
|
|
"miami", "san francisco", "houston", "dallas", "austin", "seattle",
|
2026-05-17 22:56:43 +08:00
|
|
|
|
"tel aviv",
|
2026-05-17 18:04:05 +08:00
|
|
|
|
}
|
2026-06-11 15:38:05 +08:00
|
|
|
|
CHINA_HIGH_FREQ_AIRPORT_CITIES = {
|
|
|
|
|
|
"beijing", "shanghai", "guangzhou", "qingdao", "chengdu", "chongqing", "wuhan",
|
|
|
|
|
|
}
|
2026-05-17 18:04:05 +08:00
|
|
|
|
HIGH_FREQ_AIRPORT_ICAO = {
|
|
|
|
|
|
"seoul": "RKSI", "singapore": "WSSS", "busan": "RKPK", "tokyo": "44166",
|
|
|
|
|
|
"ankara": "17128", "helsinki": "EFHK", "amsterdam": "EHAM", "istanbul": "17058",
|
2026-06-11 16:08:33 +08:00
|
|
|
|
"paris": "LFPB", "hong kong": "HKO", "taipei": "466920",
|
2026-05-17 18:04:05 +08:00
|
|
|
|
"beijing": "ZBAA", "shanghai": "ZSPD", "guangzhou": "ZGGG", "qingdao": "ZSQD",
|
2026-06-14 04:48:58 +08:00
|
|
|
|
"chengdu": "ZUUU", "chongqing": "ZUCK", "wuhan": "ZHHH", "shenzhen": "LFS",
|
2026-05-26 00:25:35 +08:00
|
|
|
|
"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",
|
2026-05-17 22:56:43 +08:00
|
|
|
|
"tel aviv": "LLBG",
|
2026-05-17 18:04:05 +08:00
|
|
|
|
}
|
2026-05-25 20:53:03 +08:00
|
|
|
|
# Settlement runway mapping — matches settlement anchor stations.
|
2026-05-20 18:16:27 +08:00
|
|
|
|
# Format: (low_number, high_number) order-independent; stored sorted for lookup.
|
|
|
|
|
|
SETTLEMENT_RUNWAY_PAIRS: Dict[str, Set[Tuple[str, str]]] = {
|
2026-05-17 13:37:51 +08:00
|
|
|
|
"shanghai": {("17L", "35R")},
|
|
|
|
|
|
"beijing": {("01", "19")},
|
|
|
|
|
|
"guangzhou": {("02L", "20R")},
|
|
|
|
|
|
"chengdu": {("02L", "20R")},
|
2026-05-20 18:16:27 +08:00
|
|
|
|
"chongqing": {("02L", "20R")},
|
|
|
|
|
|
"wuhan": {("04", "22")},
|
2026-05-28 10:55:32 +08:00
|
|
|
|
"qingdao": {("16", "34")},
|
2026-05-17 13:37:51 +08:00
|
|
|
|
"seoul": {("15R", "33L")},
|
|
|
|
|
|
}
|
2026-05-15 14:36:43 +08:00
|
|
|
|
|
2026-05-28 10:55:32 +08:00
|
|
|
|
SETTLEMENT_RUNWAY_TARGETS: Dict[str, str] = {
|
|
|
|
|
|
"shanghai": "35R",
|
|
|
|
|
|
"chengdu": "02L",
|
|
|
|
|
|
"chongqing": "02L",
|
|
|
|
|
|
"guangzhou": "02L",
|
|
|
|
|
|
"wuhan": "04",
|
|
|
|
|
|
"beijing": "01",
|
|
|
|
|
|
"qingdao": "34",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 18:16:27 +08:00
|
|
|
|
# All cities with active runway observation data (AMSC AWOS / AMOS).
|
|
|
|
|
|
RUNWAY_OBSERVATION_CITIES = {
|
2026-05-20 19:38:07 +08:00
|
|
|
|
"shanghai", "beijing", "guangzhou",
|
2026-05-20 18:16:27 +08:00
|
|
|
|
"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
|
|
|
|
|
|
|
2026-05-27 07:50:05 +08:00
|
|
|
|
_FUNCTION_HASHTAGS_ZH = {
|
2026-05-15 14:36:43 +08:00
|
|
|
|
"runway": "#跑道观测",
|
2026-05-15 15:37:25 +08:00
|
|
|
|
"airport": "#机场观测",
|
2026-05-15 14:36:43 +08:00
|
|
|
|
"trade": "#交易机会",
|
|
|
|
|
|
}
|
2026-05-27 07:50:05 +08:00
|
|
|
|
_FUNCTION_HASHTAGS_EN = {
|
|
|
|
|
|
"runway": "#RunwayObs",
|
|
|
|
|
|
"airport": "#AirportObs",
|
|
|
|
|
|
"trade": "#TradeAlert",
|
|
|
|
|
|
}
|
2026-05-15 14:36:43 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _city_hashtag(city: Optional[str]) -> Optional[str]:
|
2026-05-27 08:42:08 +08:00
|
|
|
|
text = (city or "").strip()
|
2026-05-15 14:36:43 +08:00
|
|
|
|
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]:
|
2026-05-27 08:42:08 +08:00
|
|
|
|
text = re.sub(r"[^A-Za-z0-9]+", "", (station or "").upper())
|
2026-05-15 14:36:43 +08:00
|
|
|
|
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,
|
2026-05-27 07:50:05 +08:00
|
|
|
|
language: Optional[str] = None,
|
2026-05-15 14:36:43 +08:00
|
|
|
|
) -> str:
|
|
|
|
|
|
tags: List[str] = []
|
2026-05-27 07:50:05 +08:00
|
|
|
|
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)
|
2026-05-15 14:36:43 +08:00
|
|
|
|
for item in extra or []:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
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)
|
2026-05-15 14:36:43 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-15 16:20:07 +08:00
|
|
|
|
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 "--"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-17 13:37:51 +08:00
|
|
|
|
def _normalize_runway_label(value: Any) -> str:
|
2026-05-27 08:53:36 +08:00
|
|
|
|
return re.sub(r"[^0-9A-Z]+", "", str(value or "").strip().upper())
|
2026-05-17 13:37:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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())}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-28 10:55:32 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-17 13:37:51 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 18:16:27 +08:00
|
|
|
|
def _settlement_runway_for_city(city: str) -> Optional[Tuple[str, str]]:
|
|
|
|
|
|
"""Return the settlement runway pair for a city, if configured."""
|
2026-05-27 08:42:08 +08:00
|
|
|
|
pairs = SETTLEMENT_RUNWAY_PAIRS.get((city or "").strip().lower(), set())
|
2026-05-20 18:16:27 +08:00
|
|
|
|
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."""
|
2026-05-27 08:42:08 +08:00
|
|
|
|
pair_set = SETTLEMENT_RUNWAY_PAIRS.get((city or "").strip().lower(), set())
|
2026-05-20 18:16:27 +08:00
|
|
|
|
return _runway_pair_key(r1, r2) in pair_set
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-27 07:50:05 +08:00
|
|
|
|
def _wind_regime_label(city: str, wind_dir: Optional[int], language: Optional[str] = None) -> Optional[str]:
|
2026-05-20 18:16:27 +08:00
|
|
|
|
"""Classify wind direction into a thermal regime label."""
|
|
|
|
|
|
if wind_dir is None:
|
|
|
|
|
|
return None
|
2026-05-27 08:42:08 +08:00
|
|
|
|
regimes = WIND_REGIME.get((city or "").strip().lower(), {})
|
2026-05-20 18:16:27 +08:00
|
|
|
|
sea = regimes.get("sea_breeze")
|
|
|
|
|
|
warm = regimes.get("warm_advection")
|
|
|
|
|
|
if sea and sea[0] != sea[1] and sea[0] <= wind_dir <= sea[1]:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
return _copy(language, "sea-breeze cooling", "海风降温")
|
2026-05-20 18:16:27 +08:00
|
|
|
|
if warm and warm[0] != warm[1] and warm[0] <= wind_dir <= warm[1]:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
return _copy(language, "warm advection", "暖平流增强")
|
2026-05-20 18:16:27 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-28 11:05:50 +08:00
|
|
|
|
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]:
|
2026-05-20 18:16:27 +08:00
|
|
|
|
"""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:
|
2026-05-28 11:05:50 +08:00
|
|
|
|
t = _runway_row_temp_for_city(city, r)
|
2026-05-20 18:16:27 +08:00
|
|
|
|
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,
|
2026-05-27 07:50:05 +08:00
|
|
|
|
language: Optional[str] = None,
|
2026-05-20 18:16:27 +08:00
|
|
|
|
) -> str:
|
|
|
|
|
|
"""Compute a simple runway heat signal label."""
|
|
|
|
|
|
if slope_15m is None:
|
|
|
|
|
|
return ""
|
2026-05-27 07:50:05 +08:00
|
|
|
|
regime = _wind_regime_label(city, wind_dir, language)
|
|
|
|
|
|
warm_regime = _copy(language, "warm advection", "暖平流增强")
|
|
|
|
|
|
sea_regime = _copy(language, "sea-breeze cooling", "海风降温")
|
2026-05-20 18:16:27 +08:00
|
|
|
|
if slope_15m >= 1.0:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
return _copy(language, "🚀 Peak push strengthening", "🚀 冲顶增强") if regime == warm_regime else _copy(language, "🔥 Warming", "🔥 升温中")
|
2026-05-20 18:16:27 +08:00
|
|
|
|
if slope_15m >= 0.5:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
return _copy(language, "🔥 Warming", "🔥 升温中")
|
2026-05-20 18:16:27 +08:00
|
|
|
|
if slope_15m >= -0.2:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
return _copy(language, "⚠️ Watch sea-breeze cooling", "⚠️ 高位观察") if regime == sea_regime else _copy(language, "⏸️ Holding near high", "⏸️ 高位横盘")
|
|
|
|
|
|
return _copy(language, "🧊 Peak-risk cooling", "🧊 过峰风险")
|
2026-05-20 18:16:27 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-17 13:37:51 +08:00
|
|
|
|
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 [],
|
|
|
|
|
|
)
|
2026-05-28 10:55:32 +08:00
|
|
|
|
endpoint = _settlement_endpoint_from_obs(city, runway_pairs, _points)
|
|
|
|
|
|
if endpoint is not None:
|
|
|
|
|
|
return float(endpoint["temp"])
|
2026-05-17 13:37:51 +08:00
|
|
|
|
del runway_pairs
|
|
|
|
|
|
valid = [float(t) for (t, _d) in runway_temps if t is not None]
|
|
|
|
|
|
return max(valid) if valid else None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 17:04:17 +08:00
|
|
|
|
_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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-15 00:09:46 +08:00
|
|
|
|
_AROME_CACHE: Dict[str, Any] = {}
|
|
|
|
|
|
_AROME_CACHE_TTL_SEC = 600 # AROME HD updates every 15 min; cache 10 min
|
|
|
|
|
|
|
2026-05-12 23:59:06 +08:00
|
|
|
|
def _fetch_arome_temp() -> Optional[float]:
|
2026-05-15 00:09:46 +08:00
|
|
|
|
"""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
|
2026-05-12 23:59:06 +08:00
|
|
|
|
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"
|
|
|
|
|
|
)
|
2026-05-25 02:20:15 +08:00
|
|
|
|
resp = _get_http_session().get(url, timeout=8)
|
2026-05-12 23:59:06 +08:00
|
|
|
|
data = resp.json()
|
|
|
|
|
|
temps = (data.get("minutely_15") or {}).get("temperature_2m") or []
|
2026-05-15 00:09:46 +08:00
|
|
|
|
result = float(temps[-1]) if temps else None
|
|
|
|
|
|
_AROME_CACHE["value"] = result
|
|
|
|
|
|
_AROME_CACHE["ts"] = now
|
|
|
|
|
|
return result
|
2026-05-12 23:59:06 +08:00
|
|
|
|
except Exception:
|
2026-05-15 00:09:46 +08:00
|
|
|
|
return _AROME_CACHE.get("value")
|
2026-05-12 23:59:06 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-25 21:48:20 +08:00
|
|
|
|
_LAST_AEROWEB: Dict[str, Any] = {} # {"temp": 31.0, "max_so_far": 31.0, "max_time": "14:00", "ts": epoch}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-15 23:54:19 +08:00
|
|
|
|
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:
|
2026-05-15 23:59:57 +08:00
|
|
|
|
"""Generate a market-structure interpretation based on current state."""
|
2026-05-15 23:54:19 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-15 23:59:57 +08:00
|
|
|
|
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}°")
|
2026-05-15 23:54:19 +08:00
|
|
|
|
|
2026-05-15 23:59:57 +08:00
|
|
|
|
# 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("已脱离日内峰值")
|
2026-05-15 23:54:19 +08:00
|
|
|
|
|
2026-05-15 23:59:57 +08:00
|
|
|
|
return "\n".join(lines)
|
2026-05-15 23:54:19 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 19:02:36 +08:00
|
|
|
|
def _build_airport_status_message(
|
2026-05-12 17:04:17 +08:00
|
|
|
|
city: str,
|
2026-05-12 19:02:36 +08:00
|
|
|
|
city_weather: Dict[str, Any],
|
2026-05-12 17:04:17 +08:00
|
|
|
|
deb_pred: Optional[float],
|
2026-05-12 18:57:47 +08:00
|
|
|
|
local_time: str = "",
|
2026-05-15 23:36:03 +08:00
|
|
|
|
state: str = "",
|
2026-05-25 21:28:17 +08:00
|
|
|
|
source_label: str = "",
|
2026-05-25 21:35:39 +08:00
|
|
|
|
arome_temp: Optional[float] = None,
|
2026-05-25 21:48:20 +08:00
|
|
|
|
aeroweb_available: bool = False,
|
2026-05-27 07:50:05 +08:00
|
|
|
|
language: Optional[str] = None,
|
2026-05-12 17:04:17 +08:00
|
|
|
|
) -> str:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
language = _normalize_push_language(language or _telegram_push_language())
|
2026-05-17 13:37:51 +08:00
|
|
|
|
_AIRPORT_EN = {"seoul": "Incheon", "singapore": "Changi", "busan": "Gimhae", "tokyo": "Haneda",
|
2026-05-12 21:08:43 +08:00
|
|
|
|
"ankara": "Esenboğa", "helsinki": "Vantaa", "amsterdam": "Schiphol",
|
2026-05-27 08:53:36 +08:00
|
|
|
|
"istanbul": "Airport", "paris": "Le Bourget",
|
2026-06-11 16:08:33 +08:00
|
|
|
|
"hong kong": "Observatory",
|
2026-05-27 08:53:36 +08:00
|
|
|
|
"taipei": "Songshan", "beijing": "Capital", "shanghai": "Pudong",
|
|
|
|
|
|
"guangzhou": "Baiyun", "qingdao": "Jiaodong",
|
|
|
|
|
|
"chengdu": "Shuangliu", "chongqing": "Jiangbei", "wuhan": "Tianhe",
|
2026-06-14 04:48:58 +08:00
|
|
|
|
"shenzhen": "Lau Fau Shan",
|
2026-05-27 08:53:36 +08:00
|
|
|
|
"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)
|
2026-05-28 10:55:32 +08:00
|
|
|
|
settlement_endpoint = _settlement_endpoint_from_obs(city, runway_pairs, point_temps)
|
2026-05-27 08:53:36 +08:00
|
|
|
|
|
2026-05-28 10:55:32 +08:00
|
|
|
|
# ── Display temp: settlement endpoint first, then airport temp ──
|
2026-05-27 08:53:36 +08:00
|
|
|
|
display_temp: Optional[float] = None
|
2026-05-28 10:55:32 +08:00
|
|
|
|
if settlement_endpoint is not None:
|
|
|
|
|
|
display_temp = float(settlement_endpoint["temp"])
|
2026-05-20 18:16:27 +08:00
|
|
|
|
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:
|
2026-05-23 22:04:09 +08:00
|
|
|
|
logger.warning(
|
|
|
|
|
|
"airport message fallback city={}: station {} not found in mgm_nearby, falling back to current.temp",
|
|
|
|
|
|
city, airport_icao,
|
|
|
|
|
|
)
|
2026-05-20 18:16:27 +08:00
|
|
|
|
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
|
2026-05-28 11:05:50 +08:00
|
|
|
|
slope_15m = _compute_slope_15m(amos_icao, display_temp, city) if is_amsc and display_temp is not None else None
|
2026-05-27 07:50:05 +08:00
|
|
|
|
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
|
2026-05-13 00:38:05 +08:00
|
|
|
|
|
|
|
|
|
|
max_so_far, max_temp_time = _get_airport_daily_high(city_weather)
|
2026-05-20 18:16:27 +08:00
|
|
|
|
# ── Build message ──
|
|
|
|
|
|
lines: List[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
# Header
|
2026-05-15 14:36:43 +08:00
|
|
|
|
hashtag_line = _build_telegram_hashtag_line(
|
2026-05-15 15:37:25 +08:00
|
|
|
|
"runway" if has_runway else "airport",
|
2026-05-15 14:36:43 +08:00
|
|
|
|
city=city,
|
2026-05-27 07:50:05 +08:00
|
|
|
|
language=language,
|
2026-05-15 14:36:43 +08:00
|
|
|
|
)
|
2026-05-20 18:16:27 +08:00
|
|
|
|
icao_display = f"{amos_icao} · " if amos_icao else ""
|
2026-05-28 10:55:32 +08:00
|
|
|
|
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 ""
|
2026-05-20 18:16:27 +08:00
|
|
|
|
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)
|
2026-05-15 23:54:19 +08:00
|
|
|
|
|
2026-05-20 18:16:27 +08:00
|
|
|
|
# Heat signal
|
|
|
|
|
|
if heat_signal:
|
|
|
|
|
|
lines.append("")
|
|
|
|
|
|
lines.append(heat_signal)
|
|
|
|
|
|
if state:
|
|
|
|
|
|
lines.append(state)
|
2026-05-15 23:54:19 +08:00
|
|
|
|
|
2026-05-20 18:16:27 +08:00
|
|
|
|
# All runway detail block
|
|
|
|
|
|
if has_runway:
|
2026-05-15 23:54:19 +08:00
|
|
|
|
lines.append("")
|
2026-05-15 16:20:07 +08:00
|
|
|
|
for i, ((r1, r2), (t, _d)) in enumerate(zip(runway_pairs, runway_temps)):
|
2026-05-20 18:16:27 +08:00
|
|
|
|
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)
|
2026-05-28 10:55:32 +08:00
|
|
|
|
marker = f" {_copy(language, '★Settlement', '★结算')}" if is_settlement else ""
|
2026-05-20 18:16:27 +08:00
|
|
|
|
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)}"
|
2026-05-28 10:55:32 +08:00
|
|
|
|
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}"
|
2026-05-20 18:16:27 +08:00
|
|
|
|
lines.append(line)
|
|
|
|
|
|
else:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
temp_symbol = str(city_weather.get("temp_symbol") or "°C").strip()
|
|
|
|
|
|
lines.append(f"{r1}/{r2}{marker} {t:.1f}{temp_symbol}")
|
2026-05-15 23:54:19 +08:00
|
|
|
|
|
2026-05-20 18:16:27 +08:00
|
|
|
|
# Summary stats
|
2026-05-15 23:54:19 +08:00
|
|
|
|
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 "--"
|
2026-05-25 21:41:48 +08:00
|
|
|
|
|
|
|
|
|
|
if city == "paris":
|
2026-05-25 21:48:20 +08:00
|
|
|
|
if aeroweb_available and display_temp is not None:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
lines.append(f"{_copy(language, 'Current observation', '当前实况')}: {cur_str}")
|
2026-05-25 21:41:48 +08:00
|
|
|
|
else:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
lines.append(_copy(language, "Current observation: unavailable", "当前实况:暂无"))
|
2026-05-20 18:41:59 +08:00
|
|
|
|
else:
|
2026-05-25 21:41:48 +08:00
|
|
|
|
if has_runway:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
current_label = (
|
|
|
|
|
|
_copy(language, "Settlement runway now", "结算跑道当前")
|
|
|
|
|
|
if settlement_pair
|
|
|
|
|
|
else _copy(language, "Runway now", "跑道当前")
|
|
|
|
|
|
)
|
|
|
|
|
|
lines.append(f"{current_label}: {cur_str}")
|
2026-05-25 21:41:48 +08:00
|
|
|
|
else:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
lines.append(f"{_copy(language, 'Current', '当前')}: {cur_str}")
|
2026-05-25 21:41:48 +08:00
|
|
|
|
|
2026-05-25 21:48:20 +08:00
|
|
|
|
if city == "paris":
|
|
|
|
|
|
if aeroweb_available and max_so_far is not None:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
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}")
|
2026-05-25 21:48:20 +08:00
|
|
|
|
elif not aeroweb_available:
|
|
|
|
|
|
last_temp = _LAST_AEROWEB.get("temp")
|
|
|
|
|
|
if last_temp is not None:
|
|
|
|
|
|
last_time = _LAST_AEROWEB.get("max_time", "")
|
2026-05-27 07:50:05 +08:00
|
|
|
|
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}")
|
2026-05-25 21:48:20 +08:00
|
|
|
|
elif max_so_far is not None:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
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 "")
|
2026-05-20 18:41:59 +08:00
|
|
|
|
if has_runway:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
high_label = _copy(language, "Today's runway high", "今日跑道高点")
|
|
|
|
|
|
lines.append(f"{high_label}: {max_so_far:.1f}{temp_symbol}{time_str}")
|
2026-05-20 18:41:59 +08:00
|
|
|
|
else:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
high_label = _copy(language, "Today's high", "日高")
|
|
|
|
|
|
lines.append(f"{high_label}: {max_so_far:.1f}{temp_symbol}{time_str}")
|
2026-05-20 18:16:27 +08:00
|
|
|
|
if slope_15m is not None:
|
|
|
|
|
|
sign = "+" if slope_15m >= 0 else ""
|
2026-05-27 07:50:05 +08:00
|
|
|
|
lines.append(f"{_copy(language, '15m trend', '15分钟趋势')}: {sign}{slope_15m:.1f}°")
|
2026-05-20 18:16:27 +08:00
|
|
|
|
if wind_dir is not None:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
wind_str = f"{_copy(language, 'Wind dir', '风向')}: {wind_dir}°"
|
2026-05-20 18:16:27 +08:00
|
|
|
|
if wind_label:
|
|
|
|
|
|
wind_str += f" {wind_label}"
|
|
|
|
|
|
lines.append(wind_str)
|
2026-05-22 03:57:40 +08:00
|
|
|
|
# --- 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
|
2026-05-27 07:50:05 +08:00
|
|
|
|
metar_time = f"{_copy(language, 'Beijing time', '北京时')} {bj_h:02d}:{mm}"
|
2026-05-22 03:57:40 +08:00
|
|
|
|
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)
|
2026-05-27 07:50:05 +08:00
|
|
|
|
lines.append(f"{_copy(language, 'METAR', '报文')}: {' '.join(bits)}")
|
2026-05-12 21:08:43 +08:00
|
|
|
|
if deb_pred is not None:
|
2026-05-25 21:48:20 +08:00
|
|
|
|
if city == "paris":
|
|
|
|
|
|
if aeroweb_available and display_temp is not None:
|
|
|
|
|
|
diff = display_temp - deb_pred
|
|
|
|
|
|
sign = "+" if diff >= 0 else ""
|
2026-05-27 07:50:05 +08:00
|
|
|
|
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}")
|
2026-05-25 21:48:20 +08:00
|
|
|
|
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 ""
|
2026-05-27 07:50:05 +08:00
|
|
|
|
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}")
|
2026-05-25 21:48:20 +08:00
|
|
|
|
else:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}")
|
2026-05-25 21:48:20 +08:00
|
|
|
|
else:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}")
|
2026-05-25 21:48:20 +08:00
|
|
|
|
elif display_temp is not None and display_temp > deb_pred:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
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}")
|
2026-05-15 23:54:19 +08:00
|
|
|
|
else:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
lines.append(f"DEB: {deb_pred:.1f}{temp_symbol}" if not _is_zh(language) else f"DEB:{deb_pred:.1f}{temp_symbol}")
|
2026-05-15 23:54:19 +08:00
|
|
|
|
|
2026-05-25 21:48:20 +08:00
|
|
|
|
if city == "paris":
|
|
|
|
|
|
lines.append("")
|
|
|
|
|
|
if aeroweb_available and display_temp is not None:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
label = _copy(language, "AEROWEB airport obs", "AEROWEB 机场实况")
|
|
|
|
|
|
lines.append(f"📡 {label}: {display_temp:.1f}{temp_symbol} · Météo-France")
|
2026-05-25 21:48:20 +08:00
|
|
|
|
else:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
lines.append(_copy(language, "📡 AEROWEB airport obs: unavailable", "📡 AEROWEB 机场实况:暂不可用"))
|
2026-05-25 21:48:20 +08:00
|
|
|
|
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 ""
|
2026-05-27 07:50:05 +08:00
|
|
|
|
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}")
|
2026-05-25 21:48:20 +08:00
|
|
|
|
else:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
label = _copy(language, "AROME HD 15m nowcast", "AROME HD 15分钟临近预报")
|
|
|
|
|
|
lines.append(f"🕐 {label}: {arome_temp:.1f}{temp_symbol}")
|
2026-05-25 21:48:20 +08:00
|
|
|
|
else:
|
2026-05-27 07:50:05 +08:00
|
|
|
|
label = _copy(language, "AROME HD 15m nowcast", "AROME HD 15分钟临近预报")
|
|
|
|
|
|
lines.append(f"🕐 {label}: --")
|
2026-05-25 21:48:20 +08:00
|
|
|
|
if not aeroweb_available:
|
|
|
|
|
|
lines.append("")
|
2026-05-27 07:50:05 +08:00
|
|
|
|
lines.append(_copy(language, "Note: showing model reference only; observed high is not updating.", "提示:当前仅显示模型参考,不更新实况日高。"))
|
2026-05-25 21:48:20 +08:00
|
|
|
|
elif source_label:
|
2026-05-25 21:28:17 +08:00
|
|
|
|
lines.append("")
|
|
|
|
|
|
lines.append(f"📡 {source_label}")
|
|
|
|
|
|
|
2026-05-20 18:16:27 +08:00
|
|
|
|
# Model summary (compact)
|
2026-05-15 23:54:19 +08:00
|
|
|
|
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
|
2026-05-27 07:50:05 +08:00
|
|
|
|
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}")
|
2026-05-15 23:54:19 +08:00
|
|
|
|
|
2026-05-12 17:04:17 +08:00
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 21:15:57 +08:00
|
|
|
|
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:
|
2026-05-12 21:12:53 +08:00
|
|
|
|
try:
|
2026-05-12 21:15:57 +08:00
|
|
|
|
max_so_far = round(float(max_so_far), 1)
|
2026-05-12 21:12:53 +08:00
|
|
|
|
except Exception:
|
2026-05-12 21:15:57 +08:00
|
|
|
|
max_so_far = None
|
|
|
|
|
|
return max_so_far, max_time
|
2026-05-12 21:12:53 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-05 14:38:58 +08:00
|
|
|
|
# Per-city push interval. The loop wakes every minute, but Telegram should
|
|
|
|
|
|
# read recent city cache instead of acting as an upstream observation producer.
|
2026-05-12 20:13:15 +08:00
|
|
|
|
_AIRPORT_PUSH_INTERVAL = {
|
2026-06-05 14:38:58 +08:00
|
|
|
|
city: 600 for city in HIGH_FREQ_AIRPORT_CITIES
|
2026-05-12 20:13:15 +08:00
|
|
|
|
}
|
2026-06-05 14:38:58 +08:00
|
|
|
|
_AIRPORT_PUSH_INTERVAL.update({
|
|
|
|
|
|
"seoul": 60,
|
|
|
|
|
|
"busan": 60,
|
2026-06-11 15:38:05 +08:00
|
|
|
|
**{city: 60 for city in CHINA_HIGH_FREQ_AIRPORT_CITIES},
|
2026-06-05 14:38:58 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 15:47:58 +08:00
|
|
|
|
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 = [
|
2026-06-14 17:53:24 +08:00
|
|
|
|
(payload.get("canonical_temperature") or {}).get("observed_at"),
|
2026-06-11 15:47:58 +08:00
|
|
|
|
amos.get("observation_time"),
|
|
|
|
|
|
airport_primary.get("obs_time"),
|
|
|
|
|
|
airport_current.get("obs_time"),
|
2026-06-14 17:53:24 +08:00
|
|
|
|
current.get("observed_at"),
|
2026-06-11 15:47:58 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-05 14:38:58 +08:00
|
|
|
|
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()
|
2026-06-11 15:47:58 +08:00
|
|
|
|
candidates: List[Tuple[int, int, float, str, Dict[str, Any]]] = []
|
2026-06-05 14:38:58 +08:00
|
|
|
|
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
|
2026-06-11 15:47:58 +08:00
|
|
|
|
is_fresh = age_sec <= max_age
|
2026-06-05 14:38:58 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-06-11 15:47:58 +08:00
|
|
|
|
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),
|
|
|
|
|
|
)
|
2026-06-05 14:38:58 +08:00
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.debug("airport push city cache read failed city={}: {}", normalized_city, exc)
|
2026-06-11 15:47:58 +08:00
|
|
|
|
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
|
2026-06-05 14:38:58 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-15 00:09:12 +08:00
|
|
|
|
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]]:
|
2026-06-14 17:53:24 +08:00
|
|
|
|
normalized_city = (city or "").strip().lower()
|
|
|
|
|
|
if not normalized_city:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
2026-06-15 00:09:12 +08:00
|
|
|
|
db = db or DBManager()
|
2026-06-14 17:53:24 +08:00
|
|
|
|
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
|
2026-06-15 00:09:12 +08:00
|
|
|
|
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
|
2026-06-14 17:53:24 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-05 14:38:58 +08:00
|
|
|
|
def _load_airport_city_weather_for_push(city: str) -> Dict[str, Any]:
|
|
|
|
|
|
cached = _read_cached_airport_city_weather(city)
|
2026-06-14 17:53:24 +08:00
|
|
|
|
canonical = _read_canonical_airport_city_weather(city)
|
|
|
|
|
|
if canonical is not None:
|
2026-06-15 00:09:12 +08:00
|
|
|
|
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
|
2026-06-05 14:38:58 +08:00
|
|
|
|
|
2026-06-14 17:53:24 +08:00
|
|
|
|
raise RuntimeError(f"no cached city weather for airport push city={city}")
|
2026-06-05 14:38:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 01:10:31 +08:00
|
|
|
|
# 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,
|
2026-06-11 16:08:33 +08:00
|
|
|
|
"hong kong": 1.5, "taipei": 1.5,
|
2026-05-13 01:10:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 15:04:54 +08:00
|
|
|
|
# 部分城市 Open-Meteo 算出的 peak 窗口偏窄,用 fallback 拓宽
|
|
|
|
|
|
# (例如沿海城市受海风影响,高温窗口被压缩)
|
|
|
|
|
|
_AIRPORT_PEAK_FALLBACK = {
|
|
|
|
|
|
"busan": (12, 16),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def _in_peak_time_window(city: str, city_weather: Dict[str, Any]) -> bool:
|
2026-05-13 01:10:31 +08:00
|
|
|
|
"""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")
|
2026-05-13 15:04:54 +08:00
|
|
|
|
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
|
2026-05-13 01:10:31 +08:00
|
|
|
|
local_time = city_weather.get("local_time") or ""
|
2026-05-27 08:42:08 +08:00
|
|
|
|
if first_h is None or last_h is None or not local_time:
|
2026-05-13 01:10:31 +08:00
|
|
|
|
return False
|
|
|
|
|
|
try:
|
|
|
|
|
|
current_h, current_m = int(local_time[:2]), int(local_time[3:5])
|
|
|
|
|
|
current_minutes = current_h * 60 + current_m
|
2026-05-13 01:13:07 +08:00
|
|
|
|
# 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)
|
2026-05-13 01:10:31 +08:00
|
|
|
|
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
|
2026-05-12 19:02:36 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-17 18:04:05 +08:00
|
|
|
|
def _process_airport_city(
|
|
|
|
|
|
city: str,
|
|
|
|
|
|
now_ts: int,
|
|
|
|
|
|
last_city: dict,
|
|
|
|
|
|
chat_ids: List[str],
|
|
|
|
|
|
bot: Any,
|
2026-05-17 18:57:02 +08:00
|
|
|
|
) -> Optional[Tuple[str, dict]]:
|
2026-05-17 18:04:05 +08:00
|
|
|
|
"""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 "")
|
2026-06-11 15:38:05 +08:00
|
|
|
|
last_obs_ts = (
|
|
|
|
|
|
_parse_observation_time_epoch(last_city.get("obs_ts"))
|
|
|
|
|
|
or _parse_observation_time_epoch(last_obs_time)
|
|
|
|
|
|
)
|
2026-05-17 18:04:05 +08:00
|
|
|
|
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:
|
2026-06-05 14:38:58 +08:00
|
|
|
|
city_weather = _load_airport_city_weather_for_push(city)
|
2026-05-17 18:04:05 +08:00
|
|
|
|
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:
|
2026-05-24 16:03:29 +08:00
|
|
|
|
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
|
2026-05-17 18:04:05 +08:00
|
|
|
|
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 []
|
2026-05-28 10:55:32 +08:00
|
|
|
|
runway_pairs, runway_temps, point_temps = _select_focus_runway_obs(
|
2026-05-17 18:04:05 +08:00
|
|
|
|
city, runway_pairs, runway_temps,
|
|
|
|
|
|
runway_obs.get("point_temperatures") or [],
|
|
|
|
|
|
)
|
2026-05-28 10:55:32 +08:00
|
|
|
|
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)
|
2026-05-17 18:04:05 +08:00
|
|
|
|
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 "")
|
2026-06-11 15:38:05 +08:00
|
|
|
|
current_obs_ts = _parse_observation_time_epoch(current_obs_time)
|
2026-05-25 21:28:17 +08:00
|
|
|
|
source_label = "" # human-readable data source for Paris messages
|
2026-05-25 21:38:10 +08:00
|
|
|
|
arome_temp_val = None # AROME HD temperature for display (always fetched for comparison)
|
2026-05-25 21:48:20 +08:00
|
|
|
|
aeroweb_available = False
|
2026-05-17 18:04:05 +08:00
|
|
|
|
if city == "paris":
|
2026-05-25 21:38:10 +08:00
|
|
|
|
arome_temp_val = _fetch_arome_temp()
|
2026-05-22 21:50:37 +08:00
|
|
|
|
airport_primary = city_weather.get("airport_primary") or {}
|
2026-05-25 21:48:20 +08:00
|
|
|
|
# Detect AEROWEB availability
|
|
|
|
|
|
if airport_primary.get("source_code") == "aeroweb" and airport_primary.get("temp") is not None:
|
|
|
|
|
|
aeroweb_available = True
|
2026-05-25 21:28:17 +08:00
|
|
|
|
source_label = "AEROWEB 机场实况 · Météo-France"
|
2026-05-25 21:48:20 +08:00
|
|
|
|
# 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:
|
2026-05-17 18:04:05 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
2026-06-11 16:08:33 +08:00
|
|
|
|
# Dedup: same observation → skip (with delayed retry for HKO)
|
|
|
|
|
|
_CITIES_WITH_DELAYED_API = {"hong kong"}
|
2026-05-17 18:04:05 +08:00
|
|
|
|
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:
|
2026-06-05 14:38:58 +08:00
|
|
|
|
city_weather = _load_airport_city_weather_for_push(city)
|
2026-05-17 18:04:05 +08:00
|
|
|
|
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
|
2026-06-11 15:38:05 +08:00
|
|
|
|
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:
|
2026-05-17 18:04:05 +08:00
|
|
|
|
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 ""
|
|
|
|
|
|
)
|
2026-05-25 21:48:20 +08:00
|
|
|
|
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)
|
2026-05-17 18:04:05 +08:00
|
|
|
|
|
|
|
|
|
|
# Send to all target chats
|
|
|
|
|
|
sent = False
|
|
|
|
|
|
for chat_id in chat_ids:
|
2026-06-11 15:58:13 +08:00
|
|
|
|
thread_id = 0
|
2026-05-17 18:04:05 +08:00
|
|
|
|
try:
|
|
|
|
|
|
kwargs = {}
|
|
|
|
|
|
thread_id = _resolve_thread_id(chat_id, city)
|
|
|
|
|
|
if thread_id:
|
|
|
|
|
|
kwargs["message_thread_id"] = thread_id
|
2026-06-14 03:49:18 +08:00
|
|
|
|
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
|
2026-05-25 02:20:15 +08:00
|
|
|
|
_rate_limited_send(bot, chat_id, message, **kwargs)
|
2026-05-17 18:04:05 +08:00
|
|
|
|
sent = True
|
|
|
|
|
|
except Exception as exc:
|
2026-06-11 15:58:13 +08:00
|
|
|
|
if thread_id and "message thread not found" in str(exc).lower():
|
|
|
|
|
|
logger.warning(
|
2026-06-14 03:58:40 +08:00
|
|
|
|
"airport push skipped missing forum thread city={} chat_id={} thread_id={}",
|
2026-06-11 15:58:13 +08:00
|
|
|
|
city,
|
|
|
|
|
|
chat_id,
|
|
|
|
|
|
thread_id,
|
|
|
|
|
|
)
|
2026-06-14 03:58:40 +08:00
|
|
|
|
continue
|
2026-05-17 18:04:05 +08:00
|
|
|
|
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)
|
2026-06-11 15:38:05 +08:00
|
|
|
|
return (
|
|
|
|
|
|
city,
|
|
|
|
|
|
{
|
|
|
|
|
|
"ts": now_ts,
|
|
|
|
|
|
"active": True,
|
|
|
|
|
|
"obs_time": current_obs_time,
|
|
|
|
|
|
"obs_ts": current_obs_ts,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
2026-05-17 18:04:05 +08:00
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-05 14:38:58 +08:00
|
|
|
|
def _due_airport_cities(
|
|
|
|
|
|
cities: Set[str],
|
|
|
|
|
|
now_ts: int,
|
|
|
|
|
|
last_by_city: Dict[str, Any],
|
|
|
|
|
|
) -> List[str]:
|
|
|
|
|
|
due: List[str] = []
|
2026-06-11 15:38:05 +08:00
|
|
|
|
for city in sorted(
|
|
|
|
|
|
cities,
|
|
|
|
|
|
key=lambda item: (0 if item in CHINA_HIGH_FREQ_AIRPORT_CITIES else 1, item),
|
|
|
|
|
|
):
|
2026-06-05 14:38:58 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 17:04:17 +08:00
|
|
|
|
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", {})
|
2026-05-17 20:22:42 +08:00
|
|
|
|
max_workers = max(1, min(4, _env_int("TELEGRAM_AIRPORT_PUSH_MAX_WORKERS", 1)))
|
2026-06-05 14:38:58 +08:00
|
|
|
|
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
|
2026-05-12 17:04:17 +08:00
|
|
|
|
|
2026-05-25 02:20:15 +08:00
|
|
|
|
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
|
2026-05-12 17:04:17 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-12 20:13:15 +08:00
|
|
|
|
interval_sec = max(30, _env_int("TELEGRAM_AIRPORT_PUSH_INTERVAL_SEC", 60))
|
2026-05-12 17:04:17 +08:00
|
|
|
|
|
|
|
|
|
|
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()
|
2026-05-17 18:04:05 +08:00
|
|
|
|
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")
|
2026-05-12 17:04:17 +08:00
|
|
|
|
|
|
|
|
|
|
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
|