From 58a09fe6fe326c0a8f4e5f546e7ffef141f60afd Mon Sep 17 00:00:00 2001
From: "2569718930@qq.com" <2569718930@qq.com>
Date: Tue, 24 Mar 2026 01:28:45 +0800
Subject: [PATCH] Add upper-air structure signals to intraday analysis
---
.../dashboard/FutureForecastModal.tsx | 37 ++++
frontend/lib/dashboard-types.ts | 22 ++
frontend/lib/dashboard-utils.ts | 124 +++++++++++
src/data_collection/nws_open_meteo_sources.py | 7 +-
web/analysis_service.py | 205 ++++++++++++++++++
5 files changed, 394 insertions(+), 1 deletion(-)
diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx
index 8a69fd53..2acf4927 100644
--- a/frontend/components/dashboard/FutureForecastModal.tsx
+++ b/frontend/components/dashboard/FutureForecastModal.tsx
@@ -932,6 +932,43 @@ export function FutureForecastModal() {
))}
+ {view.front.upperAirSummary ||
+ (view.front.upperAirMetrics?.length || 0) > 0 ? (
+ <>
+
+ {locale === "en-US" ? "Upper-Air Structure" : "高空结构信号"}
+
+ {view.front.upperAirSummary ? (
+
+ {view.front.upperAirSummary}
+
+ ) : null}
+
+ {(view.front.upperAirMetrics || []).map((metric) => (
+
+
{metric.label}
+
+ {metric.value}
+
+
{metric.note}
+
+ ))}
+
+ >
+ ) : null}
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts
index 25f5e075..cee9fc2c 100644
--- a/frontend/lib/dashboard-types.ts
+++ b/frontend/lib/dashboard-types.ts
@@ -153,9 +153,15 @@ export interface HourlySeries {
pressure_msl?: Array;
wind_speed_10m?: Array;
wind_direction_10m?: Array;
+ wind_speed_180m?: Array;
+ wind_direction_180m?: Array;
precipitation_probability?: Array;
cloud_cover?: Array;
radiation?: Array;
+ cape?: Array;
+ convective_inhibition?: Array;
+ lifted_index?: Array;
+ boundary_layer_height?: Array;
}
export interface WeatherGovPeriod {
@@ -298,6 +304,22 @@ export interface CityDetail {
summary?: string | null;
notes?: string[] | null;
};
+ vertical_profile_signal?: {
+ source?: string | null;
+ window_start?: string | null;
+ window_end?: string | null;
+ cape_max?: number | null;
+ cin_min?: number | null;
+ lifted_index_min?: number | null;
+ boundary_layer_height_max?: number | null;
+ shear_10m_180m_max?: number | null;
+ suppression_risk?: string | null;
+ trigger_risk?: string | null;
+ mixing_strength?: string | null;
+ shear_risk?: string | null;
+ summary_zh?: string | null;
+ summary_en?: 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 a0e5f2fb..e29b2762 100644
--- a/frontend/lib/dashboard-utils.ts
+++ b/frontend/lib/dashboard-utils.ts
@@ -579,6 +579,122 @@ export function computeFrontTrendSignal(
dateStr: string,
locale: Locale = "zh-CN",
) {
+ const upperAirSignal = detail.vertical_profile_signal || {};
+ const upperAirMetrics = upperAirSignal.source
+ ? [
+ {
+ label: isEnglish(locale) ? "Convective suppression" : "对流压温风险",
+ 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)}。`
+ : isEnglish(locale)
+ ? "Derived from the next 48h upper-air profile."
+ : "根据未来 48 小时高空剖面估算。",
+ tone:
+ upperAirSignal.suppression_risk === "high"
+ ? "cold"
+ : upperAirSignal.suppression_risk === "low"
+ ? "warm"
+ : "",
+ value:
+ upperAirSignal.suppression_risk === "high"
+ ? isEnglish(locale)
+ ? "High"
+ : "高"
+ : upperAirSignal.suppression_risk === "medium"
+ ? isEnglish(locale)
+ ? "Medium"
+ : "中"
+ : isEnglish(locale)
+ ? "Low"
+ : "低",
+ },
+ {
+ label: isEnglish(locale) ? "Trigger setup" : "午后触发性",
+ 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)}。`
+ : isEnglish(locale)
+ ? "Uses instability and lifted-index structure."
+ : "结合不稳定能量与抬升指数判断。",
+ tone:
+ upperAirSignal.trigger_risk === "high"
+ ? "cold"
+ : upperAirSignal.trigger_risk === "low"
+ ? "warm"
+ : "",
+ value:
+ upperAirSignal.trigger_risk === "high"
+ ? isEnglish(locale)
+ ? "High"
+ : "高"
+ : upperAirSignal.trigger_risk === "medium"
+ ? isEnglish(locale)
+ ? "Medium"
+ : "中"
+ : isEnglish(locale)
+ ? "Low"
+ : "低",
+ },
+ {
+ label: isEnglish(locale) ? "Deep mixing" : "深层混合",
+ 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))} 米。`
+ : isEnglish(locale)
+ ? "Tracks daytime boundary-layer depth."
+ : "跟踪白天边界层混合深度。",
+ tone:
+ upperAirSignal.mixing_strength === "strong"
+ ? "warm"
+ : upperAirSignal.mixing_strength === "weak"
+ ? "cold"
+ : "",
+ value:
+ upperAirSignal.mixing_strength === "strong"
+ ? isEnglish(locale)
+ ? "Strong"
+ : "强"
+ : upperAirSignal.mixing_strength === "medium"
+ ? isEnglish(locale)
+ ? "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"
+ : "低",
+ },
+ ]
+ : [];
const backendSummary =
dateStr === detail.local_date
? String(detail.dynamic_commentary?.summary || "").trim()
@@ -602,6 +718,10 @@ export function computeFrontTrendSignal(
tone?: string;
value: string;
}>,
+ upperAirMetrics,
+ upperAirSummary: isEnglish(locale)
+ ? String(upperAirSignal.summary_en || "").trim()
+ : String(upperAirSignal.summary_zh || "").trim(),
precipMax: 0,
score: 0,
summary:
@@ -1083,6 +1203,10 @@ export function computeFrontTrendSignal(
confidence,
label,
metrics,
+ upperAirMetrics,
+ upperAirSummary: isEnglish(locale)
+ ? String(upperAirSignal.summary_en || "").trim()
+ : String(upperAirSignal.summary_zh || "").trim(),
precipMax,
score,
summary: backendSummary || summary,
diff --git a/src/data_collection/nws_open_meteo_sources.py b/src/data_collection/nws_open_meteo_sources.py
index 7d4e8cef..0657e549 100644
--- a/src/data_collection/nws_open_meteo_sources.py
+++ b/src/data_collection/nws_open_meteo_sources.py
@@ -187,7 +187,12 @@ class NwsOpenMeteoSourceMixin:
"latitude": lat,
"longitude": lon,
"current_weather": "true",
- "hourly": "temperature_2m,shortwave_radiation,dew_point_2m,pressure_msl,wind_speed_10m,wind_direction_10m,precipitation_probability,cloud_cover",
+ "hourly": (
+ "temperature_2m,shortwave_radiation,dew_point_2m,pressure_msl,"
+ "wind_speed_10m,wind_direction_10m,wind_speed_180m,wind_direction_180m,"
+ "precipitation_probability,cloud_cover,cape,convective_inhibition,"
+ "lifted_index,boundary_layer_height"
+ ),
"daily": "temperature_2m_max,apparent_temperature_max,sunrise,sunset,sunshine_duration",
"timezone": "auto",
"forecast_days": forecast_days,
diff --git a/web/analysis_service.py b/web/analysis_service.py
index d3bf5dbc..9021e375 100644
--- a/web/analysis_service.py
+++ b/web/analysis_service.py
@@ -24,6 +24,167 @@ from src.analysis.settlement_rounding import apply_city_settlement
from src.analysis.metar_narrator import describe_metar_report
from src.data_collection.city_registry import ALIASES
+def _wind_components(speed: Optional[float], direction: Optional[float]) -> tuple[Optional[float], Optional[float]]:
+ if speed is None or direction is None:
+ return None, None
+ try:
+ import math
+
+ rad = math.radians(float(direction))
+ spd = float(speed)
+ u = -spd * math.sin(rad)
+ v = -spd * math.cos(rad)
+ return u, v
+ except Exception:
+ return None, None
+
+
+def _build_vertical_profile_signal(
+ hourly_next_48h: Dict[str, list],
+ local_date: str,
+ local_hour: int,
+) -> Dict[str, Any]:
+ times = hourly_next_48h.get("times") or []
+ if not times:
+ return {}
+
+ preferred_start = max(local_hour, 12)
+ preferred_end = 19
+ candidate_indexes = [
+ index
+ for index, ts in enumerate(times)
+ if str(ts).startswith(local_date)
+ and preferred_start <= int(str(ts).split("T")[1][:2]) <= preferred_end
+ ]
+ if not candidate_indexes:
+ candidate_indexes = [
+ index
+ for index, ts in enumerate(times)
+ if str(ts).startswith(local_date)
+ ]
+ if not candidate_indexes:
+ return {}
+
+ def _series(name: str) -> list:
+ values = hourly_next_48h.get(name) or []
+ return [values[idx] if idx < len(values) else None for idx in candidate_indexes]
+
+ def _max_numeric(values: list) -> Optional[float]:
+ valid = [_sf(value) for value in values if _sf(value) is not None]
+ return max(valid) if valid else None
+
+ def _min_numeric(values: list) -> Optional[float]:
+ valid = [_sf(value) for value in values if _sf(value) is not None]
+ return min(valid) if valid else None
+
+ cape_max = _max_numeric(_series("cape"))
+ cin_min = _min_numeric(_series("convective_inhibition"))
+ lifted_index_min = _min_numeric(_series("lifted_index"))
+ boundary_layer_height_max = _max_numeric(_series("boundary_layer_height"))
+
+ shear_values: list[float] = []
+ speed_10m = hourly_next_48h.get("wind_speed_10m") or []
+ direction_10m = hourly_next_48h.get("wind_direction_10m") or []
+ speed_180m = hourly_next_48h.get("wind_speed_180m") or []
+ direction_180m = hourly_next_48h.get("wind_direction_180m") or []
+ for idx in candidate_indexes:
+ s10 = _sf(speed_10m[idx]) if idx < len(speed_10m) else None
+ d10 = _sf(direction_10m[idx]) if idx < len(direction_10m) else None
+ s180 = _sf(speed_180m[idx]) if idx < len(speed_180m) else None
+ d180 = _sf(direction_180m[idx]) if idx < len(direction_180m) else None
+ u10, v10 = _wind_components(s10, d10)
+ u180, v180 = _wind_components(s180, d180)
+ if None in (u10, v10, u180, v180):
+ continue
+ import math
+
+ shear_values.append(math.sqrt((u180 - u10) ** 2 + (v180 - v10) ** 2))
+ 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
+ ):
+ suppression_risk = "high"
+ elif (cape_max is not None and cape_max >= 300) or (
+ cin_min is not None and cin_min <= -20
+ ):
+ suppression_risk = "medium"
+
+ trigger_risk = "low"
+ if (
+ cape_max is not None
+ and cape_max >= 800
+ and lifted_index_min is not None
+ and lifted_index_min <= -2
+ ):
+ trigger_risk = "high"
+ elif (
+ cape_max is not None
+ and cape_max >= 250
+ and lifted_index_min is not None
+ and lifted_index_min <= 0
+ ):
+ trigger_risk = "medium"
+
+ mixing_strength = "weak"
+ if boundary_layer_height_max is not None and boundary_layer_height_max >= 1800:
+ mixing_strength = "strong"
+ elif boundary_layer_height_max is not None and boundary_layer_height_max >= 1000:
+ mixing_strength = "medium"
+
+ shear_risk = "low"
+ if shear_10m_180m_max is not None and shear_10m_180m_max >= 12:
+ shear_risk = "high"
+ elif shear_10m_180m_max is not None and shear_10m_180m_max >= 6:
+ shear_risk = "medium"
+
+ zh_parts = []
+ en_parts = []
+ if suppression_risk == "high":
+ zh_parts.append("午后对流压温风险偏高。")
+ en_parts.append("Afternoon convective suppression risk is elevated.")
+ elif suppression_risk == "medium":
+ zh_parts.append("存在一定云雨压温风险。")
+ en_parts.append("There is some cloud and shower suppression risk.")
+ 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.")
+ if shear_risk == "high":
+ zh_parts.append("高空风切变较强,午后结构波动可能加大。")
+ en_parts.append("Upper-level shear is relatively strong and may increase afternoon volatility.")
+ 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:
+ zh_parts.append("高空结构整体平稳,暂未看到明显压温信号。")
+ if not en_parts:
+ en_parts.append("The upper-air structure looks fairly stable, without a strong suppression signal yet.")
+
+ return {
+ "source": "open-meteo-gfs",
+ "window_start": times[candidate_indexes[0]] if candidate_indexes else None,
+ "window_end": times[candidate_indexes[-1]] if candidate_indexes else None,
+ "cape_max": cape_max,
+ "cin_min": cin_min,
+ "lifted_index_min": lifted_index_min,
+ "boundary_layer_height_max": boundary_layer_height_max,
+ "shear_10m_180m_max": shear_10m_180m_max,
+ "suppression_risk": suppression_risk,
+ "trigger_risk": trigger_risk,
+ "mixing_strength": mixing_strength,
+ "shear_risk": shear_risk,
+ "summary_zh": "".join(zh_parts),
+ "summary_en": " ".join(en_parts),
+ }
+
+
def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
"""Fetch, analyse, and return structured weather data for one city."""
# Check cache
@@ -301,8 +462,14 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
h_pressure = hourly.get("pressure_msl", [])
h_wspd = hourly.get("wind_speed_10m", [])
h_wdir = hourly.get("wind_direction_10m", [])
+ h_wspd_180m = hourly.get("wind_speed_180m", [])
+ h_wdir_180m = hourly.get("wind_direction_180m", [])
h_precip_prob = hourly.get("precipitation_probability", [])
h_cloud_cover = hourly.get("cloud_cover", [])
+ h_cape = hourly.get("cape", [])
+ h_cin = hourly.get("convective_inhibition", [])
+ h_lifted_index = hourly.get("lifted_index", [])
+ h_boundary_layer_height = hourly.get("boundary_layer_height", [])
if (not h_times or not h_temps) and metar:
metar_today_obs = metar.get("today_obs", []) or []
parsed_obs = []
@@ -324,8 +491,14 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
h_pressure = [None for _ in parsed_obs]
h_wspd = [None for _ in parsed_obs]
h_wdir = [None for _ in parsed_obs]
+ h_wspd_180m = [None for _ in parsed_obs]
+ h_wdir_180m = [None for _ in parsed_obs]
h_precip_prob = [None for _ in parsed_obs]
h_cloud_cover = [None for _ in parsed_obs]
+ h_cape = [None for _ in parsed_obs]
+ h_cin = [None for _ in parsed_obs]
+ h_lifted_index = [None for _ in parsed_obs]
+ h_boundary_layer_height = [None for _ in parsed_obs]
peak_hours = []
if h_times and h_temps and om_today is not None:
@@ -422,8 +595,14 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
"pressure_msl": [],
"wind_speed_10m": [],
"wind_direction_10m": [],
+ "wind_speed_180m": [],
+ "wind_direction_180m": [],
"precipitation_probability": [],
"cloud_cover": [],
+ "cape": [],
+ "convective_inhibition": [],
+ "lifted_index": [],
+ "boundary_layer_height": [],
}
try:
local_anchor = datetime.strptime(
@@ -454,12 +633,36 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
next_48h_hourly["wind_direction_10m"].append(
h_wdir[i] if i < len(h_wdir) else None
)
+ next_48h_hourly["wind_speed_180m"].append(
+ h_wspd_180m[i] if i < len(h_wspd_180m) else None
+ )
+ next_48h_hourly["wind_direction_180m"].append(
+ h_wdir_180m[i] if i < len(h_wdir_180m) else None
+ )
next_48h_hourly["precipitation_probability"].append(
h_precip_prob[i] if i < len(h_precip_prob) else None
)
next_48h_hourly["cloud_cover"].append(
h_cloud_cover[i] if i < len(h_cloud_cover) else None
)
+ next_48h_hourly["cape"].append(
+ h_cape[i] if i < len(h_cape) else None
+ )
+ next_48h_hourly["convective_inhibition"].append(
+ h_cin[i] if i < len(h_cin) else None
+ )
+ next_48h_hourly["lifted_index"].append(
+ h_lifted_index[i] if i < len(h_lifted_index) else None
+ )
+ next_48h_hourly["boundary_layer_height"].append(
+ h_boundary_layer_height[i] if i < len(h_boundary_layer_height) else None
+ )
+
+ vertical_profile_signal = _build_vertical_profile_signal(
+ next_48h_hourly,
+ local_date_str,
+ local_hour,
+ )
# ── 13. Cloud description (METAR primary, MGM fallback) ──
clouds = mc.get("clouds", [])
@@ -683,6 +886,7 @@ def _analyze(city: str, force_refresh: bool = False) -> Dict[str, Any]:
"dynamic_commentary": dynamic_commentary,
"hourly": today_hourly,
"hourly_next_48h": next_48h_hourly,
+ "vertical_profile_signal": vertical_profile_signal,
"metar_today_obs": metar_today_obs_payload,
"metar_recent_obs": metar_recent_obs_payload,
"settlement_today_obs": settlement_today_obs,
@@ -866,6 +1070,7 @@ def _build_city_detail_payload(
},
"probabilities": data.get("probabilities") or {"mu": None, "distribution": []},
"dynamic_commentary": data.get("dynamic_commentary") or {"summary": "", "notes": []},
+ "vertical_profile_signal": data.get("vertical_profile_signal") or {},
"market_scan": market_scan,
"risk": data.get("risk"),
"nearby_source": data.get("nearby_source") or ("mgm" if data.get("name") == "ankara" else "metar_cluster"),