Add market detail depth caching for dashboard data

This commit is contained in:
2569718930@qq.com
2026-04-14 12:03:37 +08:00
parent baaccc6636
commit 2e2c55d77a
5 changed files with 126 additions and 10 deletions
@@ -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}
+114 -7
View File
@@ -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<string, number | null> | undefined,
): value is Record<string, number | null> {
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(
+3 -2
View File
@@ -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);
+1 -1
View File
@@ -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;
+2
View File
@@ -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: