From 2670e2f8eebc9695a58ab6b84430c0eec33a07ef Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 27 May 2026 01:16:49 +0800 Subject: [PATCH] feat: implement interactive temperature chart dashboard with runway trend visualization and multi-source data collection support --- .../LiveTemperatureThresholdChart.tsx | 8 ++- .../scan-terminal/ModelCurvesSummary.tsx | 4 +- .../scan-terminal/TemperatureChartCanvas.tsx | 12 ++-- .../TemperatureRunwayDetails.tsx | 14 ++-- .../scan-terminal/TemperatureStatsBars.tsx | 30 ++++---- .../TemperatureTooltipContent.tsx | 4 +- .../__tests__/refreshCadencePolicy.test.ts | 64 ++++++++++++++++- .../__tests__/ssePatchArchitecture.test.ts | 16 +++++ .../scan-terminal/temperature-chart-logic.ts | 69 +++++++++++++++---- src/data_collection/country_networks.py | 22 +++++- src/data_collection/weather_sources.py | 36 ++++++++-- tests/test_country_networks.py | 27 ++++++++ tests/test_multi_model_sources.py | 54 +++++++++++++++ 13 files changed, 308 insertions(+), 52 deletions(-) diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 72057703..f4065564 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -390,6 +390,7 @@ export function LiveTemperatureThresholdChart({ const cityThresholds = useMemo(() => { if (!row || !allRows || !allRows.length) return []; + const tempSymbol = row.temp_symbol || "°C"; const cityKey = String(row.city || "").toLowerCase().trim(); const sameCityRows = allRows.filter( (r) => String(r.city || "").toLowerCase().trim() === cityKey @@ -409,7 +410,7 @@ export function LiveTemperatureThresholdChart({ list.push({ threshold: t, - label: r.target_label || `${t}°C`, + label: r.target_label || `${t}${tempSymbol}`, isBreached, kind, }); @@ -564,6 +565,7 @@ export function LiveTemperatureThresholdChart({ isEn={isEn} compact={compact} timeframe={timeframe} + tempSymbol={row?.temp_symbol || "°C"} runwayHeaderLabel={runwayHeaderLabel} metarHeaderLabel={metarHeaderLabel} runwayHighLabel={runwayHighLabel} @@ -583,11 +585,11 @@ export function LiveTemperatureThresholdChart({ /> {timeframe === "1D" && !compact && ( - + )} {timeframe === "1D" && !compact && ( - + )} s.key.startsWith("model_curve_")); if (!modelSeries.length) return null; @@ -25,7 +27,7 @@ export function ModelCurvesSummary({ {s.label} - {temp(stats.latest)} + {temp(stats.latest, tempSymbol)} ); })} diff --git a/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx b/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx index 9ca731df..b8742ac9 100644 --- a/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx +++ b/frontend/components/dashboard/scan-terminal/TemperatureChartCanvas.tsx @@ -73,6 +73,7 @@ export function TemperatureChartCanvas({ }) { const chartHostRef = useRef(null); const [chartSize, setChartSize] = useState({ width: 0, height: 0 }); + const tempSymbol = row?.temp_symbol || "°C"; useEffect(() => { const host = chartHostRef.current; @@ -177,7 +178,7 @@ export function TemperatureChartCanvas({ `${Number(v).toFixed(0)}°`} + tickFormatter={(v) => `${Number(v).toFixed(0)}${tempSymbol}`} axisLine={{ stroke: "#cbd5e1" }} tickLine={false} domain={chartDomain} @@ -186,8 +187,8 @@ export function TemperatureChartCanvas({ {timeframe === "1D" && cityThresholds.map((t, idx) => { const isSelected = row && (Number(row.target_threshold ?? row.target_value) === t.threshold); const labelText = isEn - ? `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}° [${t.isBreached ? "Excluded" : "Active"}]` - : `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}° [${t.isBreached ? "已排除" : "活跃"}]`; + ? `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}${tempSymbol} [${t.isBreached ? "Excluded" : "Active"}]` + : `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}${tempSymbol} [${t.isBreached ? "已排除" : "活跃"}]`; return ( }> | undefined} data={zoomedData} series={activeSeries} + tempSymbol={tempSymbol} /> )} formatter={(value: unknown) => { if (Array.isArray(value)) { const [low, high] = value; if (typeof low === "number" && typeof high === "number") { - return `${low.toFixed(1)}° - ${high.toFixed(1)}°`; + return `${low.toFixed(1)}${tempSymbol} - ${high.toFixed(1)}${tempSymbol}`; } } const num = Number(value); - return Number.isFinite(num) ? `${num.toFixed(2)}°` : String(value); + return Number.isFinite(num) ? `${num.toFixed(2)}${tempSymbol}` : String(value); }} /> {hasRunwayData && ( diff --git a/frontend/components/dashboard/scan-terminal/TemperatureRunwayDetails.tsx b/frontend/components/dashboard/scan-terminal/TemperatureRunwayDetails.tsx index 7c81faab..268d6ee7 100644 --- a/frontend/components/dashboard/scan-terminal/TemperatureRunwayDetails.tsx +++ b/frontend/components/dashboard/scan-terminal/TemperatureRunwayDetails.tsx @@ -16,9 +16,11 @@ type RunwayPlate = { export function TemperatureRunwayDetails({ isEn, plates, + tempSymbol, }: { isEn: boolean; plates: RunwayPlate[]; + tempSymbol: string; }) { if (!plates.length) return null; @@ -52,13 +54,13 @@ export function TemperatureRunwayDetails({ )} -
TDZ: {plate.tdzTemp !== null ? `${plate.tdzTemp.toFixed(1)}°C` : "--"}
-
MID: {plate.midTemp !== null ? `${plate.midTemp.toFixed(1)}°C` : "--"}
-
END: {plate.endTemp !== null ? `${plate.endTemp.toFixed(1)}°C` : "--"}
-
max: {plate.maxTemp !== null ? `${plate.maxTemp.toFixed(1)}°C` : "--"}
-
high: {plate.dailyHigh !== null ? `${plate.dailyHigh.toFixed(1)}°C` : "--"}
+
TDZ: {plate.tdzTemp !== null ? `${plate.tdzTemp.toFixed(1)}${tempSymbol}` : "--"}
+
MID: {plate.midTemp !== null ? `${plate.midTemp.toFixed(1)}${tempSymbol}` : "--"}
+
END: {plate.endTemp !== null ? `${plate.endTemp.toFixed(1)}${tempSymbol}` : "--"}
+
max: {plate.maxTemp !== null ? `${plate.maxTemp.toFixed(1)}${tempSymbol}` : "--"}
+
high: {plate.dailyHigh !== null ? `${plate.dailyHigh.toFixed(1)}${tempSymbol}` : "--"}
0 ? "text-orange-600 font-bold" : "text-slate-500")}> - 15m: {plate.trend_15m !== null ? `${plate.trend_15m >= 0 ? "+" : ""}${plate.trend_15m.toFixed(1)}°C` : "--"} + 15m: {plate.trend_15m !== null ? `${plate.trend_15m >= 0 ? "+" : ""}${plate.trend_15m.toFixed(1)}${tempSymbol}` : "--"}
))} diff --git a/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx b/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx index af496df8..153f287d 100644 --- a/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx +++ b/frontend/components/dashboard/scan-terminal/TemperatureStatsBars.tsx @@ -7,6 +7,7 @@ export function TemperatureStatsBars({ isEn, compact, timeframe, + tempSymbol, runwayHeaderLabel, metarHeaderLabel, runwayHighLabel, @@ -27,6 +28,7 @@ export function TemperatureStatsBars({ isEn: boolean; compact: boolean; timeframe: string; + tempSymbol: string; runwayHeaderLabel: string; metarHeaderLabel: string; runwayHighLabel: string; @@ -51,18 +53,18 @@ export function TemperatureStatsBars({
{isEn ? "Runway" : runwayHeaderLabel}:{" "} - {temp(displayRunwayTemp)} + {temp(displayRunwayTemp, tempSymbol)} | {isEn ? "METAR" : (isShenzhen ? "当日最高" : metarHeaderLabel)}:{" "} - {temp(observedHighMetar)} + {temp(observedHighMetar, tempSymbol)}
) : (
- DEB: {temp(debVal)} + DEB: {temp(debVal, tempSymbol)} {modelMin !== null && modelMax !== null && ( <> @@ -70,7 +72,7 @@ export function TemperatureStatsBars({ {isEn ? "Models" : "多模型"}:{" "} - {temp(modelMin)} - {temp(modelMax)} + {temp(modelMin, tempSymbol)} - {temp(modelMax, tempSymbol)} @@ -94,7 +96,7 @@ export function TemperatureStatsBars({ {isEn ? "Runway Live (1m)" : `${runwayHeaderLabel}`} - {temp(displayRunwayTemp)} + {temp(displayRunwayTemp, tempSymbol)}
@@ -102,7 +104,7 @@ export function TemperatureStatsBars({ {isEn ? "METAR Settlement · Daily High" : `${metarHeaderLabel} · 当日最高`} - {temp(observedHighMetar)} + {temp(observedHighMetar, tempSymbol)}
@@ -113,7 +115,7 @@ export function TemperatureStatsBars({ DEB Max - {temp(debVal)} + {temp(debVal, tempSymbol)}
@@ -121,7 +123,7 @@ export function TemperatureStatsBars({ {isEn ? "Model Range" : "多模型区间"} - {modelMin !== null && modelMax !== null ? `${temp(modelMin)} - ${temp(modelMax)}` : "--"} + {modelMin !== null && modelMax !== null ? `${temp(modelMin, tempSymbol)} - ${temp(modelMax, tempSymbol)}` : "--"}
@@ -132,13 +134,13 @@ export function TemperatureStatsBars({ {isEn ? "Daily Peak" : "当日最高气温"}
- {isEn ? "Runway" : runwayHighLabel}: {temp(observedHighRunway)} + {isEn ? "Runway" : runwayHighLabel}: {temp(observedHighRunway, tempSymbol)} | - {isEn ? "METAR" : metarHighLabel}: {temp(observedHighMetar)} + {isEn ? "METAR" : metarHighLabel}: {temp(observedHighMetar, tempSymbol)} {wundergroundDailyHigh !== null && ( <> | - WU: {temp(wundergroundDailyHigh)} + WU: {temp(wundergroundDailyHigh, tempSymbol)} )}
@@ -152,7 +154,7 @@ export function TemperatureStatsBars({ {isEn ? "Model Range" : "模型区间"} - {modelMin !== null && modelMax !== null ? `${temp(modelMin)} - ${temp(modelMax)}` : "--"} + {modelMin !== null && modelMax !== null ? `${temp(modelMin, tempSymbol)} - ${temp(modelMax, tempSymbol)}` : "--"}
@@ -160,7 +162,7 @@ export function TemperatureStatsBars({ DEB - {temp(debVal)} + {temp(debVal, tempSymbol)}
@@ -168,7 +170,7 @@ export function TemperatureStatsBars({ {isEn ? "Spread" : "分歧"} - {spread !== null ? `${spread.toFixed(1)}°C` : "--"} + {spread !== null ? `${spread.toFixed(1)}${tempSymbol}` : "--"} {spreadLabel && ` · ${isEn ? spreadLabelEn : spreadLabel}`}
diff --git a/frontend/components/dashboard/scan-terminal/TemperatureTooltipContent.tsx b/frontend/components/dashboard/scan-terminal/TemperatureTooltipContent.tsx index 3a0c0bc1..3c577b63 100644 --- a/frontend/components/dashboard/scan-terminal/TemperatureTooltipContent.tsx +++ b/frontend/components/dashboard/scan-terminal/TemperatureTooltipContent.tsx @@ -31,12 +31,14 @@ export function TemperatureTooltipContent({ payload, data, series, + tempSymbol = "°C", }: { active?: boolean; label?: string | number; payload?: ReadonlyArray<{ payload?: Record }>; data: Array>; series: TooltipSeries[]; + tempSymbol?: string; }) { if (!active || !payload?.length || !series.length) return null; const activePoint = payload[0]?.payload || {}; @@ -60,7 +62,7 @@ export function TemperatureTooltipContent({ {item.label} - {item.value.toFixed(2)}° + {item.value.toFixed(2)}{tempSymbol} ))} diff --git a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts index 81d2b251..5a2afe0f 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/refreshCadencePolicy.test.ts @@ -6,12 +6,23 @@ import { } from "@/lib/refresh-policy"; import { scanTerminalQueryPolicy } from "@/components/dashboard/scan-terminal/scan-terminal-client"; import { __shouldPollLiveChartForTest } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart"; +import { + MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS, + __resetHourlyDetailRequestQueueForTest, + __runQueuedHourlyDetailRequestForTest, +} from "@/components/dashboard/scan-terminal/temperature-chart-logic"; function assert(condition: unknown, message: string) { if (!condition) throw new Error(message); } -export function runTests() { +async function flushMicrotasks() { + for (let i = 0; i < 8; i += 1) { + await Promise.resolve(); + } +} + +export async function runTests() { assert(DASHBOARD_REFRESH_POLICY_MS.observation === 60_000, "observation layer should refresh every 60 seconds"); assert(DASHBOARD_REFRESH_POLICY_MS.scanRows === 5 * 60_000, "region/city rows should refresh every 5 minutes"); assert(DASHBOARD_REFRESH_POLICY_MS.marketOverview === 10 * 60_000, "market overview should refresh every 10 minutes"); @@ -68,4 +79,55 @@ export function runTests() { chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"), "terminal charts should render from row data immediately and dedupe concurrent city detail requests", ); + + __resetHourlyDetailRequestQueueForTest(); + let activeRequests = 0; + let maxActiveRequests = 0; + let startedRequests = 0; + const releases: Array<(() => void) | undefined> = []; + const requestCount = MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS + 3; + + const requests = Array.from({ length: requestCount }, (_, index) => + __runQueuedHourlyDetailRequestForTest( + () => + new Promise((resolve) => { + startedRequests += 1; + activeRequests += 1; + maxActiveRequests = Math.max(maxActiveRequests, activeRequests); + releases[index] = () => { + activeRequests -= 1; + resolve(index); + }; + }), + ), + ); + + await flushMicrotasks(); + assert( + startedRequests === MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS, + "city detail queue should start only the configured number of concurrent requests", + ); + assert( + maxActiveRequests === MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS, + "city detail queue must cap simultaneous full-detail requests", + ); + + releases[0]?.(); + await flushMicrotasks(); + assert( + startedRequests === MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS + 1, + "city detail queue should start the next pending request when one active request finishes", + ); + + for (let index = 1; index < requestCount; index += 1) { + await flushMicrotasks(); + assert(Boolean(releases[index]), `city detail queue should eventually start queued request #${index}`); + releases[index]?.(); + } + const results = await Promise.all(requests); + assert( + results.length === requestCount && results.every((value, index) => value === index), + "city detail queue should resolve every queued request in order", + ); + __resetHourlyDetailRequestQueueForTest(); } diff --git a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts index 12a220a4..e0d4c520 100644 --- a/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts +++ b/frontend/components/dashboard/scan-terminal/__tests__/ssePatchArchitecture.test.ts @@ -91,12 +91,16 @@ export function runTests() { const chartStatsPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "TemperatureStatsBars.tsx"); const chartRunwayPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "TemperatureRunwayDetails.tsx"); const chartTooltipPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "TemperatureTooltipContent.tsx"); + const chartSummaryPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "ModelCurvesSummary.tsx"); assert(fs.existsSync(chartCanvasPath), "temperature chart Recharts canvas must live in TemperatureChartCanvas.tsx"); assert(fs.existsSync(chartStatsPath), "temperature chart stat bars must live in TemperatureStatsBars.tsx"); assert(fs.existsSync(chartRunwayPath), "temperature chart runway detail panel must live in TemperatureRunwayDetails.tsx"); assert(fs.existsSync(chartTooltipPath), "temperature chart tooltip must live in TemperatureTooltipContent.tsx"); + assert(fs.existsSync(chartSummaryPath), "temperature chart model summary must live in ModelCurvesSummary.tsx"); const chartCanvas = fs.readFileSync(chartCanvasPath, "utf8"); const chartTooltip = fs.readFileSync(chartTooltipPath, "utf8"); + const chartRunway = fs.readFileSync(chartRunwayPath, "utf8"); + const chartSummary = fs.readFileSync(chartSummaryPath, "utf8"); const chartLogicPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "temperature-chart-logic.ts"); assert(fs.existsSync(chartLogicPath), "temperature chart pure data logic must live in temperature-chart-logic.ts"); const chartLogic = fs.readFileSync(chartLogicPath, "utf8"); @@ -112,6 +116,18 @@ export function runTests() { assert(chart.includes("TemperatureChartCanvas"), "temperature chart shell must compose the extracted chart canvas"); assert(chart.includes("TemperatureStatsBars"), "temperature chart shell must compose the extracted stat bars"); assert(chart.includes("TemperatureRunwayDetails"), "temperature chart shell must compose the extracted runway panel"); + assert(chart.includes("tempSymbol={row?.temp_symbol || \"°C\"}"), "temperature chart shell must pass the city temperature unit into stat bars"); + assert(chart.includes(" `${Number(v).toFixed(0)}${tempSymbol}`}"), "temperature y-axis ticks must include °C/°F instead of a bare degree mark"); + assert(chartTooltip.includes("tempSymbol"), "temperature tooltip must accept the city temperature unit"); assert(!chart.includes("from \"recharts\""), "LiveTemperatureThresholdChart.tsx must not import Recharts directly"); assert(!chart.includes("function TemperatureTooltipContent"), "LiveTemperatureThresholdChart.tsx must not define tooltip content inline"); assert(chartCanvas.includes("TemperatureTooltipContent"), "temperature chart canvas must use a custom tooltip content component"); diff --git a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts index 96659ae3..8c057f00 100644 --- a/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts +++ b/frontend/components/dashboard/scan-terminal/temperature-chart-logic.ts @@ -203,6 +203,9 @@ const MAX_OBS_POINTS = 1440; const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar; const _hourlyCache = new Map(); const _hourlyRequestCache = new Map>(); +const MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS = 3; +let _hourlyActiveDetailRequests = 0; +const _hourlyDetailRequestQueue: Array<() => void> = []; const RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"]; const SESSION_CACHE_PREFIX = "polyweather_city_detail_v1:"; @@ -231,6 +234,34 @@ function writeSessionCache(city: string, data: HourlyForecast) { } catch {} } +function drainHourlyDetailRequestQueue() { + while ( + _hourlyActiveDetailRequests < MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS && + _hourlyDetailRequestQueue.length > 0 + ) { + const start = _hourlyDetailRequestQueue.shift(); + if (start) start(); + } +} + +function runQueuedHourlyDetailRequest(task: () => Promise): Promise { + return new Promise((resolve, reject) => { + const start = () => { + _hourlyActiveDetailRequests += 1; + Promise.resolve() + .then(task) + .then(resolve, reject) + .finally(() => { + _hourlyActiveDetailRequests = Math.max(0, _hourlyActiveDetailRequests - 1); + drainHourlyDetailRequestQueue(); + }); + }; + + _hourlyDetailRequestQueue.push(start); + drainHourlyDetailRequestQueue(); + }); +} + export function clearCityDetailCache() { _hourlyCache.clear(); _hourlyRequestCache.clear(); @@ -246,6 +277,13 @@ export function clearCityDetailCache() { } } +function __resetHourlyDetailRequestQueueForTest() { + _hourlyActiveDetailRequests = 0; + _hourlyDetailRequestQueue.length = 0; +} + +const __runQueuedHourlyDetailRequestForTest = runQueuedHourlyDetailRequest; + function validNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } @@ -619,20 +657,22 @@ async function fetchHourlyForecastForCity( const pending = _hourlyRequestCache.get(requestKey); if (pending) return pending; - const request = fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false&resolution=${resParam}`, { - headers: { Accept: "application/json" }, - }) - .then(async (res) => { - if (!res.ok) return null; - return res.json() as Promise; - }) - .then((json) => { - const data = parseHourlyForecastFromCityDetail(json); - if (!data) return null; - _hourlyCache.set(cacheKey, { ts: Date.now(), data }); - writeSessionCache(cacheKey, data); - return data; + const request = runQueuedHourlyDetailRequest(() => + fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false&resolution=${resParam}`, { + headers: { Accept: "application/json" }, }) + .then(async (res) => { + if (!res.ok) return null; + return res.json() as Promise; + }) + .then((json) => { + const data = parseHourlyForecastFromCityDetail(json); + if (!data) return null; + _hourlyCache.set(cacheKey, { ts: Date.now(), data }); + writeSessionCache(cacheKey, data); + return data; + }), + ) .finally(() => { _hourlyRequestCache.delete(requestKey); }); @@ -1597,8 +1637,11 @@ function binObservationsToSlots( } export { + MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS, HOURLY_CACHE_TTL_MS, _hourlyCache, + __resetHourlyDetailRequestQueueForTest, + __runQueuedHourlyDetailRequestForTest, buildChartDomain, buildFullDayChartData, getDebPeakWindowRange, diff --git a/src/data_collection/country_networks.py b/src/data_collection/country_networks.py index 92ce682f..1f35526b 100644 --- a/src/data_collection/country_networks.py +++ b/src/data_collection/country_networks.py @@ -32,6 +32,15 @@ def _safe_float(value: Any) -> Optional[float]: return None +def _display_temp_from_c(temp_c: Any, use_fahrenheit: bool) -> Optional[float]: + numeric = _safe_float(temp_c) + if numeric is None: + return None + if use_fahrenheit: + return round(numeric * 9.0 / 5.0 + 32.0, 1) + return round(numeric, 1) + + def _parse_obs_datetime( value: Any, epoch_value: Any = None, @@ -248,11 +257,18 @@ def _airport_primary_from_raw(city: str, raw: Dict[str, Any]) -> Dict[str, Any]: # High-frequency realtime sources take priority over plain METAR. madis = raw.get("madis_hfmetar_current") or {} - if madis.get("temp_c") is not None: + madis_temp_c = _safe_float(madis.get("temp_c")) + madis_temp = _safe_float(madis.get("temp")) + if madis_temp is None and madis_temp_c is not None: + madis_temp = _display_temp_from_c( + madis_temp_c, + bool(meta.get("use_fahrenheit")), + ) + if madis_temp is not None: return _normalize_station_row( station_code=meta.get("icao") or madis.get("icao"), station_label=meta.get("airport_name") or meta.get("icao"), - temp=madis["temp_c"], + temp=madis_temp, obs_time=madis.get("obs_time") or metar.get("observation_time"), source_code="madis_hfmetar", source_label="NOAA MADIS", @@ -260,6 +276,8 @@ def _airport_primary_from_raw(city: str, raw: Dict[str, Any]) -> Dict[str, Any]: is_airport_station=True, is_settlement_anchor=False, extra={ + "temp_c": madis_temp_c, + "unit": "fahrenheit" if bool(meta.get("use_fahrenheit")) else "celsius", "max_so_far": _safe_float(current.get("max_temp_so_far")), "max_temp_time": current.get("max_temp_time"), "obs_age_min": None, diff --git a/src/data_collection/weather_sources.py b/src/data_collection/weather_sources.py index eed7c690..4b83541f 100644 --- a/src/data_collection/weather_sources.py +++ b/src/data_collection/weather_sources.py @@ -925,6 +925,16 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour def _uses_fahrenheit(self, city_lower: str) -> bool: return city_lower in self.US_CITIES + @staticmethod + def _display_temp_from_c(temp_c: Any, use_fahrenheit: bool) -> Optional[float]: + try: + numeric = float(temp_c) + except (TypeError, ValueError): + return None + if use_fahrenheit: + return round(numeric * 9.0 / 5.0 + 32.0, 1) + return round(numeric, 1) + def _supports_aviationweather(self, city_lower: str) -> bool: city_meta = self.CITY_REGISTRY.get(str(city_lower or "").strip().lower(), {}) or {} settlement_source = str(city_meta.get("settlement_source") or "").strip().lower() @@ -1451,7 +1461,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour logger.warning("AMSC AWOS attach failed city={}: {}", city_lower, exc) def _attach_madis_hfmetar_data( - self, results: Dict, city_lower: str + self, results: Dict, city_lower: str, use_fahrenheit: bool ) -> None: """Fetch MADIS HFMETAR data and attach matching US station observations.""" # Only run for US cities (ICAO starts with K) @@ -1476,19 +1486,33 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour # Find this city's station in the MADIS results for obs in obs_list: if obs["icao"] == icao: + temp_c = self._display_temp_from_c(obs.get("temp_c"), False) + display_temp = self._display_temp_from_c( + temp_c, + use_fahrenheit, + ) + if temp_c is None or display_temp is None: + continue + display_unit = "fahrenheit" if use_fahrenheit else "celsius" # Inject into results so it flows through to airport_primary - results["madis_hfmetar_current"] = obs + results["madis_hfmetar_current"] = { + **obs, + "temp_c": temp_c, + "temp": display_temp, + "unit": display_unit, + } self._emit_temperature_patch_if_changed( city_lower, - obs.get("temp_c"), + display_temp, obs.get("obs_time"), source="madis_hfmetar", + extra={"temp_c": temp_c, "unit": display_unit}, ) try: DBManager().append_airport_obs( icao=icao, city=city_lower, - temp_c=obs["temp_c"], + temp_c=temp_c, wind_kt=obs.get("wind_kt"), pressure_hpa=obs.get("pressure_hpa"), obs_time=obs["obs_time"] or datetime.now().isoformat(), @@ -1686,7 +1710,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour ) self._attach_korean_amos_data(results, city_lower, use_fahrenheit) self._attach_china_amsc_awos_data(results, city_lower, use_fahrenheit) - self._attach_madis_hfmetar_data(results, city_lower) + self._attach_madis_hfmetar_data(results, city_lower, use_fahrenheit) self._attach_singapore_mss_data(results, city_lower) self._attach_israel_ims_data(results, city_lower) self._attach_saudi_ncm_data(results, city_lower) @@ -1738,7 +1762,7 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour ) self._attach_korean_amos_data(results, city_lower, use_fahrenheit) self._attach_china_amsc_awos_data(results, city_lower, use_fahrenheit) - self._attach_madis_hfmetar_data(results, city_lower) + self._attach_madis_hfmetar_data(results, city_lower, use_fahrenheit) self._attach_singapore_mss_data(results, city_lower) self._attach_israel_ims_data(results, city_lower) self._attach_saudi_ncm_data(results, city_lower) diff --git a/tests/test_country_networks.py b/tests/test_country_networks.py index 65b75fc1..d69f9dbd 100644 --- a/tests/test_country_networks.py +++ b/tests/test_country_networks.py @@ -344,6 +344,33 @@ def test_moscow_provider_uses_realtime_metar_cluster_not_station_archive_rows(): assert snapshot["official_nearby"][0]["is_airport_station"] is True +def test_us_madis_airport_primary_uses_city_display_unit(): + raw = { + "madis_hfmetar_current": { + "icao": "KHOU", + "temp_c": 19.4, + "temp": 66.9, + "obs_time": "2026-05-27T17:00:00+00:00", + "wind_kt": 8.0, + }, + "metar": { + "observation_time": "2026-05-27T17:00:00+00:00", + "current": { + "temp": 66.9, + "max_temp_so_far": 84.9, + }, + }, + } + + snapshot = build_country_network_snapshot("houston", raw) + airport_primary = snapshot["airport_primary_current"] + + assert airport_primary["source_code"] == "madis_hfmetar" + assert airport_primary["source_label"] == "NOAA MADIS" + assert airport_primary["temp"] == 66.9 + assert airport_primary["temp_c"] == 19.4 + + def test_city_detail_payload_exposes_airport_and_official_network_layers(): payload = _build_city_detail_payload( { diff --git a/tests/test_multi_model_sources.py b/tests/test_multi_model_sources.py index 0f8be5f4..5c1e74f2 100644 --- a/tests/test_multi_model_sources.py +++ b/tests/test_multi_model_sources.py @@ -53,6 +53,60 @@ def test_multi_model_order_includes_legacy_and_new_sources(): assert "jma_seamless" in OPEN_METEO_MULTI_MODEL_ORDER +def test_madis_patch_uses_city_display_unit_for_us(monkeypatch): + collector = WeatherDataCollector({}) + emitted = [] + stored = [] + + monkeypatch.setattr( + collector, + "fetch_madis_hfmetar", + lambda: [ + { + "icao": "KHOU", + "temp_c": 19.4, + "obs_time": "2026-05-27T17:00:00+00:00", + "wind_kt": 8.0, + } + ], + ) + monkeypatch.setattr( + collector, + "_emit_temperature_patch_if_changed", + lambda city, temp, obs_time=None, **kwargs: emitted.append( + { + "city": city, + "temp": temp, + "obs_time": obs_time, + **kwargs, + } + ), + ) + + class FakeDBManager: + def append_airport_obs(self, **kwargs): + stored.append(kwargs) + + monkeypatch.setattr( + "src.data_collection.weather_sources.DBManager", + lambda: FakeDBManager(), + ) + + results = {} + collector._attach_madis_hfmetar_data( + results, + "houston", + use_fahrenheit=True, + ) + + assert results["madis_hfmetar_current"]["temp"] == 66.9 + assert results["madis_hfmetar_current"]["temp_c"] == 19.4 + assert emitted[0]["temp"] == 66.9 + assert emitted[0]["extra"]["temp_c"] == 19.4 + assert emitted[0]["extra"]["unit"] == "fahrenheit" + assert stored[0]["temp_c"] == 19.4 + + def test_fetch_all_sources_prioritizes_multi_model_before_forecast(monkeypatch, tmp_path): monkeypatch.setenv("OPEN_METEO_DISK_CACHE_PATH", str(tmp_path / "om-cache.json")) collector = WeatherDataCollector({})