}> | 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({})