From c795d909936bba344d5ed03bedf47edc615bb1cc Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Sat, 21 Mar 2026 18:54:23 +0800
Subject: [PATCH] Sync dynamic commentary across bot and dashboard
---
data/probability_training_snapshots.jsonl | 18 +
.../dashboard/FutureForecastModal.tsx | 11 +-
frontend/lib/dashboard-types.ts | 4 +
frontend/lib/dashboard-utils.ts | 330 ++++++++++++++----
src/analysis/trend_engine.py | 129 ++++++-
tests/test_trend_engine.py | 47 +++
web/analysis_service.py | 4 +
7 files changed, 472 insertions(+), 71 deletions(-)
diff --git a/data/probability_training_snapshots.jsonl b/data/probability_training_snapshots.jsonl
index 837beb5a..84b649a6 100644
--- a/data/probability_training_snapshots.jsonl
+++ b/data/probability_training_snapshots.jsonl
@@ -89,3 +89,21 @@
{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
+{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
+{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
+{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
+{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
+{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
+{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
+{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
+{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625}
+{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
+{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375}
+{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
+{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "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": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875}
+{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375}
+{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375}
diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx
index 4b2ea834..20c32995 100644
--- a/frontend/components/dashboard/FutureForecastModal.tsx
+++ b/frontend/components/dashboard/FutureForecastModal.tsx
@@ -870,9 +870,9 @@ export function FutureForecastModal() {
}}
/>
-
-
- {t("future.judgement")}: {view.front.label}
+
+
+ {t("future.judgement")}: {view.front.label}
{t("future.confidence")}:{" "}
@@ -883,6 +883,11 @@ export function FutureForecastModal() {
{Math.round(view.front.precipMax)}%
+ {view.front.summary ? (
+
+ {view.front.summary}
+
+ ) : null}
{view.front.metrics.slice(0, 6).map((metric) => (
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts
index 218d17a5..09a04f52 100644
--- a/frontend/lib/dashboard-types.ts
+++ b/frontend/lib/dashboard-types.ts
@@ -293,6 +293,10 @@ export interface CityDetail {
}>;
trend?: TrendInfo;
peak?: PeakInfo;
+ dynamic_commentary?: {
+ summary?: string | null;
+ notes?: string[] | null;
+ };
ai_analysis?: string | AiAnalysisStructured | null;
updated_at?: string;
multi_model_daily?: Record;
diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts
index c8bcbe8c..73505b8b 100644
--- a/frontend/lib/dashboard-utils.ts
+++ b/frontend/lib/dashboard-utils.ts
@@ -528,6 +528,15 @@ export function computeFrontTrendSignal(
dateStr: string,
locale: Locale = "zh-CN",
) {
+ const backendSummary =
+ dateStr === detail.local_date
+ ? String(detail.dynamic_commentary?.summary || "").trim()
+ : "";
+ const backendNotes = Array.isArray(detail.dynamic_commentary?.notes)
+ ? detail.dynamic_commentary?.notes
+ ?.map((item) => String(item || "").trim())
+ .filter(Boolean) || []
+ : [];
const slice = getFutureSlice(detail, dateStr);
const currentTemp = Number(detail.current?.temp);
const currentDew = Number(detail.current?.dewpoint);
@@ -544,9 +553,11 @@ export function computeFrontTrendSignal(
}>,
precipMax: 0,
score: 0,
- summary: isEnglish(locale)
- ? "Insufficient intraday structured data. Keep baseline monitoring."
- : "当日日内结构化数据不足,暂时只保留基础监控。",
+ summary:
+ backendSummary ||
+ (isEnglish(locale)
+ ? "Insufficient intraday structured data. Keep baseline monitoring."
+ : "当日日内结构化数据不足,暂时只保留基础监控。"),
weatherGovPeriods: [] as ReturnType,
};
}
@@ -722,76 +733,261 @@ export function computeFrontTrendSignal(
const label = score >= 18 ? warmLabel : score <= -18 ? coldLabel : monitorLabel;
const confidence =
Math.abs(score) >= 45 ? "high" : Math.abs(score) >= 22 ? "medium" : "low";
+ const summary = (() => {
+ const parts: string[] = [];
+
+ if (isEnglish(locale)) {
+ if (lastBucket === "southerly" && firstBucket !== "southerly") {
+ parts.push("Low-level wind turns more southerly.");
+ } else if (lastBucket === "northerly" && firstBucket !== "northerly") {
+ parts.push("Low-level wind shifts toward a northerly regime.");
+ }
+
+ if (tempDelta >= 0.8) {
+ parts.push(`Temperature rises by ${formatDelta(tempDelta, detail.temp_symbol)}.`);
+ } else if (tempDelta <= -0.8) {
+ parts.push(`Temperature eases by ${formatDelta(tempDelta, detail.temp_symbol)}.`);
+ }
+
+ if (dewDelta >= 0.8) {
+ parts.push("Dew point is lifting, suggesting moisture transport is strengthening.");
+ } else if (dewDelta <= -0.8) {
+ parts.push("Dew point is falling, so low-level air is turning drier.");
+ }
+
+ if (cloudDelta >= 15) {
+ parts.push("Cloud cover is building.");
+ } else if (cloudDelta <= -15) {
+ parts.push("Cloud cover is easing.");
+ }
+
+ if (pressureDelta >= 1) {
+ parts.push("Pressure rebound argues for a cooler push.");
+ } else if (pressureDelta <= -1) {
+ parts.push("Pressure is softening, which is less hostile to warming.");
+ }
+
+ if (precipMax >= 50) {
+ parts.push("Precipitation risk is high enough to watch for cloud/rain suppression.");
+ }
+
+ if (!parts.length) {
+ parts.push(`Structured trend is mixed, so the core judgement still centers on ${windowText}.`);
+ } else {
+ parts.push(`Core judgement remains focused on ${windowText}.`);
+ }
+ } else {
+ if (lastBucket === "southerly" && firstBucket !== "southerly") {
+ parts.push("低层风向更偏南,暖空气输送权重上升。");
+ } else if (lastBucket === "northerly" && firstBucket !== "northerly") {
+ parts.push("低层风向转偏北,冷空气影响权重上升。");
+ }
+
+ if (tempDelta >= 0.8) {
+ parts.push(`气温抬升 ${formatDelta(tempDelta, detail.temp_symbol)}。`);
+ } else if (tempDelta <= -0.8) {
+ parts.push(`气温回落 ${formatDelta(tempDelta, detail.temp_symbol)}。`);
+ }
+
+ if (dewDelta >= 0.8) {
+ parts.push("露点同步上升,说明暖湿输送在增强。");
+ } else if (dewDelta <= -0.8) {
+ parts.push("露点回落,低层空气在转干。");
+ }
+
+ if (cloudDelta >= 15) {
+ parts.push("云量正在增多。");
+ } else if (cloudDelta <= -15) {
+ parts.push("云量正在回落。");
+ }
+
+ if (pressureDelta >= 1) {
+ parts.push("气压回升,更偏向冷空气压入。");
+ } else if (pressureDelta <= -1) {
+ parts.push("气压走低,对增温压制减弱。");
+ }
+
+ if (precipMax >= 50) {
+ parts.push("降水概率已足以关注云雨压温。");
+ }
+
+ if (!parts.length) {
+ parts.push(`结构信号分化较大,核心仍围绕${windowText}观察。`);
+ } else {
+ parts.push(`核心判断窗口仍以${windowText}为主。`);
+ }
+ }
+
+ return parts.join(isEnglish(locale) ? " " : "");
+ })();
+ const cloudNote = (() => {
+ if (cloudDelta >= 15 && tempDelta >= 0.8 && dewDelta >= 0.8) {
+ return isEnglish(locale)
+ ? "Clouds are increasing while temperature and dew point still rise; this usually fits ongoing warm-moist transport rather than immediate cooling."
+ : "云量上升时温度和露点仍在抬升,更像暖湿输送持续中,而不是立刻转凉。";
+ }
+ if (cloudDelta >= 15 && tempDelta >= 0 && lastBucket === "southerly") {
+ return isEnglish(locale)
+ ? "Clouds are building without clear cooling, and the low-level wind still leans southerly; watch for warm advection to continue."
+ : "云量增多但未明显降温,且低层风仍偏南,需继续关注暖平流是否延续。";
+ }
+ if (cloudDelta >= 15 && tempDelta < 0 && precipMax >= 40) {
+ return isEnglish(locale)
+ ? "Clouds are thickening while temperature eases and precipitation risk is elevated; cloud/rain suppression is becoming more likely."
+ : "云量增厚且气温回落,同时降水概率偏高,更像云雨压温开始生效。";
+ }
+ if (cloudDelta >= 15 && tempDelta < 0 && pressureDelta >= 1) {
+ return isEnglish(locale)
+ ? "Clouds are increasing while temperature softens and pressure rebounds; watch for cold-air push or frontal suppression."
+ : "云量上升同时气温走弱、气压回升,需留意冷空气压入或锋面压温。";
+ }
+ if (cloudDelta <= -15 && tempDelta >= 0.8) {
+ return isEnglish(locale)
+ ? "Cloud cover is easing while temperature rises; daytime heating efficiency is improving."
+ : "云量回落且温度抬升,白天增温效率在改善。";
+ }
+ return isEnglish(locale)
+ ? "Read cloud-cover change together with temperature, dew point, wind, and precipitation; cloud change alone does not define the regime."
+ : "云量变化需要结合温度、露点、风向和降水一起看,不能单独决定天气形势。";
+ })();
+ const dewNote = (() => {
+ if (dewDelta >= 1.2 && tempDelta >= 0.8) {
+ return isEnglish(locale)
+ ? "Dew point and temperature rise together, which usually supports strengthening warm-moist transport."
+ : "露点和温度同步抬升,更偏向暖湿输送增强。";
+ }
+ if (dewDelta >= 1.2 && precipMax >= 40) {
+ return isEnglish(locale)
+ ? "Moisture is building while precipitation risk is already notable; watch for showers to cap daytime heating."
+ : "水汽在累积且降水风险已抬升,需关注阵雨对午后增温的压制。";
+ }
+ if (dewDelta <= -1.2 && tempDelta <= 0) {
+ return isEnglish(locale)
+ ? "Drier low-level air is arriving together with softer temperature, which leans away from warm-moist support."
+ : "低层空气在转干且温度偏弱,暖湿支撑正在减弱。";
+ }
+ return isEnglish(locale)
+ ? "Use dew-point change to judge whether low-level warm-moist transport is strengthening or fading."
+ : "露点变化主要用于判断低层暖湿输送是在增强还是减弱。";
+ })();
+ const pressureNote = (() => {
+ if (pressureDelta >= 1.2 && tempDelta <= -0.8) {
+ return isEnglish(locale)
+ ? "Pressure rebound with cooling usually points to a cooler push or frontal suppression."
+ : "气压回升且温度走弱,更像冷空气压入或锋面压温。";
+ }
+ if (pressureDelta <= -1.0 && tempDelta >= 0.8) {
+ return isEnglish(locale)
+ ? "Pressure is softening while temperature rises, a setup less hostile to warming."
+ : "气压走低同时温度抬升,对增温的压制相对减弱。";
+ }
+ return isEnglish(locale)
+ ? "Pressure change is used as a supporting signal for cold-air push versus warming resilience."
+ : "气压变化更适合作为冷空气压入或增温韧性的辅助判断。";
+ })();
+ const windNote = (() => {
+ if (firstBucket !== lastBucket && lastBucket === "southerly") {
+ return isEnglish(locale)
+ ? "Wind turns toward a southerly regime, which is more favorable for warming."
+ : "风向转偏南,更有利于增温。";
+ }
+ if (firstBucket !== lastBucket && lastBucket === "northerly") {
+ return isEnglish(locale)
+ ? "Wind turns toward a northerly regime, which is more favorable for cooling."
+ : "风向转偏北,更有利于降温。";
+ }
+ if (lastBucket === "southerly") {
+ return isEnglish(locale)
+ ? "Low-level flow remains southerly, so warm advection has not been disrupted."
+ : "低层风维持偏南,暖平流支撑尚未被破坏。";
+ }
+ if (lastBucket === "northerly") {
+ return isEnglish(locale)
+ ? "Low-level flow remains northerly, so cooling-side support is still present."
+ : "低层风维持偏北,降温侧支撑仍在。";
+ }
+ return isEnglish(locale)
+ ? "Wind-direction change matters most when it crosses into southerly or northerly buckets."
+ : "风向变化最关键的是是否跨入偏南或偏北风桶。";
+ })();
+ const precipNote = (() => {
+ if (precipMax >= 60) {
+ return isEnglish(locale)
+ ? "Precipitation risk is high enough that cloud/rain suppression can materially change the peak outcome."
+ : "降水概率已高到足以显著改变峰值兑现结果,需要重点防压温。";
+ }
+ if (precipMax >= 40) {
+ return isEnglish(locale)
+ ? "Precipitation risk is meaningful; watch whether cloud and showers interrupt daytime heating."
+ : "降水概率已有存在感,需要关注云系和阵雨是否打断白天增温。";
+ }
+ return isEnglish(locale)
+ ? "Precipitation risk remains limited and is used mainly as a suppression check."
+ : "降水风险暂时有限,主要作为压温风险校验项。";
+ })();
+
+ const metrics = [
+ {
+ label: isEnglish(locale) ? "Temperature delta" : "温度变化",
+ note: isEnglish(locale)
+ ? `Official Open-Meteo hourly data; window: ${windowText}`
+ : `官方 Open-Meteo 小时数据;计算窗口:${windowText}`,
+ tone: tempDelta >= 0.8 ? "warm" : tempDelta <= -0.8 ? "cold" : "",
+ value: formatDelta(tempDelta, detail.temp_symbol),
+ },
+ {
+ label: isEnglish(locale) ? "Dew point delta" : "露点变化",
+ note: dewNote,
+ tone: dewDelta >= 0.8 ? "warm" : dewDelta <= -0.8 ? "cold" : "",
+ value: formatDelta(dewDelta, detail.temp_symbol),
+ },
+ {
+ label: isEnglish(locale) ? "Pressure delta" : "气压变化",
+ note: pressureNote,
+ tone: pressureDelta >= 1 ? "cold" : pressureDelta <= -1 ? "warm" : "",
+ value: formatDelta(pressureDelta, " hPa"),
+ },
+ {
+ label: isEnglish(locale) ? "Wind-direction evolution" : "风向演变",
+ note: windNote,
+ value: `${bucketLabel(firstBucket, locale)} -> ${bucketLabel(lastBucket, locale)}`,
+ },
+ {
+ label: isEnglish(locale) ? "Precip probability" : "降水概率",
+ note: precipNote,
+ tone: precipMax >= 50 ? "cold" : "",
+ value: `${Math.round(precipMax)}%`,
+ },
+ {
+ label: isEnglish(locale) ? "Cloud-cover delta" : "云量变化",
+ note: cloudNote,
+ tone:
+ cloudDelta >= 15 && tempDelta >= 0
+ ? "warm"
+ : cloudDelta >= 15 && tempDelta < 0
+ ? "cold"
+ : "",
+ value: formatDelta(cloudDelta, "%"),
+ },
+ ];
+
+ if (backendNotes.length) {
+ backendNotes.slice(0, metrics.length).forEach((note, index) => {
+ if (!note) return;
+ metrics[index] = {
+ ...metrics[index],
+ note,
+ };
+ });
+ }
return {
confidence,
label,
- metrics: [
- {
- label: isEnglish(locale) ? "Temperature delta" : "温度变化",
- note: isEnglish(locale)
- ? `Official Open-Meteo hourly data; window: ${windowText}`
- : `官方 Open-Meteo 小时数据;计算窗口:${windowText}`,
- tone: tempDelta >= 0.8 ? "warm" : tempDelta <= -0.8 ? "cold" : "",
- value: formatDelta(tempDelta, detail.temp_symbol),
- },
- {
- label: isEnglish(locale) ? "Dew point delta" : "露点变化",
- note: isEnglish(locale)
- ? "Rising dew point often supports warm/wet advection"
- : "露点上升更偏向暖湿平流",
- tone: dewDelta >= 0.8 ? "warm" : dewDelta <= -0.8 ? "cold" : "",
- value: formatDelta(dewDelta, detail.temp_symbol),
- },
- {
- label: isEnglish(locale) ? "Pressure delta" : "气压变化",
- note: isEnglish(locale)
- ? "Pressure rebound usually implies cold-air push"
- : "气压回升更偏向冷空气压入",
- tone: pressureDelta >= 1 ? "cold" : pressureDelta <= -1 ? "warm" : "",
- value: formatDelta(pressureDelta, " hPa"),
- },
- {
- label: isEnglish(locale) ? "Wind-direction evolution" : "风向演变",
- note: isEnglish(locale)
- ? "Warming-favor: S/SW; cooling-favor: N/NW"
- : "增温有利:南/西南风;降温有利:北/西北风",
- value: `${bucketLabel(firstBucket, locale)} -> ${bucketLabel(lastBucket, locale)}`,
- },
- {
- label: isEnglish(locale) ? "Precip probability" : "降水概率",
- note: "weather.gov / Open-Meteo",
- tone: precipMax >= 50 ? "cold" : "",
- value: `${Math.round(precipMax)}%`,
- },
- {
- label: isEnglish(locale) ? "Cloud-cover delta" : "云量变化",
- note: isEnglish(locale)
- ? "Cloud increase without cooling may imply warm advection"
- : "云量抬升但未降温,常见于暖平流前段",
- tone:
- cloudDelta >= 15 && tempDelta >= 0
- ? "warm"
- : cloudDelta >= 15 && tempDelta < 0
- ? "cold"
- : "",
- value: formatDelta(cloudDelta, "%"),
- },
- ],
+ metrics,
precipMax,
score,
- summary:
- label === warmLabel
- ? isEnglish(locale)
- ? `Southerly flow strengthens with rising dew point and temperature. ${windowText} leans warm advection.`
- : `风向更偏南 / 西南,露点与温度整体抬升,${windowText}偏向暖平流。`
- : label === coldLabel
- ? isEnglish(locale)
- ? `Temperature declines with pressure rebound and/or northerly shift. ${windowText} leans cold-front suppression.`
- : `温度下滑、气压回升或风向转北,${windowText}更像冷锋或冷平流压制。`
- : isEnglish(locale)
- ? `Structured trend uses weather.gov + Open-Meteo, with core judgement focused on ${windowText}.`
- : `结构化来源以 weather.gov 和 Open-Meteo 为主,核心判断窗口为${windowText}。`,
+ summary: backendSummary || summary,
weatherGovPeriods,
};
}
diff --git a/src/analysis/trend_engine.py b/src/analysis/trend_engine.py
index 10c47b31..41ea50f6 100644
--- a/src/analysis/trend_engine.py
+++ b/src/analysis/trend_engine.py
@@ -31,6 +31,14 @@ SETTLEMENT_SOURCE_LABELS = {
"mgm": "MGM",
}
+_CLOUD_RANK_LABELS = {
+ 0: "晴空到少云",
+ 1: "少云",
+ 2: "散云",
+ 3: "多云",
+ 4: "阴天",
+}
+
def _sf(v):
"""Safe float conversion — prevents JSON str types from breaking math."""
@@ -53,6 +61,107 @@ def _resolve_settlement_source_label(city_name: Optional[str]) -> str:
return SETTLEMENT_SOURCE_LABELS.get(source, source.upper())
+def _wind_bucket_label(wdir: Optional[float]) -> str:
+ if wdir is None:
+ return "风向信号不明确"
+ deg = float(wdir) % 360
+ if 135 <= deg < 225:
+ return "南风主导"
+ if 45 <= deg < 135:
+ return "东风主导"
+ if 225 <= deg < 315:
+ return "西风主导"
+ return "北风主导"
+
+
+def _describe_recent_structure(
+ recent_obs: List[Dict[str, Any]],
+ peak_status: str,
+ trend_direction: str,
+ cur_temp: Optional[float],
+ max_so_far: Optional[float],
+ temp_symbol: str,
+ primary_current: Dict[str, Any],
+) -> Tuple[str, List[str]]:
+ if len(recent_obs) < 2:
+ return "", []
+
+ oldest = recent_obs[-1]
+ newest = recent_obs[0]
+
+ temp_old = _sf(oldest.get("temp"))
+ temp_new = _sf(newest.get("temp"))
+ wdir_old = _sf(oldest.get("wdir"))
+ wdir_new = _sf(newest.get("wdir"))
+ altim_old = _sf(oldest.get("altim"))
+ altim_new = _sf(newest.get("altim"))
+ cloud_old = int(oldest.get("cloud_rank") or 0)
+ cloud_new = int(newest.get("cloud_rank") or 0)
+ humidity = _sf(primary_current.get("humidity"))
+ wx_desc = str(primary_current.get("wx_desc") or "").strip()
+
+ temp_delta = None
+ if temp_old is not None and temp_new is not None:
+ temp_delta = temp_new - temp_old
+
+ wind_angle = None
+ if wdir_old is not None and wdir_new is not None:
+ wind_angle = abs(wdir_new - wdir_old)
+ if wind_angle > 180:
+ wind_angle = 360 - wind_angle
+
+ altim_delta = None
+ if altim_old is not None and altim_new is not None:
+ altim_delta = altim_new - altim_old
+
+ cloud_delta = cloud_new - cloud_old
+ lines: List[str] = []
+
+ if cloud_delta >= 2 and temp_delta is not None and temp_delta >= 0:
+ lines.append("云层明显增厚,但近报尚未跟随降温,短时更像中高云增多或暖湿输送前段。")
+ elif cloud_delta >= 2 and temp_delta is not None and temp_delta <= -0.5:
+ lines.append("云量抬升且温度同步回落,云雨压温的约束正在增强。")
+ elif cloud_delta <= -2 and temp_delta is not None and temp_delta >= 0.5:
+ lines.append("云量回落并伴随升温,短时日照增温效率在改善。")
+
+ if wind_angle is not None and wind_angle >= 60:
+ lines.append(
+ f"低层风向出现明显切换,由 {_wind_bucket_label(wdir_old)} 转为 {_wind_bucket_label(wdir_new)}。"
+ )
+ elif wdir_new is not None:
+ lines.append(f"当前低层风场以{_wind_bucket_label(wdir_new)}为主。")
+
+ if altim_delta is not None:
+ if altim_delta <= -1.5 and trend_direction != "falling":
+ lines.append("气压继续走低,边界层仍偏活跃,峰值尚不能轻判结束。")
+ elif altim_delta >= 1.5 and peak_status != "before":
+ lines.append("气压回升信号更明显,若后续再配合回落,日高温锁定概率会继续上升。")
+
+ if humidity is not None and humidity >= 80 and not wx_desc:
+ lines.append(f"湿度已到 {humidity:.0f}% 左右,后续若云层继续增厚,需要防范压温。")
+ elif wx_desc:
+ lines.append(f"当前伴随“{wx_desc}”天气现象,短时体感与实测升温效率通常都会受抑制。")
+
+ if max_so_far is not None and cur_temp is not None:
+ gap = max_so_far - cur_temp
+ if gap >= 2.0 and peak_status != "before":
+ lines.append(
+ f"当前温度较今日峰值已回落 {gap:.1f}{temp_symbol},若后续再无明显回补,日高温大概率已接近锁定。"
+ )
+ elif gap <= 0.5 and peak_status == "in_window":
+ lines.append("当前温度仍贴近当日峰值,窗口内仍保留再创新高的可能。")
+
+ if not lines:
+ if trend_direction == "rising":
+ lines.append("近报仍偏升温,短时还看不到明确见顶信号。")
+ elif trend_direction == "falling":
+ lines.append("近报已进入回落段,后续重点看回落是否延续。")
+ else:
+ lines.append("当前结构信号偏中性,仍需继续盯近报温度与风云演变。")
+
+ return lines[0], lines
+
+
def analyze_weather_trend(
weather_data: dict,
temp_symbol: str,
@@ -329,6 +438,21 @@ def analyze_weather_trend(
"暂不能单凭回落判定今日高温已锁定。"
)
+ recent_obs = metar.get("recent_obs", [])
+ dynamic_summary, dynamic_notes = _describe_recent_structure(
+ recent_obs=recent_obs,
+ peak_status=peak_status,
+ trend_direction=trend_direction,
+ cur_temp=cur_temp,
+ max_so_far=max_so_far,
+ temp_symbol=temp_symbol,
+ primary_current=primary_current,
+ )
+ if dynamic_summary:
+ insights.append(f"🧩 结构解读:{dynamic_summary}")
+ for note in dynamic_notes:
+ ai_features.append(f"🧩 结构解读: {note}")
+
# === Ensemble ===
ensemble = weather_data.get("ensemble", {})
ens_p10 = _sf(ensemble.get("p10"))
@@ -379,7 +503,6 @@ def analyze_weather_trend(
# Shock Score
shock_score = 0.0
- recent_obs = metar.get("recent_obs", [])
if len(recent_obs) >= 2:
oldest = recent_obs[-1]
newest = recent_obs[0]
@@ -788,6 +911,10 @@ def analyze_weather_trend(
"max_so_far": max_so_far,
"cur_temp": cur_temp,
"wu_settle": apply_city_settlement(city_name, max_so_far) if max_so_far is not None else None,
+ "dynamic_commentary": {
+ "summary": dynamic_summary,
+ "notes": dynamic_notes,
+ },
}
display_str = "\n".join(insights) if insights else ""
return display_str, "\n".join(ai_features), structured
diff --git a/tests/test_trend_engine.py b/tests/test_trend_engine.py
index 6145af34..f4fac316 100644
--- a/tests/test_trend_engine.py
+++ b/tests/test_trend_engine.py
@@ -258,3 +258,50 @@ class TestTrendDirection:
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
assert sd["trend_info"]["direction"] == "stagnant"
+
+
+class TestDynamicCommentary:
+ @patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
+ @patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
+ @patch("src.analysis.trend_engine.append_probability_snapshot")
+ @patch("src.analysis.trend_engine.update_daily_record")
+ def test_dynamic_commentary_detects_cloud_build_without_cooling(
+ self, _udr, _snapshot, _deb_acc, _dw
+ ):
+ data = _make_weather_data(
+ cur_temp=28.0,
+ max_so_far=28.2,
+ local_time="2026-03-04 14:00",
+ recent_temps=[("14:00", 28.0), ("13:00", 27.6), ("12:00", 27.0)],
+ recent_obs=[
+ {"temp": 28.0, "wdir": 185, "wspd": 8, "cloud_rank": 3, "altim": 1009.2},
+ {"temp": 27.6, "wdir": 170, "wspd": 7, "cloud_rank": 2, "altim": 1010.0},
+ {"temp": 27.2, "wdir": 155, "wspd": 6, "cloud_rank": 1, "altim": 1010.8},
+ ],
+ )
+
+ display_str, ai_context, sd = analyze_weather_trend(data, "°C", "test_city")
+
+ summary = sd["dynamic_commentary"]["summary"]
+ notes = sd["dynamic_commentary"]["notes"]
+ assert summary
+ assert "云层明显增厚" in summary
+ assert "结构解读" in display_str
+ assert any("云层明显增厚" in note for note in notes)
+ assert "结构解读" in ai_context
+
+ @patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
+ @patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
+ @patch("src.analysis.trend_engine.append_probability_snapshot")
+ @patch("src.analysis.trend_engine.update_daily_record")
+ def test_dynamic_commentary_falls_back_when_recent_obs_missing(
+ self, _udr, _snapshot, _deb_acc, _dw
+ ):
+ data = _make_weather_data(recent_obs=[])
+
+ display_str, ai_context, sd = analyze_weather_trend(data, "°C", "test_city")
+
+ assert sd["dynamic_commentary"]["summary"] == ""
+ assert sd["dynamic_commentary"]["notes"] == []
+ assert "结构解读" not in display_str
+ assert "结构解读" not in ai_context
diff --git a/web/analysis_service.py b/web/analysis_service.py
index e017109d..6787c3eb 100644
--- a/web/analysis_service.py
+++ b/web/analysis_service.py
@@ -350,6 +350,7 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
probability_calibrated_mu = None
probability_calibrated_sigma = None
ai_text = ""
+ dynamic_commentary = {"summary": "", "notes": []}
try:
_, _ai_context, sd = _trend_analyze(raw, sym, city)
@@ -364,6 +365,7 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
probability_raw_sigma = sd.get("probability_raw_sigma")
probability_calibrated_mu = sd.get("probability_calibrated_mu")
probability_calibrated_sigma = sd.get("probability_calibrated_sigma")
+ dynamic_commentary = sd.get("dynamic_commentary") or dynamic_commentary
trend_info["is_dead_market"] = sd.get("trend_info", {}).get("is_dead_market", False)
trend_info["direction"] = sd.get("trend_info", {}).get("direction", trend_info.get("direction", "unknown"))
trend_info["is_cooling"] = sd.get("trend_info", {}).get("is_cooling", False)
@@ -666,6 +668,7 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
"last_h": last_peak_h,
"status": peak_status,
},
+ "dynamic_commentary": dynamic_commentary,
"hourly": today_hourly,
"hourly_next_48h": next_48h_hourly,
"metar_today_obs": metar_today_obs_payload,
@@ -850,6 +853,7 @@ def _build_city_detail_payload(
if not _is_excluded_model_name(k)
},
"probabilities": data.get("probabilities") or {"mu": None, "distribution": []},
+ "dynamic_commentary": data.get("dynamic_commentary") or {"summary": "", "notes": []},
"market_scan": market_scan,
"risk": data.get("risk"),
"ai_analysis": data.get("ai_analysis") or "",