feat: implement interactive temperature chart dashboard with runway trend visualization and multi-source data collection support
This commit is contained in:
@@ -390,6 +390,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
|
|
||||||
const cityThresholds = useMemo(() => {
|
const cityThresholds = useMemo(() => {
|
||||||
if (!row || !allRows || !allRows.length) return [];
|
if (!row || !allRows || !allRows.length) return [];
|
||||||
|
const tempSymbol = row.temp_symbol || "°C";
|
||||||
const cityKey = String(row.city || "").toLowerCase().trim();
|
const cityKey = String(row.city || "").toLowerCase().trim();
|
||||||
const sameCityRows = allRows.filter(
|
const sameCityRows = allRows.filter(
|
||||||
(r) => String(r.city || "").toLowerCase().trim() === cityKey
|
(r) => String(r.city || "").toLowerCase().trim() === cityKey
|
||||||
@@ -409,7 +410,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
|
|
||||||
list.push({
|
list.push({
|
||||||
threshold: t,
|
threshold: t,
|
||||||
label: r.target_label || `${t}°C`,
|
label: r.target_label || `${t}${tempSymbol}`,
|
||||||
isBreached,
|
isBreached,
|
||||||
kind,
|
kind,
|
||||||
});
|
});
|
||||||
@@ -564,6 +565,7 @@ export function LiveTemperatureThresholdChart({
|
|||||||
isEn={isEn}
|
isEn={isEn}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
timeframe={timeframe}
|
timeframe={timeframe}
|
||||||
|
tempSymbol={row?.temp_symbol || "°C"}
|
||||||
runwayHeaderLabel={runwayHeaderLabel}
|
runwayHeaderLabel={runwayHeaderLabel}
|
||||||
metarHeaderLabel={metarHeaderLabel}
|
metarHeaderLabel={metarHeaderLabel}
|
||||||
runwayHighLabel={runwayHighLabel}
|
runwayHighLabel={runwayHighLabel}
|
||||||
@@ -583,11 +585,11 @@ export function LiveTemperatureThresholdChart({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{timeframe === "1D" && !compact && (
|
{timeframe === "1D" && !compact && (
|
||||||
<TemperatureRunwayDetails isEn={isEn} plates={runwayPlates} />
|
<TemperatureRunwayDetails isEn={isEn} plates={runwayPlates} tempSymbol={row?.temp_symbol || "°C"} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{timeframe === "1D" && !compact && (
|
{timeframe === "1D" && !compact && (
|
||||||
<ModelCurvesSummary isEn={isEn} activeSeries={activeSeries} />
|
<ModelCurvesSummary isEn={isEn} activeSeries={activeSeries} tempSymbol={row?.temp_symbol || "°C"} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TemperatureChartCanvas
|
<TemperatureChartCanvas
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import { temp } from "@/components/dashboard/scan-terminal/utils";
|
|||||||
export function ModelCurvesSummary({
|
export function ModelCurvesSummary({
|
||||||
isEn,
|
isEn,
|
||||||
activeSeries,
|
activeSeries,
|
||||||
|
tempSymbol,
|
||||||
}: {
|
}: {
|
||||||
isEn: boolean;
|
isEn: boolean;
|
||||||
activeSeries: EvidenceSeries[];
|
activeSeries: EvidenceSeries[];
|
||||||
|
tempSymbol: string;
|
||||||
}) {
|
}) {
|
||||||
const modelSeries = activeSeries.filter((s) => s.key.startsWith("model_curve_"));
|
const modelSeries = activeSeries.filter((s) => s.key.startsWith("model_curve_"));
|
||||||
if (!modelSeries.length) return null;
|
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 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="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-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>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export function TemperatureChartCanvas({
|
|||||||
}) {
|
}) {
|
||||||
const chartHostRef = useRef<HTMLDivElement | null>(null);
|
const chartHostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [chartSize, setChartSize] = useState({ width: 0, height: 0 });
|
const [chartSize, setChartSize] = useState({ width: 0, height: 0 });
|
||||||
|
const tempSymbol = row?.temp_symbol || "°C";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = chartHostRef.current;
|
const host = chartHostRef.current;
|
||||||
@@ -177,7 +178,7 @@ export function TemperatureChartCanvas({
|
|||||||
<YAxis
|
<YAxis
|
||||||
orientation="right"
|
orientation="right"
|
||||||
tick={{ fontSize: 9, fill: "#64748b" }}
|
tick={{ fontSize: 9, fill: "#64748b" }}
|
||||||
tickFormatter={(v) => `${Number(v).toFixed(0)}°`}
|
tickFormatter={(v) => `${Number(v).toFixed(0)}${tempSymbol}`}
|
||||||
axisLine={{ stroke: "#cbd5e1" }}
|
axisLine={{ stroke: "#cbd5e1" }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
domain={chartDomain}
|
domain={chartDomain}
|
||||||
@@ -186,8 +187,8 @@ export function TemperatureChartCanvas({
|
|||||||
{timeframe === "1D" && cityThresholds.map((t, idx) => {
|
{timeframe === "1D" && cityThresholds.map((t, idx) => {
|
||||||
const isSelected = row && (Number(row.target_threshold ?? row.target_value) === t.threshold);
|
const isSelected = row && (Number(row.target_threshold ?? row.target_value) === t.threshold);
|
||||||
const labelText = isEn
|
const labelText = isEn
|
||||||
? `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}° [${t.isBreached ? "Excluded" : "Active"}]`
|
? `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}${tempSymbol} [${t.isBreached ? "Excluded" : "Active"}]`
|
||||||
: `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}° [${t.isBreached ? "已排除" : "活跃"}]`;
|
: `${t.kind === "gte" ? "≥" : "≤"} ${t.threshold.toFixed(1)}${tempSymbol} [${t.isBreached ? "已排除" : "活跃"}]`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
@@ -221,17 +222,18 @@ export function TemperatureChartCanvas({
|
|||||||
payload={props.payload as ReadonlyArray<{ payload?: Record<string, any> }> | undefined}
|
payload={props.payload as ReadonlyArray<{ payload?: Record<string, any> }> | undefined}
|
||||||
data={zoomedData}
|
data={zoomedData}
|
||||||
series={activeSeries}
|
series={activeSeries}
|
||||||
|
tempSymbol={tempSymbol}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
formatter={(value: unknown) => {
|
formatter={(value: unknown) => {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
const [low, high] = value;
|
const [low, high] = value;
|
||||||
if (typeof low === "number" && typeof high === "number") {
|
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);
|
const num = Number(value);
|
||||||
return Number.isFinite(num) ? `${num.toFixed(2)}°` : String(value);
|
return Number.isFinite(num) ? `${num.toFixed(2)}${tempSymbol}` : String(value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{hasRunwayData && (
|
{hasRunwayData && (
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ type RunwayPlate = {
|
|||||||
export function TemperatureRunwayDetails({
|
export function TemperatureRunwayDetails({
|
||||||
isEn,
|
isEn,
|
||||||
plates,
|
plates,
|
||||||
|
tempSymbol,
|
||||||
}: {
|
}: {
|
||||||
isEn: boolean;
|
isEn: boolean;
|
||||||
plates: RunwayPlate[];
|
plates: RunwayPlate[];
|
||||||
|
tempSymbol: string;
|
||||||
}) {
|
}) {
|
||||||
if (!plates.length) return null;
|
if (!plates.length) return null;
|
||||||
|
|
||||||
@@ -52,13 +54,13 @@ export function TemperatureRunwayDetails({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>TDZ: <strong>{plate.tdzTemp !== null ? `${plate.tdzTemp.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)}°C` : "--"}</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)}°C` : "--"}</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)}°C` : "--"}</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)}°C` : "--"}</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")}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export function TemperatureStatsBars({
|
|||||||
isEn,
|
isEn,
|
||||||
compact,
|
compact,
|
||||||
timeframe,
|
timeframe,
|
||||||
|
tempSymbol,
|
||||||
runwayHeaderLabel,
|
runwayHeaderLabel,
|
||||||
metarHeaderLabel,
|
metarHeaderLabel,
|
||||||
runwayHighLabel,
|
runwayHighLabel,
|
||||||
@@ -27,6 +28,7 @@ export function TemperatureStatsBars({
|
|||||||
isEn: boolean;
|
isEn: boolean;
|
||||||
compact: boolean;
|
compact: boolean;
|
||||||
timeframe: string;
|
timeframe: string;
|
||||||
|
tempSymbol: string;
|
||||||
runwayHeaderLabel: string;
|
runwayHeaderLabel: string;
|
||||||
metarHeaderLabel: string;
|
metarHeaderLabel: string;
|
||||||
runwayHighLabel: string;
|
runwayHighLabel: string;
|
||||||
@@ -51,18 +53,18 @@ export function TemperatureStatsBars({
|
|||||||
<div className="flex items-center gap-4 text-[11px]">
|
<div className="flex items-center gap-4 text-[11px]">
|
||||||
<span className="font-semibold text-slate-500">
|
<span className="font-semibold text-slate-500">
|
||||||
{isEn ? "Runway" : runwayHeaderLabel}:{" "}
|
{isEn ? "Runway" : runwayHeaderLabel}:{" "}
|
||||||
<strong className="text-[#009688] font-mono">{temp(displayRunwayTemp)}</strong>
|
<strong className="text-[#009688] font-mono">{temp(displayRunwayTemp, tempSymbol)}</strong>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-300">|</span>
|
<span className="text-slate-300">|</span>
|
||||||
<span className="font-semibold text-slate-500">
|
<span className="font-semibold text-slate-500">
|
||||||
{isEn ? "METAR" : (isShenzhen ? "当日最高" : metarHeaderLabel)}:{" "}
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-4 text-[11px]">
|
<div className="flex items-center gap-4 text-[11px]">
|
||||||
<span className="font-semibold text-slate-500">
|
<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>
|
</span>
|
||||||
{modelMin !== null && modelMax !== null && (
|
{modelMin !== null && modelMax !== null && (
|
||||||
<>
|
<>
|
||||||
@@ -70,7 +72,7 @@ export function TemperatureStatsBars({
|
|||||||
<span className="font-semibold text-slate-500">
|
<span className="font-semibold text-slate-500">
|
||||||
{isEn ? "Models" : "多模型"}:{" "}
|
{isEn ? "Models" : "多模型"}:{" "}
|
||||||
<strong className="text-slate-700 font-mono">
|
<strong className="text-slate-700 font-mono">
|
||||||
{temp(modelMin)} - {temp(modelMax)}
|
{temp(modelMin, tempSymbol)} - {temp(modelMax, tempSymbol)}
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
@@ -94,7 +96,7 @@ export function TemperatureStatsBars({
|
|||||||
{isEn ? "Runway Live (1m)" : `${runwayHeaderLabel}`}
|
{isEn ? "Runway Live (1m)" : `${runwayHeaderLabel}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-2xl font-bold font-mono text-[#009688] mt-1">
|
<span className="text-2xl font-bold font-mono text-[#009688] mt-1">
|
||||||
{temp(displayRunwayTemp)}
|
{temp(displayRunwayTemp, tempSymbol)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -102,7 +104,7 @@ export function TemperatureStatsBars({
|
|||||||
{isEn ? "METAR Settlement · Daily High" : `${metarHeaderLabel} · 当日最高`}
|
{isEn ? "METAR Settlement · Daily High" : `${metarHeaderLabel} · 当日最高`}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-2xl font-bold font-mono text-blue-600 mt-1">
|
<span className="text-2xl font-bold font-mono text-blue-600 mt-1">
|
||||||
{temp(observedHighMetar)}
|
{temp(observedHighMetar, tempSymbol)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,7 +115,7 @@ export function TemperatureStatsBars({
|
|||||||
DEB Max
|
DEB Max
|
||||||
</span>
|
</span>
|
||||||
<span className="text-2xl font-bold font-mono text-orange-600 mt-1">
|
<span className="text-2xl font-bold font-mono text-orange-600 mt-1">
|
||||||
{temp(debVal)}
|
{temp(debVal, tempSymbol)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -121,7 +123,7 @@ export function TemperatureStatsBars({
|
|||||||
{isEn ? "Model Range" : "多模型区间"}
|
{isEn ? "Model Range" : "多模型区间"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-2xl font-bold font-mono text-slate-700 mt-1">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,13 +134,13 @@ export function TemperatureStatsBars({
|
|||||||
{isEn ? "Daily Peak" : "当日最高气温"}
|
{isEn ? "Daily Peak" : "当日最高气温"}
|
||||||
</span>
|
</span>
|
||||||
<div className="mt-1 flex items-center gap-2 text-xs font-mono text-slate-600">
|
<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>|</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 && (
|
{wundergroundDailyHigh !== null && (
|
||||||
<>
|
<>
|
||||||
<span>|</span>
|
<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>
|
</div>
|
||||||
@@ -152,7 +154,7 @@ export function TemperatureStatsBars({
|
|||||||
{isEn ? "Model Range" : "模型区间"}
|
{isEn ? "Model Range" : "模型区间"}
|
||||||
</span>
|
</span>
|
||||||
<strong className="text-slate-800 font-bold">
|
<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>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
@@ -160,7 +162,7 @@ export function TemperatureStatsBars({
|
|||||||
DEB
|
DEB
|
||||||
</span>
|
</span>
|
||||||
<strong className="text-blue-600 font-bold">
|
<strong className="text-blue-600 font-bold">
|
||||||
{temp(debVal)}
|
{temp(debVal, tempSymbol)}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
@@ -168,7 +170,7 @@ export function TemperatureStatsBars({
|
|||||||
{isEn ? "Spread" : "分歧"}
|
{isEn ? "Spread" : "分歧"}
|
||||||
</span>
|
</span>
|
||||||
<strong className={clsx("font-bold", spreadLabel === "高分歧" ? "text-amber-600" : "text-slate-600")}>
|
<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}`}
|
{spreadLabel && ` · ${isEn ? spreadLabelEn : spreadLabel}`}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,12 +31,14 @@ export function TemperatureTooltipContent({
|
|||||||
payload,
|
payload,
|
||||||
data,
|
data,
|
||||||
series,
|
series,
|
||||||
|
tempSymbol = "°C",
|
||||||
}: {
|
}: {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
label?: string | number;
|
label?: string | number;
|
||||||
payload?: ReadonlyArray<{ payload?: Record<string, any> }>;
|
payload?: ReadonlyArray<{ payload?: Record<string, any> }>;
|
||||||
data: Array<Record<string, any>>;
|
data: Array<Record<string, any>>;
|
||||||
series: TooltipSeries[];
|
series: TooltipSeries[];
|
||||||
|
tempSymbol?: string;
|
||||||
}) {
|
}) {
|
||||||
if (!active || !payload?.length || !series.length) return null;
|
if (!active || !payload?.length || !series.length) return null;
|
||||||
const activePoint = payload[0]?.payload || {};
|
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="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||||
<span className="font-semibold">{item.label}</span>
|
<span className="font-semibold">{item.label}</span>
|
||||||
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,12 +6,23 @@ import {
|
|||||||
} from "@/lib/refresh-policy";
|
} from "@/lib/refresh-policy";
|
||||||
import { scanTerminalQueryPolicy } from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
import { scanTerminalQueryPolicy } from "@/components/dashboard/scan-terminal/scan-terminal-client";
|
||||||
import { __shouldPollLiveChartForTest } from "@/components/dashboard/scan-terminal/LiveTemperatureThresholdChart";
|
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) {
|
function assert(condition: unknown, message: string) {
|
||||||
if (!condition) throw new Error(message);
|
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.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.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");
|
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))"),
|
chartSource.includes("setHourly(seedHourlyForecastFromRow(row))"),
|
||||||
"terminal charts should render from row data immediately and dedupe concurrent city detail requests",
|
"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 chartStatsPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "TemperatureStatsBars.tsx");
|
||||||
const chartRunwayPath = path.join(process.cwd(), "components", "dashboard", "scan-terminal", "TemperatureRunwayDetails.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 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(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(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(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(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 chartCanvas = fs.readFileSync(chartCanvasPath, "utf8");
|
||||||
const chartTooltip = fs.readFileSync(chartTooltipPath, "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");
|
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");
|
assert(fs.existsSync(chartLogicPath), "temperature chart pure data logic must live in temperature-chart-logic.ts");
|
||||||
const chartLogic = fs.readFileSync(chartLogicPath, "utf8");
|
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("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("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("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("from \"recharts\""), "LiveTemperatureThresholdChart.tsx must not import Recharts directly");
|
||||||
assert(!chart.includes("function TemperatureTooltipContent"), "LiveTemperatureThresholdChart.tsx must not define tooltip content inline");
|
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");
|
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 HOURLY_CACHE_TTL_MS = DASHBOARD_REFRESH_POLICY_MS.metar;
|
||||||
const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>();
|
const _hourlyCache = new Map<string, { ts: number; data: HourlyForecast }>();
|
||||||
const _hourlyRequestCache = new Map<string, Promise<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 RUNWAY_LINE_COLORS = ["#00897b", "#d97706", "#7c3aed", "#0891b2", "#ea580c", "#64748b"];
|
||||||
|
|
||||||
const SESSION_CACHE_PREFIX = "polyweather_city_detail_v1:";
|
const SESSION_CACHE_PREFIX = "polyweather_city_detail_v1:";
|
||||||
@@ -231,6 +234,34 @@ function writeSessionCache(city: string, data: HourlyForecast) {
|
|||||||
} catch {}
|
} 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() {
|
export function clearCityDetailCache() {
|
||||||
_hourlyCache.clear();
|
_hourlyCache.clear();
|
||||||
_hourlyRequestCache.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 {
|
function validNumber(value: unknown): number | null {
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
}
|
}
|
||||||
@@ -619,20 +657,22 @@ async function fetchHourlyForecastForCity(
|
|||||||
const pending = _hourlyRequestCache.get(requestKey);
|
const pending = _hourlyRequestCache.get(requestKey);
|
||||||
if (pending) return pending;
|
if (pending) return pending;
|
||||||
|
|
||||||
const request = fetch(`/api/city/${encodeURIComponent(city)}/detail?depth=full&force_refresh=false&resolution=${resParam}`, {
|
const request = runQueuedHourlyDetailRequest(() =>
|
||||||
headers: { Accept: "application/json" },
|
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;
|
|
||||||
})
|
})
|
||||||
|
.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(() => {
|
.finally(() => {
|
||||||
_hourlyRequestCache.delete(requestKey);
|
_hourlyRequestCache.delete(requestKey);
|
||||||
});
|
});
|
||||||
@@ -1597,8 +1637,11 @@ function binObservationsToSlots(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
MAX_HOURLY_DETAIL_CONCURRENT_REQUESTS,
|
||||||
HOURLY_CACHE_TTL_MS,
|
HOURLY_CACHE_TTL_MS,
|
||||||
_hourlyCache,
|
_hourlyCache,
|
||||||
|
__resetHourlyDetailRequestQueueForTest,
|
||||||
|
__runQueuedHourlyDetailRequestForTest,
|
||||||
buildChartDomain,
|
buildChartDomain,
|
||||||
buildFullDayChartData,
|
buildFullDayChartData,
|
||||||
getDebPeakWindowRange,
|
getDebPeakWindowRange,
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ def _safe_float(value: Any) -> Optional[float]:
|
|||||||
return None
|
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(
|
def _parse_obs_datetime(
|
||||||
value: Any,
|
value: Any,
|
||||||
epoch_value: Any = None,
|
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.
|
# High-frequency realtime sources take priority over plain METAR.
|
||||||
madis = raw.get("madis_hfmetar_current") or {}
|
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(
|
return _normalize_station_row(
|
||||||
station_code=meta.get("icao") or madis.get("icao"),
|
station_code=meta.get("icao") or madis.get("icao"),
|
||||||
station_label=meta.get("airport_name") or meta.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"),
|
obs_time=madis.get("obs_time") or metar.get("observation_time"),
|
||||||
source_code="madis_hfmetar",
|
source_code="madis_hfmetar",
|
||||||
source_label="NOAA MADIS",
|
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_airport_station=True,
|
||||||
is_settlement_anchor=False,
|
is_settlement_anchor=False,
|
||||||
extra={
|
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_so_far": _safe_float(current.get("max_temp_so_far")),
|
||||||
"max_temp_time": current.get("max_temp_time"),
|
"max_temp_time": current.get("max_temp_time"),
|
||||||
"obs_age_min": None,
|
"obs_age_min": None,
|
||||||
|
|||||||
@@ -925,6 +925,16 @@ class WeatherDataCollector(OpenMeteoCacheMixin, SettlementSourceMixin, MetarSour
|
|||||||
def _uses_fahrenheit(self, city_lower: str) -> bool:
|
def _uses_fahrenheit(self, city_lower: str) -> bool:
|
||||||
return city_lower in self.US_CITIES
|
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:
|
def _supports_aviationweather(self, city_lower: str) -> bool:
|
||||||
city_meta = self.CITY_REGISTRY.get(str(city_lower or "").strip().lower(), {}) or {}
|
city_meta = self.CITY_REGISTRY.get(str(city_lower or "").strip().lower(), {}) or {}
|
||||||
settlement_source = str(city_meta.get("settlement_source") or "").strip().lower()
|
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)
|
logger.warning("AMSC AWOS attach failed city={}: {}", city_lower, exc)
|
||||||
|
|
||||||
def _attach_madis_hfmetar_data(
|
def _attach_madis_hfmetar_data(
|
||||||
self, results: Dict, city_lower: str
|
self, results: Dict, city_lower: str, use_fahrenheit: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Fetch MADIS HFMETAR data and attach matching US station observations."""
|
"""Fetch MADIS HFMETAR data and attach matching US station observations."""
|
||||||
# Only run for US cities (ICAO starts with K)
|
# 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
|
# Find this city's station in the MADIS results
|
||||||
for obs in obs_list:
|
for obs in obs_list:
|
||||||
if obs["icao"] == icao:
|
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
|
# 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(
|
self._emit_temperature_patch_if_changed(
|
||||||
city_lower,
|
city_lower,
|
||||||
obs.get("temp_c"),
|
display_temp,
|
||||||
obs.get("obs_time"),
|
obs.get("obs_time"),
|
||||||
source="madis_hfmetar",
|
source="madis_hfmetar",
|
||||||
|
extra={"temp_c": temp_c, "unit": display_unit},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
DBManager().append_airport_obs(
|
DBManager().append_airport_obs(
|
||||||
icao=icao,
|
icao=icao,
|
||||||
city=city_lower,
|
city=city_lower,
|
||||||
temp_c=obs["temp_c"],
|
temp_c=temp_c,
|
||||||
wind_kt=obs.get("wind_kt"),
|
wind_kt=obs.get("wind_kt"),
|
||||||
pressure_hpa=obs.get("pressure_hpa"),
|
pressure_hpa=obs.get("pressure_hpa"),
|
||||||
obs_time=obs["obs_time"] or datetime.now().isoformat(),
|
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_korean_amos_data(results, city_lower, use_fahrenheit)
|
||||||
self._attach_china_amsc_awos_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_singapore_mss_data(results, city_lower)
|
||||||
self._attach_israel_ims_data(results, city_lower)
|
self._attach_israel_ims_data(results, city_lower)
|
||||||
self._attach_saudi_ncm_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_korean_amos_data(results, city_lower, use_fahrenheit)
|
||||||
self._attach_china_amsc_awos_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_singapore_mss_data(results, city_lower)
|
||||||
self._attach_israel_ims_data(results, city_lower)
|
self._attach_israel_ims_data(results, city_lower)
|
||||||
self._attach_saudi_ncm_data(results, city_lower)
|
self._attach_saudi_ncm_data(results, city_lower)
|
||||||
|
|||||||
@@ -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
|
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():
|
def test_city_detail_payload_exposes_airport_and_official_network_layers():
|
||||||
payload = _build_city_detail_payload(
|
payload = _build_city_detail_payload(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,6 +53,60 @@ def test_multi_model_order_includes_legacy_and_new_sources():
|
|||||||
assert "jma_seamless" in OPEN_METEO_MULTI_MODEL_ORDER
|
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):
|
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"))
|
monkeypatch.setenv("OPEN_METEO_DISK_CACHE_PATH", str(tmp_path / "om-cache.json"))
|
||||||
collector = WeatherDataCollector({})
|
collector = WeatherDataCollector({})
|
||||||
|
|||||||
Reference in New Issue
Block a user