feat: implement temperature chart visibility logic with comprehensive unit testing and analysis service support
This commit is contained in:
@@ -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({
|
||||
<main className="min-h-0 flex-1 overflow-hidden flex flex-col p-2 bg-[#eef2f6]">
|
||||
{activeNavKey === "training" ? (
|
||||
<TrainingDashboard isEn={isEn} />
|
||||
) : activeNavKey === "guide" ? (
|
||||
<UsageGuideDashboard isEn={isEn} />
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile layout */}
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-md border border-blue-100 bg-blue-50 text-blue-600">
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<h3 className="text-sm font-black text-slate-900">{title}</h3>
|
||||
<p className="mt-2 text-xs leading-5 text-slate-500">{body}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({
|
||||
icon: Icon,
|
||||
title,
|
||||
eyebrow,
|
||||
}: {
|
||||
icon: typeof BookOpenCheck;
|
||||
title: string;
|
||||
eyebrow: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div className="grid h-7 w-7 place-items-center rounded-md border border-slate-200 bg-white text-blue-600">
|
||||
<Icon size={15} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] font-black uppercase tracking-wide text-slate-400">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h2 className="text-sm font-black text-slate-900">{title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="h-full overflow-auto bg-[#f5f7fa]">
|
||||
<div className="mx-auto max-w-6xl p-4">
|
||||
<div className="mb-4 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-md border border-blue-100 bg-blue-50 px-2.5 py-1 text-[11px] font-black text-blue-700">
|
||||
<BookOpenCheck size={13} />
|
||||
{isEn ? "Terminal Guide" : "决策台使用指南"}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-black tracking-tight text-slate-950">
|
||||
{isEn ? "Read the terminal in four passes" : "按四步阅读天气决策台"}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
|
||||
{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 路径,最后用概率带和图层显隐确认城市是否偏离主预期。"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-[11px] font-bold text-slate-600">
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<div className="font-mono text-sm font-black text-slate-950">1-9</div>
|
||||
{isEn ? "Charts" : "图表位"}
|
||||
</div>
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<div className="font-mono text-sm font-black text-slate-950">Live</div>
|
||||
{isEn ? "Anchor" : "锚点"}
|
||||
</div>
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<div className="font-mono text-sm font-black text-slate-950">DEB</div>
|
||||
{isEn ? "Path" : "路径"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionTitle
|
||||
icon={Monitor}
|
||||
eyebrow={isEn ? "Quick start" : "快速开始"}
|
||||
title={isEn ? "The default reading order" : "默认阅读顺序"}
|
||||
/>
|
||||
<div className="mb-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{quickStart[locale].map((item, index) => (
|
||||
<GuideCard
|
||||
key={item.title}
|
||||
icon={quickIcons[index]}
|
||||
title={`${index + 1}. ${item.title}`}
|
||||
body={item.body}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SectionTitle
|
||||
icon={Eye}
|
||||
eyebrow={isEn ? "Legend" : "图表图例"}
|
||||
title={isEn ? "What each layer means" : "每条曲线代表什么"}
|
||||
/>
|
||||
<div className="mb-5 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{legendItems[locale].map((item, index) => (
|
||||
<GuideCard
|
||||
key={item.title}
|
||||
icon={legendIcons[index]}
|
||||
title={item.title}
|
||||
body={item.body}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-5 grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<div>
|
||||
<SectionTitle
|
||||
icon={MousePointer2}
|
||||
eyebrow={isEn ? "Controls" : "常用操作"}
|
||||
title={isEn ? "Daily workflow controls" : "日常巡检操作"}
|
||||
/>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{operations[locale].map((item) => (
|
||||
<div key={item.title} className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<CheckCircle2 size={15} className="text-emerald-600" />
|
||||
<h3 className="text-sm font-black text-slate-900">{item.title}</h3>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-slate-500">{item.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SectionTitle
|
||||
icon={CircleHelp}
|
||||
eyebrow={isEn ? "Rules" : "默认规则"}
|
||||
title={isEn ? "Visibility and access rules" : "图层和权益规则"}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
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) => (
|
||||
<div key={text} className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs font-semibold leading-5 text-amber-900">
|
||||
{text}
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Users size={15} className="text-blue-600" />
|
||||
<h3 className="text-sm font-black text-slate-900">
|
||||
{isEn ? "Pro membership" : "Pro 会员"}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-slate-500">
|
||||
{isEn
|
||||
? "Monthly and quarterly Pro unlock the full paid workflow, including the Telegram group entry after subscription activation."
|
||||
: "月付和季度 Pro 开通后解锁完整付费工作流,并在账户页显示 Telegram 群入口。"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+61
-8
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user