251 lines
10 KiB
TypeScript
251 lines
10 KiB
TypeScript
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
|
|
import { getTodayPaceView } from "@/lib/pace-utils";
|
|||
|
|
import {
|
|||
|
|
formatTemperatureValue,
|
|||
|
|
normalizeTemperatureSymbol,
|
|||
|
|
} from "@/lib/temperature-utils";
|
|||
|
|
import {
|
|||
|
|
formatAirportReportRead,
|
|||
|
|
formatAirportWeatherRead,
|
|||
|
|
} from "./opportunity-airport-read";
|
|||
|
|
import { getLocalizedRowText } from "./opportunity-copy";
|
|||
|
|
import { formatPeakWindowTiming } from "./opportunity-observation";
|
|||
|
|
import { getModelSourceSummary } from "./opportunity-model-summary";
|
|||
|
|
import { getTargetRange } from "./opportunity-target";
|
|||
|
|
import type { OpportunityGroup } from "./opportunity-groups";
|
|||
|
|
import type { V4CityForecast } from "./opportunity-v4-types";
|
|||
|
|
|
|||
|
|
export function getPaceDeviationRead(
|
|||
|
|
detail: CityDetail | null,
|
|||
|
|
locale: string,
|
|||
|
|
tempSymbol?: string | null,
|
|||
|
|
) {
|
|||
|
|
if (!detail) return null;
|
|||
|
|
const paceView = getTodayPaceView(detail, locale === "en-US" ? "en-US" : "zh-CN");
|
|||
|
|
if (!paceView) return null;
|
|||
|
|
const unit = normalizeTemperatureSymbol(tempSymbol || detail.temp_symbol);
|
|||
|
|
const observed = formatTemperatureValue(paceView.observedNow, unit, { digits: 1 });
|
|||
|
|
const expected = formatTemperatureValue(paceView.expectedNow, unit, { digits: 1 });
|
|||
|
|
const delta = `${paceView.delta > 0 ? "+" : ""}${paceView.delta.toFixed(1)}${unit}`;
|
|||
|
|
const isEn = locale === "en-US";
|
|||
|
|
const toneText =
|
|||
|
|
paceView.biasTone === "warm"
|
|||
|
|
? isEn
|
|||
|
|
? "running hotter"
|
|||
|
|
: "偏热"
|
|||
|
|
: paceView.biasTone === "cold"
|
|||
|
|
? isEn
|
|||
|
|
? "running cooler"
|
|||
|
|
: "偏冷"
|
|||
|
|
: isEn
|
|||
|
|
? "tracking"
|
|||
|
|
: "基本跟踪";
|
|||
|
|
return {
|
|||
|
|
adjustedHigh: paceView.paceAdjustedHigh,
|
|||
|
|
delta: paceView.delta,
|
|||
|
|
label: paceView.badge,
|
|||
|
|
read: isEn
|
|||
|
|
? `Observed path vs DEB curve: ${observed} now vs ${expected} expected, ${delta} (${toneText}).`
|
|||
|
|
: `实测路径对比 DEB 曲线:当前 ${observed},同刻预期 ${expected},偏差 ${delta}(${toneText})。`,
|
|||
|
|
tone: paceView.biasTone,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getPaceSignalLabel(forecast: V4CityForecast, locale: string, tempSymbol?: string | null) {
|
|||
|
|
const isEn = locale === "en-US";
|
|||
|
|
if (forecast.paceDelta == null || !Number.isFinite(Number(forecast.paceDelta))) {
|
|||
|
|
return isEn ? "Path pending" : "路径待确认";
|
|||
|
|
}
|
|||
|
|
const unit = normalizeTemperatureSymbol(tempSymbol);
|
|||
|
|
const delta = `${forecast.paceDelta > 0 ? "+" : ""}${Number(forecast.paceDelta).toFixed(1)}${unit}`;
|
|||
|
|
if (forecast.paceTone === "warm") return isEn ? `Hot path ${delta}` : `实测偏热 ${delta}`;
|
|||
|
|
if (forecast.paceTone === "cold") return isEn ? `Cool path ${delta}` : `实测偏冷 ${delta}`;
|
|||
|
|
return isEn ? `On path ${delta}` : `路径跟踪 ${delta}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getPaceDecisionTail(forecast: V4CityForecast, locale: string, tempSymbol?: string | null) {
|
|||
|
|
if (forecast.paceDelta == null || !Number.isFinite(Number(forecast.paceDelta))) return "";
|
|||
|
|
const isEn = locale === "en-US";
|
|||
|
|
const unit = normalizeTemperatureSymbol(tempSymbol);
|
|||
|
|
const delta = `${forecast.paceDelta > 0 ? "+" : ""}${Number(forecast.paceDelta).toFixed(1)}${unit}`;
|
|||
|
|
if (forecast.paceTone === "warm") {
|
|||
|
|
return isEn
|
|||
|
|
? ` Observations are running ${delta} above the DEB path, so upside boundaries need extra caution.`
|
|||
|
|
: ` 实测比 DEB 路径偏高 ${delta},上方阈值要额外谨慎。`;
|
|||
|
|
}
|
|||
|
|
if (forecast.paceTone === "cold") {
|
|||
|
|
return isEn
|
|||
|
|
? ` Observations are running ${delta} below the DEB path, which weakens upside breakout odds.`
|
|||
|
|
: ` 实测比 DEB 路径偏低 ${delta},上破概率需要下修。`;
|
|||
|
|
}
|
|||
|
|
return isEn
|
|||
|
|
? " Observations are still tracking the DEB path."
|
|||
|
|
: " 实测仍基本跟踪 DEB 路径。";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function median(values: number[]) {
|
|||
|
|
if (!values.length) return null;
|
|||
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|||
|
|
const mid = Math.floor(sorted.length / 2);
|
|||
|
|
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getV4CityForecast(
|
|||
|
|
row: ScanOpportunityRow,
|
|||
|
|
group: OpportunityGroup,
|
|||
|
|
detail: CityDetail | null,
|
|||
|
|
locale: string,
|
|||
|
|
tempSymbol?: string | null,
|
|||
|
|
): V4CityForecast {
|
|||
|
|
const isEn = locale === "en-US";
|
|||
|
|
const aiPredicted =
|
|||
|
|
row.ai_predicted_max != null && Number.isFinite(Number(row.ai_predicted_max))
|
|||
|
|
? Number(row.ai_predicted_max)
|
|||
|
|
: null;
|
|||
|
|
const aiLow =
|
|||
|
|
row.ai_predicted_low != null && Number.isFinite(Number(row.ai_predicted_low))
|
|||
|
|
? Number(row.ai_predicted_low)
|
|||
|
|
: null;
|
|||
|
|
const aiHigh =
|
|||
|
|
row.ai_predicted_high != null && Number.isFinite(Number(row.ai_predicted_high))
|
|||
|
|
? Number(row.ai_predicted_high)
|
|||
|
|
: null;
|
|||
|
|
const modelValues = Object.values(row.model_cluster_sources || {})
|
|||
|
|
.map((value) => Number(value))
|
|||
|
|
.filter((value) => Number.isFinite(value));
|
|||
|
|
const fallbackPredicted =
|
|||
|
|
aiPredicted ??
|
|||
|
|
(row.deb_prediction != null && Number.isFinite(Number(row.deb_prediction))
|
|||
|
|
? Number(row.deb_prediction)
|
|||
|
|
: median(modelValues));
|
|||
|
|
const fallbackLow = aiLow ?? (modelValues.length ? Math.min(...modelValues) : fallbackPredicted);
|
|||
|
|
const fallbackHigh = aiHigh ?? (modelValues.length ? Math.max(...modelValues) : fallbackPredicted);
|
|||
|
|
const peakWindow =
|
|||
|
|
getLocalizedRowText(row, locale, row.ai_peak_window_zh, row.ai_peak_window_en) ||
|
|||
|
|
formatPeakWindowTiming(row, locale);
|
|||
|
|
const airportRead =
|
|||
|
|
getLocalizedRowText(
|
|||
|
|
row,
|
|||
|
|
locale,
|
|||
|
|
row.ai_airport_metar_read_zh,
|
|||
|
|
row.ai_airport_metar_read_en,
|
|||
|
|
) || formatAirportReportRead(row, detail, locale, tempSymbol);
|
|||
|
|
const weatherRead = formatAirportWeatherRead(row, detail, locale);
|
|||
|
|
const paceRead = getPaceDeviationRead(detail, locale, tempSymbol);
|
|||
|
|
const modelNote =
|
|||
|
|
row.ai_city_model_cluster_note ||
|
|||
|
|
row.ai_model_cluster_note ||
|
|||
|
|
getModelSourceSummary(row, locale, tempSymbol);
|
|||
|
|
const reason =
|
|||
|
|
getLocalizedRowText(row, locale, row.ai_forecast_reason_zh, row.ai_forecast_reason_en) ||
|
|||
|
|
getLocalizedRowText(row, locale, row.ai_city_thesis_zh, row.ai_city_thesis_en) ||
|
|||
|
|
(fallbackPredicted != null
|
|||
|
|
? isEn
|
|||
|
|
? `${group.cityName} final high is centered near ${formatTemperatureValue(fallbackPredicted, tempSymbol, { digits: 1 })}; market temperature buckets are only mapped against that forecast range.`
|
|||
|
|
: `${group.cityName} 最终最高温先以 ${formatTemperatureValue(fallbackPredicted, tempSymbol, { digits: 1 })} 附近为中枢,市场温度桶只用于对照 AI 预测区间。`
|
|||
|
|
: null);
|
|||
|
|
return {
|
|||
|
|
predicted: fallbackPredicted,
|
|||
|
|
low: fallbackLow,
|
|||
|
|
high: fallbackHigh,
|
|||
|
|
confidence: row.ai_forecast_confidence || row.ai_city_confidence,
|
|||
|
|
peakWindow,
|
|||
|
|
airportRead,
|
|||
|
|
weatherRead,
|
|||
|
|
paceRead: paceRead?.read || null,
|
|||
|
|
paceTone: paceRead?.tone || null,
|
|||
|
|
paceDelta: paceRead?.delta ?? null,
|
|||
|
|
paceAdjustedHigh: paceRead?.adjustedHigh ?? null,
|
|||
|
|
reason,
|
|||
|
|
modelNote,
|
|||
|
|
source: aiPredicted != null ? "ai" : "fallback",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getForecastRangeLabel(forecast: V4CityForecast, tempSymbol?: string | null) {
|
|||
|
|
if (forecast.low == null && forecast.high == null) return "--";
|
|||
|
|
if (forecast.low != null && forecast.high != null) {
|
|||
|
|
if (Math.abs(forecast.low - forecast.high) < 0.05) {
|
|||
|
|
return formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 });
|
|||
|
|
}
|
|||
|
|
return `${formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 })} ~ ${formatTemperatureValue(forecast.high, tempSymbol, { digits: 1 })}`;
|
|||
|
|
}
|
|||
|
|
if (forecast.low != null) return `>= ${formatTemperatureValue(forecast.low, tempSymbol, { digits: 1 })}`;
|
|||
|
|
return `<= ${formatTemperatureValue(Number(forecast.high), tempSymbol, { digits: 1 })}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getForecastContractFit(
|
|||
|
|
row: ScanOpportunityRow,
|
|||
|
|
forecast: V4CityForecast,
|
|||
|
|
locale: string,
|
|||
|
|
tempSymbol?: string | null,
|
|||
|
|
) {
|
|||
|
|
const isEn = locale === "en-US";
|
|||
|
|
const explicitMatch = String(row.ai_forecast_match || "").toLowerCase();
|
|||
|
|
const explicitReason = getLocalizedRowText(
|
|||
|
|
row,
|
|||
|
|
locale,
|
|||
|
|
row.ai_forecast_match_reason_zh,
|
|||
|
|
row.ai_forecast_match_reason_en,
|
|||
|
|
);
|
|||
|
|
if (explicitMatch && explicitReason) {
|
|||
|
|
return {
|
|||
|
|
label:
|
|||
|
|
explicitMatch === "core"
|
|||
|
|
? isEn ? "Core bucket" : "核心桶"
|
|||
|
|
: explicitMatch === "outside"
|
|||
|
|
? isEn ? "Outside forecast" : "预测区间外"
|
|||
|
|
: explicitMatch === "edge"
|
|||
|
|
? isEn ? "Boundary bucket" : "边界桶"
|
|||
|
|
: isEn ? "Watch" : "观察",
|
|||
|
|
tone: explicitMatch === "core" ? "approve" : explicitMatch === "outside" ? "veto" : "watchlist",
|
|||
|
|
reason: explicitReason,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { lower, upper } = getTargetRange(row);
|
|||
|
|
const predicted = forecast.predicted;
|
|||
|
|
if (predicted == null || (lower == null && upper == null)) {
|
|||
|
|
return {
|
|||
|
|
label: isEn ? "Await forecast" : "等待预测",
|
|||
|
|
tone: "watchlist",
|
|||
|
|
reason: isEn ? "AI has no stable max-temperature center yet." : "AI 还没有稳定的最高温中枢。",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
const unit = normalizeTemperatureSymbol(tempSymbol);
|
|||
|
|
const tolerance = String(unit).toUpperCase().includes("F") ? 1.0 : 0.5;
|
|||
|
|
const inside =
|
|||
|
|
(lower == null || predicted >= lower - tolerance) &&
|
|||
|
|
(upper == null || predicted <= upper + tolerance);
|
|||
|
|
const rangeOverlaps =
|
|||
|
|
forecast.low != null &&
|
|||
|
|
forecast.high != null &&
|
|||
|
|
(lower == null || forecast.high >= lower - tolerance) &&
|
|||
|
|
(upper == null || forecast.low <= upper + tolerance);
|
|||
|
|
if (inside) {
|
|||
|
|
return {
|
|||
|
|
label: isEn ? "Core bucket" : "核心桶",
|
|||
|
|
tone: "approve",
|
|||
|
|
reason: isEn
|
|||
|
|
? `AI max-temperature center ${formatTemperatureValue(predicted, unit, { digits: 1 })} sits inside this bucket.`
|
|||
|
|
: `AI 最高温中枢 ${formatTemperatureValue(predicted, unit, { digits: 1 })} 落在这个温度桶内。`,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
if (rangeOverlaps) {
|
|||
|
|
return {
|
|||
|
|
label: isEn ? "Boundary bucket" : "边界桶",
|
|||
|
|
tone: "watchlist",
|
|||
|
|
reason: isEn
|
|||
|
|
? `This bucket touches the AI interval ${getForecastRangeLabel(forecast, unit)}, but is not the center.`
|
|||
|
|
: `该桶触及 AI 区间 ${getForecastRangeLabel(forecast, unit)},但不是预测中枢。`,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
label: isEn ? "Outside forecast" : "预测区间外",
|
|||
|
|
tone: "veto",
|
|||
|
|
reason: isEn
|
|||
|
|
? `This bucket is outside the AI max-temperature interval ${getForecastRangeLabel(forecast, unit)}.`
|
|||
|
|
: `该桶位于 AI 最高温区间 ${getForecastRangeLabel(forecast, unit)} 之外。`,
|
|||
|
|
};
|
|||
|
|
}
|