105 lines
3.5 KiB
TypeScript
105 lines
3.5 KiB
TypeScript
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
|
|
import {
|
|||
|
|
formatTemperatureValue,
|
|||
|
|
normalizeTemperatureSymbol,
|
|||
|
|
} from "@/lib/temperature-utils";
|
|||
|
|
import { formatModelClusterRange } from "./opportunity-model-summary";
|
|||
|
|
import { getMetarObservationContext } from "./opportunity-observation";
|
|||
|
|
import type { V4CityForecast } from "./opportunity-v4-types";
|
|||
|
|
|
|||
|
|
export function getForecastRiskItems(
|
|||
|
|
row: ScanOpportunityRow,
|
|||
|
|
detail: CityDetail | null,
|
|||
|
|
forecast: V4CityForecast,
|
|||
|
|
locale: string,
|
|||
|
|
tempSymbol?: string | null,
|
|||
|
|
) {
|
|||
|
|
const isEn = locale === "en-US";
|
|||
|
|
const obs = getMetarObservationContext(row, detail);
|
|||
|
|
const risks: string[] = [];
|
|||
|
|
const phase = String(row.window_phase || "").toLowerCase();
|
|||
|
|
if (
|
|||
|
|
phase === "early_today" ||
|
|||
|
|
phase === "setup_today" ||
|
|||
|
|
(row.minutes_until_peak_start != null && Number(row.minutes_until_peak_start) > 0)
|
|||
|
|
) {
|
|||
|
|
risks.push(
|
|||
|
|
isEn
|
|||
|
|
? "Peak window has not arrived; current METAR is only path evidence."
|
|||
|
|
: "峰值窗口尚未到达,当前 METAR 只能作为路径证据。",
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
if (row.trend_alignment === false) {
|
|||
|
|
risks.push(
|
|||
|
|
isEn
|
|||
|
|
? "Intraday observation path does not fully support the forecast center."
|
|||
|
|
: "日内实况路径还没有完全支持预测中枢。",
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
if (forecast.paceRead && forecast.paceTone !== "neutral") {
|
|||
|
|
risks.push(
|
|||
|
|
isEn
|
|||
|
|
? `Observed pace is deviating from the DEB curve: ${forecast.paceRead}`
|
|||
|
|
: `实测节奏正在偏离 DEB 曲线:${forecast.paceRead}`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
if (obs.stale || obs.lastTemp == null) {
|
|||
|
|
risks.push(
|
|||
|
|
isEn
|
|||
|
|
? "Same-day METAR confirmation is still weak."
|
|||
|
|
: "同日 METAR 确认仍然偏弱。",
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
if (forecast.low != null && forecast.high != null) {
|
|||
|
|
const spread = Math.abs(forecast.high - forecast.low);
|
|||
|
|
const wide = String(normalizeTemperatureSymbol(tempSymbol)).toUpperCase().includes("F")
|
|||
|
|
? spread > 2
|
|||
|
|
: spread > 1;
|
|||
|
|
if (wide) {
|
|||
|
|
risks.push(
|
|||
|
|
isEn
|
|||
|
|
? "Model range is wide; treat boundary buckets conservatively."
|
|||
|
|
: "模型区间偏宽,边界温度桶需要保守处理。",
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (!risks.length) {
|
|||
|
|
risks.push(
|
|||
|
|
isEn
|
|||
|
|
? "Residual risk is late METAR revision or a shifted afternoon peak."
|
|||
|
|
: "残余风险主要是后续 METAR 修订或峰值窗口漂移。",
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
return Array.from(new Set(risks)).slice(0, 3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getDecisionReasonItems(
|
|||
|
|
row: ScanOpportunityRow,
|
|||
|
|
forecast: V4CityForecast,
|
|||
|
|
modelSupportText: string,
|
|||
|
|
locale: string,
|
|||
|
|
tempSymbol?: string | null,
|
|||
|
|
) {
|
|||
|
|
const isEn = locale === "en-US";
|
|||
|
|
const modelRange = formatModelClusterRange(row.model_cluster_sources, tempSymbol);
|
|||
|
|
const reasons: string[] = [];
|
|||
|
|
if (modelRange !== "--") {
|
|||
|
|
reasons.push(
|
|||
|
|
isEn
|
|||
|
|
? `Model cluster sits around ${modelRange}; ${modelSupportText}.`
|
|||
|
|
: `模型区间集中在 ${modelRange},${modelSupportText}。`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
if (forecast.predicted != null) {
|
|||
|
|
reasons.push(
|
|||
|
|
isEn
|
|||
|
|
? `AI high-temperature center is ${formatTemperatureValue(forecast.predicted, tempSymbol, { digits: 1 })}.`
|
|||
|
|
: `AI 最高温中枢约 ${formatTemperatureValue(forecast.predicted, tempSymbol, { digits: 1 })}。`,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
if (forecast.paceRead) reasons.push(forecast.paceRead);
|
|||
|
|
if (forecast.peakWindow) reasons.push(forecast.peakWindow);
|
|||
|
|
if (forecast.weatherRead) reasons.push(forecast.weatherRead);
|
|||
|
|
return Array.from(new Set(reasons)).slice(0, 3);
|
|||
|
|
}
|