diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css
index bc2140d9..23487491 100644
--- a/frontend/components/dashboard/Dashboard.module.css
+++ b/frontend/components/dashboard/Dashboard.module.css
@@ -2921,6 +2921,97 @@
gap: 10px;
}
+.root :global(.future-v2-sync-strip-compact) {
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 8px;
+}
+
+.root :global(.future-v2-sync-strip-compact .future-v2-sync-chip) {
+ padding: 8px 10px;
+ border-radius: 8px;
+ background: rgba(15, 23, 42, 0.62);
+}
+
+.root :global(.future-v2-sync-strip-compact .future-v2-sync-copy span) {
+ display: none;
+}
+
+.root :global(.future-v2-meteorology-brief) {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(320px, 0.56fr);
+ gap: 14px;
+ padding: 16px;
+ border: 1px solid rgba(34, 211, 238, 0.22);
+ border-radius: 8px;
+ background:
+ linear-gradient(180deg, rgba(8, 47, 73, 0.42), rgba(15, 23, 42, 0.78));
+}
+
+.root :global(.future-v2-meteorology-copy) {
+ display: grid;
+ gap: 10px;
+ min-width: 0;
+}
+
+.root :global(.future-v2-meteorology-copy h3) {
+ margin: 0;
+ color: #f8fafc;
+ font-size: 18px;
+ font-weight: 800;
+ line-height: 1.35;
+ letter-spacing: 0;
+}
+
+.root :global(.future-v2-meteorology-meta) {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.root :global(.future-v2-meteorology-meta span) {
+ min-height: 26px;
+ display: inline-flex;
+ align-items: center;
+ padding: 5px 8px;
+ border: 1px solid rgba(148, 163, 184, 0.18);
+ border-radius: 8px;
+ background: rgba(15, 23, 42, 0.62);
+ color: #cbd5e1;
+ font-size: 12px;
+ line-height: 1.35;
+}
+
+.root :global(.future-v2-meteorology-paths) {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.root :global(.future-v2-meteorology-paths div) {
+ min-width: 0;
+ padding: 12px;
+ border: 1px solid rgba(34, 211, 238, 0.18);
+ border-radius: 8px;
+ background: rgba(2, 6, 23, 0.5);
+}
+
+.root :global(.future-v2-meteorology-paths span) {
+ display: block;
+ color: var(--text-muted);
+ font-size: 11px;
+ line-height: 1.3;
+}
+
+.root :global(.future-v2-meteorology-paths strong) {
+ display: block;
+ margin-top: 8px;
+ color: #67e8f9;
+ font-size: 22px;
+ font-weight: 800;
+ line-height: 1;
+ letter-spacing: 0;
+}
+
.root :global(.future-v2-sync-chip) {
display: flex;
align-items: flex-start;
@@ -3688,6 +3779,124 @@
min-height: 340px;
}
+.root :global(.future-v2-chart-thresholds) {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 12px;
+}
+
+.root :global(.future-v2-chart-thresholds span) {
+ min-height: 26px;
+ display: inline-flex;
+ align-items: center;
+ padding: 5px 8px;
+ border: 1px solid rgba(34, 211, 238, 0.16);
+ border-radius: 8px;
+ background: rgba(8, 47, 73, 0.24);
+ color: #cbd5e1;
+ font-size: 12px;
+ line-height: 1.35;
+}
+
+.root :global(.future-v2-meteorology-grid) {
+ display: grid;
+ grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.9fr) minmax(0, 0.9fr);
+ gap: 12px;
+}
+
+.root :global(.future-v2-evidence-list) {
+ display: grid;
+ gap: 8px;
+}
+
+.root :global(.future-v2-evidence-row) {
+ padding: 10px;
+ border: 1px solid rgba(148, 163, 184, 0.14);
+ border-left-width: 3px;
+ border-radius: 8px;
+ background: rgba(15, 23, 42, 0.5);
+}
+
+.root :global(.future-v2-evidence-row.cyan) {
+ border-left-color: #22d3ee;
+}
+
+.root :global(.future-v2-evidence-row.amber) {
+ border-left-color: #f59e0b;
+}
+
+.root :global(.future-v2-evidence-row.blue) {
+ border-left-color: #60a5fa;
+}
+
+.root :global(.future-v2-evidence-head) {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ align-items: flex-start;
+}
+
+.root :global(.future-v2-evidence-head strong) {
+ color: #f8fafc;
+ font-size: 13px;
+ line-height: 1.35;
+}
+
+.root :global(.future-v2-evidence-head span) {
+ color: var(--text-muted);
+ font-size: 11px;
+ line-height: 1.35;
+ text-align: right;
+}
+
+.root :global(.future-v2-evidence-row p) {
+ margin: 7px 0 0;
+ color: var(--text-secondary);
+ font-size: 12px;
+ line-height: 1.55;
+}
+
+.root :global(.future-v2-rule-list) {
+ display: grid;
+ gap: 8px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.root :global(.future-v2-rule-list li) {
+ position: relative;
+ padding-left: 16px;
+ color: var(--text-secondary);
+ font-size: 12px;
+ line-height: 1.55;
+}
+
+.root :global(.future-v2-rule-list li::before) {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0.65em;
+ width: 6px;
+ height: 6px;
+ border-radius: 999px;
+ background: #22d3ee;
+}
+
+.root :global(.future-v2-rule-panel:nth-child(2) .future-v2-rule-list li::before) {
+ background: #f59e0b;
+}
+
+.root :global(.future-v2-model-note) {
+ margin-top: 12px;
+ padding-top: 10px;
+ border-top: 1px solid rgba(148, 163, 184, 0.14);
+ color: var(--text-muted);
+ font-size: 12px;
+ line-height: 1.55;
+}
+
.root :global(.future-modal-section) {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border-subtle);
@@ -3909,6 +4118,15 @@
}
@media (max-width: 960px) {
+ .root :global(.future-v2-meteorology-brief),
+ .root :global(.future-v2-meteorology-grid) {
+ grid-template-columns: 1fr;
+ }
+
+ .root :global(.future-v2-meteorology-paths) {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
.root :global(.future-v2-layout) {
grid-template-columns: 1fr;
}
@@ -3934,6 +4152,18 @@
padding-right: 0;
}
+ .root :global(.future-v2-meteorology-brief) {
+ padding: 12px;
+ }
+
+ .root :global(.future-v2-meteorology-paths) {
+ grid-template-columns: 1fr;
+ }
+
+ .root :global(.future-v2-meteorology-copy h3) {
+ font-size: 16px;
+ }
+
.root :global(.future-v2-card),
.root :global(.future-modal-section) {
border-radius: 12px;
diff --git a/frontend/components/dashboard/FutureForecastModal.tsx b/frontend/components/dashboard/FutureForecastModal.tsx
index 7871011a..d062b164 100644
--- a/frontend/components/dashboard/FutureForecastModal.tsx
+++ b/frontend/components/dashboard/FutureForecastModal.tsx
@@ -31,6 +31,7 @@ import {
getTemperatureChartData,
getWeatherSummary,
} from "@/lib/dashboard-utils";
+import type { IntradayMeteorologySignal } from "@/lib/dashboard-types";
function normalizeMarketValue(value?: number | null) {
if (value == null) return null;
@@ -175,6 +176,35 @@ function parsePercentFromText(value?: string | number | null) {
return parseLeadingNumber(text);
}
+function formatConfidenceLabel(value?: string | null, locale = "zh-CN") {
+ const normalized = String(value || "").trim().toLowerCase();
+ if (normalized === "high") return locale === "en-US" ? "High" : "高";
+ if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
+ if (normalized === "low") return locale === "en-US" ? "Low" : "低";
+ return locale === "en-US" ? "Pending" : "待确认";
+}
+
+function formatSignalDirection(value?: string | null, locale = "zh-CN") {
+ const normalized = String(value || "").trim().toLowerCase();
+ if (normalized === "support") return locale === "en-US" ? "Support" : "支持升温";
+ if (normalized === "suppress") return locale === "en-US" ? "Suppress" : "压制峰值";
+ return locale === "en-US" ? "Neutral" : "中性";
+}
+
+function formatSignalStrength(value?: string | null, locale = "zh-CN") {
+ const normalized = String(value || "").trim().toLowerCase();
+ if (normalized === "strong") return locale === "en-US" ? "Strong" : "强";
+ if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
+ return locale === "en-US" ? "Weak" : "弱";
+}
+
+function signalTone(signal?: IntradayMeteorologySignal | null) {
+ const direction = String(signal?.direction || "").trim().toLowerCase();
+ if (direction === "support") return "cyan";
+ if (direction === "suppress") return "amber";
+ return "blue";
+}
+
function getTrendMetricVisual(metric: {
label?: string;
value?: string;
@@ -1081,6 +1111,58 @@ export function FutureForecastModal() {
}
return lines.slice(0, 3);
}, [boundaryRiskView, isToday, locale, localizedAiCommentaryLines, networkLeadView, paceView, showDeferredTodaySections]);
+ const intradayMeteorology = detail.intraday_meteorology || {};
+ const meteorologySignals = Array.isArray(intradayMeteorology.signal_contributions)
+ ? intradayMeteorology.signal_contributions
+ : [];
+ const invalidationRules = Array.isArray(intradayMeteorology.invalidation_rules)
+ ? intradayMeteorology.invalidation_rules
+ : [];
+ const confirmationRules = Array.isArray(intradayMeteorology.confirmation_rules)
+ ? intradayMeteorology.confirmation_rules
+ : [];
+ const meteorologyHeadline =
+ String(intradayMeteorology.headline || "").trim() ||
+ todayTradeSummaryLines[0] ||
+ (locale === "en-US"
+ ? "Intraday meteorology layers are still syncing; use the next observation as the anchor."
+ : "关键日内气象层仍在同步,先以下一次观测作为判断锚点。");
+ const baseCaseBucket =
+ String(intradayMeteorology.base_case_bucket || "").trim() ||
+ formatBucketLabel(topProbabilityBucket);
+ const nextObservationTime =
+ String(intradayMeteorology.next_observation_time || "").trim() || "--";
+ const baseBucketNumber = parseLeadingNumber(baseCaseBucket);
+ const referenceObservedTemp =
+ topObservedTemp != null && Number.isFinite(Number(topObservedTemp))
+ ? Number(topObservedTemp)
+ : detail.current?.temp != null
+ ? Number(detail.current.temp)
+ : null;
+ const gapToBaseBucket =
+ baseBucketNumber != null && referenceObservedTemp != null
+ ? Math.max(0, baseBucketNumber - referenceObservedTemp)
+ : null;
+ const pathStatus =
+ gapToBaseBucket == null
+ ? locale === "en-US"
+ ? "Awaiting anchor"
+ : "等待锚点"
+ : gapToBaseBucket <= 0.05
+ ? locale === "en-US"
+ ? "Base path touched"
+ : "基准路径已触达"
+ : gapToBaseBucket <= 1.0
+ ? locale === "en-US"
+ ? "Base path open"
+ : "基准路径开放"
+ : locale === "en-US"
+ ? "Needs peak push"
+ : "需要峰值推动";
+ const peakWindowText =
+ String(intradayMeteorology.peak_window || "").trim() ||
+ paceView?.peakWindowText ||
+ "--";
const syncStatusItems = [
{
key: "base",
@@ -1215,7 +1297,49 @@ export function FutureForecastModal() {
-
+ {isToday && (
+
+
+
+ {locale === "en-US" ? "Professional meteorology read" : "专业气象判断"}
+
+
{meteorologyHeadline}
+
+
+ {locale === "en-US" ? "Confidence" : "置信度"} ·{" "}
+ {formatConfidenceLabel(intradayMeteorology.confidence, locale)}
+
+
+ {locale === "en-US" ? "Peak window" : "峰值窗口"} · {peakWindowText}
+
+
+ {locale === "en-US" ? "Next observation" : "下一观测"} · {nextObservationTime}
+
+
+
+
+
+ {locale === "en-US" ? "Base case" : "基准路径"}
+ {baseCaseBucket || "--"}
+
+
+ {locale === "en-US" ? "Upside" : "上修路径"}
+ {intradayMeteorology.upside_bucket || "--"}
+
+
+ {locale === "en-US" ? "Downside" : "下修路径"}
+ {intradayMeteorology.downside_bucket || "--"}
+
+
+
+ )}
+
{syncStatusItems.map((item) => (
{detail.current?.obs_time || "--"}
+
+
+ {locale === "en-US" ? "Gap to base" : "距基准档"}
+
+
+ {gapToBaseBucket != null
+ ? `${gapToBaseBucket.toFixed(1)}${detail.temp_symbol}`
+ : "--"}
+
+
+
+
+ {locale === "en-US" ? "Path state" : "路径状态"}
+
+ {pathStatus}
+
{locale === "en-US" ? "Sunrise" : "日出时间"}
@@ -1484,16 +1624,103 @@ export function FutureForecastModal() {
+
+ {locale === "en-US" ? "Base" : "基准"} · {baseCaseBucket || "--"}
+ {locale === "en-US" ? "Upside" : "上修"} · {intradayMeteorology.upside_bucket || "--"}
+ {locale === "en-US" ? "Invalidates at" : "失效观察"} · {nextObservationTime}
+
+
+
+
+
+ {locale === "en-US" ? "Evidence chain" : "气象证据链"}
+
+
{locale === "en-US" ? "Signal Contributions" : "信号贡献"}
+
+
+ {meteorologySignals.length > 0 ? (
+ meteorologySignals.map((signal, index) => (
+
+
+ {signal.label || "--"}
+
+ {formatSignalDirection(signal.direction, locale)} ·{" "}
+ {formatSignalStrength(signal.strength, locale)}
+
+
+
{signal.summary || "--"}
+
+ ))
+ ) : (
+
+ {locale === "en-US"
+ ? "Meteorology signals are still loading."
+ : "气象信号仍在加载。"}
+
+ )}
+
+
+
+
+
+
+ {locale === "en-US" ? "Failure modes" : "失效条件"}
+
+
{locale === "en-US" ? "What Downgrades the Read" : "什么会让判断降级"}
+
+
+ {(invalidationRules.length > 0
+ ? invalidationRules
+ : [
+ locale === "en-US"
+ ? "If observations stop tracking the expected curve, wait for the next refresh."
+ : "若实测不再贴近预期曲线,等待下一次刷新确认。",
+ ]
+ ).map((rule, index) => (
+ - {rule}
+ ))}
+
+
+
+
+
+
+ {locale === "en-US" ? "Confirmation" : "确认条件"}
+
+
{locale === "en-US" ? "What Confirms the Path" : "什么会确认主路径"}
+
+
+ {(confirmationRules.length > 0
+ ? confirmationRules
+ : [
+ locale === "en-US"
+ ? "Keep watching the next settlement-source observation."
+ : "继续观察下一次结算源报文。",
+ ]
+ ).map((rule, index) => (
+ - {rule}
+ ))}
+
+ {modelSummary}
+
+
+
- {locale === "en-US" ? "Probability layer" : "概率层"}
+ {locale === "en-US" ? "Auxiliary probability" : "辅助概率"}
- {locale === "en-US" ? "Current Hit Odds" : "当前命中胜率"}
+ {locale === "en-US" ? "Model & Market Reference" : "模型与市场参考"}
@@ -1503,6 +1730,7 @@ export function FutureForecastModal() {
diff --git a/frontend/components/dashboard/PanelSections.tsx b/frontend/components/dashboard/PanelSections.tsx
index 256c3b5d..d54fcef3 100644
--- a/frontend/components/dashboard/PanelSections.tsx
+++ b/frontend/components/dashboard/PanelSections.tsx
@@ -485,12 +485,12 @@ export function ProbabilityDistribution({
const buyNoText = toPriceCents(bucket.no_buy);
const marketTag = buyYesText
? locale === "en-US"
- ? `Buy Yes: ${buyYesText}`
- : `买 Yes: ${buyYesText}`
+ ? `Market ref: ${buyYesText}`
+ : `市场参考: ${buyYesText}`
: buyNoText
? locale === "en-US"
- ? `Buy No: ${buyNoText}`
- : `买 No: ${buyNoText}`
+ ? `Market hedge: ${buyNoText}`
+ : `市场反向: ${buyNoText}`
: null;
return (
@@ -530,23 +530,23 @@ export function ProbabilityDistribution({
Math.abs(bucketTemp - marketBucketTemp) < 0.26;
const marketTag = isMarketBucket
? locale === "en-US"
- ? `Buy Yes: ${marketYesText || "--"}`
- : `买 Yes: ${marketYesText || "--"}`
+ ? `Market ref: ${marketYesText || "--"}`
+ : `市场参考: ${marketYesText || "--"}`
: marketNoText
? locale === "en-US"
- ? `Buy No: ${marketNoText}`
- : `买 No: ${marketNoText}`
+ ? `Market hedge: ${marketNoText}`
+ : `市场反向: ${marketNoText}`
: null;
const yesPriceText = toPriceCents(marketYesPrice);
const noPriceText = toPriceCents(marketNoPrice);
const marketTagFinal = isMarketBucket
? locale === "en-US"
- ? `Buy Yes: ${yesPriceText || "--"}`
- : `买 Yes: ${yesPriceText || "--"}`
+ ? `Market ref: ${yesPriceText || "--"}`
+ : `市场参考: ${yesPriceText || "--"}`
: noPriceText
? locale === "en-US"
- ? `Buy No: ${noPriceText}`
- : `买 No: ${noPriceText}`
+ ? `Market hedge: ${noPriceText}`
+ : `市场反向: ${noPriceText}`
: marketTag;
let bucketLabel =
bucket.label || `${bucket.value}${detail.temp_symbol}`;
diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx
index 3cccc13e..ee74b9f4 100644
--- a/frontend/hooks/useDashboardStore.tsx
+++ b/frontend/hooks/useDashboardStore.tsx
@@ -14,6 +14,10 @@ import {
toCitySummary,
} from "@/lib/dashboard-client";
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
+import {
+ getSupabaseBrowserClient,
+ hasSupabasePublicEnv,
+} from "@/lib/supabase/client";
import {
CityDetail,
CityListItem,
@@ -95,6 +99,36 @@ const SELECTED_CITY_STORAGE_KEY = "polyWeather_selected_city_v1";
const BACKGROUND_SUMMARY_REFRESH_MS = 30_000;
type CityDetailDepth = "panel" | "market" | "nearby" | "full";
+async function buildAuthMeHeaders(): Promise {
+ const headers: Record = {
+ Accept: "application/json",
+ };
+ if (!hasSupabasePublicEnv()) {
+ return headers;
+ }
+
+ try {
+ const {
+ data: { session: cachedSession },
+ } = await getSupabaseBrowserClient().auth.getSession();
+ let accessToken = String(cachedSession?.access_token || "").trim();
+ const expiresAtSec = Number(cachedSession?.expires_at || 0);
+ const nowSec = Math.floor(Date.now() / 1000);
+ if (!accessToken || (Number.isFinite(expiresAtSec) && expiresAtSec <= nowSec + 60)) {
+ const {
+ data: { session: refreshedSession },
+ } = await getSupabaseBrowserClient().auth.refreshSession();
+ accessToken = String(refreshedSession?.access_token || accessToken || "").trim();
+ }
+ if (accessToken) {
+ headers.Authorization = `Bearer ${accessToken}`;
+ }
+ } catch {
+ // The same-origin route can still fall back to cookie-backed auth.
+ }
+ return headers;
+}
+
function countAvailableModels(
detail?: CityDetail | null,
targetDate?: string | null,
@@ -563,11 +597,10 @@ export function DashboardStoreProvider({
error: null,
}));
try {
+ const headers = await buildAuthMeHeaders();
const response = await fetch("/api/auth/me", {
cache: "no-store",
- headers: {
- Accept: "application/json",
- },
+ headers,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
diff --git a/frontend/lib/dashboard-types.ts b/frontend/lib/dashboard-types.ts
index 13ac8776..30ecf1d4 100644
--- a/frontend/lib/dashboard-types.ts
+++ b/frontend/lib/dashboard-types.ts
@@ -316,6 +316,26 @@ export interface MarketScan {
websocket?: Record;
}
+export interface IntradayMeteorologySignal {
+ label?: string | null;
+ direction?: "support" | "suppress" | "neutral" | string | null;
+ strength?: "weak" | "medium" | "strong" | string | null;
+ summary?: string | null;
+}
+
+export interface IntradayMeteorology {
+ headline?: string | null;
+ confidence?: "low" | "medium" | "high" | string | null;
+ base_case_bucket?: string | null;
+ upside_bucket?: string | null;
+ downside_bucket?: string | null;
+ next_observation_time?: string | null;
+ peak_window?: string | null;
+ invalidation_rules?: string[] | null;
+ confirmation_rules?: string[] | null;
+ signal_contributions?: IntradayMeteorologySignal[] | null;
+}
+
export interface AiAnalysisStructured {
summary?: string | null;
text?: string | null;
@@ -473,6 +493,7 @@ export interface CityDetail {
multi_model_daily?: Record;
source_forecasts?: SourceForecasts;
market_scan?: MarketScan;
+ intraday_meteorology?: IntradayMeteorology;
}
export interface HistoryPoint {
diff --git a/tests/test_country_networks.py b/tests/test_country_networks.py
index cc6f79cf..4107dde9 100644
--- a/tests/test_country_networks.py
+++ b/tests/test_country_networks.py
@@ -1,6 +1,6 @@
from src.data_collection.country_networks import build_country_network_snapshot
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
-from web.analysis_service import _build_city_detail_payload
+from web.analysis_service import _build_city_detail_payload, _build_intraday_meteorology
def test_new_south_asia_city_registry_entries_are_wired():
@@ -198,3 +198,84 @@ def test_city_detail_payload_exposes_airport_and_official_network_layers():
assert payload["official"]["airport_primary"]["source_code"] == "metar"
assert payload["official"]["official_nearby"][0]["source_code"] == "mgm"
assert payload["settlement_station"]["settlement_station_code"] == "LTAC"
+
+
+def test_intraday_meteorology_supportive_heating_case():
+ payload = _build_intraday_meteorology(
+ {
+ "local_time": "12:04",
+ "temp_symbol": "°C",
+ "current": {"temp": 38.2, "max_so_far": 38.2},
+ "deb": {"prediction": 40.4},
+ "probabilities": {"distribution": [{"value": 40, "probability": 0.42}]},
+ "peak": {"first_h": 14, "last_h": 15, "status": "before"},
+ "deviation_monitor": {"direction": "hot", "severity": "strong", "current_delta": 1.9},
+ "vertical_profile_signal": {
+ "heating_setup": "supportive",
+ "summary_zh": "混合层偏深,仍支持午后继续升温。",
+ },
+ "taf": {"signal": {"available": True, "suppression_level": "low", "summary_zh": "TAF 暂未提示强云雨压温。"}},
+ }
+ )
+
+ assert "上修空间" in payload["headline"]
+ assert payload["confidence"] == "high"
+ assert payload["base_case_bucket"] == "40°C"
+ assert payload["next_observation_time"] == "12:30"
+ assert any(item["direction"] == "support" for item in payload["signal_contributions"])
+
+
+def test_intraday_meteorology_suppressed_cloud_rain_case():
+ payload = _build_intraday_meteorology(
+ {
+ "local_time": "13:10",
+ "temp_symbol": "°C",
+ "current": {"temp": 36.0, "max_so_far": 36.5},
+ "deb": {"prediction": 40.2},
+ "probabilities": {"distribution": [{"value": 40, "probability": 0.35}]},
+ "peak": {"first_h": 14, "last_h": 15, "status": "before"},
+ "deviation_monitor": {"direction": "cold", "severity": "strong", "current_delta": -2.0},
+ "vertical_profile_signal": {"heating_setup": "suppressed", "suppression_risk": "high"},
+ "taf": {"signal": {"available": True, "suppression_level": "high", "disruption_level": "high"}},
+ }
+ )
+
+ assert "压制" in payload["headline"]
+ assert payload["confidence"] == "high"
+ assert any("云雨" in rule for rule in payload["invalidation_rules"])
+ assert any(item["direction"] == "suppress" for item in payload["signal_contributions"])
+
+
+def test_intraday_meteorology_handles_sparse_observations():
+ payload = _build_intraday_meteorology(
+ {
+ "local_time": "08:00",
+ "temp_symbol": "°C",
+ "current": {},
+ "probabilities": {"distribution": []},
+ "peak": {},
+ "taf": {},
+ "vertical_profile_signal": {},
+ }
+ )
+
+ assert payload["confidence"] == "low"
+ assert payload["next_observation_time"] == "08:30"
+ assert payload["signal_contributions"][0]["label"] == "数据完整性"
+
+
+def test_intraday_meteorology_past_peak_case():
+ payload = _build_intraday_meteorology(
+ {
+ "local_time": "17:20",
+ "temp_symbol": "°C",
+ "current": {"temp": 33.0, "max_so_far": 39.0},
+ "probabilities": {"distribution": [{"value": 39, "probability": 0.5}]},
+ "peak": {"first_h": 13, "last_h": 15, "status": "past"},
+ "deviation_monitor": {"direction": "normal", "severity": "normal", "current_delta": 0.1},
+ }
+ )
+
+ assert "峰值窗口已过" in payload["headline"]
+ assert payload["base_case_bucket"] == "39°C"
+ assert any("最终高点" in rule for rule in payload["invalidation_rules"])
diff --git a/web/analysis_service.py b/web/analysis_service.py
index 81d9d417..b7e578a8 100644
--- a/web/analysis_service.py
+++ b/web/analysis_service.py
@@ -1118,6 +1118,296 @@ def _build_taf_signal(
}
+def _clock_minutes(value: Any) -> Optional[int]:
+ text = str(value or "").strip()
+ match = re.search(r"\b(\d{1,2}):(\d{2})\b", text)
+ if not match:
+ return None
+ hour = int(match.group(1))
+ minute = int(match.group(2))
+ if hour < 0 or hour > 23 or minute < 0 or minute > 59:
+ return None
+ return hour * 60 + minute
+
+
+def _format_clock_minutes(value: int) -> str:
+ value = max(0, min(23 * 60 + 59, int(value)))
+ return f"{value // 60:02d}:{value % 60:02d}"
+
+
+def _next_observation_clock(local_time: Any) -> str:
+ minutes = _clock_minutes(local_time)
+ if minutes is None:
+ return "--"
+ next_slot = ((minutes // 30) + 1) * 30
+ if next_slot > 23 * 60 + 59:
+ return "23:59"
+ return _format_clock_minutes(next_slot)
+
+
+def _bucket_label_from_value(value: Optional[float], unit: str) -> Optional[str]:
+ if value is None:
+ return None
+ try:
+ return f"{int(round(float(value)))}{unit or '°C'}"
+ except Exception:
+ return None
+
+
+def _top_probability_bucket(distribution: Any) -> Optional[Dict[str, Any]]:
+ if not isinstance(distribution, list):
+ return None
+ candidates = [row for row in distribution if isinstance(row, dict)]
+ if not candidates:
+ return None
+ return max(candidates, key=lambda row: _sf(row.get("probability")) or -1.0)
+
+
+def _bucket_label(row: Optional[Dict[str, Any]], unit: str) -> Optional[str]:
+ if not isinstance(row, dict):
+ return None
+ for key in ("label", "bucket", "range"):
+ raw = str(row.get(key) or "").strip()
+ if raw:
+ return raw
+ return _bucket_label_from_value(_sf(row.get("value")), unit)
+
+
+def _add_signal(
+ signals: list,
+ *,
+ label: str,
+ direction: str,
+ strength: str,
+ summary: str,
+) -> None:
+ signals.append(
+ {
+ "label": label,
+ "direction": direction,
+ "strength": strength,
+ "summary": summary,
+ }
+ )
+
+
+def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]:
+ """Build a paid-product intraday meteorology read from existing layers."""
+ current = data.get("current") or {}
+ probabilities = data.get("probabilities") or {}
+ distribution = probabilities.get("distribution") or []
+ top_bucket = _top_probability_bucket(distribution)
+ unit = str(data.get("temp_symbol") or "°C")
+ deb = data.get("deb") or {}
+ peak = data.get("peak") or {}
+ deviation = data.get("deviation_monitor") or {}
+ taf_signal = (
+ ((data.get("taf") or {}).get("signal") or {})
+ if isinstance(data.get("taf"), dict)
+ else {}
+ )
+ vertical = data.get("vertical_profile_signal") or {}
+
+ current_temp = _sf(current.get("temp"))
+ max_so_far = _sf(current.get("max_so_far"))
+ deb_prediction = _sf(deb.get("prediction"))
+ base_value = _sf(top_bucket.get("value")) if isinstance(top_bucket, dict) else None
+ if base_value is None:
+ base_value = deb_prediction
+ if base_value is None:
+ base_value = max_so_far if max_so_far is not None else current_temp
+
+ base_case_bucket = _bucket_label(top_bucket, unit) or _bucket_label_from_value(base_value, unit)
+ upside_bucket = _bucket_label_from_value(base_value + 1.0, unit) if base_value is not None else None
+ downside_bucket = _bucket_label_from_value(base_value - 1.0, unit) if base_value is not None else None
+
+ signals: list = []
+ support_score = 0
+ suppress_score = 0
+ available_layers = 0
+
+ direction = str(deviation.get("direction") or "").lower()
+ severity = str(deviation.get("severity") or "normal").lower()
+ delta = _sf(deviation.get("current_delta"))
+ if direction:
+ available_layers += 1
+ strength = "strong" if severity == "strong" else ("medium" if severity == "light" else "weak")
+ if direction == "hot":
+ support_score += 2 if strength == "strong" else 1
+ _add_signal(
+ signals,
+ label="日内节奏",
+ direction="support",
+ strength=strength,
+ summary=f"实测较预期路径偏高 {abs(delta or 0):.1f}{unit},峰值仍有上修空间。",
+ )
+ elif direction == "cold":
+ suppress_score += 2 if strength == "strong" else 1
+ _add_signal(
+ signals,
+ label="日内节奏",
+ direction="suppress",
+ strength=strength,
+ summary=f"实测较预期路径偏低 {abs(delta or 0):.1f}{unit},追更高温档需要等待后续观测确认。",
+ )
+ else:
+ _add_signal(
+ signals,
+ label="日内节奏",
+ direction="neutral",
+ strength="weak",
+ summary="实测大体贴近当前预期路径,下一步主要看峰值窗口内是否继续抬升。",
+ )
+
+ heating_setup = str(vertical.get("heating_setup") or "").lower()
+ suppression_risk = str(vertical.get("suppression_risk") or "").lower()
+ if heating_setup or suppression_risk:
+ available_layers += 1
+ if heating_setup == "supportive":
+ support_score += 2
+ _add_signal(
+ signals,
+ label="边界层结构",
+ direction="support",
+ strength="strong",
+ summary=str(vertical.get("summary_zh") or "边界层结构支持白天继续混合升温。"),
+ )
+ elif heating_setup == "suppressed" or suppression_risk == "high":
+ suppress_score += 2
+ _add_signal(
+ signals,
+ label="边界层结构",
+ direction="suppress",
+ strength="strong",
+ summary=str(vertical.get("summary_zh") or "边界层或云雨结构对午后峰值形成压制。"),
+ )
+ else:
+ _add_signal(
+ signals,
+ label="边界层结构",
+ direction="neutral",
+ strength="medium",
+ summary=str(vertical.get("summary_zh") or "边界层结构暂未给出单边信号。"),
+ )
+
+ taf_suppression = str(taf_signal.get("suppression_level") or "").lower()
+ taf_disruption = str(taf_signal.get("disruption_level") or "").lower()
+ if taf_signal.get("available") or taf_suppression:
+ available_layers += 1
+ if taf_suppression == "high" or taf_disruption == "high":
+ suppress_score += 2
+ direction_value = "suppress"
+ strength = "strong"
+ elif taf_suppression == "medium" or taf_disruption == "medium":
+ suppress_score += 1
+ direction_value = "suppress"
+ strength = "medium"
+ else:
+ support_score += 1
+ direction_value = "support"
+ strength = "weak"
+ _add_signal(
+ signals,
+ label="TAF 云雨扰动",
+ direction=direction_value,
+ strength=strength,
+ summary=str(taf_signal.get("summary_zh") or "TAF 暂未提示强云雨压温信号。"),
+ )
+
+ airport_delta = _sf(data.get("airport_vs_network_delta"))
+ lead_signal = data.get("network_lead_signal") or {}
+ if airport_delta is not None:
+ available_layers += 1
+ leader = str(lead_signal.get("leader_station_label") or lead_signal.get("leader_station_code") or "").strip()
+ if airport_delta <= -0.4:
+ support_score += 1
+ _add_signal(
+ signals,
+ label="站网对比",
+ direction="support",
+ strength="medium",
+ summary=f"周边站网较机场锚点偏热 {abs(airport_delta):.1f}{unit}{f',领先点位 {leader}' if leader else ''}。",
+ )
+ elif airport_delta >= 0.4:
+ suppress_score += 1
+ _add_signal(
+ signals,
+ label="站网对比",
+ direction="suppress",
+ strength="medium",
+ summary=f"机场锚点较周边站网偏热 {abs(airport_delta):.1f}{unit},继续上修需要机场自身后续报文确认。",
+ )
+ else:
+ _add_signal(
+ signals,
+ label="站网对比",
+ direction="neutral",
+ strength="weak",
+ summary="机场锚点与周边站网基本同步,暂不构成单独上修或下修理由。",
+ )
+
+ peak_status = str(peak.get("status") or "").lower()
+ first_h = _sf(peak.get("first_h"))
+ last_h = _sf(peak.get("last_h"))
+ peak_window = (
+ f"{int(first_h):02d}:00-{int(last_h):02d}:59"
+ if first_h is not None and last_h is not None
+ else "--"
+ )
+ if peak_status == "past":
+ headline = "峰值窗口已过,后续更偏向确认最终高点而非继续上修。"
+ confidence = "high" if available_layers >= 2 else "medium"
+ elif suppress_score >= support_score + 2:
+ headline = "峰值存在云雨或结构压制,当前更偏防守高温上修。"
+ confidence = "high" if available_layers >= 3 else "medium"
+ elif support_score >= suppress_score + 2:
+ headline = "峰值仍有上修空间,后续重点看峰值窗口内报文能否继续抬升。"
+ confidence = "high" if available_layers >= 3 else "medium"
+ elif available_layers == 0:
+ headline = "关键日内层仍在补齐,先以观测锚点和下一次报文为主。"
+ confidence = "low"
+ else:
+ headline = "当前处于分歧判断区,峰值窗口内的下一组观测将决定方向。"
+ confidence = "medium" if available_layers >= 2 else "low"
+
+ next_observation = _next_observation_clock(data.get("local_time") or current.get("obs_time"))
+ threshold = base_value
+ invalidation_rules = []
+ confirmation_rules = []
+ if peak_status == "past":
+ invalidation_rules.append("若后续官方结算源补录更高值,以结算源最终高点为准。")
+ confirmation_rules.append("若峰值窗口后连续两次观测不再创新高,当前高点基本确认。")
+ else:
+ watch_clock = _format_clock_minutes(int(first_h or 13) * 60 + 30)
+ if threshold is not None:
+ invalidation_rules.append(f"{watch_clock} 前若仍未接近 {threshold:.0f}{unit},上修路径降级。")
+ confirmation_rules.append(f"峰值窗口内任一结算源观测触达或超过 {threshold:.0f}{unit},基准路径确认度上升。")
+ invalidation_rules.append("若 TAF 或实况报文出现阵雨、雷暴或低云/云雨压制,高温上沿需要下调。")
+ confirmation_rules.append("若实测继续贴近 DEB 曲线且云雨信号不增强,维持当前主路径。")
+
+ if not signals:
+ _add_signal(
+ signals,
+ label="数据完整性",
+ direction="neutral",
+ strength="weak",
+ summary="当前缺少足够的日内结构层,等待下一次观测刷新后再提高判断权重。",
+ )
+
+ return {
+ "headline": headline,
+ "confidence": confidence,
+ "base_case_bucket": base_case_bucket,
+ "upside_bucket": upside_bucket,
+ "downside_bucket": downside_bucket,
+ "next_observation_time": next_observation,
+ "peak_window": peak_window,
+ "invalidation_rules": invalidation_rules[:4],
+ "confirmation_rules": confirmation_rules[:3],
+ "signal_contributions": signals[:5],
+ }
+
+
def _analyze(
city: str,
force_refresh: bool = False,
@@ -1983,6 +2273,7 @@ def _analyze(
"ai_analysis": "",
"updated_at": datetime.now(timezone.utc).isoformat(),
}
+ result["intraday_meteorology"] = _build_intraday_meteorology(result)
if include_llm_commentary:
result["dynamic_commentary"] = _maybe_enrich_dynamic_commentary_with_groq(
@@ -2483,6 +2774,8 @@ def _build_city_detail_payload(
},
"probabilities": data.get("probabilities") or {"mu": None, "distribution": []},
"dynamic_commentary": data.get("dynamic_commentary") or {"summary": "", "notes": []},
+ "intraday_meteorology": data.get("intraday_meteorology")
+ or _build_intraday_meteorology(data),
"vertical_profile_signal": data.get("vertical_profile_signal") or {},
"taf": data.get("taf") or {},
"market_scan": market_scan,