Files
PolyWeather/frontend/components/dashboard/opportunity-v4-risk.ts
T

105 lines
3.5 KiB
TypeScript
Raw Normal View History

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);
}