feat: implement interactive temperature chart dashboard with runway trend visualization and multi-source data collection support

This commit is contained in:
2569718930@qq.com
2026-05-27 01:16:49 +08:00
parent c4119c1aab
commit 2670e2f8ee
13 changed files with 308 additions and 52 deletions
@@ -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 && (
<TemperatureRunwayDetails isEn={isEn} plates={runwayPlates} />
<TemperatureRunwayDetails isEn={isEn} plates={runwayPlates} tempSymbol={row?.temp_symbol || "°C"} />
)}
{timeframe === "1D" && !compact && (
<ModelCurvesSummary isEn={isEn} activeSeries={activeSeries} />
<ModelCurvesSummary isEn={isEn} activeSeries={activeSeries} tempSymbol={row?.temp_symbol || "°C"} />
)}
<TemperatureChartCanvas
@@ -6,9 +6,11 @@ import { temp } from "@/components/dashboard/scan-terminal/utils";
export function ModelCurvesSummary({
isEn,
activeSeries,
tempSymbol,
}: {
isEn: boolean;
activeSeries: EvidenceSeries[];
tempSymbol: string;
}) {
const modelSeries = activeSeries.filter((s) => s.key.startsWith("model_curve_"));
if (!modelSeries.length) return null;
@@ -25,7 +27,7 @@ export function ModelCurvesSummary({
<span key={s.key} className="inline-flex items-center gap-1.5 font-mono">
<span className="h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: s.color }} />
<span className="text-slate-700 font-bold">{s.label}</span>
<span className="text-slate-500">{temp(stats.latest)}</span>
<span className="text-slate-500">{temp(stats.latest, tempSymbol)}</span>
</span>
);
})}
@@ -73,6 +73,7 @@ export function TemperatureChartCanvas({
}) {
const chartHostRef = useRef<HTMLDivElement | null>(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({
<YAxis
orientation="right"
tick={{ fontSize: 9, fill: "#64748b" }}
tickFormatter={(v) => `${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 (
<ReferenceLine
@@ -221,17 +222,18 @@ export function TemperatureChartCanvas({
payload={props.payload as ReadonlyArray<{ payload?: Record<string, any> }> | 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 && (
@@ -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({
</span>
)}
</div>
<div>TDZ: <strong>{plate.tdzTemp !== null ? `${plate.tdzTemp.toFixed(1)}°C` : "--"}</strong></div>
<div>MID: <strong>{plate.midTemp !== null ? `${plate.midTemp.toFixed(1)}°C` : "--"}</strong></div>
<div>END: <strong>{plate.endTemp !== null ? `${plate.endTemp.toFixed(1)}°C` : "--"}</strong></div>
<div>max: <strong>{plate.maxTemp !== null ? `${plate.maxTemp.toFixed(1)}°C` : "--"}</strong></div>
<div>high: <strong>{plate.dailyHigh !== null ? `${plate.dailyHigh.toFixed(1)}°C` : "--"}</strong></div>
<div>TDZ: <strong>{plate.tdzTemp !== null ? `${plate.tdzTemp.toFixed(1)}${tempSymbol}` : "--"}</strong></div>
<div>MID: <strong>{plate.midTemp !== null ? `${plate.midTemp.toFixed(1)}${tempSymbol}` : "--"}</strong></div>
<div>END: <strong>{plate.endTemp !== null ? `${plate.endTemp.toFixed(1)}${tempSymbol}` : "--"}</strong></div>
<div>max: <strong>{plate.maxTemp !== null ? `${plate.maxTemp.toFixed(1)}${tempSymbol}` : "--"}</strong></div>
<div>high: <strong>{plate.dailyHigh !== null ? `${plate.dailyHigh.toFixed(1)}${tempSymbol}` : "--"}</strong></div>
<div className={clsx(plate.trend_15m !== null && plate.trend_15m > 0 ? "text-orange-600 font-bold" : "text-slate-500")}>
15m: <strong>{plate.trend_15m !== null ? `${plate.trend_15m >= 0 ? "+" : ""}${plate.trend_15m.toFixed(1)}°C` : "--"}</strong>
15m: <strong>{plate.trend_15m !== null ? `${plate.trend_15m >= 0 ? "+" : ""}${plate.trend_15m.toFixed(1)}${tempSymbol}` : "--"}</strong>
</div>
</div>
))}
@@ -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({
<div className="flex items-center gap-4 text-[11px]">
<span className="font-semibold text-slate-500">
{isEn ? "Runway" : runwayHeaderLabel}:{" "}
<strong className="text-[#009688] font-mono">{temp(displayRunwayTemp)}</strong>
<strong className="text-[#009688] font-mono">{temp(displayRunwayTemp, tempSymbol)}</strong>
</span>
<span className="text-slate-300">|</span>
<span className="font-semibold text-slate-500">
{isEn ? "METAR" : (isShenzhen ? "当日最高" : metarHeaderLabel)}:{" "}
<strong className="text-blue-600 font-mono">{temp(observedHighMetar)}</strong>
<strong className="text-blue-600 font-mono">{temp(observedHighMetar, tempSymbol)}</strong>
</span>
</div>
) : (
<div className="flex items-center gap-4 text-[11px]">
<span className="font-semibold text-slate-500">
DEB: <strong className="text-orange-600 font-mono">{temp(debVal)}</strong>
DEB: <strong className="text-orange-600 font-mono">{temp(debVal, tempSymbol)}</strong>
</span>
{modelMin !== null && modelMax !== null && (
<>
@@ -70,7 +72,7 @@ export function TemperatureStatsBars({
<span className="font-semibold text-slate-500">
{isEn ? "Models" : "多模型"}:{" "}
<strong className="text-slate-700 font-mono">
{temp(modelMin)} - {temp(modelMax)}
{temp(modelMin, tempSymbol)} - {temp(modelMax, tempSymbol)}
</strong>
</span>
</>
@@ -94,7 +96,7 @@ export function TemperatureStatsBars({
{isEn ? "Runway Live (1m)" : `${runwayHeaderLabel}`}
</span>
<span className="text-2xl font-bold font-mono text-[#009688] mt-1">
{temp(displayRunwayTemp)}
{temp(displayRunwayTemp, tempSymbol)}
</span>
</div>
<div className="flex flex-col">
@@ -102,7 +104,7 @@ export function TemperatureStatsBars({
{isEn ? "METAR Settlement · Daily High" : `${metarHeaderLabel} · 当日最高`}
</span>
<span className="text-2xl font-bold font-mono text-blue-600 mt-1">
{temp(observedHighMetar)}
{temp(observedHighMetar, tempSymbol)}
</span>
</div>
</div>
@@ -113,7 +115,7 @@ export function TemperatureStatsBars({
DEB Max
</span>
<span className="text-2xl font-bold font-mono text-orange-600 mt-1">
{temp(debVal)}
{temp(debVal, tempSymbol)}
</span>
</div>
<div className="flex flex-col">
@@ -121,7 +123,7 @@ export function TemperatureStatsBars({
{isEn ? "Model Range" : "多模型区间"}
</span>
<span className="text-2xl font-bold font-mono text-slate-700 mt-1">
{modelMin !== null && modelMax !== null ? `${temp(modelMin)} - ${temp(modelMax)}` : "--"}
{modelMin !== null && modelMax !== null ? `${temp(modelMin, tempSymbol)} - ${temp(modelMax, tempSymbol)}` : "--"}
</span>
</div>
</div>
@@ -132,13 +134,13 @@ export function TemperatureStatsBars({
{isEn ? "Daily Peak" : "当日最高气温"}
</span>
<div className="mt-1 flex items-center gap-2 text-xs font-mono text-slate-600">
<span>{isEn ? "Runway" : runwayHighLabel}: <strong className="text-[#009688]">{temp(observedHighRunway)}</strong></span>
<span>{isEn ? "Runway" : runwayHighLabel}: <strong className="text-[#009688]">{temp(observedHighRunway, tempSymbol)}</strong></span>
<span>|</span>
<span>{isEn ? "METAR" : metarHighLabel}: <strong className="text-blue-600">{temp(observedHighMetar)}</strong></span>
<span>{isEn ? "METAR" : metarHighLabel}: <strong className="text-blue-600">{temp(observedHighMetar, tempSymbol)}</strong></span>
{wundergroundDailyHigh !== null && (
<>
<span>|</span>
<span>WU: <strong className="text-purple-600">{temp(wundergroundDailyHigh)}</strong></span>
<span>WU: <strong className="text-purple-600">{temp(wundergroundDailyHigh, tempSymbol)}</strong></span>
</>
)}
</div>
@@ -152,7 +154,7 @@ export function TemperatureStatsBars({
{isEn ? "Model Range" : "模型区间"}
</span>
<strong className="text-slate-800 font-bold">
{modelMin !== null && modelMax !== null ? `${temp(modelMin)} - ${temp(modelMax)}` : "--"}
{modelMin !== null && modelMax !== null ? `${temp(modelMin, tempSymbol)} - ${temp(modelMax, tempSymbol)}` : "--"}
</strong>
</div>
<div className="flex flex-col gap-0.5">
@@ -160,7 +162,7 @@ export function TemperatureStatsBars({
DEB
</span>
<strong className="text-blue-600 font-bold">
{temp(debVal)}
{temp(debVal, tempSymbol)}
</strong>
</div>
<div className="flex flex-col gap-0.5">
@@ -168,7 +170,7 @@ export function TemperatureStatsBars({
{isEn ? "Spread" : "分歧"}
</span>
<strong className={clsx("font-bold", spreadLabel === "高分歧" ? "text-amber-600" : "text-slate-600")}>
{spread !== null ? `${spread.toFixed(1)}°C` : "--"}
{spread !== null ? `${spread.toFixed(1)}${tempSymbol}` : "--"}
{spreadLabel && ` · ${isEn ? spreadLabelEn : spreadLabel}`}
</strong>
</div>
@@ -31,12 +31,14 @@ export function TemperatureTooltipContent({
payload,
data,
series,
tempSymbol = "°C",
}: {
active?: boolean;
label?: string | number;
payload?: ReadonlyArray<{ payload?: Record<string, any> }>;
data: Array<Record<string, any>>;
series: TooltipSeries[];
tempSymbol?: string;
}) {
if (!active || !payload?.length || !series.length) return null;
const activePoint = payload[0]?.payload || {};
@@ -60,7 +62,7 @@ export function TemperatureTooltipContent({
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
<span className="font-semibold">{item.label}</span>
</span>
<strong className="font-mono text-slate-900">{item.value.toFixed(2)}°</strong>
<strong className="font-mono text-slate-900">{item.value.toFixed(2)}{tempSymbol}</strong>
</div>
))}
</div>
@@ -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<number>((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();
}
@@ -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("<TemperatureRunwayDetails") && chart.includes("tempSymbol={row?.temp_symbol || \"°C\"}"), "temperature chart shell must pass the city unit into runway details");
assert(chart.includes("<ModelCurvesSummary") && chart.includes("tempSymbol={row?.temp_symbol || \"°C\"}"), "temperature chart shell must pass the city unit into model summaries");
const chartStats = fs.readFileSync(chartStatsPath, "utf8");
assert(chartStats.includes("tempSymbol"), "temperature stat bars must accept the city temperature unit");
assert(chartStats.includes("temp(displayRunwayTemp, tempSymbol)"), "temperature stat bars must render live observations with the city unit");
assert(chartRunway.includes("tempSymbol") && !chartRunway.includes("}°C`"), "runway detail rows must render values with the city unit");
assert(chartSummary.includes("tempSymbol") && chartSummary.includes("temp(stats.latest, tempSymbol)"), "model curve summaries must render values with the city unit");
assert(chartCanvas.includes("const tempSymbol = row?.temp_symbol || \"°C\""), "temperature chart canvas must derive the city unit from the row");
assert(chartCanvas.includes("tempSymbol={tempSymbol}"), "temperature chart canvas must pass the city unit into tooltips");
assert(chartCanvas.includes("tickFormatter={(v) => `${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");
@@ -203,6 +203,9 @@ const MAX_OBS_POINTS = 1440;
const HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>();
const _hourlyRequestCache = new Map<string, Promise<HourlyForecast>>();
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<T>(task: () => Promise<T>): Promise<T> {
return new Promise<T>((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<CityDetail>;
})
.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<CityDetail>;
})
.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,
+20 -2
View File
@@ -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,
+30 -6
View File
@@ -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)
+27
View File
@@ -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(
{
+54
View File
@@ -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({})