From 974b55e34ff30fddb3e2ffb44885b222d0909d70 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 22 Apr 2026 01:43:13 +0800 Subject: [PATCH] Add full probability distributions to dashboard --- .../components/dashboard/PanelSections.tsx | 16 +++++++++-- frontend/lib/dashboard-types.ts | 2 ++ frontend/lib/dashboard-utils.ts | 10 +++++++ src/analysis/probability_calibration.py | 28 +++++++++++++------ src/analysis/trend_engine.py | 15 ++++++++-- tests/test_probability_calibration.py | 2 ++ web/analysis_service.py | 6 ++++ 7 files changed, 65 insertions(+), 14 deletions(-) diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx index 96bad106..136d4c7e 100644 --- a/frontend/components/dashboard/PanelSections.tsx +++ b/frontend/components/dashboard/PanelSections.tsx @@ -858,6 +858,10 @@ export function ProbabilityDistribution({ ? formatBucketDisplayLabel(topProbability, detail) : null; const topProbabilityTemp = topProbability ? getBucketTemp(topProbability) : null; + const probabilitiesForMarketContracts = + view.probabilitiesAll?.length > 0 + ? view.probabilitiesAll + : view.probabilities || []; const marketContractRows = useMemo(() => { if (!isToday || !marketScan?.available || marketAllBuckets.length === 0) { return []; @@ -867,7 +871,7 @@ export function ProbabilityDistribution({ const seenKeys = new Set(); for (const marketBucket of marketAllBuckets) { const probability = getAggregatedModelProbabilityForMarketBucket( - view.probabilities || [], + probabilitiesForMarketContracts, marketBucket, detail, ); @@ -886,7 +890,13 @@ export function ProbabilityDistribution({ }); } return rows; - }, [detail, isToday, marketAllBuckets, marketScan?.available, view.probabilities]); + }, [ + detail, + isToday, + marketAllBuckets, + marketScan?.available, + probabilitiesForMarketContracts, + ]); const modelProbabilityRows = useMemo( () => (view.probabilities || []).slice(0, 6).map((bucket, index) => { @@ -938,7 +948,7 @@ export function ProbabilityDistribution({ topProbabilityLabel || null; const aggregatedMarketProbability = getAggregatedModelProbabilityForMarketBucket( - view.probabilities || [], + probabilitiesForMarketContracts, linkedMarketBucket, detail, ); diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts index 3d5837a9..68909572 100644 --- a/frontend/lib/dashboard-types.ts +++ b/frontend/lib/dashboard-types.ts @@ -488,6 +488,7 @@ export interface CityDetail { probabilities?: { mu?: number | null; distribution?: ProbabilityBucket[]; + distribution_all?: ProbabilityBucket[]; engine?: string | null; calibration_mode?: string | null; calibration_version?: string | null; @@ -496,6 +497,7 @@ export interface CityDetail { calibrated_mu?: number | null; calibrated_sigma?: number | null; shadow_distribution?: ProbabilityBucket[]; + shadow_distribution_all?: ProbabilityBucket[]; }; hourly?: { times?: string[]; diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts index 9ac57327..f47b4010 100644 --- a/frontend/lib/dashboard-utils.ts +++ b/frontend/lib/dashboard-utils.ts @@ -1212,7 +1212,15 @@ export function getProbabilityView(detail: CityDetail, targetDate?: string | nul engine: detail.probabilities?.engine ?? null, mu: detail.probabilities?.mu ?? null, probabilities: detail.probabilities?.distribution || [], + probabilitiesAll: + detail.probabilities?.distribution_all || + detail.probabilities?.distribution || + [], shadowProbabilities: detail.probabilities?.shadow_distribution || [], + shadowProbabilitiesAll: + detail.probabilities?.shadow_distribution_all || + detail.probabilities?.shadow_distribution || + [], }; } @@ -1223,7 +1231,9 @@ export function getProbabilityView(detail: CityDetail, targetDate?: string | nul engine: null, mu: daily?.deb?.prediction ?? null, probabilities: daily?.probabilities || [], + probabilitiesAll: daily?.probabilities || [], shadowProbabilities: [], + shadowProbabilitiesAll: [], }; } diff --git a/src/analysis/probability_calibration.py b/src/analysis/probability_calibration.py index 49aa8b66..7bc8480c 100644 --- a/src/analysis/probability_calibration.py +++ b/src/analysis/probability_calibration.py @@ -169,7 +169,7 @@ def _bucket_probabilities( sigma: float, max_so_far: Optional[float], city_name: str, -) -> Tuple[List[Dict[str, Any]], List[Tuple[int, float]]]: +) -> Tuple[List[Dict[str, Any]], List[Tuple[int, float]], List[Dict[str, Any]]]: sigma = max(0.1, float(sigma)) min_possible = ( apply_city_settlement(city_name, max_so_far) if max_so_far is not None else -999 @@ -193,24 +193,24 @@ def _bucket_probabilities( total = sum(probs.values()) if total <= 0: - return [], [] + return [], [], [] normalized = {key: val / total for key, val in probs.items()} sorted_probs = sorted(normalized.items(), key=lambda item: item[1], reverse=True) - distribution = [] - for value, prob in sorted_probs[:4]: + full_distribution = [] + for value, prob in sorted_probs: if is_exact: bucket_range = "[{0}.0~{1}.0)".format(value, value + 1) else: bucket_range = "[{0}~{1})".format(value - 0.5, value + 0.5) - distribution.append( + full_distribution.append( { "value": int(value), "range": bucket_range, "probability": round(prob, 3), } ) - return distribution, sorted_probs + return full_distribution[:4], sorted_probs, full_distribution def _top_bucket_value(distribution: Optional[List[Dict[str, Any]]]) -> Optional[int]: @@ -299,7 +299,9 @@ def apply_probability_calibration( "mode": selected_mode, "engine": ENGINE_MODE_LEGACY, "distribution": legacy_distribution or [], + "distribution_all": legacy_distribution or [], "shadow_distribution": [], + "shadow_distribution_all": [], "raw_mu": raw_mu, "raw_sigma": raw_sigma, "calibrated_mu": None, @@ -314,7 +316,9 @@ def apply_probability_calibration( "mode": selected_mode, "engine": ENGINE_MODE_LEGACY, "distribution": legacy_distribution or [], + "distribution_all": legacy_distribution or [], "shadow_distribution": [], + "shadow_distribution_all": [], "raw_mu": raw_mu, "raw_sigma": raw_sigma, "calibrated_mu": None, @@ -384,7 +388,7 @@ def apply_probability_calibration( calibrated_mu = observed_floor calibrated_sigma = max(0.1, _blend_value(raw_sigma, calibrated_sigma, blend_alpha_sigma)) calibrated_sigma = _clamp_sigma(raw_sigma, calibrated_sigma, sigma_constraints) - calibrated_distribution, calibrated_sorted = _bucket_probabilities( + calibrated_distribution, calibrated_sorted, calibrated_distribution_all = _bucket_probabilities( calibrated_mu, calibrated_sigma, max_so_far=max_so_far, @@ -393,23 +397,29 @@ def apply_probability_calibration( engine = ENGINE_MODE_LEGACY selected_distribution = legacy_distribution or [] + selected_distribution_all = legacy_distribution or [] selected_sorted: List[Tuple[int, float]] = [] shadow_distribution: List[Dict[str, Any]] = [] + shadow_distribution_all: List[Dict[str, Any]] = [] shadow_sorted: List[Tuple[int, float]] = [] if selected_mode == ENGINE_MODE_EMOS_PRIMARY: engine = "emos" selected_distribution = calibrated_distribution + selected_distribution_all = calibrated_distribution_all selected_sorted = calibrated_sorted elif selected_mode == ENGINE_MODE_EMOS_SHADOW: shadow_distribution = calibrated_distribution + shadow_distribution_all = calibrated_distribution_all shadow_sorted = calibrated_sorted return { "mode": selected_mode, "engine": engine, "distribution": selected_distribution, + "distribution_all": selected_distribution_all, "selected_sorted_probs": selected_sorted, "shadow_distribution": shadow_distribution, + "shadow_distribution_all": shadow_distribution_all, "shadow_sorted_probs": shadow_sorted, "raw_mu": raw_mu, "raw_sigma": raw_sigma, @@ -551,7 +561,7 @@ def fit_calibration( == apply_city_settlement(city, actual_high) else 0.0 ) - legacy_distribution, _ = _bucket_probabilities( + legacy_distribution, _, _ = _bucket_probabilities( legacy_mu, legacy_sigma, max_so_far=None, @@ -627,7 +637,7 @@ def fit_calibration( city = row["city"] crps_values.append(_gaussian_crps(actual_high, mu_hat, sigma_hat)) mae_values.append(abs(mu_hat - actual_high)) - distribution, _ = _bucket_probabilities( + distribution, _, _ = _bucket_probabilities( mu_hat, sigma_hat, max_so_far=None, diff --git a/src/analysis/trend_engine.py b/src/analysis/trend_engine.py index 6ff818bf..5258fbea 100644 --- a/src/analysis/trend_engine.py +++ b/src/analysis/trend_engine.py @@ -594,7 +594,9 @@ def analyze_weather_trend( # === Probability Engine === probabilities: List[Dict[str, Any]] = [] + probabilities_all: List[Dict[str, Any]] = [] shadow_probabilities: List[Dict[str, Any]] = [] + shadow_probabilities_all: List[Dict[str, Any]] = [] forecast_miss_deg = 0.0 probability_features = None calibration_summary = { @@ -621,6 +623,7 @@ def analyze_weather_trend( probabilities = [ {"value": settled_wu, "range": f"[{settled_wu-0.5}~{settled_wu+0.5})", "probability": 1.0} ] + probabilities_all = probabilities elif (ens_p10 is not None and ens_p90 is not None) or fallback_sigma: # Forecast miss magnitude if max_so_far is not None and forecast_median is not None: @@ -665,6 +668,7 @@ def analyze_weather_trend( ) mu = probs_result.get("mu", mu) probabilities = probs_result.get("probabilities", []) + probabilities_all = probs_result.get("probabilities_all", probabilities) sorted_probs = probs_result.get("sorted_probs", []) probability_features = build_probability_features( @@ -698,10 +702,12 @@ def analyze_weather_trend( "calibration_source": calibration_result.get("calibration_source"), } shadow_probabilities = calibration_result.get("shadow_distribution") or [] + shadow_probabilities_all = calibration_result.get("shadow_distribution_all") or shadow_probabilities if calibration_result.get("engine") == "emos": mu = calibration_result.get("calibrated_mu", mu) sigma = calibration_result.get("calibrated_sigma", sigma) probabilities = calibration_result.get("distribution") or probabilities + probabilities_all = calibration_result.get("distribution_all") or probabilities_all or probabilities sorted_probs = calibration_result.get("selected_sorted_probs") or sorted_probs if sorted_probs: @@ -926,7 +932,9 @@ def analyze_weather_trend( structured = { "mu": mu, "probabilities": probabilities, + "probabilities_all": probabilities_all or probabilities, "shadow_probabilities": shadow_probabilities, + "shadow_probabilities_all": shadow_probabilities_all or shadow_probabilities, "probability_engine": calibration_summary["engine"], "probability_calibration_mode": calibration_summary["mode"], "probability_calibration_version": calibration_summary["calibration_version"], @@ -999,21 +1007,24 @@ def calculate_prob_distribution( total_p = sum(probs.values()) sorted_probs = [] probabilities = [] + probabilities_all = [] if total_p > 0: norm_probs = {k: v / total_p for k, v in probs.items()} sorted_probs = sorted(norm_probs.items(), key=lambda x: x[1], reverse=True) - for t, p in sorted_probs[:4]: + for t, p in sorted_probs: rng_str = f"[{t}.0~{t+1}.0)" if is_exact else f"[{t-0.5}~{t+0.5})" - probabilities.append({ + probabilities_all.append({ "value": int(t), "range": rng_str, "probability": round(p, 3) }) + probabilities = probabilities_all[:4] return { "mu": mu, "sigma": sigma, "probabilities": probabilities, + "probabilities_all": probabilities_all, "sorted_probs": sorted_probs } diff --git a/tests/test_probability_calibration.py b/tests/test_probability_calibration.py index 4636f303..4ada3779 100644 --- a/tests/test_probability_calibration.py +++ b/tests/test_probability_calibration.py @@ -96,6 +96,7 @@ def test_shadow_mode_keeps_legacy_distribution(tmp_path): assert result["shadow_distribution"] assert result["calibrated_mu"] == 10.5 assert result["calibrated_sigma"] == 1.2 + assert len(result["shadow_distribution_all"]) >= len(result["shadow_distribution"]) def test_primary_mode_switches_to_calibrated_distribution(tmp_path): @@ -129,6 +130,7 @@ def test_primary_mode_switches_to_calibrated_distribution(tmp_path): assert result["calibrated_mu"] == 10.5 assert result["calibrated_sigma"] == 1.2 assert result["distribution"] + assert len(result["distribution_all"]) >= len(result["distribution"]) assert result["distribution"][0]["value"] >= 10 diff --git a/web/analysis_service.py b/web/analysis_service.py index 9fb2e90b..68387c85 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -2006,7 +2006,9 @@ def _analyze( from src.analysis.trend_engine import analyze_weather_trend as _trend_analyze, calculate_prob_distribution probabilities = [] + probabilities_all = [] shadow_probabilities = [] + shadow_probabilities_all = [] mu = None probability_engine = "legacy" probability_calibration_mode = "legacy" @@ -2022,7 +2024,9 @@ def _analyze( # Use structured data from shared engine mu = sd.get("mu") probabilities = sd.get("probabilities", []) + probabilities_all = sd.get("probabilities_all", probabilities) shadow_probabilities = sd.get("shadow_probabilities", []) + shadow_probabilities_all = sd.get("shadow_probabilities_all", shadow_probabilities) probability_engine = sd.get("probability_engine", "legacy") probability_calibration_mode = sd.get("probability_calibration_mode", "legacy") probability_calibration_version = sd.get("probability_calibration_version") @@ -2406,6 +2410,7 @@ def _analyze( "probabilities": { "mu": round(mu, 1) if mu is not None else None, "distribution": probabilities, + "distribution_all": probabilities_all or probabilities, "engine": probability_engine, "calibration_mode": probability_calibration_mode, "calibration_version": probability_calibration_version, @@ -2414,6 +2419,7 @@ def _analyze( "calibrated_mu": probability_calibrated_mu, "calibrated_sigma": probability_calibrated_sigma, "shadow_distribution": shadow_probabilities, + "shadow_distribution_all": shadow_probabilities_all or shadow_probabilities, }, "trend": trend_info, "peak": {