import type { ScanOpportunityRow } from "@/lib/dashboard-types"; import { REGIONS, getCityRegion, } from "@/components/dashboard/scan-terminal/continent-grouping"; export const MODEL_SUMMARY_MODEL_COLUMNS = [ { key: "ECMWF", label: "ECMWF" }, { key: "ECMWF AIFS", label: "ECMWF AIFS" }, { key: "GFS", label: "GFS" }, { key: "ICON", label: "ICON" }, { key: "ICON-EU", label: "ICON-EU" }, { key: "GEM", label: "GEM" }, { key: "GDPS", label: "GDPS" }, { key: "JMA", label: "JMA" }, { key: "AROME HD", label: "AROME HD" }, { key: "HRRR", label: "HRRR" }, { key: "NAM", label: "NAM" }, ] as const; export type ModelSummaryColumnKey = (typeof MODEL_SUMMARY_MODEL_COLUMNS)[number]["key"]; export type ModelSummaryProbabilityBucket = { key: string; label: string; value: number; lower: number; upper: number; probability: number; }; export type ModelSummaryMarketMatch = { key: string; label: string; modelProbability: number | null; marketUrl: string | null; }; export type ModelSummaryRow = { cityKey: string; cityName: string; regionLabel: string; regionLabelZh: string; regionSort: number; tempSymbol: string; localTime: string; timezoneOffsetSeconds: number | null; debPrediction: number | null; models: Record; modelMedian: number | null; modelSpread: number | null; probabilityBuckets: ModelSummaryProbabilityBucket[]; probabilityBucketMap: Record; gaussianMu: number | null; probabilityEngine: string | null; topProbabilityBucketKey: string | null; marketMatches: ModelSummaryMarketMatch[]; searchText: string; }; export type ModelSummaryFilters = { query: string; debOnly: boolean; wideSpreadOnly: boolean; }; const WIDE_SPREAD_THRESHOLD = 2; function finiteNumber(value: unknown): number | null { if (value == null) return null; if (typeof value === "string" && value.trim() === "") return null; const numericValue = Number(value); return Number.isFinite(numericValue) ? numericValue : null; } function roundToOneDecimal(value: number) { return Math.round(value * 10) / 10; } function median(values: number[]) { if (!values.length) return null; const sorted = [...values].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); if (sorted.length % 2 === 1) return roundToOneDecimal(sorted[mid]); return roundToOneDecimal((sorted[mid - 1] + sorted[mid]) / 2); } function spread(values: number[]) { if (!values.length) return null; return roundToOneDecimal(Math.max(...values) - Math.min(...values)); } function probabilityFromBucket(bucket: Record) { const raw = finiteNumber(bucket.probability ?? bucket.model_probability); if (raw == null) return null; return raw > 1 ? raw / 100 : raw; } function rangeFromBucket(bucket: Record, value: number) { const rawRange = String(bucket.range || bucket.bucket || bucket.label || "").trim(); const rangeMatch = rawRange.match(/(-?\d+(?:\.\d+)?)\s*(?:~|-|to)\s*(-?\d+(?:\.\d+)?)/i); if (rangeMatch) { const lower = Number(rangeMatch[1]); const upper = Number(rangeMatch[2]); if (Number.isFinite(lower) && Number.isFinite(upper) && upper > lower) { return { lower, upper }; } } return { lower: Number((value - 0.5).toFixed(2)), upper: Number((value + 0.5).toFixed(2)), }; } function formatBucketBound(value: number) { return Number(value.toFixed(1)).toString(); } function probabilityBucketKey(lower: number, upper: number, unit: string) { return `${formatBucketBound(lower)}-${formatBucketBound(upper)}${unit || "°C"}`; } function probabilityBucketLabel(lower: number, upper: number, unit: string) { return probabilityBucketKey(lower, upper, unit); } function buildProbabilityBuckets(row: ScanOpportunityRow): ModelSummaryProbabilityBucket[] { const rawBuckets = ( Array.isArray(row.distribution_full) && row.distribution_full.length ? row.distribution_full : Array.isArray(row.distribution_preview) ? row.distribution_preview : [] ) as Array>; const unit = row.temp_symbol || "°C"; return rawBuckets .map((bucket) => { const value = finiteNumber(bucket.value ?? bucket.temp ?? bucket.temperature); const probability = probabilityFromBucket(bucket); if (value == null || probability == null || probability <= 0) return null; const { lower, upper } = rangeFromBucket(bucket, value); const key = probabilityBucketKey(lower, upper, unit); return { key, label: probabilityBucketLabel(lower, upper, unit), value, lower, upper, probability, }; }) .filter((bucket): bucket is ModelSummaryProbabilityBucket => bucket !== null) .sort((a, b) => a.lower - b.lower || a.upper - b.upper); } function weightedProbabilityMu(buckets: ModelSummaryProbabilityBucket[]) { const totalProbability = buckets.reduce((sum, bucket) => sum + bucket.probability, 0); if (totalProbability <= 0) return null; const weightedValue = buckets.reduce( (sum, bucket) => sum + bucket.value * bucket.probability, 0, ); return roundToOneDecimal(weightedValue / totalProbability); } function normalizeProbability(value: unknown) { const numericValue = finiteNumber(value); if (numericValue == null) return null; return numericValue > 1 ? numericValue / 100 : numericValue; } function marketBucketLabel(bucket: Record, tempSymbol: string) { const textLabel = String(bucket.label || bucket.bucket || bucket.range || "").trim(); if (textLabel) return textLabel; const lower = finiteNumber(bucket.lower); const upper = finiteNumber(bucket.upper); if (lower != null && upper != null && upper > lower) { return probabilityBucketLabel(lower, upper, String(bucket.unit || tempSymbol || "°C")); } const value = finiteNumber(bucket.value ?? bucket.temp ?? bucket.temperature); return value == null ? "—" : `${formatBucketBound(value)}${bucket.unit || tempSymbol || "°C"}`; } function buildMarketMatches(row: ScanOpportunityRow): ModelSummaryMarketMatch[] { const marketRow = row as ScanOpportunityRow & { all_buckets?: Array> | null; top_buckets?: Array> | null; }; const sourceBuckets = ( Array.isArray(marketRow.all_buckets) && marketRow.all_buckets.length ? marketRow.all_buckets : Array.isArray(marketRow.top_buckets) ? marketRow.top_buckets : [] ) as Array>; const tempSymbol = row.temp_symbol || "°C"; return sourceBuckets .map((bucket, index) => { const label = marketBucketLabel(bucket, tempSymbol); const modelProbability = normalizeProbability(bucket.model_probability ?? bucket.probability); return { key: `${label}-${index}`, label, modelProbability, marketUrl: typeof bucket.market_url === "string" ? bucket.market_url : null, }; }) .sort((a, b) => { return (b.modelProbability ?? -1) - (a.modelProbability ?? -1); }); } function normalizeCityKey(row: ScanOpportunityRow, index: number) { const rawKey = row.city || row.city_display_name || row.display_name || `row-${index}`; return String(rawKey).trim().toLowerCase(); } function normalizeLocalTime(value: unknown) { const text = String(value || "").trim(); if (!text) return ""; const match = text.match(/(\d{1,2}):(\d{2})/); if (!match) return text; return `${match[1].padStart(2, "0")}:${match[2]}`; } function resolveRegion(row: ScanOpportunityRow, isEn: boolean) { const configuredRegionKey = getCityRegion(row); const configuredRegion = configuredRegionKey ? REGIONS.find((region) => region.key === configuredRegionKey) : null; if (configuredRegion) { return { label: isEn ? configuredRegion.labelEn : configuredRegion.labelZh, labelEn: configuredRegion.labelEn, labelZh: configuredRegion.labelZh, sort: configuredRegion.sort, }; } const labelEn = row.trading_region_label || row.trading_region_label_zh || "—"; const labelZh = row.trading_region_label_zh || row.trading_region_label || "—"; return { label: isEn ? labelEn : labelZh, labelEn, labelZh, sort: finiteNumber(row.trading_region_sort) ?? 999, }; } export function formatModelSummaryTemp(value: number | null | undefined, symbol = "°C") { const numericValue = finiteNumber(value); if (numericValue == null) return "—"; return `${numericValue.toFixed(1)}${symbol || "°C"}`; } export function formatModelSummaryProbability(value: number | null | undefined) { const numericValue = finiteNumber(value); if (numericValue == null) return "—"; return `${Math.round((numericValue > 1 ? numericValue / 100 : numericValue) * 100)}%`; } export function formatModelSummaryLocalTime( row: Pick, nowMs: number | null | undefined = Date.now(), ) { const offsetSeconds = finiteNumber(row.timezoneOffsetSeconds); const timestampMs = finiteNumber(nowMs); if (offsetSeconds == null || timestampMs == null) return row.localTime || "—"; const localDate = new Date(timestampMs + offsetSeconds * 1000); const hours = String(localDate.getUTCHours()).padStart(2, "0"); const minutes = String(localDate.getUTCMinutes()).padStart(2, "0"); return `${hours}:${minutes}`; } export function buildModelSummaryRows( rows: ScanOpportunityRow[], isEn: boolean, ): ModelSummaryRow[] { const byCity = new Map(); rows.forEach((row, index) => { const cityKey = normalizeCityKey(row, index); if (byCity.has(cityKey)) return; const cityName = row.city_display_name || row.display_name || row.city || "—"; const region = resolveRegion(row, isEn); const rawModelSources = row.model_cluster_sources || {}; const models = MODEL_SUMMARY_MODEL_COLUMNS.reduce( (acc, column) => { acc[column.key] = finiteNumber(rawModelSources[column.key]); return acc; }, {} as Record, ); const modelValues = MODEL_SUMMARY_MODEL_COLUMNS.map((column) => models[column.key]).filter( (value): value is number => value != null, ); const modelSearchText = MODEL_SUMMARY_MODEL_COLUMNS.filter( (column) => models[column.key] != null, ) .map((column) => column.label) .join(" "); const probabilityBuckets = buildProbabilityBuckets(row); const probabilityBucketMap = Object.fromEntries( probabilityBuckets.map((bucket) => [bucket.key, bucket]), ); const topProbabilityBucket = probabilityBuckets.length > 0 ? probabilityBuckets.reduce((best, bucket) => bucket.probability > best.probability ? bucket : best, ) : null; const probabilitySearchText = probabilityBuckets .map((bucket) => `${bucket.label} ${formatModelSummaryProbability(bucket.probability)}`) .join(" "); const marketMatches = buildMarketMatches(row); const marketSearchText = marketMatches .map( (match) => `${match.label} ${formatModelSummaryProbability(match.modelProbability)}`, ) .join(" "); byCity.set(cityKey, { cityKey, cityName, regionLabel: region.label, regionLabelZh: region.labelZh, regionSort: region.sort, tempSymbol: row.temp_symbol || "°C", localTime: normalizeLocalTime(row.local_time), timezoneOffsetSeconds: finiteNumber(row.tz_offset_seconds), debPrediction: finiteNumber(row.deb_prediction), models, modelMedian: median(modelValues), modelSpread: spread(modelValues), probabilityBuckets, probabilityBucketMap, gaussianMu: weightedProbabilityMu(probabilityBuckets), probabilityEngine: row.probability_engine || (probabilityBuckets.length ? "legacy" : null), topProbabilityBucketKey: topProbabilityBucket?.key || null, marketMatches, searchText: `${cityName} ${row.city || ""} ${region.labelEn} ${region.labelZh} ${modelSearchText} ${probabilitySearchText} ${marketSearchText}`.toLowerCase(), }); }); return [...byCity.values()].sort((a, b) => { if (a.regionSort !== b.regionSort) return a.regionSort - b.regionSort; return a.cityName.localeCompare(b.cityName, isEn ? "en" : "zh-CN", { sensitivity: "base", }); }); } export function filterModelSummaryRows( rows: ModelSummaryRow[], filters: ModelSummaryFilters, ): ModelSummaryRow[] { const query = filters.query.trim().toLowerCase(); return rows.filter((row) => { if (query && !row.searchText.includes(query)) return false; if (filters.debOnly && row.debPrediction == null) return false; if ( filters.wideSpreadOnly && (row.modelSpread == null || row.modelSpread < WIDE_SPREAD_THRESHOLD) ) { return false; } return true; }); } export function hasModelSummaryForecastData(rows: ModelSummaryRow[]) { return rows.some((row) => { if (row.debPrediction != null) return true; return MODEL_SUMMARY_MODEL_COLUMNS.some((column) => row.models[column.key] != null); }); }