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,