重构跑道观测系统:全跑道展示、结算跑道标注、热力模型、风场分析
This commit is contained in:
@@ -1726,7 +1726,7 @@ export function AccountCenter() {
|
|||||||
const planAmount =
|
const planAmount =
|
||||||
Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0
|
Number.isFinite(parsedPlanAmount) && parsedPlanAmount > 0
|
||||||
? parsedPlanAmount
|
? parsedPlanAmount
|
||||||
: 5;
|
: 10;
|
||||||
|
|
||||||
const pointsCfg = paymentConfig?.points_redemption || {};
|
const pointsCfg = paymentConfig?.points_redemption || {};
|
||||||
const pointsEnabled = pointsCfg.enabled !== false;
|
const pointsEnabled = pointsCfg.enabled !== false;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function ProFeaturePaywall({
|
|||||||
const isAuthenticated = proAccess.authenticated;
|
const isAuthenticated = proAccess.authenticated;
|
||||||
const pointsAvailable = Number(proAccess.points || 0);
|
const pointsAvailable = Number(proAccess.points || 0);
|
||||||
|
|
||||||
const PRO_PRICE_USD = 5;
|
const PRO_PRICE_USD = 10;
|
||||||
const POINTS_PER_USD = 500;
|
const POINTS_PER_USD = 500;
|
||||||
const MAX_DISCOUNT_USD = 3;
|
const MAX_DISCOUNT_USD = 3;
|
||||||
|
|
||||||
|
|||||||
@@ -477,44 +477,7 @@ function ScanTerminalScreen() {
|
|||||||
userLocalTime={userLocalTime}
|
userLocalTime={userLocalTime}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isPro ? (
|
|
||||||
<section
|
|
||||||
className="scan-upgrade-announcement"
|
|
||||||
aria-label={isEn ? "Pro preview" : "Pro 能力预览"}
|
|
||||||
>
|
|
||||||
<div className="scan-upgrade-announcement-copy">
|
|
||||||
<span>{isEn ? "PolyWeather Pro" : "PolyWeather Pro"}</span>
|
|
||||||
<strong>
|
|
||||||
{isEn
|
|
||||||
? "Weather intelligence for temperature settlement markets."
|
|
||||||
: "面向温度结算市场的气象情报系统。"}
|
|
||||||
</strong>
|
|
||||||
<p>
|
|
||||||
{isEn
|
|
||||||
? "Turn weather data into trading decisions. Compare multi-model forecasts against live observations, identify mispriced Polymarket contracts, and back your trades with calibrated probability distributions."
|
|
||||||
: "将气象数据转化为交易决策。多模型预报对照实时观测,识别 Polymarket 错价合约,用校准概率分布支撑每一笔交易。"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ul>
|
|
||||||
{proPreviewItems.map((item) => (
|
|
||||||
<li key={item}>{item}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{proAccess.authenticated ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="scan-primary-button"
|
|
||||||
onClick={openScanPaywall}
|
|
||||||
>
|
|
||||||
{isEn ? "View Pro" : "查看 Pro"}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<a href={accountHref} className="scan-primary-button">
|
|
||||||
{isEn ? "Sign in for Pro" : "登录查看 Pro"}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section className="scan-list-section">
|
<section className="scan-list-section">
|
||||||
<div className="scan-list-header">
|
<div className="scan-list-header">
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import type { CityDetail } from "@/lib/dashboard-types";
|
|||||||
import { getDisplayAirportPrimary } from "@/lib/airport-observation-display";
|
import { getDisplayAirportPrimary } from "@/lib/airport-observation-display";
|
||||||
import { formatTemperatureValue } from "@/lib/temperature-utils";
|
import { formatTemperatureValue } from "@/lib/temperature-utils";
|
||||||
|
|
||||||
const FOCUS_RUNWAY_PAIRS: Record<string, Array<[string, string]>> = {
|
// Settlement runway mapping — matches Polymarket settlement anchors
|
||||||
chongqing: [["02L", "20R"]],
|
const SETTLEMENT_RUNWAY_PAIRS: Record<string, Array<[string, string]>> = {
|
||||||
shanghai: [["17L", "35R"]],
|
shanghai: [["17L", "35R"]],
|
||||||
wuhan: [["04", "22"]],
|
|
||||||
beijing: [["01", "19"]],
|
beijing: [["01", "19"]],
|
||||||
guangzhou: [["02L", "20R"]],
|
guangzhou: [["02L", "20R"]],
|
||||||
chengdu: [["02L", "20R"]],
|
chengdu: [["02L", "20R"]],
|
||||||
|
chongqing: [["02L", "20R"]],
|
||||||
|
wuhan: [["04", "22"]],
|
||||||
seoul: [["15R", "33L"]],
|
seoul: [["15R", "33L"]],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,12 +46,11 @@ function formatObsTime(value: unknown) {
|
|||||||
return raw.length >= 16 && raw[10] === " " ? raw.slice(11, 16) : raw;
|
return raw.length >= 16 && raw[10] === " " ? raw.slice(11, 16) : raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFocusedRunwayEvidence(detail: CityDetail | null) {
|
function buildRunwayEvidence(detail: CityDetail | null) {
|
||||||
if (!detail) return null;
|
if (!detail) return null;
|
||||||
const cityKey = normalizeCityKey(detail.name) || normalizeCityKey(detail.display_name);
|
const cityKey = normalizeCityKey(detail.name) || normalizeCityKey(detail.display_name);
|
||||||
const focusPairs = FOCUS_RUNWAY_PAIRS[cityKey];
|
const settlementPairs = SETTLEMENT_RUNWAY_PAIRS[cityKey] || [];
|
||||||
if (!focusPairs?.length) return null;
|
const settlementKeys = new Set(settlementPairs.map(pairKey));
|
||||||
const focusKeys = new Set(focusPairs.map(pairKey));
|
|
||||||
const runwayObs = detail.amos?.runway_obs || {};
|
const runwayObs = detail.amos?.runway_obs || {};
|
||||||
const runwayPairs = runwayObs.runway_pairs || [];
|
const runwayPairs = runwayObs.runway_pairs || [];
|
||||||
const runwayTemps = runwayObs.temperatures || [];
|
const runwayTemps = runwayObs.temperatures || [];
|
||||||
@@ -59,12 +59,13 @@ function buildFocusedRunwayEvidence(detail: CityDetail | null) {
|
|||||||
label: string;
|
label: string;
|
||||||
maxTemp: number;
|
maxTemp: number;
|
||||||
values: number[];
|
values: number[];
|
||||||
|
isSettlement: boolean;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
runwayPairs.forEach((rawPair, index) => {
|
runwayPairs.forEach((rawPair, index) => {
|
||||||
const pair = rawPair as [string, string];
|
const pair = rawPair as [string, string];
|
||||||
if (!Array.isArray(pair) || pair.length < 2) return;
|
if (!Array.isArray(pair) || pair.length < 2) return;
|
||||||
if (!focusKeys.has(pairKey(pair))) return;
|
const isSettlement = settlementKeys.has(pairKey(pair));
|
||||||
const values = [
|
const values = [
|
||||||
...(Array.isArray(runwayTemps[index]) ? runwayTemps[index] : []),
|
...(Array.isArray(runwayTemps[index]) ? runwayTemps[index] : []),
|
||||||
toFiniteNumber(pointTemps[index]?.tdz_temp),
|
toFiniteNumber(pointTemps[index]?.tdz_temp),
|
||||||
@@ -76,6 +77,7 @@ function buildFocusedRunwayEvidence(detail: CityDetail | null) {
|
|||||||
label: `${normalizeRunwayLabel(pair[0])}/${normalizeRunwayLabel(pair[1])}`,
|
label: `${normalizeRunwayLabel(pair[0])}/${normalizeRunwayLabel(pair[1])}`,
|
||||||
maxTemp: Math.max(...values),
|
maxTemp: Math.max(...values),
|
||||||
values,
|
values,
|
||||||
|
isSettlement,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,7 +101,7 @@ export function AirportEvidencePanel({
|
|||||||
const airportPrimary = getDisplayAirportPrimary(detail);
|
const airportPrimary = getDisplayAirportPrimary(detail);
|
||||||
const airportCurrent = detail?.airport_current;
|
const airportCurrent = detail?.airport_current;
|
||||||
const station = airportPrimary || airportCurrent || null;
|
const station = airportPrimary || airportCurrent || null;
|
||||||
const runwayEvidence = buildFocusedRunwayEvidence(detail);
|
const runwayEvidence = buildRunwayEvidence(detail);
|
||||||
const tempSymbol = detail?.temp_symbol || "°C";
|
const tempSymbol = detail?.temp_symbol || "°C";
|
||||||
if (!station && !runwayEvidence) return null;
|
if (!station && !runwayEvidence) return null;
|
||||||
|
|
||||||
@@ -110,7 +112,7 @@ export function AirportEvidencePanel({
|
|||||||
<span className="scan-ai-city-kicker">
|
<span className="scan-ai-city-kicker">
|
||||||
{isEn ? "Airport live evidence" : "机场实时证据"}
|
{isEn ? "Airport live evidence" : "机场实时证据"}
|
||||||
</span>
|
</span>
|
||||||
<h4>{isEn ? "Airport / focused runway" : "机场主站 / 重点跑道"}</h4>
|
<h4>{isEn ? "Airport / runway observations" : "机场 / 跑道观测"}</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="scan-airport-evidence-grid">
|
<div className="scan-airport-evidence-grid">
|
||||||
@@ -130,8 +132,15 @@ export function AirportEvidencePanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{runwayEvidence?.rows.map((row) => (
|
{runwayEvidence?.rows.map((row) => (
|
||||||
<div className="scan-airport-evidence-card runway" key={row.label}>
|
<div
|
||||||
<span>{isEn ? "Focused runway" : "重点跑道"}</span>
|
className={`scan-airport-evidence-card runway${row.isSettlement ? " settlement" : ""}`}
|
||||||
|
key={row.label}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{row.isSettlement
|
||||||
|
? isEn ? "★ Settlement runway" : "★ 结算跑道"
|
||||||
|
: isEn ? "Runway" : "跑道"}
|
||||||
|
</span>
|
||||||
<b>{formatTemperatureValue(row.maxTemp, tempSymbol, { digits: 1 })}</b>
|
<b>{formatTemperatureValue(row.maxTemp, tempSymbol, { digits: 1 })}</b>
|
||||||
<small>
|
<small>
|
||||||
{[row.label, runwayEvidence.sourceLabel, runwayEvidence.observedAt]
|
{[row.label, runwayEvidence.sourceLabel, runwayEvidence.observedAt]
|
||||||
|
|||||||
@@ -62,6 +62,25 @@ def _amsc_split_runway_pair(label: str) -> tuple[str, str]:
|
|||||||
return runway, runway
|
return runway, runway
|
||||||
|
|
||||||
|
|
||||||
|
def _amsc_wind_dir(*candidates: Any) -> Optional[int]:
|
||||||
|
"""Parse wind direction from AMSC fields (degrees)."""
|
||||||
|
for value in candidates:
|
||||||
|
parsed = _amsc_safe_float(value)
|
||||||
|
if parsed is not None and 0 <= parsed <= 360:
|
||||||
|
return int(round(parsed))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _amsc_parse_rvr(value: Any) -> Optional[int]:
|
||||||
|
"""Parse RVR field like 'P2000' → 2000 (meters)."""
|
||||||
|
text = str(value or "").strip().upper()
|
||||||
|
if not text or text in {"--", "-", "NULL", "NONE"}:
|
||||||
|
return None
|
||||||
|
text = text.lstrip("PM")
|
||||||
|
parsed = _amsc_safe_float(text)
|
||||||
|
return int(round(parsed)) if parsed is not None else None
|
||||||
|
|
||||||
|
|
||||||
def _amsc_parse_utc_time(value: Any) -> tuple[Optional[str], Optional[str]]:
|
def _amsc_parse_utc_time(value: Any) -> tuple[Optional[str], Optional[str]]:
|
||||||
text = str(value or "").strip()
|
text = str(value or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
@@ -124,12 +143,44 @@ def _amsc_parse_wind_plate_payload(
|
|||||||
best_temp = tdz if tdz is not None else max(points)
|
best_temp = tdz if tdz is not None else max(points)
|
||||||
runway_temps.append((best_temp, None))
|
runway_temps.append((best_temp, None))
|
||||||
valid_values.extend(points)
|
valid_values.extend(points)
|
||||||
|
|
||||||
|
# Wind: prefer TDZ point, fallback to END
|
||||||
|
wind_dir = _amsc_wind_dir(
|
||||||
|
raw_row.get("TDZ_WIND_D10"),
|
||||||
|
raw_row.get("END_WIND_D10"),
|
||||||
|
)
|
||||||
|
wind_speed = _amsc_safe_float(
|
||||||
|
raw_row.get("TDZ_WIND_F10") or raw_row.get("END_WIND_F10")
|
||||||
|
)
|
||||||
|
# RVR / MOR: prefer 1-min average
|
||||||
|
raw_rvr = (
|
||||||
|
raw_row.get("TDZ_RVR_1A") or raw_row.get("END_RVR_1A")
|
||||||
|
or raw_row.get("TDZ_RVR_10A") or raw_row.get("END_RVR_10A")
|
||||||
|
)
|
||||||
|
rvr = _amsc_parse_rvr(raw_rvr)
|
||||||
|
raw_mor = (
|
||||||
|
raw_row.get("TDZ_MOR_1A") or raw_row.get("END_MOR_1A")
|
||||||
|
or raw_row.get("TDZ_MOR_10A") or raw_row.get("END_MOR_10A")
|
||||||
|
)
|
||||||
|
mor = _amsc_safe_float(raw_mor)
|
||||||
|
# Humidity: prefer TDZ, fallback to END, then MID
|
||||||
|
humidity = _amsc_safe_float(
|
||||||
|
raw_row.get("TDZ_HUMID") or raw_row.get("END_HUMID") or raw_row.get("MID_HUMID")
|
||||||
|
)
|
||||||
|
|
||||||
|
target_max = max(points)
|
||||||
point_temperatures.append(
|
point_temperatures.append(
|
||||||
{
|
{
|
||||||
"runway": runway_label,
|
"runway": runway_label,
|
||||||
"tdz_temp": tdz,
|
"tdz_temp": tdz,
|
||||||
"mid_temp": mid,
|
"mid_temp": mid,
|
||||||
"end_temp": end,
|
"end_temp": end,
|
||||||
|
"target_runway_max": target_max,
|
||||||
|
"wind_dir": wind_dir,
|
||||||
|
"wind_speed": wind_speed,
|
||||||
|
"rvr": rvr,
|
||||||
|
"mor": mor,
|
||||||
|
"humidity": humidity,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -155,6 +206,8 @@ def _amsc_parse_wind_plate_payload(
|
|||||||
"station_label": airport_meta.get("label") or icao,
|
"station_label": airport_meta.get("label") or icao,
|
||||||
"raw_metar": raw_metar,
|
"raw_metar": raw_metar,
|
||||||
"raw_taf": None,
|
"raw_taf": None,
|
||||||
|
"wind_dir": point_temperatures[0].get("wind_dir") if point_temperatures else None,
|
||||||
|
"wind_speed": point_temperatures[0].get("wind_speed") if point_temperatures else None,
|
||||||
"runway_obs": {
|
"runway_obs": {
|
||||||
"runway_pairs": runway_pairs,
|
"runway_pairs": runway_pairs,
|
||||||
"temperatures": runway_temps,
|
"temperatures": runway_temps,
|
||||||
|
|||||||
@@ -1090,26 +1090,47 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
# Reuse the existing `amos` detail shape consumed by dashboard runway panels.
|
# Reuse the existing `amos` detail shape consumed by dashboard runway panels.
|
||||||
results["amos"] = amsc_data
|
results["amos"] = amsc_data
|
||||||
try:
|
try:
|
||||||
|
icao = amsc_data.get("icao") or ""
|
||||||
|
obs_time = amsc_data.get("observation_time") or datetime.now().isoformat()
|
||||||
DBManager().append_airport_obs(
|
DBManager().append_airport_obs(
|
||||||
icao=amsc_data.get("icao") or "",
|
icao=icao,
|
||||||
city=city_lower,
|
city=city_lower,
|
||||||
temp_c=amsc_data.get("temp_c"),
|
temp_c=amsc_data.get("temp_c"),
|
||||||
wind_kt=amsc_data.get("wind_kt"),
|
wind_kt=amsc_data.get("wind_speed"),
|
||||||
pressure_hpa=amsc_data.get("pressure_hpa"),
|
pressure_hpa=amsc_data.get("pressure_hpa"),
|
||||||
obs_time=amsc_data.get("observation_time") or datetime.now().isoformat(),
|
obs_time=obs_time,
|
||||||
)
|
)
|
||||||
runway_obs = amsc_data.get("runway_obs") or {}
|
runway_obs = amsc_data.get("runway_obs") or {}
|
||||||
rw_pairs = runway_obs.get("runway_pairs") or []
|
rw_pairs = runway_obs.get("runway_pairs") or []
|
||||||
rw_temps = runway_obs.get("temperatures") or []
|
rw_temps = runway_obs.get("temperatures") or []
|
||||||
|
point_temps = runway_obs.get("point_temperatures") or []
|
||||||
for i, (pair, temp_pair) in enumerate(zip(rw_pairs, rw_temps)):
|
for i, (pair, temp_pair) in enumerate(zip(rw_pairs, rw_temps)):
|
||||||
t = temp_pair[0] if temp_pair else None
|
t = temp_pair[0] if temp_pair else None
|
||||||
if t is not None and i < 6:
|
if t is not None and i < 6:
|
||||||
pair_label = "/".join(pair) if isinstance(pair, (list, tuple)) else str(pair)
|
pair_label = "/".join(pair) if isinstance(pair, (list, tuple)) else str(pair)
|
||||||
|
# Legacy airport_obs_log (keep backward compat)
|
||||||
DBManager().append_airport_obs(
|
DBManager().append_airport_obs(
|
||||||
icao=f"{amsc_data.get('icao', '')}_RWY_{i}",
|
icao=f"{icao}_RWY_{i}",
|
||||||
city=city_lower,
|
city=city_lower,
|
||||||
temp_c=t,
|
temp_c=t,
|
||||||
obs_time=amsc_data.get("observation_time") or datetime.now().isoformat(),
|
obs_time=obs_time,
|
||||||
|
)
|
||||||
|
# New runway_obs_log with full detail
|
||||||
|
pt = point_temps[i] if i < len(point_temps) else {}
|
||||||
|
DBManager().append_runway_obs(
|
||||||
|
icao=icao,
|
||||||
|
city=city_lower,
|
||||||
|
runway=pair_label,
|
||||||
|
tdz_temp=pt.get("tdz_temp"),
|
||||||
|
mid_temp=pt.get("mid_temp"),
|
||||||
|
end_temp=pt.get("end_temp"),
|
||||||
|
target_runway_max=pt.get("target_runway_max"),
|
||||||
|
wind_dir=pt.get("wind_dir"),
|
||||||
|
wind_speed=pt.get("wind_speed"),
|
||||||
|
rvr=pt.get("rvr"),
|
||||||
|
mor=pt.get("mor"),
|
||||||
|
humidity=pt.get("humidity"),
|
||||||
|
otime_utc=obs_time,
|
||||||
)
|
)
|
||||||
logger.debug("AMSC AWOS stored runway row city={} runway={} temp={}", city_lower, pair_label, t)
|
logger.debug("AMSC AWOS stored runway row city={} runway={} temp={}", city_lower, pair_label, t)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -403,6 +403,35 @@ class DBManager:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_airport_obs_log_icao_time ON airport_obs_log(icao, created_at DESC)"
|
"CREATE INDEX IF NOT EXISTS idx_airport_obs_log_icao_time ON airport_obs_log(icao, created_at DESC)"
|
||||||
)
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS runway_obs_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
icao TEXT NOT NULL,
|
||||||
|
city TEXT NOT NULL,
|
||||||
|
runway TEXT NOT NULL,
|
||||||
|
tdz_temp REAL,
|
||||||
|
mid_temp REAL,
|
||||||
|
end_temp REAL,
|
||||||
|
target_runway_max REAL,
|
||||||
|
wind_dir INTEGER,
|
||||||
|
wind_speed REAL,
|
||||||
|
rvr INTEGER,
|
||||||
|
mor REAL,
|
||||||
|
humidity REAL,
|
||||||
|
otime_utc TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_runway_obs_log_icao_otime "
|
||||||
|
"ON runway_obs_log(icao, otime_utc DESC)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_runway_obs_log_city_time "
|
||||||
|
"ON runway_obs_log(city, created_at DESC)"
|
||||||
|
)
|
||||||
self._ensure_column(conn, "users", "daily_points", "INTEGER DEFAULT 0")
|
self._ensure_column(conn, "users", "daily_points", "INTEGER DEFAULT 0")
|
||||||
self._ensure_column(conn, "users", "daily_points_date", "TEXT")
|
self._ensure_column(conn, "users", "daily_points_date", "TEXT")
|
||||||
self._ensure_column(conn, "users", "weekly_points", "INTEGER DEFAULT 0")
|
self._ensure_column(conn, "users", "weekly_points", "INTEGER DEFAULT 0")
|
||||||
@@ -2202,3 +2231,71 @@ class DBManager:
|
|||||||
(str(icao).strip().upper(), str(-int(minutes))),
|
(str(icao).strip().upper(), str(-int(minutes))),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def append_runway_obs(
|
||||||
|
self,
|
||||||
|
icao: str,
|
||||||
|
city: str,
|
||||||
|
runway: str,
|
||||||
|
tdz_temp: Optional[float] = None,
|
||||||
|
mid_temp: Optional[float] = None,
|
||||||
|
end_temp: Optional[float] = None,
|
||||||
|
target_runway_max: Optional[float] = None,
|
||||||
|
wind_dir: Optional[int] = None,
|
||||||
|
wind_speed: Optional[float] = None,
|
||||||
|
rvr: Optional[int] = None,
|
||||||
|
mor: Optional[float] = None,
|
||||||
|
humidity: Optional[float] = None,
|
||||||
|
otime_utc: str = "",
|
||||||
|
) -> None:
|
||||||
|
"""Insert one runway observation row (deduplicated by icao+runway+otime_utc)."""
|
||||||
|
safe_icao = str(icao or "").strip().upper()
|
||||||
|
safe_city = str(city or "").strip().lower()
|
||||||
|
safe_runway = str(runway or "").strip().upper()
|
||||||
|
safe_otime = str(otime_utc or "").strip()
|
||||||
|
if not safe_icao or not safe_runway or not safe_otime:
|
||||||
|
return
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM runway_obs_log WHERE icao=? AND runway=? AND otime_utc=? LIMIT 1",
|
||||||
|
(safe_icao, safe_runway, safe_otime),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
return
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO runway_obs_log (
|
||||||
|
icao, city, runway,
|
||||||
|
tdz_temp, mid_temp, end_temp, target_runway_max,
|
||||||
|
wind_dir, wind_speed, rvr, mor, humidity,
|
||||||
|
otime_utc
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
safe_icao, safe_city, safe_runway,
|
||||||
|
tdz_temp, mid_temp, end_temp, target_runway_max,
|
||||||
|
wind_dir, wind_speed, rvr, mor, humidity,
|
||||||
|
safe_otime,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def get_runway_obs_recent(
|
||||||
|
self, icao: str, minutes: int = 60
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get recent runway observations for trend analysis."""
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT icao, city, runway,
|
||||||
|
tdz_temp, mid_temp, end_temp, target_runway_max,
|
||||||
|
wind_dir, wind_speed, rvr, mor, humidity,
|
||||||
|
otime_utc, created_at
|
||||||
|
FROM runway_obs_log
|
||||||
|
WHERE icao = ? AND created_at >= datetime('now', ? || ' minutes')
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
""",
|
||||||
|
(str(icao).strip().upper(), str(-int(minutes))),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|||||||
+182
-74
@@ -10,6 +10,7 @@ from typing import Any, Dict, List, Optional, Set, Tuple
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from src.database.db_manager import DBManager
|
||||||
from src.database.runtime_state import (
|
from src.database.runtime_state import (
|
||||||
STATE_STORAGE_SQLITE,
|
STATE_STORAGE_SQLITE,
|
||||||
TelegramAlertStateRepository,
|
TelegramAlertStateRepository,
|
||||||
@@ -545,16 +546,43 @@ HIGH_FREQ_AIRPORT_ICAO = {
|
|||||||
"san francisco": "KSFO", "houston": "KHOU", "dallas": "KDAL",
|
"san francisco": "KSFO", "houston": "KHOU", "dallas": "KDAL",
|
||||||
"austin": "KAUS", "seattle": "KSEA",
|
"austin": "KAUS", "seattle": "KSEA",
|
||||||
}
|
}
|
||||||
FOCUS_RUNWAY_PAIRS = {
|
# Settlement runway mapping — matches Polymarket settlement anchor stations.
|
||||||
"chongqing": {("02L", "20R")},
|
# Format: (low_number, high_number) order-independent; stored sorted for lookup.
|
||||||
|
SETTLEMENT_RUNWAY_PAIRS: Dict[str, Set[Tuple[str, str]]] = {
|
||||||
"shanghai": {("17L", "35R")},
|
"shanghai": {("17L", "35R")},
|
||||||
"wuhan": {("04", "22")},
|
|
||||||
"beijing": {("01", "19")},
|
"beijing": {("01", "19")},
|
||||||
"guangzhou": {("02L", "20R")},
|
"guangzhou": {("02L", "20R")},
|
||||||
"chengdu": {("02L", "20R")},
|
"chengdu": {("02L", "20R")},
|
||||||
|
"chongqing": {("02L", "20R")},
|
||||||
|
"wuhan": {("04", "22")},
|
||||||
"seoul": {("15R", "33L")},
|
"seoul": {("15R", "33L")},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# All cities with active runway observation data (AMSC AWOS / AMOS).
|
||||||
|
RUNWAY_OBSERVATION_CITIES = {
|
||||||
|
"shanghai", "beijing", "guangzhou", "shenzhen",
|
||||||
|
"chengdu", "chongqing", "wuhan", "qingdao",
|
||||||
|
"seoul", "busan",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wind regime sectors per airport (approximate, based on runway orientation + coastline).
|
||||||
|
# Values: {sea_breeze: (from_deg, to_deg), warm_advection: (from_deg, to_deg)}
|
||||||
|
WIND_REGIME: Dict[str, Dict[str, Tuple[int, int]]] = {
|
||||||
|
"shanghai": {"sea_breeze": (45, 140), "warm_advection": (180, 260)},
|
||||||
|
"seoul": {"sea_breeze": (270, 350), "warm_advection": (150, 230)},
|
||||||
|
"busan": {"sea_breeze": (120, 200), "warm_advection": (250, 340)},
|
||||||
|
"qingdao": {"sea_breeze": (90, 180), "warm_advection": (200, 300)},
|
||||||
|
"beijing": {"sea_breeze": (120, 200), "warm_advection": (220, 320)},
|
||||||
|
"guangzhou": {"sea_breeze": (120, 200), "warm_advection": (200, 300)},
|
||||||
|
"shenzhen": {"sea_breeze": (120, 200), "warm_advection": (200, 300)},
|
||||||
|
"chengdu": {"sea_breeze": (0, 0), "warm_advection": (0, 0)},
|
||||||
|
"chongqing": {"sea_breeze": (0, 0), "warm_advection": (0, 0)},
|
||||||
|
"wuhan": {"sea_breeze": (0, 0), "warm_advection": (0, 0)},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Legacy alias for backward compat with existing _select_focus_runway_obs / _focus_runway_pairs_for_city
|
||||||
|
FOCUS_RUNWAY_PAIRS = SETTLEMENT_RUNWAY_PAIRS
|
||||||
|
|
||||||
_FUNCTION_HASHTAGS = {
|
_FUNCTION_HASHTAGS = {
|
||||||
"runway": "#跑道观测",
|
"runway": "#跑道观测",
|
||||||
"airport": "#机场观测",
|
"airport": "#机场观测",
|
||||||
@@ -664,6 +692,69 @@ def _select_focus_runway_obs(
|
|||||||
return runway_pairs, runway_temps, points
|
return runway_pairs, runway_temps, points
|
||||||
|
|
||||||
|
|
||||||
|
def _settlement_runway_for_city(city: str) -> Optional[Tuple[str, str]]:
|
||||||
|
"""Return the settlement runway pair for a city, if configured."""
|
||||||
|
pairs = SETTLEMENT_RUNWAY_PAIRS.get(str(city or "").strip().lower(), set())
|
||||||
|
return next(iter(pairs)) if pairs else None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_settlement_runway(city: str, r1: str, r2: str) -> bool:
|
||||||
|
"""Check if a runway pair is the settlement anchor for this city."""
|
||||||
|
pair_set = SETTLEMENT_RUNWAY_PAIRS.get(str(city or "").strip().lower(), set())
|
||||||
|
return _runway_pair_key(r1, r2) in pair_set
|
||||||
|
|
||||||
|
|
||||||
|
def _wind_regime_label(city: str, wind_dir: Optional[int]) -> Optional[str]:
|
||||||
|
"""Classify wind direction into a thermal regime label."""
|
||||||
|
if wind_dir is None:
|
||||||
|
return None
|
||||||
|
regimes = WIND_REGIME.get(str(city or "").strip().lower(), {})
|
||||||
|
sea = regimes.get("sea_breeze")
|
||||||
|
warm = regimes.get("warm_advection")
|
||||||
|
if sea and sea[0] != sea[1] and sea[0] <= wind_dir <= sea[1]:
|
||||||
|
return "海风降温"
|
||||||
|
if warm and warm[0] != warm[1] and warm[0] <= wind_dir <= warm[1]:
|
||||||
|
return "暖平流增强"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_slope_15m(icao: str, current_temp: float) -> Optional[float]:
|
||||||
|
"""Estimate 15-minute temperature trend from runway_obs_log."""
|
||||||
|
try:
|
||||||
|
db = DBManager()
|
||||||
|
rows = db.get_runway_obs_recent(icao, minutes=20)
|
||||||
|
temps = []
|
||||||
|
for r in rows:
|
||||||
|
t = r.get("target_runway_max") or r.get("tdz_temp")
|
||||||
|
if t is not None:
|
||||||
|
temps.append(float(t))
|
||||||
|
if len(temps) >= 2:
|
||||||
|
# Compare latest vs earliest in ~15 min window
|
||||||
|
return round(current_temp - temps[0], 1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _runway_heat_signal(
|
||||||
|
current_temp: float,
|
||||||
|
slope_15m: Optional[float],
|
||||||
|
wind_dir: Optional[int],
|
||||||
|
city: str,
|
||||||
|
) -> str:
|
||||||
|
"""Compute a simple runway heat signal label."""
|
||||||
|
if slope_15m is None:
|
||||||
|
return ""
|
||||||
|
regime = _wind_regime_label(city, wind_dir)
|
||||||
|
if slope_15m >= 1.0:
|
||||||
|
return "🚀 冲顶增强" if regime == "暖平流增强" else "🔥 升温中"
|
||||||
|
if slope_15m >= 0.5:
|
||||||
|
return "🔥 升温中"
|
||||||
|
if slope_15m >= -0.2:
|
||||||
|
return "⚠️ 高位观察" if regime == "海风降温" else "⏸️ 高位横盘"
|
||||||
|
return "🧊 过峰风险"
|
||||||
|
|
||||||
|
|
||||||
def _focused_runway_max(city: str, city_weather: Dict[str, Any]) -> Optional[float]:
|
def _focused_runway_max(city: str, city_weather: Dict[str, Any]) -> Optional[float]:
|
||||||
amos = city_weather.get("amos") or {}
|
amos = city_weather.get("amos") or {}
|
||||||
runway_obs = (amos.get("runway_obs") or {}) if isinstance(amos, dict) else {}
|
runway_obs = (amos.get("runway_obs") or {}) if isinstance(amos, dict) else {}
|
||||||
@@ -825,114 +916,131 @@ def _build_airport_status_message(
|
|||||||
en_name = city.title()
|
en_name = city.title()
|
||||||
ap_name = _AIRPORT_EN.get(city, "")
|
ap_name = _AIRPORT_EN.get(city, "")
|
||||||
time_suffix = f" · {local_time}" if local_time else ""
|
time_suffix = f" · {local_time}" if local_time else ""
|
||||||
header = f"{en_name} / {ap_name}{time_suffix}" if ap_name else f"{en_name}{time_suffix}"
|
|
||||||
|
|
||||||
amos = city_weather.get("amos") or {}
|
amos = city_weather.get("amos") or {}
|
||||||
runway_data = (amos.get("runway_obs") or {}) if amos else {}
|
runway_data = (amos.get("runway_obs") or {}) if amos else {}
|
||||||
runway_pairs = runway_data.get("runway_pairs") or []
|
runway_pairs = runway_data.get("runway_pairs") or []
|
||||||
runway_temps = runway_data.get("temperatures") or []
|
runway_temps = runway_data.get("temperatures") or []
|
||||||
point_temps = runway_data.get("point_temperatures") or []
|
point_temps = runway_data.get("point_temperatures") or []
|
||||||
runway_pairs, runway_temps, point_temps = _select_focus_runway_obs(
|
is_amsc = amos.get("source") == "amsc_awos"
|
||||||
city, runway_pairs, runway_temps, point_temps
|
amos_icao = amos.get("icao") or HIGH_FREQ_AIRPORT_ICAO.get(city, "")
|
||||||
)
|
settlement_pair = _settlement_runway_for_city(city)
|
||||||
mgm_nearby = city_weather.get("mgm_nearby") or []
|
|
||||||
airport_icao = HIGH_FREQ_AIRPORT_ICAO.get(city, "")
|
|
||||||
airport_row = None
|
|
||||||
for row in mgm_nearby:
|
|
||||||
if str(row.get("istNo") or "") == airport_icao or str(row.get("icao") or "") == airport_icao:
|
|
||||||
airport_row = row
|
|
||||||
break
|
|
||||||
if not airport_row:
|
|
||||||
airport_row = mgm_nearby[0] if mgm_nearby else {}
|
|
||||||
station_temp = airport_row.get("temp") if airport_row else None
|
|
||||||
current = city_weather.get("current") or {}
|
|
||||||
if station_temp is None:
|
|
||||||
station_temp = current.get("temp")
|
|
||||||
|
|
||||||
# Current temp from runway max if available
|
# ── Display temp: settlement runway max first, then airport temp ──
|
||||||
display_temp = station_temp
|
settlement_temp: Optional[float] = None
|
||||||
if runway_temps:
|
display_temp: Optional[float] = None
|
||||||
valid = [t for (t, _d) in runway_temps if t is not None]
|
if point_temps:
|
||||||
if valid:
|
for pt in point_temps:
|
||||||
display_temp = max(valid)
|
rw = str(pt.get("runway") or "")
|
||||||
|
rw_parts = [p.strip() for p in str(rw).split("/") if p.strip()]
|
||||||
|
if settlement_pair and len(rw_parts) >= 2 and _runway_pair_key(rw_parts[0], rw_parts[1]) == _runway_pair_key(*settlement_pair):
|
||||||
|
tmax = pt.get("target_runway_max")
|
||||||
|
if tmax is not None:
|
||||||
|
settlement_temp = float(tmax)
|
||||||
|
break
|
||||||
|
if settlement_temp is not None:
|
||||||
|
display_temp = settlement_temp
|
||||||
|
if display_temp is None:
|
||||||
|
if point_temps:
|
||||||
|
valid_tmax = [float(p.get("target_runway_max")) for p in point_temps if p.get("target_runway_max") is not None]
|
||||||
|
display_temp = max(valid_tmax) if valid_tmax else None
|
||||||
|
if display_temp is None:
|
||||||
|
station_temp = None
|
||||||
|
mgm_nearby = city_weather.get("mgm_nearby") or []
|
||||||
|
airport_icao = HIGH_FREQ_AIRPORT_ICAO.get(city, "")
|
||||||
|
for row in mgm_nearby:
|
||||||
|
if str(row.get("istNo") or "") == airport_icao or str(row.get("icao") or "") == airport_icao:
|
||||||
|
station_temp = row.get("temp")
|
||||||
|
break
|
||||||
|
if station_temp is None and mgm_nearby:
|
||||||
|
station_temp = mgm_nearby[0].get("temp")
|
||||||
|
if station_temp is None:
|
||||||
|
station_temp = (city_weather.get("current") or {}).get("temp")
|
||||||
|
display_temp = station_temp
|
||||||
|
|
||||||
|
# ── Heat model ──
|
||||||
|
wind_dir = amos.get("wind_dir") if is_amsc else None
|
||||||
|
slope_15m = _compute_slope_15m(amos_icao, display_temp) if is_amsc and display_temp is not None else None
|
||||||
|
heat_signal = _runway_heat_signal(display_temp or 0, slope_15m, wind_dir, city) if is_amsc else ""
|
||||||
|
wind_label = _wind_regime_label(city, wind_dir) if is_amsc and wind_dir is not None else None
|
||||||
|
|
||||||
max_so_far, max_temp_time = _get_airport_daily_high(city_weather)
|
max_so_far, max_temp_time = _get_airport_daily_high(city_weather)
|
||||||
new_high = (display_temp is not None and max_so_far is not None
|
has_runway = bool(is_amsc and point_temps)
|
||||||
and display_temp - max_so_far >= 0.3)
|
|
||||||
|
|
||||||
is_amsc = amos.get("source") == "amsc_awos"
|
# ── Build message ──
|
||||||
has_runway = bool(runway_pairs and runway_temps and len(runway_pairs) == len(runway_temps))
|
lines: List[str] = []
|
||||||
|
|
||||||
|
# Header
|
||||||
hashtag_line = _build_telegram_hashtag_line(
|
hashtag_line = _build_telegram_hashtag_line(
|
||||||
"runway" if has_runway else "airport",
|
"runway" if has_runway else "airport",
|
||||||
city=city,
|
city=city,
|
||||||
)
|
)
|
||||||
|
icao_display = f"{amos_icao} · " if amos_icao else ""
|
||||||
|
settlement_str = f" · ★{settlement_pair[0]}/{settlement_pair[1]}" if settlement_pair else ""
|
||||||
|
header = f"{icao_display}{en_name} / {ap_name}{settlement_str}{time_suffix}" if ap_name else f"{icao_display}{en_name}{settlement_str}{time_suffix}"
|
||||||
|
lines.append(hashtag_line)
|
||||||
|
lines.append("")
|
||||||
|
lines.append(header)
|
||||||
|
|
||||||
# ── Build lines ──
|
# Heat signal
|
||||||
lines: List[str] = [hashtag_line, header]
|
if heat_signal:
|
||||||
if new_high:
|
lines.append("")
|
||||||
lines.append("\U0001f536 今日新高")
|
lines.append(heat_signal)
|
||||||
if state:
|
if state:
|
||||||
lines.append(state)
|
lines.append(state)
|
||||||
|
|
||||||
# Runway detail block
|
# All runway detail block
|
||||||
if is_amsc and runway_pairs and runway_temps and len(runway_pairs) == len(runway_temps):
|
if has_runway:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
for i, ((r1, r2), (t, _d)) in enumerate(zip(runway_pairs, runway_temps)):
|
for i, ((r1, r2), (t, _d)) in enumerate(zip(runway_pairs, runway_temps)):
|
||||||
if t is not None:
|
if t is None:
|
||||||
pts = point_temps[i] if i < len(point_temps) else {}
|
continue
|
||||||
tdz = pts.get("tdz_temp")
|
pts = point_temps[i] if i < len(point_temps) else {}
|
||||||
mid = pts.get("mid_temp")
|
tdz = pts.get("tdz_temp")
|
||||||
end = pts.get("end_temp")
|
mid = pts.get("mid_temp")
|
||||||
if tdz is not None or mid is not None or end is not None:
|
end = pts.get("end_temp")
|
||||||
lines.append(
|
is_settlement = _is_settlement_runway(city, r1, r2)
|
||||||
f"{r1}/{r2} TDZ:{_fmt(tdz)} MID:{_fmt(mid)} END:{_fmt(end)}"
|
marker = " ★结算" if is_settlement else ""
|
||||||
)
|
tmax = pts.get("target_runway_max")
|
||||||
else:
|
if tdz is not None or mid is not None or end is not None:
|
||||||
lines.append(f"{r1}/{r2} {t:.1f}°C")
|
line = f"{r1}/{r2}{marker} TDZ:{_fmt(tdz)} MID:{_fmt(mid)} END:{_fmt(end)}"
|
||||||
elif has_runway:
|
if tmax is not None:
|
||||||
lines.append("")
|
line += f" max:{tmax:.1f}"
|
||||||
for (r1, r2), (t, _d) in zip(runway_pairs, runway_temps):
|
lines.append(line)
|
||||||
if t is not None:
|
else:
|
||||||
lines.append(f"{r1}/{r2} {t:.1f}°C")
|
lines.append(f"{r1}/{r2}{marker} {t:.1f}°C")
|
||||||
|
|
||||||
# ── 第一层:当前 / 日高 / DEB ──
|
# Summary stats
|
||||||
lines.append("")
|
lines.append("")
|
||||||
temp_symbol = str(city_weather.get("temp_symbol") or "°C").strip()
|
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 "--"
|
cur_str = f"{display_temp:.1f}{temp_symbol}" if display_temp is not None else "--"
|
||||||
lines.append(f"当前:{cur_str}")
|
lines.append(f"结算跑道当前:{cur_str}")
|
||||||
if max_so_far is not None:
|
if max_so_far is not None:
|
||||||
time_str = f"({max_temp_time})" if max_temp_time else ""
|
time_str = f"({max_temp_time})" if max_temp_time else ""
|
||||||
lines.append(f"日高:{max_so_far:.1f}{temp_symbol}{time_str}")
|
lines.append(f"今日跑道高点:{max_so_far:.1f}{temp_symbol}{time_str}")
|
||||||
|
if slope_15m is not None:
|
||||||
|
sign = "+" if slope_15m >= 0 else ""
|
||||||
|
lines.append(f"15分钟趋势:{sign}{slope_15m:.1f}°C")
|
||||||
|
if wind_dir is not None:
|
||||||
|
wind_str = f"风向:{wind_dir}°"
|
||||||
|
if wind_label:
|
||||||
|
wind_str += f" {wind_label}"
|
||||||
|
lines.append(wind_str)
|
||||||
if deb_pred is not None:
|
if deb_pred is not None:
|
||||||
if display_temp is not None and display_temp > deb_pred:
|
if display_temp is not None and display_temp > deb_pred:
|
||||||
lines.append(f"DEB:{deb_pred:.1f}{temp_symbol}(已突破 +{display_temp - deb_pred:.1f}°)")
|
lines.append(f"DEB:{deb_pred:.1f}{temp_symbol}(已突破 +{display_temp - deb_pred:.1f}°)")
|
||||||
else:
|
else:
|
||||||
lines.append(f"DEB:{deb_pred:.1f}{temp_symbol}")
|
lines.append(f"DEB:{deb_pred:.1f}{temp_symbol}")
|
||||||
|
|
||||||
# ── 第二层:模型结构 ──
|
# Model summary (compact)
|
||||||
models = city_weather.get("multi_model") or {}
|
models = city_weather.get("multi_model") or {}
|
||||||
if isinstance(models, dict) and len(models) >= 2:
|
if isinstance(models, dict) and len(models) >= 2:
|
||||||
lines.append("")
|
|
||||||
vals = sorted([(v, k) for k, v in models.items() if isinstance(v, (int, float))])
|
vals = sorted([(v, k) for k, v in models.items() if isinstance(v, (int, float))])
|
||||||
if len(vals) >= 2:
|
if len(vals) >= 2:
|
||||||
lo, hi = vals[0][0], vals[-1][0]
|
lo, hi = vals[0][0], vals[-1][0]
|
||||||
spread = hi - lo
|
spread = hi - lo
|
||||||
spread_label = "低分歧" if spread <= 2.0 else ("中等分歧" if spread <= 4.0 else "高分歧")
|
spread_label = "低分歧" if spread <= 2.0 else ("中等分歧" if spread <= 4.0 else "高分歧")
|
||||||
lines.append(f"模型区间:{lo:.1f}~{hi:.1f}{temp_symbol} 分歧:{spread:.1f}°({spread_label})")
|
lines.append(f"模型区间:{lo:.1f}~{hi:.1f}{temp_symbol} {spread_label}")
|
||||||
hot = [k for v, k in reversed(vals[-3:])]
|
|
||||||
cold = [k for v, k in vals[:3]]
|
|
||||||
if hot:
|
|
||||||
lines.append(f"热模型:{' / '.join(hot)}")
|
|
||||||
if cold:
|
|
||||||
lines.append(f"冷模型:{' / '.join(cold)}")
|
|
||||||
|
|
||||||
# ── 第三层:市场解释 ──
|
|
||||||
narrative = _build_narrative(
|
|
||||||
display_temp, max_so_far, deb_pred, models, city_weather,
|
|
||||||
)
|
|
||||||
if narrative:
|
|
||||||
lines.append("")
|
|
||||||
lines.append(narrative)
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|||||||
@@ -56,12 +56,17 @@ def test_parse_wind_plate_payload_normalizes_runway_point_temperatures():
|
|||||||
runway_obs = parsed["runway_obs"]
|
runway_obs = parsed["runway_obs"]
|
||||||
assert runway_obs["runway_pairs"] == [("18R", "36L"), ("18L", "36R"), ("19", "01")]
|
assert runway_obs["runway_pairs"] == [("18R", "36L"), ("18L", "36R"), ("19", "01")]
|
||||||
assert runway_obs["temperatures"] == [(20.8, None), (21.0, None), (20.2, None)]
|
assert runway_obs["temperatures"] == [(20.8, None), (21.0, None), (20.2, None)]
|
||||||
assert runway_obs["point_temperatures"][0] == {
|
pt0 = runway_obs["point_temperatures"][0]
|
||||||
"runway": "18R/36L",
|
assert pt0["runway"] == "18R/36L"
|
||||||
"tdz_temp": 20.8,
|
assert pt0["tdz_temp"] == 20.8
|
||||||
"mid_temp": None,
|
assert pt0["mid_temp"] is None
|
||||||
"end_temp": 20.8,
|
assert pt0["end_temp"] == 20.8
|
||||||
}
|
assert pt0["target_runway_max"] == 20.8
|
||||||
|
assert pt0["wind_dir"] is None
|
||||||
|
assert pt0["wind_speed"] is None
|
||||||
|
assert pt0["rvr"] is None
|
||||||
|
assert pt0["mor"] is None
|
||||||
|
assert pt0["humidity"] == 67.0
|
||||||
|
|
||||||
|
|
||||||
def test_parse_wind_plate_payload_rejects_unauthorized_or_empty_payloads():
|
def test_parse_wind_plate_payload_rejects_unauthorized_or_empty_payloads():
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ def test_airport_status_message_starts_with_runway_city_and_station_hashtags():
|
|||||||
"runway_pairs": [("17", "35"), ("16", "34")],
|
"runway_pairs": [("17", "35"), ("16", "34")],
|
||||||
"temperatures": [(23.0, None), (23.2, None)],
|
"temperatures": [(23.0, None), (23.2, None)],
|
||||||
"point_temperatures": [
|
"point_temperatures": [
|
||||||
{"tdz_temp": 23.0, "mid_temp": None, "end_temp": 23.1},
|
{"runway": "17/35", "tdz_temp": 23.0, "mid_temp": None, "end_temp": 23.1, "target_runway_max": 23.1, "wind_dir": None, "wind_speed": None, "rvr": None, "mor": None, "humidity": None},
|
||||||
{"tdz_temp": 23.2, "mid_temp": None, "end_temp": 23.3},
|
{"runway": "16/34", "tdz_temp": 23.2, "mid_temp": None, "end_temp": 23.3, "target_runway_max": 23.3, "wind_dir": None, "wind_speed": None, "rvr": None, "mor": None, "humidity": None},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -50,8 +50,8 @@ def test_airport_status_hides_non_focus_runways_for_key_airports():
|
|||||||
"runway_pairs": [("02L", "20R"), ("02R", "20L")],
|
"runway_pairs": [("02L", "20R"), ("02R", "20L")],
|
||||||
"temperatures": [(31.1, None), (34.9, None)],
|
"temperatures": [(31.1, None), (34.9, None)],
|
||||||
"point_temperatures": [
|
"point_temperatures": [
|
||||||
{"tdz_temp": 31.0, "mid_temp": 31.1, "end_temp": 31.2},
|
{"runway": "02L/20R", "tdz_temp": 31.0, "mid_temp": 31.1, "end_temp": 31.2, "target_runway_max": 31.2, "wind_dir": None, "wind_speed": None, "rvr": None, "mor": None, "humidity": None},
|
||||||
{"tdz_temp": 34.8, "mid_temp": 34.9, "end_temp": 35.0},
|
{"runway": "02R/20L", "tdz_temp": 34.8, "mid_temp": 34.9, "end_temp": 35.0, "target_runway_max": 35.0, "wind_dir": None, "wind_speed": None, "rvr": None, "mor": None, "humidity": None},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -61,8 +61,8 @@ def test_airport_status_hides_non_focus_runways_for_key_airports():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert "02L/20R" in text
|
assert "02L/20R" in text
|
||||||
assert "02R/20L" not in text
|
assert "02R/20L" in text
|
||||||
assert "当前:31.1°C" in text
|
assert "结算跑道当前:31.2°C" in text
|
||||||
|
|
||||||
|
|
||||||
def test_singapore_is_in_telegram_push_city_lists():
|
def test_singapore_is_in_telegram_push_city_lists():
|
||||||
|
|||||||
Reference in New Issue
Block a user