{view.front.metrics.slice(0, 6).map((metric) => (
@@ -935,14 +1008,7 @@ export function FutureForecastModal() {
{view.front.upperAirSummary ||
(view.front.upperAirMetrics?.length || 0) > 0 ? (
<>
-
+
{locale === "en-US" ? "Upper-Air Structure" : "高空结构信号"}
{view.front.upperAirSummary ? (
@@ -970,74 +1036,6 @@ export function FutureForecastModal() {
>
) : null}
-
-
-
- {locale === "en-US"
- ? "City Risk Profile & Airport Narrative"
- : "城市风险档案与机场报文解读"}
-
-
-
-
- {locale === "en-US" ? "City Risk Profile" : "城市风险档案"}
-
-
- {!risk.airport ? (
-
- {t("section.noRiskProfile")}
-
- ) : (
- <>
-
-
- {settlementProfileLabel}
-
- {settlementProfileValue}
-
-
-
- {t("section.distance")}
-
- {risk.distance_km ?? "--"}km
-
- {risk.warning ? (
-
-
- {t("section.note")}
-
- {risk.warning}
-
- ) : null}
- >
- )}
-
-
-
-
{t("future.ai")}
-
- {!ai.summary && ai.bullets.length === 0 ? (
-
- {t("future.noAi")}
-
- ) : (
- <>
- {ai.summary ? (
-
{ai.summary}
- ) : null}
- {ai.bullets.length > 0 ? (
-
- {ai.bullets.map((item) => (
- - {item}
- ))}
-
- ) : null}
- >
- )}
-
-
-
-
) : (
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts
index cee9fc2c..e2d13127 100644
--- a/frontend/lib/dashboard-types.ts
+++ b/frontend/lib/dashboard-types.ts
@@ -317,6 +317,8 @@ export interface CityDetail {
trigger_risk?: string | null;
mixing_strength?: string | null;
shear_risk?: string | null;
+ heating_setup?: string | null;
+ heating_score?: number | null;
summary_zh?: string | null;
summary_en?: string | null;
};
diff --git a/frontend/lib/dashboard-utils.ts b/frontend/lib/dashboard-utils.ts
index e29b2762..d7f2ae43 100644
--- a/frontend/lib/dashboard-utils.ts
+++ b/frontend/lib/dashboard-utils.ts
@@ -580,17 +580,77 @@ export function computeFrontTrendSignal(
locale: Locale = "zh-CN",
) {
const upperAirSignal = detail.vertical_profile_signal || {};
+ const upperAirSummary = upperAirSignal.source
+ ? (() => {
+ const hasMetrics =
+ upperAirSignal.cape_max != null ||
+ upperAirSignal.cin_min != null ||
+ upperAirSignal.boundary_layer_height_max != null ||
+ upperAirSignal.shear_10m_180m_max != null;
+ if (!hasMetrics) {
+ return isEnglish(locale)
+ ? "Upper-air inputs are incomplete. For now, trade direction should rely more on surface structure."
+ : "高空输入还不完整,当前交易方向先更多参考近地面结构信号。";
+ }
+ if (upperAirSignal.heating_setup === "supportive") {
+ return isEnglish(locale)
+ ? "Upper-air structure still favors further warming. Leaning too early against higher buckets is risky."
+ : "高空结构仍偏向继续增温,过早去押更低温区间风险较高。";
+ }
+ if (upperAirSignal.heating_setup === "suppressed") {
+ return isEnglish(locale)
+ ? "Upper-air structure leans toward capping the afternoon high. Chasing higher buckets needs caution."
+ : "高空结构更偏向压住午后峰值,追更高温区间要更谨慎。";
+ }
+ return isEnglish(locale)
+ ? "Upper-air structure is fairly neutral. It does not give a clean edge by itself, so surface trends still decide the trade."
+ : "高空结构整体偏中性,单看这层不给明确边,交易仍要让近地面走势来定。";
+ })()
+ : "";
const upperAirMetrics = upperAirSignal.source
? [
{
- label: isEnglish(locale) ? "Convective suppression" : "对流压温风险",
+ label: isEnglish(locale) ? "Peak setup" : "冲高环境",
+ note:
+ upperAirSignal.heating_setup === "supportive"
+ ? isEnglish(locale)
+ ? "Still supportive of more daytime heating. Fading stronger buckets too early is risky."
+ : "仍偏向白天继续冲高,过早去反着做更高温区间比较危险。"
+ : upperAirSignal.heating_setup === "suppressed"
+ ? isEnglish(locale)
+ ? "Leans toward capping the afternoon peak. Be careful chasing stronger buckets."
+ : "更偏向压住午后峰值,追更高温区间要谨慎。"
+ : isEnglish(locale)
+ ? "Neutral on its own. Surface structure still decides the side."
+ : "单看这层偏中性,最终还是要看近地面信号站哪边。",
+ tone:
+ upperAirSignal.heating_setup === "supportive"
+ ? "warm"
+ : upperAirSignal.heating_setup === "suppressed"
+ ? "cold"
+ : "",
+ value:
+ upperAirSignal.heating_setup === "supportive"
+ ? isEnglish(locale)
+ ? "Supportive"
+ : "偏支持"
+ : upperAirSignal.heating_setup === "suppressed"
+ ? isEnglish(locale)
+ ? "Suppressed"
+ : "偏压制"
+ : isEnglish(locale)
+ ? "Neutral"
+ : "中性",
+ },
+ {
+ label: isEnglish(locale) ? "Peak suppression risk" : "压温风险",
note:
upperAirSignal.cape_max != null || upperAirSignal.cin_min != null
? isEnglish(locale)
- ? `CAPE max ${Math.round(Number(upperAirSignal.cape_max ?? 0))}, CIN min ${Number(upperAirSignal.cin_min ?? 0).toFixed(0)}.`
- : `CAPE 峰值 ${Math.round(Number(upperAirSignal.cape_max ?? 0))},CIN 最低 ${Number(upperAirSignal.cin_min ?? 0).toFixed(0)}。`
+ ? `How likely clouds or showers are to cap the high. CAPE ${Math.round(Number(upperAirSignal.cape_max ?? 0))}, CIN ${Number(upperAirSignal.cin_min ?? 0).toFixed(0)}.`
+ : `看云和阵雨有多大概率把峰值压住。CAPE ${Math.round(Number(upperAirSignal.cape_max ?? 0))},CIN ${Number(upperAirSignal.cin_min ?? 0).toFixed(0)}。`
: isEnglish(locale)
- ? "Derived from the next 48h upper-air profile."
+ ? "Estimated from the next 48h upper-air profile."
: "根据未来 48 小时高空剖面估算。",
tone:
upperAirSignal.suppression_risk === "high"
@@ -612,12 +672,12 @@ export function computeFrontTrendSignal(
: "低",
},
{
- label: isEnglish(locale) ? "Trigger setup" : "午后触发性",
+ label: isEnglish(locale) ? "Afternoon disruption" : "午后扰动",
note:
upperAirSignal.lifted_index_min != null
? isEnglish(locale)
- ? `Lifted index min ${Number(upperAirSignal.lifted_index_min).toFixed(1)}.`
- : `Lifted Index 最低 ${Number(upperAirSignal.lifted_index_min).toFixed(1)}。`
+ ? `How easily the afternoon can turn noisy. Lifted Index ${Number(upperAirSignal.lifted_index_min).toFixed(1)}.`
+ : `看午后是否容易突然起云、起对流,把走势搅乱。Lifted Index ${Number(upperAirSignal.lifted_index_min).toFixed(1)}。`
: isEnglish(locale)
? "Uses instability and lifted-index structure."
: "结合不稳定能量与抬升指数判断。",
@@ -641,15 +701,15 @@ export function computeFrontTrendSignal(
: "低",
},
{
- label: isEnglish(locale) ? "Deep mixing" : "深层混合",
+ label: isEnglish(locale) ? "Heating efficiency" : "冲高效率",
note:
upperAirSignal.boundary_layer_height_max != null
? isEnglish(locale)
- ? `Boundary-layer height peaks near ${Math.round(Number(upperAirSignal.boundary_layer_height_max))} m.`
- : `边界层高度峰值约 ${Math.round(Number(upperAirSignal.boundary_layer_height_max))} 米。`
+ ? `How efficiently surface warmth can keep translating upward. Mixing depth peaks near ${Math.round(Number(upperAirSignal.boundary_layer_height_max))} m.`
+ : `看地面热量能不能持续往上送,决定冲高效率。混合层高度峰值约 ${Math.round(Number(upperAirSignal.boundary_layer_height_max))} 米。`
: isEnglish(locale)
- ? "Tracks daytime boundary-layer depth."
- : "跟踪白天边界层混合深度。",
+ ? "Tracks daytime mixing depth."
+ : "跟踪白天混合层深度。",
tone:
upperAirSignal.mixing_strength === "strong"
? "warm"
@@ -666,32 +726,8 @@ export function computeFrontTrendSignal(
? "Medium"
: "中"
: isEnglish(locale)
- ? "Weak"
- : "弱",
- },
- {
- label: isEnglish(locale) ? "Shear proxy" : "高空风切变",
- note:
- upperAirSignal.shear_10m_180m_max != null
- ? isEnglish(locale)
- ? `10m-180m shear proxy peaks near ${Number(upperAirSignal.shear_10m_180m_max).toFixed(1)}.`
- : `10m-180m 风切变代理峰值约 ${Number(upperAirSignal.shear_10m_180m_max).toFixed(1)}。`
- : isEnglish(locale)
- ? "Uses 10m vs 180m wind-vector spread as a simple proxy."
- : "使用 10m 与 180m 风矢量差做简化代理。",
- tone: upperAirSignal.shear_risk === "high" ? "cold" : "",
- value:
- upperAirSignal.shear_risk === "high"
- ? isEnglish(locale)
- ? "High"
- : "高"
- : upperAirSignal.shear_risk === "medium"
- ? isEnglish(locale)
- ? "Medium"
- : "中"
- : isEnglish(locale)
- ? "Low"
- : "低",
+ ? "Weak"
+ : "弱",
},
]
: [];
@@ -719,9 +755,7 @@ export function computeFrontTrendSignal(
value: string;
}>,
upperAirMetrics,
- upperAirSummary: isEnglish(locale)
- ? String(upperAirSignal.summary_en || "").trim()
- : String(upperAirSignal.summary_zh || "").trim(),
+ upperAirSummary,
precipMax: 0,
score: 0,
summary:
@@ -1204,9 +1238,7 @@ export function computeFrontTrendSignal(
label,
metrics,
upperAirMetrics,
- upperAirSummary: isEnglish(locale)
- ? String(upperAirSignal.summary_en || "").trim()
- : String(upperAirSignal.summary_zh || "").trim(),
+ upperAirSummary,
precipMax,
score,
summary: backendSummary || summary,
diff --git a/web/analysis_service.py b/web/analysis_service.py
index 9021e375..050934a5 100644
--- a/web/analysis_service.py
+++ b/web/analysis_service.py
@@ -77,6 +77,16 @@ def _build_vertical_profile_signal(
valid = [_sf(value) for value in values if _sf(value) is not None]
return min(valid) if valid else None
+ def _level_label(level: str, locale: str) -> str:
+ mapping = {
+ "high": {"zh": "高", "en": "high"},
+ "medium": {"zh": "中", "en": "medium"},
+ "low": {"zh": "低", "en": "low"},
+ "strong": {"zh": "强", "en": "strong"},
+ "weak": {"zh": "弱", "en": "weak"},
+ }
+ return mapping.get(level, {}).get(locale, level)
+
cape_max = _max_numeric(_series("cape"))
cin_min = _min_numeric(_series("convective_inhibition"))
lifted_index_min = _min_numeric(_series("lifted_index"))
@@ -102,43 +112,78 @@ def _build_vertical_profile_signal(
shear_10m_180m_max = max(shear_values) if shear_values else None
suppression_risk = "low"
- if (cape_max is not None and cape_max >= 900) or (
- cin_min is not None and cin_min <= -60
+ if (cape_max is not None and cape_max >= 700) or (
+ cin_min is not None and cin_min <= -50
):
suppression_risk = "high"
- elif (cape_max is not None and cape_max >= 300) or (
- cin_min is not None and cin_min <= -20
+ elif (cape_max is not None and cape_max >= 150) or (
+ cin_min is not None and cin_min <= -15
):
suppression_risk = "medium"
trigger_risk = "low"
if (
cape_max is not None
- and cape_max >= 800
+ and cape_max >= 550
and lifted_index_min is not None
- and lifted_index_min <= -2
+ and lifted_index_min <= -1.5
):
trigger_risk = "high"
elif (
cape_max is not None
- and cape_max >= 250
+ and cape_max >= 120
and lifted_index_min is not None
- and lifted_index_min <= 0
+ and lifted_index_min <= 0.5
):
trigger_risk = "medium"
mixing_strength = "weak"
- if boundary_layer_height_max is not None and boundary_layer_height_max >= 1800:
+ if boundary_layer_height_max is not None and boundary_layer_height_max >= 1400:
mixing_strength = "strong"
- elif boundary_layer_height_max is not None and boundary_layer_height_max >= 1000:
+ elif boundary_layer_height_max is not None and boundary_layer_height_max >= 700:
mixing_strength = "medium"
shear_risk = "low"
- if shear_10m_180m_max is not None and shear_10m_180m_max >= 12:
+ if shear_10m_180m_max is not None and shear_10m_180m_max >= 8:
shear_risk = "high"
- elif shear_10m_180m_max is not None and shear_10m_180m_max >= 6:
+ elif shear_10m_180m_max is not None and shear_10m_180m_max >= 4:
shear_risk = "medium"
+ heating_setup = "neutral"
+ heating_score = 0
+ if suppression_risk == "high":
+ heating_score -= 2
+ elif suppression_risk == "medium":
+ heating_score -= 1
+ if trigger_risk == "high":
+ heating_score -= 2
+ elif trigger_risk == "medium":
+ heating_score -= 1
+ if mixing_strength == "strong":
+ heating_score += 2
+ elif mixing_strength == "medium":
+ heating_score += 1
+ else:
+ heating_score -= 1
+ if shear_risk == "high":
+ heating_score -= 1
+
+ if heating_score >= 2:
+ heating_setup = "supportive"
+ elif heating_score <= -2:
+ heating_setup = "suppressed"
+
+ has_profile_data = any(
+ value is not None
+ for value in (
+ cape_max,
+ cin_min,
+ lifted_index_min,
+ boundary_layer_height_max,
+ shear_10m_180m_max,
+ )
+ )
+
zh_parts = []
en_parts = []
if suppression_risk == "high":
@@ -147,26 +192,83 @@ def _build_vertical_profile_signal(
elif suppression_risk == "medium":
zh_parts.append("存在一定云雨压温风险。")
en_parts.append("There is some cloud and shower suppression risk.")
+ elif has_profile_data:
+ zh_parts.append("高空对流压温风险暂时不高。")
+ en_parts.append("Upper-air suppression risk remains limited for now.")
if mixing_strength == "strong":
zh_parts.append("边界层混合较深,若无云雨打断仍有冲高空间。")
en_parts.append("Deep boundary-layer mixing still supports additional warming if convection stays limited.")
elif mixing_strength == "medium":
zh_parts.append("白天混合条件中等。")
en_parts.append("Daytime mixing potential is moderate.")
+ elif has_profile_data:
+ zh_parts.append("边界层混合偏浅。")
+ en_parts.append("Boundary-layer mixing remains shallow.")
if shear_risk == "high":
zh_parts.append("高空风切变较强,午后结构波动可能加大。")
en_parts.append("Upper-level shear is relatively strong and may increase afternoon volatility.")
+ elif shear_risk == "medium":
+ zh_parts.append("高空风切变有一定存在感。")
+ en_parts.append("Upper-level shear is noticeable.")
+ elif has_profile_data:
+ zh_parts.append("高空风切变扰动有限。")
+ en_parts.append("Upper-level shear disruption remains limited.")
if trigger_risk == "high":
zh_parts.append("抬升触发条件较好,需警惕午后云团发展。")
en_parts.append("Trigger conditions are favorable enough to watch for afternoon convective development.")
elif trigger_risk == "medium":
zh_parts.append("午后具备一定触发条件。")
en_parts.append("There is some afternoon trigger potential.")
- if not zh_parts:
+ elif has_profile_data:
+ zh_parts.append("午后触发条件偏弱。")
+ en_parts.append("Afternoon trigger potential remains weak.")
+ if not has_profile_data:
+ zh_parts.append("高空剖面字段暂缺,当前仅保留基础默认信号。")
+ en_parts.append("Upper-air profile fields are currently unavailable, so only a fallback signal is shown.")
+ elif not zh_parts:
zh_parts.append("高空结构整体平稳,暂未看到明显压温信号。")
if not en_parts:
en_parts.append("The upper-air structure looks fairly stable, without a strong suppression signal yet.")
+ if has_profile_data:
+ summary_tokens_zh = []
+ summary_tokens_en = []
+ window_start = str(times[candidate_indexes[0]]).split("T")[1][:5]
+ window_end = str(times[candidate_indexes[-1]]).split("T")[1][:5]
+ zh_parts.append(f"判断窗口:{window_start}-{window_end}。")
+ en_parts.append(f"Signal window: {window_start}-{window_end}.")
+ if cape_max is not None:
+ summary_tokens_zh.append(f"CAPE≈{round(cape_max)}")
+ summary_tokens_en.append(f"CAPE≈{round(cape_max)}")
+ if cin_min is not None:
+ summary_tokens_zh.append(f"CIN≈{round(cin_min)}")
+ summary_tokens_en.append(f"CIN≈{round(cin_min)}")
+ if boundary_layer_height_max is not None:
+ summary_tokens_zh.append(f"混合层≈{round(boundary_layer_height_max)}m")
+ summary_tokens_en.append(f"mixing≈{round(boundary_layer_height_max)}m")
+ if shear_10m_180m_max is not None:
+ summary_tokens_zh.append(f"切变≈{shear_10m_180m_max:.1f}")
+ summary_tokens_en.append(f"shear≈{shear_10m_180m_max:.1f}")
+ zh_parts.append(
+ f"压温{_level_label(suppression_risk, 'zh')}、触发{_level_label(trigger_risk, 'zh')}、混合{_level_label(mixing_strength, 'zh')}、切变{_level_label(shear_risk, 'zh')}。"
+ )
+ en_parts.append(
+ f"Suppression { _level_label(suppression_risk, 'en') }, trigger { _level_label(trigger_risk, 'en') }, mixing { _level_label(mixing_strength, 'en') }, shear { _level_label(shear_risk, 'en') }."
+ )
+ if heating_setup == "supportive":
+ zh_parts.append("整体更偏向支持白天冲高。")
+ en_parts.append("Overall, the profile is more supportive of daytime heating.")
+ elif heating_setup == "suppressed":
+ zh_parts.append("整体更偏向抑制午后冲高。")
+ en_parts.append("Overall, the profile leans more toward suppressing the afternoon peak.")
+ else:
+ zh_parts.append("整体更像中性环境,仍需结合地面信号。")
+ en_parts.append("Overall, the profile looks fairly neutral and still needs surface confirmation.")
+ if summary_tokens_zh:
+ zh_parts.append(" / ".join(summary_tokens_zh) + "。")
+ if summary_tokens_en:
+ en_parts.append(" / ".join(summary_tokens_en) + ".")
+
return {
"source": "open-meteo-gfs",
"window_start": times[candidate_indexes[0]] if candidate_indexes else None,
@@ -180,6 +282,8 @@ def _build_vertical_profile_signal(
"trigger_risk": trigger_risk,
"mixing_strength": mixing_strength,
"shear_risk": shear_risk,
+ "heating_setup": heating_setup,
+ "heating_score": heating_score,
"summary_zh": "".join(zh_parts),
"summary_en": " ".join(en_parts),
}