From 2e2c55d77adbdb7cbda5ed176ee0584af24225a1 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 14 Apr 2026 12:03:37 +0800 Subject: [PATCH] Add market detail depth caching for dashboard data --- data/probability_training_snapshots.jsonl | 6 ++ frontend/hooks/useDashboardStore.tsx | 121 ++++++++++++++++++++-- frontend/lib/dashboard-client.ts | 5 +- frontend/lib/dashboard-types.ts | 2 +- web/routes.py | 2 + 5 files changed, 126 insertions(+), 10 deletions(-) diff --git a/data/probability_training_snapshots.jsonl b/data/probability_training_snapshots.jsonl index ecc6d6bd..61c59202 100644 --- a/data/probability_training_snapshots.jsonl +++ b/data/probability_training_snapshots.jsonl @@ -177,3 +177,9 @@ {"city": "tokyo", "timestamp": "2026-04-13T10:00:00.000Z", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.4349999999999998, "deb_prediction": 21.9, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 20.3, "ECMWF": 22.8, "GFS": 22.0, "ICON": 22.6, "GEM": 23.2, "JMA": 20.3}, "max_so_far": 23.0, "observation": {"current_temp": 20.0, "humidity": null, "wind_speed_kt": 10.0, "visibility_mi": null, "local_hour": 19.583333333333332}, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null} {"city": "hong kong", "timestamp": "2026-04-13T18:30:00+08:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.47999999999999987, "deb_prediction": 27.5, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 30.0, "ECMWF": 27.5, "GFS": 26.9, "ICON": 26.8, "GEM": 27.7, "JMA": 28.1}, "max_so_far": 29.7, "observation": {"current_temp": 27.4, "humidity": 77.0, "wind_speed_kt": 2.2, "visibility_mi": null, "local_hour": 18.666666666666668}, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null} {"city": "hong kong", "timestamp": "2026-04-13T18:30:00+08:00", "date": "2026-04-13", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.10952569444444488, "deb_prediction": 27.5, "ensemble": {"p10": 28.0, "median": 28.3, "p90": 28.6}, "multi_model": {"Open-Meteo": 26.8, "HKO(港天文)": 30.0, "ECMWF": 27.5, "GFS": 26.9, "ICON": 26.8, "GEM": 27.7, "JMA": 28.1}, "max_so_far": 29.7, "observation": {"current_temp": 27.4, "humidity": 77.0, "wind_speed_kt": 2.2, "visibility_mi": null, "local_hour": 18.666666666666668}, "peak_status": "past", "prob_snapshot": [{"v": 29, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null} +{"city": "busan", "timestamp": "2026-04-14T03:00:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 19.3, "raw_sigma": 1.8499999999999996, "deb_prediction": 17.1, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 15.8, "ECMWF": 17.1, "GFS": 16.3, "ICON": 18.4, "GEM": 19.5, "JMA": 15.8}, "max_so_far": 19.0, "observation": {"current_temp": 19.0, "humidity": null, "wind_speed_kt": 8.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 19, "p": 0.321}, {"v": 20, "p": 0.303}, {"v": 21, "p": 0.215}, {"v": 22, "p": 0.115}], "shadow_prob_snapshot": [{"v": 19, "p": 0.254}, {"v": 20, "p": 0.247}, {"v": 21, "p": 0.204}, {"v": 22, "p": 0.144}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 19.3, "calibrated_sigma": 2.4974999999999996} +{"city": "busan", "timestamp": "2026-04-14T03:00:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 19.3, "raw_sigma": 0.9765625, "deb_prediction": 17.1, "ensemble": {"p10": 15.0, "median": 16.0, "p90": 17.5}, "multi_model": {"Open-Meteo": 15.8, "ECMWF": 17.1, "GFS": 16.3, "ICON": 18.4, "GEM": 19.5, "JMA": 15.8}, "max_so_far": 19.0, "observation": {"current_temp": 19.0, "humidity": null, "wind_speed_kt": 8.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 19, "p": 0.48}, {"v": 20, "p": 0.396}, {"v": 21, "p": 0.125}], "shadow_prob_snapshot": [{"v": 19, "p": 0.4}, {"v": 20, "p": 0.359}, {"v": 21, "p": 0.186}, {"v": 22, "p": 0.055}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 19.3, "calibrated_sigma": 1.318359375} +{"city": "seoul", "timestamp": "2026-04-14T03:30:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 23.3, "raw_sigma": 3.8000000000000007, "deb_prediction": 18.7, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 16.3, "ECMWF": 22.3, "GFS": 14.7, "ICON": 21.0, "GEM": 21.8, "JMA": 16.3}, "max_so_far": 23.0, "observation": {"current_temp": 23.0, "humidity": null, "wind_speed_kt": 5.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 23, "p": 0.184}, {"v": 24, "p": 0.181}, {"v": 25, "p": 0.167}, {"v": 26, "p": 0.143}], "shadow_prob_snapshot": [{"v": 23, "p": 0.221}, {"v": 24, "p": 0.216}, {"v": 25, "p": 0.189}, {"v": 26, "p": 0.148}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.3, "calibrated_sigma": 3.0} +{"city": "seoul", "timestamp": "2026-04-14T03:30:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 23.3, "raw_sigma": 0.6666666666666673, "deb_prediction": 18.7, "ensemble": {"p10": 17.7, "median": 18.2, "p90": 19.3}, "multi_model": {"Open-Meteo": 16.3, "ECMWF": 22.3, "GFS": 14.7, "ICON": 21.0, "GEM": 21.8, "JMA": 16.3}, "max_so_far": 23.0, "observation": {"current_temp": 23.0, "humidity": null, "wind_speed_kt": 5.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 23, "p": 0.569}, {"v": 24, "p": 0.391}, {"v": 25, "p": 0.04}], "shadow_prob_snapshot": [{"v": 23, "p": 0.498}, {"v": 24, "p": 0.398}, {"v": 25, "p": 0.104}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.3, "calibrated_sigma": 0.9000000000000009} +{"city": "tokyo", "timestamp": "2026-04-14T03:30:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 22.7, "raw_sigma": 1.3499999999999996, "deb_prediction": 22.3, "ensemble": {"p10": null, "median": null, "p90": null}, "multi_model": {"Open-Meteo": 20.6, "ECMWF": 22.9, "GFS": 22.7, "ICON": 21.7, "GEM": 23.3, "JMA": 20.6}, "max_so_far": 20.0, "observation": {"current_temp": 20.0, "humidity": null, "wind_speed_kt": 7.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 23, "p": 0.285}, {"v": 22, "p": 0.257}, {"v": 24, "p": 0.188}, {"v": 21, "p": 0.137}], "shadow_prob_snapshot": [{"v": 23, "p": 0.294}, {"v": 22, "p": 0.263}, {"v": 24, "p": 0.188}, {"v": 21, "p": 0.134}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 22.7, "calibrated_sigma": 1.3014441113240975} +{"city": "tokyo", "timestamp": "2026-04-14T03:30:00.000Z", "date": "2026-04-14", "temp_symbol": "°C", "raw_mu": 23.089999999999996, "raw_sigma": 1.2890624999999998, "deb_prediction": 22.3, "ensemble": {"p10": 21.8, "median": 24.0, "p90": 25.0}, "multi_model": {"Open-Meteo": 20.6, "ECMWF": 22.9, "GFS": 22.7, "ICON": 21.7, "GEM": 23.3, "JMA": 20.6}, "max_so_far": 20.0, "observation": {"current_temp": 20.0, "humidity": null, "wind_speed_kt": 7.0, "visibility_mi": null, "local_hour": 12.966666666666667}, "peak_status": "before", "prob_snapshot": [{"v": 23, "p": 0.303}, {"v": 24, "p": 0.24}, {"v": 22, "p": 0.216}, {"v": 25, "p": 0.107}], "shadow_prob_snapshot": [{"v": 23, "p": 0.313}, {"v": 24, "p": 0.244}, {"v": 22, "p": 0.218}, {"v": 25, "p": 0.103}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260402162744", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.089999999999996, "calibrated_sigma": 1.2428312132581776} diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index 9a376475..a052fb80 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -133,7 +133,7 @@ const EAGER_SUMMARY_PRIORITY_CITY_ORDER = [ "milan", "madrid", ] as const; -type CityDetailDepth = "panel" | "nearby" | "full"; +type CityDetailDepth = "panel" | "market" | "nearby" | "full"; function countAvailableModels( detail?: CityDetail | null, @@ -172,7 +172,16 @@ function hasSparseDetailCoverage( ); } +function hasMarketDetailCoverage( + detail?: CityDetail | null, + targetDate?: string | null, +): boolean { + if (!detail) return false; + return countAvailableModels(detail, targetDate) > 1; +} + function normalizeDetailDepth(detail?: CityDetail | null): CityDetailDepth { + if (detail?.detail_depth === "market") return "market"; if (detail?.detail_depth === "nearby") return "nearby"; if (detail?.detail_depth === "panel") return "panel"; return "full"; @@ -181,9 +190,18 @@ function normalizeDetailDepth(detail?: CityDetail | null): CityDetailDepth { function detailSatisfiesDepth( detail: CityDetail | null | undefined, depth: CityDetailDepth, + targetDate?: string | null, ) { if (!detail) return false; if (depth === "panel") return true; + if (depth === "market") { + const normalized = normalizeDetailDepth(detail); + return ( + normalized === "market" || + normalized === "full" || + hasMarketDetailCoverage(detail, targetDate) + ); + } if (depth === "nearby") { const normalized = normalizeDetailDepth(detail); return normalized === "nearby" || normalized === "full"; @@ -192,7 +210,67 @@ function detailSatisfiesDepth( } function shouldCheckSparseCoverageForDepth(depth: CityDetailDepth) { - return depth === "panel" || depth === "full"; + return depth === "panel" || depth === "market" || depth === "full"; +} + +function hasMeaningfulModelMap( + value: Record | undefined, +): value is Record { + return Boolean( + value && + Object.values(value).some((entry) => Number.isFinite(Number(entry))), + ); +} + +function hasMeaningfulDailyModelMap( + value: CityDetail["multi_model_daily"] | undefined, +) { + return Boolean( + value && + Object.values(value).some((day) => + hasMeaningfulModelMap(day?.models || undefined), + ), + ); +} + +function mergeCityDetail( + current: CityDetail | undefined, + incoming: CityDetail, +): CityDetail { + if (!current) return incoming; + if (incoming.detail_depth !== "market") return incoming; + + const mergedDepth = + current.detail_depth === "full" || current.detail_depth === "nearby" + ? current.detail_depth + : incoming.detail_depth; + + return { + ...current, + ...incoming, + detail_depth: mergedDepth, + current: incoming.current || current.current, + airport_current: incoming.airport_current || current.airport_current, + deb: incoming.deb || current.deb, + probabilities: incoming.probabilities || current.probabilities, + trend: incoming.trend || current.trend, + multi_model: hasMeaningfulModelMap(incoming.multi_model) + ? incoming.multi_model + : current.multi_model, + multi_model_daily: hasMeaningfulDailyModelMap(incoming.multi_model_daily) + ? { + ...(current.multi_model_daily || {}), + ...(incoming.multi_model_daily || {}), + } + : current.multi_model_daily, + forecast: current.forecast || incoming.forecast, + official_nearby: current.official_nearby || incoming.official_nearby, + mgm_nearby: current.mgm_nearby || incoming.mgm_nearby, + network_lead_signal: + current.network_lead_signal || incoming.network_lead_signal, + airport_vs_network_delta: + current.airport_vs_network_delta ?? incoming.airport_vs_network_delta, + }; } function getStoredSelectedCityName(cities: CityListItem[]) { @@ -420,7 +498,7 @@ export function DashboardStoreProvider({ setCityDetailsByName((current) => ({ ...current, - [cityName]: detail, + [cityName]: mergeCityDetail(current[cityName], detail), })); setCitySummariesByName((current) => ({ ...current, @@ -444,10 +522,18 @@ export function DashboardStoreProvider({ ) => { const cached = cityDetailsByName[cityName]; const cachedMeta = cityDetailMetaByName[cityName]; - const hasRequestedDepth = detailSatisfiesDepth(cached, depth); + const marketTargetDate = + depth === "market" ? selectedForecastDate || cached?.local_date : null; + const hasRequestedDepth = detailSatisfiesDepth( + cached, + depth, + marketTargetDate, + ); const cachedIsSparse = shouldCheckSparseCoverageForDepth(depth) && - hasSparseDetailCoverage(cached, cached?.local_date); + (depth === "market" + ? hasSparseModelCoverage(cached, marketTargetDate) + : hasSparseDetailCoverage(cached, cached?.local_date)); if ( !force && cached && @@ -472,7 +558,7 @@ export function DashboardStoreProvider({ const detail = latestDetail; setCityDetailsByName((current) => ({ ...current, - [cityName]: detail, + [cityName]: mergeCityDetail(current[cityName], detail), })); setCitySummariesByName((current) => ({ ...current, @@ -508,7 +594,7 @@ export function DashboardStoreProvider({ const detail = latestDetail; setCityDetailsByName((current) => ({ ...current, - [cityName]: detail, + [cityName]: mergeCityDetail(current[cityName], detail), })); setCitySummariesByName((current) => ({ ...current, @@ -858,6 +944,7 @@ export function DashboardStoreProvider({ if (selectedCityRef.current !== cityName) return; setSelectedForecastDate(detail.local_date); if (access.authenticated && access.subscriptionActive) { + void ensureCityDetail(cityName, false, "market").catch(() => {}); // 预热市场数据,不做 await 阻塞,后台静默拉取 void ensureCityMarketScan( cityName, @@ -1101,10 +1188,18 @@ export function DashboardStoreProvider({ const hasFullCachedDetail = detailSatisfiesDepth(cachedDetail, "full") && !hasSparseDetailCoverage(cachedDetail, dateStr); + const hasMarketCachedDetail = detailSatisfiesDepth( + cachedDetail, + "market", + dateStr, + ); setFutureModalDate(dateStr); const cacheKey = getMarketScanCacheKey(selectedCity, dateStr); setLoadingState((current) => ({ ...current, marketScan: true })); + if (!hasMarketCachedDetail || forceRefresh) { + void ensureCityDetail(cityName, forceRefresh, "market").catch(() => {}); + } if (!hasFullCachedDetail || forceRefresh) { setLoadingState((current) => ({ ...current, @@ -1154,6 +1249,11 @@ export function DashboardStoreProvider({ const hasFullCachedDetail = detailSatisfiesDepth(cachedDetail, "full") && !hasSparseDetailCoverage(cachedDetail, cachedDetail?.local_date); + const hasMarketCachedDetail = detailSatisfiesDepth( + cachedDetail, + "market", + cachedDetail?.local_date, + ); const targetDate = cachedDetail?.local_date || selectedForecastDate || null; if (targetDate) { @@ -1171,6 +1271,13 @@ export function DashboardStoreProvider({ futureDeep: needsDetailRefresh, marketScan: true, })); + if (!hasMarketCachedDetail || forceRefresh) { + void ensureCityDetail( + cityName, + Boolean(forceRefresh), + "market", + ).catch(() => {}); + } const initialTargetDate = cachedDetail?.local_date || selectedForecastDate || null; const initialMarketKey = getMarketScanCacheKey( diff --git a/frontend/lib/dashboard-client.ts b/frontend/lib/dashboard-client.ts index 8f5b198c..1f4e5845 100644 --- a/frontend/lib/dashboard-client.ts +++ b/frontend/lib/dashboard-client.ts @@ -29,9 +29,10 @@ function normalizeCityName(cityName: string) { return encodeURIComponent(String(cityName).replace(/\s/g, "-")); } -function normalizeDetailDepth(depth?: "panel" | "nearby" | "full") { +function normalizeDetailDepth(depth?: "panel" | "market" | "nearby" | "full") { if (depth === "full") return "full"; if (depth === "nearby") return "nearby"; + if (depth === "market") return "market"; return "panel"; } @@ -163,7 +164,7 @@ export const dashboardClient = { async getCityDetail( cityName: string, - options?: { force?: boolean; depth?: "panel" | "nearby" | "full" }, + options?: { force?: boolean; depth?: "panel" | "market" | "nearby" | "full" }, ) { const force = options?.force ?? false; const depth = normalizeDetailDepth(options?.depth); diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 74bf37da..cb9cca89 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -327,7 +327,7 @@ export interface AiAnalysisStructured { export interface CityDetail { name: string; display_name: string; - detail_depth?: "panel" | "nearby" | "full"; + detail_depth?: "panel" | "market" | "nearby" | "full"; lat: number; lon: number; temp_symbol: string; diff --git a/web/routes.py b/web/routes.py index cb56959f..2e71db8d 100644 --- a/web/routes.py +++ b/web/routes.py @@ -439,6 +439,8 @@ async def city_detail( normalized_depth = str(depth or "panel").strip().lower() if normalized_depth == "full": detail_mode = "full" + elif normalized_depth == "market": + detail_mode = "market" elif normalized_depth == "nearby": detail_mode = "nearby" else: