From 68416b058e5d69487aa77c1262b2da26a1cc80f1 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 29 May 2026 22:40:33 +0800 Subject: [PATCH] feat: implement temperature chart visibility logic with comprehensive unit testing and analysis service support --- .../dashboard/ScanTerminalDashboard.tsx | 10 +- .../scan-terminal/UsageGuideDashboard.tsx | 277 ++++++++++++++++++ ...temperatureDefaultVisibilityPolicy.test.ts | 69 ++++- .../scan-terminal/temperature-chart-logic.ts | 18 +- tests/test_country_networks.py | 18 +- web/analysis_service.py | 15 +- 6 files changed, 385 insertions(+), 22 deletions(-) create mode 100644 frontend/components/dashboard/scan-terminal/UsageGuideDashboard.tsx diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index da9794cf..af8fb315 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -4,13 +4,13 @@ import clsx from "clsx"; import Link from "next/link"; import { Activity, + BookOpenCheck, ChevronDown, ChevronLeft, ChevronRight, GraduationCap, Menu, Search, - Table2, UserRound, Users, } from "lucide-react"; @@ -45,6 +45,7 @@ import { scanRootClass } from "@/components/dashboard/scan-root-styles"; import { useRelativeTime } from "@/hooks/useRelativeTime"; import { Panel } from "@/components/dashboard/scan-terminal/Panel"; import { TrainingDashboard } from "@/components/dashboard/scan-terminal/TrainingDashboard"; +import { UsageGuideDashboard } from "@/components/dashboard/scan-terminal/UsageGuideDashboard"; import { LiveTemperatureThresholdChart, clearCityDetailCache } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart"; import { KoyfinRowsTable } from "@/components/dashboard/scan-terminal/KoyfinRowsTable"; import { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils"; @@ -100,7 +101,7 @@ const TERM = { liq: { en: "Liq", zh: "流动性" }, signal: { en: "Signal", zh: "信号" }, searchPlaceholder: { en: "Search city, threshold, station, or signal", zh: "搜索城市、阈值、站点或信号" }, - weatherThresholds: { en: "Weather Thresholds", zh: "天气阈值" }, + weatherThresholds: { en: "Weather Decisions", zh: "天气决策" }, selectedThresholdMonitor: { en: "Selected Threshold Monitor", zh: "选中阈值监控" }, probabilityDistribution: { en: "Probability Distribution", zh: "概率分布" }, signalList: { en: "Signal List", zh: "信号列表" }, @@ -465,8 +466,9 @@ function PolyWeatherTerminal({ }; const NAV_ITEMS = [ - { key: "thresholds", Icon: Table2, labelEn: "Thresholds", labelZh: "天气阈值" }, + { key: "thresholds", Icon: Activity, labelEn: "Decision", labelZh: "天气决策" }, { key: "training", Icon: GraduationCap, labelEn: "Training", labelZh: "训练数据" }, + { key: "guide", Icon: BookOpenCheck, labelEn: "Guide", labelZh: "使用指南" }, ]; useEffect(() => { @@ -719,6 +721,8 @@ function PolyWeatherTerminal({
{activeNavKey === "training" ? ( + ) : activeNavKey === "guide" ? ( + ) : ( <> {/* Mobile layout */} diff --git a/frontend/components/dashboard/scan-terminal/UsageGuideDashboard.tsx b/frontend/components/dashboard/scan-terminal/UsageGuideDashboard.tsx new file mode 100644 index 00000000..df88b8f4 --- /dev/null +++ b/frontend/components/dashboard/scan-terminal/UsageGuideDashboard.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { + BookOpenCheck, + ChartSpline, + CheckCircle2, + CircleHelp, + Eye, + Gauge, + Layers3, + MapPinned, + Monitor, + MousePointer2, + Plane, + RadioTower, + SlidersHorizontal, + Sparkles, + Users, +} from "lucide-react"; + +type GuideCopy = { + title: string; + body: string; +}; + +const quickStart: Record<"zh" | "en", GuideCopy[]> = { + zh: [ + { + title: "先选城市", + body: "左上角城市名用于切换当前图表,网格布局可同时观察多个城市。", + }, + { + title: "先看实测", + body: "青绿色粗线是当前更重要的实况锚点,结算跑道或官方站优先于普通机场报文。", + }, + { + title: "再看 DEB", + body: "橙色 DEB Forecast 是融合模型和日内修正后的路径,用来判断后续升温或降温空间。", + }, + { + title: "最后看概率", + body: "紫色区域和虚线表示高概率温度带,适合判断当前实测是否偏离主预期。", + }, + ], + en: [ + { + title: "Pick cities first", + body: "Use the city name in each chart header to switch slots and monitor multiple cities in the grid.", + }, + { + title: "Read live evidence", + body: "The teal anchor is the key live observation layer; settlement runway or official station data takes priority.", + }, + { + title: "Compare DEB", + body: "The orange DEB Forecast blends model context with intraday correction to frame the remaining move.", + }, + { + title: "Check probability", + body: "The purple band and dotted line show the high-probability temperature zone for fast deviation checks.", + }, + ], +}; + +const legendItems: Record<"zh" | "en", GuideCopy[]> = { + zh: [ + { title: "实测 / 结算线", body: "优先展示结算跑道、官方站或城市核心实况,用于判断已兑现温度。" }, + { title: "DEB Forecast", body: "橙色预测路径,重点看它和实测线在峰值窗口前后的分歧。" }, + { title: "高概率带", body: "紫色带表示当前概率分布的主要落点,虚线是概率均值附近。" }, + { title: "机场报文", body: "METAR / MGM 作为机场站参考,默认只在适合的城市自动显示。" }, + { title: "模型线", body: "ECMWF、GFS、ICON、GEM 等提供背景,默认弱化为辅助判断。" }, + { title: "跑道明细", body: "打开后可查看各跑道传感器,关闭后仍保留结算跑道温度。" }, + ], + en: [ + { title: "Live / settlement", body: "Settlement runway, official station, or core live observation used as the realized anchor." }, + { title: "DEB Forecast", body: "Orange forecast path; focus on its gap versus live observations near the peak window." }, + { title: "Probability band", body: "Purple band marks the main probability zone, with the dotted line near the probability mean." }, + { title: "Airport reports", body: "METAR / MGM are airport references and are auto-shown only where they are useful by default." }, + { title: "Model lines", body: "ECMWF, GFS, ICON, GEM, and related model layers provide background context." }, + { title: "Runway details", body: "When disabled, the chart still keeps the settlement runway temperature visible." }, + ], +}; + +const operations: Record<"zh" | "en", GuideCopy[]> = { + zh: [ + { title: "布局", body: "右上角可切换 1x1 到 3x3,适合从单城复盘切到多城巡检。" }, + { title: "换城市", body: "点击图表标题栏城市名,在当前卡片内搜索并替换城市。" }, + { title: "高温模式", body: "卡片右上角高温按钮用于聚焦最高温兑现窗口。" }, + { title: "曲线显隐", body: "图例可自定义显示机场报文、模型线和跑道明细。" }, + ], + en: [ + { title: "Layout", body: "Switch from 1x1 to 3x3 in the top-right control for review or multi-city scanning." }, + { title: "Change city", body: "Click the city name in a chart header to search and replace that slot." }, + { title: "High mode", body: "Use the High button to focus the chart on the high-temperature payoff window." }, + { title: "Layer toggles", body: "Use the legend to customize airport reports, model lines, and runway details." }, + ], +}; + +function GuideCard({ + icon: Icon, + title, + body, +}: { + icon: typeof BookOpenCheck; + title: string; + body: string; +}) { + return ( +
+
+ +
+

{title}

+

{body}

+
+ ); +} + +function SectionTitle({ + icon: Icon, + title, + eyebrow, +}: { + icon: typeof BookOpenCheck; + title: string; + eyebrow: string; +}) { + return ( +
+
+ +
+
+
+ {eyebrow} +
+

{title}

+
+
+ ); +} + +export function UsageGuideDashboard({ isEn }: { isEn: boolean }) { + const locale = isEn ? "en" : "zh"; + const quickIcons = [MapPinned, RadioTower, ChartSpline, Gauge]; + const legendIcons = [RadioTower, ChartSpline, Sparkles, Plane, Layers3, SlidersHorizontal]; + + return ( +
+
+
+
+ + {isEn ? "Terminal Guide" : "决策台使用指南"} +
+
+
+

+ {isEn ? "Read the terminal in four passes" : "按四步阅读天气决策台"} +

+

+ {isEn + ? "Start from live evidence, compare DEB, then use probability and layer toggles to confirm whether the city is moving away from the main path." + : "先看实况锚点,再对照 DEB 路径,最后用概率带和图层显隐确认城市是否偏离主预期。"} +

+
+
+
+
1-9
+ {isEn ? "Charts" : "图表位"} +
+
+
Live
+ {isEn ? "Anchor" : "锚点"} +
+
+
DEB
+ {isEn ? "Path" : "路径"} +
+
+
+
+ + +
+ {quickStart[locale].map((item, index) => ( + + ))} +
+ + +
+ {legendItems[locale].map((item, index) => ( + + ))} +
+ +
+
+ +
+ {operations[locale].map((item) => ( +
+
+ +

{item.title}

+
+

{item.body}

+
+ ))} +
+
+ +
+ +
+ {[ + isEn + ? "For cities other than Hong Kong and Shenzhen, airport METAR temperature is hidden by default. Users can still enable it manually." + : "除香港和深圳外,机场 METAR 温度默认不参与图表展示;用户仍可手动打开。", + isEn + ? "Turkey airport-station curves use MGM data when available, so Ankara and Istanbul should be read from the MGM airport anchor." + : "土耳其机场站优先使用 MGM 数据,安卡拉和伊斯坦布尔应以 MGM 机场锚点阅读。", + isEn + ? "The 3-day trial has the same core terminal access as Pro, except the paid Telegram group link is hidden." + : "3 天试用拥有和 Pro 一致的核心决策台权益,仅不显示付费 Telegram 群链接。", + ].map((text) => ( +
+ {text} +
+ ))} +
+
+ +

+ {isEn ? "Pro membership" : "Pro 会员"} +

+
+

+ {isEn + ? "Monthly and quarterly Pro unlock the full paid workflow, including the Telegram group entry after subscription activation." + : "月付和季度 Pro 开通后解锁完整付费工作流,并在账户页显示 Telegram 群入口。"} +

+
+
+
+
+
+
+ ); +} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts index 38cd7ce4..ff4837f5 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/temperatureDefaultVisibilityPolicy.test.ts @@ -192,22 +192,30 @@ export function runTests() { "settlement/HKO observations should be visible by default", ); assert( - !__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "metar"), - "METAR observations should be hidden by default outside Hong Kong and Shenzhen", + __isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "metar"), + "METAR observations should be visible by default outside Hong Kong and Shenzhen", ); assert( - !activeDefaultSeries.some((item) => item.key === "metar"), - "non-Hong Kong/Shenzhen airport METAR observations should not affect the active chart series by default", + activeDefaultSeries.some((item) => item.key === "metar"), + "non-Hong Kong/Shenzhen airport METAR observations should be part of the active chart series by default", ); assert( - __getVisibleTemperatureSeriesForTest("guangzhou", series, { metar: true }).some( + !__getVisibleTemperatureSeriesForTest("guangzhou", series, { metar: false }).some( (item) => item.key === "metar", ), - "users should still be able to enable the hidden airport METAR series from the legend", + "users should still be able to hide the airport METAR series from the legend", ); assert( - __isTemperatureSeriesVisibleByDefaultForTest("hong kong", "metar"), - "Hong Kong METAR/HKO observations should remain visible by default", + !__isTemperatureSeriesVisibleByDefaultForTest("hong kong", "metar"), + "Hong Kong airport METAR observations should stay hidden by default because HKO is the primary local station", + ); + assert( + !__isTemperatureSeriesVisibleByDefaultForTest("Lau Fau Shan", "metar"), + "Lau Fau Shan airport METAR observations should stay hidden by default because HKO is the primary local station", + ); + assert( + !__isTemperatureSeriesVisibleByDefaultForTest("shenzhen", "metar"), + "Shenzhen airport METAR observations should stay hidden by default because HKO is the primary local station", ); assert( __isTemperatureSeriesVisibleByDefaultForTest("Lau Fau Shan", "madis"), @@ -221,6 +229,51 @@ export function runTests() { !__isTemperatureSeriesVisibleByDefaultForTest("new york", "madis"), "non-Hong Kong/Shenzhen airport-primary observations should be hidden by default", ); + + const ankaraMgmWithMetarBackup = __buildTemperatureChartDataForTest( + { + city: "ankara", + local_date: "2026-05-29", + local_time: "17:28", + tz_offset_seconds: 3 * 60 * 60, + airport: "LTAC", + metar_today_obs: [ + { time: "2026-05-29T12:00:00Z", temp: 15.0 }, + { time: "2026-05-29T13:00:00Z", temp: 16.0 }, + { time: "2026-05-29T14:00:00Z", temp: 17.0 }, + ], + } as any, + { + localTime: "17:28", + times: ["00:00", "06:00", "12:00", "18:00"], + temps: [15, 14, 16, 15], + airportPrimary: { + source_code: "mgm", + source_label: "MGM", + temp: 14.0, + obs_time: "2026-05-29T14:28:00Z", + }, + airportPrimaryTodayObs: [ + { time: "2026-05-29T13:28:00Z", temp: 17.0 }, + { time: "2026-05-29T14:28:00Z", temp: 14.0 }, + ], + } as any, + "1D", + ); + const ankaraDefaultSeries = __getActiveTemperatureSeriesForTest( + "ankara", + ankaraMgmWithMetarBackup.series as any, + {}, + true, + ); + assert( + ankaraDefaultSeries.some((item: any) => item.key === "madis" && item.label === "MGM"), + "Ankara MGM airport-primary weather-station curve should be visible by default", + ); + assert( + ankaraDefaultSeries.some((item: any) => item.key === "metar"), + "Ankara local METAR backup curve should remain visible by default because MGM history can be incomplete", + ); assert( !__isTemperatureSeriesVisibleByDefaultForTest("guangzhou", "model_curve_ECMWF"), "multi-model curves should be hidden by default", diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index 809d1178..23d88075 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -86,9 +86,23 @@ function isTemperatureSeriesVisibleByDefault(city: string, seriesKey: string) { if (seriesKey.startsWith("model_curve_")) { return normalizeCityKey(city) === "paris" && seriesKey === "model_curve_AROME HD"; } - if (seriesKey === "metar" || seriesKey === "madis") { + if (seriesKey === "metar") { const cityKey = normalizeCityKey(city); - return cityKey === "hongkong" || cityKey === "laufaushan" || cityKey === "shenzhen"; + return ( + cityKey !== "hongkong" && + cityKey !== "laufaushan" && + cityKey !== "shenzhen" + ); + } + if (seriesKey === "madis") { + const cityKey = normalizeCityKey(city); + return ( + cityKey === "hongkong" || + cityKey === "laufaushan" || + cityKey === "shenzhen" || + cityKey === "ankara" || + cityKey === "istanbul" + ); } return true; } diff --git a/tests/test_country_networks.py b/tests/test_country_networks.py index 10c1854d..c2d6d1b2 100644 --- a/tests/test_country_networks.py +++ b/tests/test_country_networks.py @@ -1,5 +1,6 @@ import threading from datetime import datetime, timedelta, timezone +from typing import Any from src.data_collection.country_networks import build_country_network_snapshot from src.data_collection.city_registry import ALIASES, CITY_REGISTRY @@ -20,6 +21,14 @@ class _DummyMetarSource(MetarSourceMixin): metar_cache_ttl_sec = 600 metar_fast_cache_ttl_sec = 60 + def __init__(self): + self._metar_cache = {} + self._metar_cache_lock = threading.Lock() + self.metar_timeout_sec = 0.0 + self.metar_latest_timeout_sec = 0.0 + self.timeout = 0.0 + self._http_get: Any = None + def test_new_city_registry_entries_are_wired(): assert CITY_REGISTRY["manila"]["settlement_source"] == "wunderground" @@ -59,7 +68,9 @@ def test_paris_registry_uses_le_bourget_anchor(): assert paris["icao"] == "LFPB" assert paris["settlement_source"] == "aeroweb" assert paris["settlement_station_code"] == "LFPB" - assert "bonneuil-en-france/LFPB" in paris["settlement_url"] + settlement_url = paris.get("settlement_url") + assert isinstance(settlement_url, str) + assert "bonneuil-en-france/LFPB" in settlement_url assert CITIES["paris"]["lat"] == paris["lat"] assert CITIES["paris"]["settlement_source"] == "aeroweb" assert _DummyMetarSource.CITY_TO_ICAO["paris"] == "LFPB" @@ -76,12 +87,12 @@ def test_turkey_metar_uses_fast_cache_ttl(): def test_metar_marks_previous_local_day_report_stale(monkeypatch): class FixedDateTime(datetime): @classmethod - def now(cls, tz=None): + def now(cls, tz=None): # type: ignore value = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc) return value if tz is None else value.astimezone(tz) @classmethod - def utcnow(cls): + def utcnow(cls): # type: ignore return datetime(2026, 4, 21, 12, 0) class FakeResponse: @@ -112,6 +123,7 @@ def test_metar_marks_previous_local_day_report_stale(monkeypatch): result = source.fetch_metar("karachi", utc_offset=18000) + assert result is not None assert result["stale_for_today"] is True assert result["observation_local_date"] == "2026-04-20" assert result["current_local_date"] == "2026-04-21" diff --git a/web/analysis_service.py b/web/analysis_service.py index 7eaf7bc8..cdc0c663 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -117,7 +117,7 @@ def _should_build_country_network_snapshot( if not is_panel_mode: return True - city_lower = str(city or "").strip().lower() + city_lower = (city or "").strip().lower() if city_lower not in TURKISH_MGM_CITIES: return False @@ -147,7 +147,7 @@ def _normalize_runway_label(value: Any) -> str: def _split_runway_pair_label(value: Any) -> tuple[str, str]: - parts = [_normalize_runway_label(part) for part in str(value or "").split("/") if str(part).strip()] + parts = [_normalize_runway_label(part) for part in str(value or "").split("/") if part.strip()] if len(parts) >= 2: return parts[0], parts[1] runway = _normalize_runway_label(value) @@ -166,7 +166,10 @@ def _settlement_runway_endpoint_temp(city: str, row: Dict[str, Any]) -> Optional return None pair = _split_runway_pair_label(row.get("runway")) - configured = tuple(_normalize_runway_label(part) for part in configured_pair) + configured = ( + _normalize_runway_label(configured_pair[0]), + _normalize_runway_label(configured_pair[1]), + ) if not _runway_pair_matches(pair, configured): return None @@ -1676,8 +1679,9 @@ def _analyze( if day_m: try: deb_result = calculate_deb_prediction(city, day_m) - if deb_result.get("prediction") is not None: - d_val = deb_result.get("prediction") + d_prediction = _sf(deb_result.get("prediction")) + if d_prediction is not None: + d_val = d_prediction d_raw_val = deb_result.get("raw_prediction") d_version = deb_result.get("version") d_bias_adjustment = deb_result.get("bias_adjustment") or 0.0 @@ -1727,7 +1731,6 @@ def _analyze( continue temp_val = _runway_history_temp_for_city(city, r) if temp_val is not None: - temp_val = float(temp_val) if is_f: temp_val = round(temp_val * 9.0 / 5.0 + 32.0, 1) else: