Files
PolyWeather/web/services/intraday_meteorology.py
2569718930@qq.com dca4f2d618 架构重构:拆分 core/ops/DBManager,统一 SQLite 锁,DEB 改进,新增注意力模型
- web/core.py 858→236行,拆出 schemas/middleware/auth/diagnostics
- ops_api.py 2876→4个 domain 模块 (users/payments/health/config)
- DBManager Supabase HTTP 调用提取到 SupabaseAdminClient
- 新增 LockedSQLiteConnection 统一多进程读写锁
- 新增 WeatherCacheManager 替代 12 个独立缓存字典
- METAR 缓存迁移至统一缓存管理器
- analysis_service 提取 _build_intraday_meteorology 到独立模块
- DEB 改进:偏差惩罚、分歧回退、自适应 lookback (MAE ↓12.6%)
- 新增 PyTorch 注意力模型 deb_attention.py (数据积累后启用)
- 新增 torch 到 requirements.lock
2026-06-16 02:00:22 +08:00

312 lines
16 KiB
Python

"""Intraday meteorology — paid-product signal layer.
Reads existing analysis layers (current, probabilities, DEB, peak, deviation,
TAF, vertical profile, station network) and produces a structured meteorology
read with headline, confidence, signals, and invalidation/confirmation rules.
"""
from __future__ import annotations
from typing import Any, Dict
from web.services.analysis_utils import (
add_signal as _add_signal,
bucket_label as _bucket_label,
bucket_label_from_value as _bucket_label_from_value,
format_clock_minutes as _format_clock_minutes,
next_observation_clock as _next_observation_clock,
top_probability_bucket as _top_probability_bucket,
)
def _sf(v: Any) -> Any:
if v is None:
return None
try:
return float(v)
except Exception:
return None
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="日内节奏",
label_en="Intraday pace",
direction="support",
strength=strength,
summary=f"实测较预期路径偏高 {abs(delta or 0):.1f}{unit},峰值仍有上修空间。",
summary_en=f"Observed temperature is running {abs(delta or 0):.1f}{unit} above the expected path; the peak still has upside room.",
)
elif direction == "cold":
suppress_score += 2 if strength == "strong" else 1
_add_signal(
signals,
label="日内节奏",
label_en="Intraday pace",
direction="suppress",
strength=strength,
summary=f"实测较预期路径偏低 {abs(delta or 0):.1f}{unit},追更高温档需要等待后续观测确认。",
summary_en=f"Observed temperature is running {abs(delta or 0):.1f}{unit} below the expected path; higher buckets need confirmation from later observations.",
)
else:
_add_signal(
signals,
label="日内节奏",
label_en="Intraday pace",
direction="neutral",
strength="weak",
summary="实测大体贴近当前预期路径,下一步主要看峰值窗口内是否继续抬升。",
summary_en="Observed temperature is broadly tracking the expected path; the next question is whether it keeps lifting through the peak window.",
)
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="边界层结构",
label_en="Boundary-layer setup",
direction="support",
strength="strong",
summary=str(vertical.get("summary_zh") or "边界层结构支持白天继续混合升温。"),
summary_en=str(vertical.get("summary_en") or "The boundary-layer setup supports continued daytime mixing and warming."),
)
elif heating_setup == "suppressed" or suppression_risk == "high":
suppress_score += 2
_add_signal(
signals,
label="边界层结构",
label_en="Boundary-layer setup",
direction="suppress",
strength="strong",
summary=str(vertical.get("summary_zh") or "边界层或云雨结构对午后峰值形成压制。"),
summary_en=str(vertical.get("summary_en") or "Boundary-layer or cloud/rain structure is capping the afternoon peak."),
)
else:
_add_signal(
signals,
label="边界层结构",
label_en="Boundary-layer setup",
direction="neutral",
strength="medium",
summary=str(vertical.get("summary_zh") or "边界层结构暂未给出单边信号。"),
summary_en=str(vertical.get("summary_en") or "The boundary-layer setup does not yet provide a one-sided signal."),
)
taf_suppression = str(taf_signal.get("suppression_level") or "").lower()
taf_disruption = str(taf_signal.get("disruption_level") or "").lower()
taf_has_cloud_rain_cap = taf_suppression in {"medium", "high"} or taf_disruption in {
"medium",
"high",
}
structural_cap = False
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 云雨扰动",
label_en="TAF cloud/rain disruption",
direction=direction_value,
strength=strength,
summary=str(taf_signal.get("summary_zh") or "TAF 暂未提示强云雨压温信号。"),
summary_en=str(taf_signal.get("summary_en") or "TAF does not yet flag a strong cloud/rain temperature cap."),
)
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()
sync_status = str(lead_signal.get("leader_sync_status") or "").strip().lower()
sync_delta = _sf(lead_signal.get("leader_time_delta_vs_anchor_minutes"))
sync_suffix_zh = ""
sync_suffix_en = ""
if sync_status in {"near_realtime", "lagged"} and sync_delta is not None:
sync_suffix_zh = f";但与机场锚点约差 {sync_delta:.0f} 分钟,作为降权信号处理"
sync_suffix_en = f"; timing differs from the airport anchor by about {sync_delta:.0f} minutes, so this signal is down-weighted"
elif sync_status == "unknown":
sync_suffix_zh = ";周边站观测时间不可完全校验,作为弱参考"
sync_suffix_en = "; station timing is not fully verified, so this is treated as a weak reference"
if airport_delta <= -0.4:
support_score += 1
_add_signal(
signals,
label="站网对比",
label_en="Station-network comparison",
direction="support",
strength="weak" if sync_suffix_zh else "medium",
summary=f"周边站网较机场锚点偏热 {abs(airport_delta):.1f}{unit}{f',领先点位 {leader}' if leader else ''}{sync_suffix_zh}。",
summary_en=f"Nearby stations are {abs(airport_delta):.1f}{unit} warmer than the airport anchor{f'; leading site: {leader}' if leader else ''}{sync_suffix_en}.",
)
elif airport_delta >= 0.4:
suppress_score += 1
_add_signal(
signals,
label="站网对比",
label_en="Station-network comparison",
direction="suppress",
strength="weak" if sync_suffix_zh else "medium",
summary=f"机场锚点较周边站网偏热 {abs(airport_delta):.1f}{unit},继续上修需要机场自身后续报文确认{sync_suffix_zh}。",
summary_en=f"The airport anchor is {abs(airport_delta):.1f}{unit} warmer than nearby stations; further upside needs confirmation from later airport reports{sync_suffix_en}.",
)
else:
_add_signal(
signals,
label="站网对比",
label_en="Station-network comparison",
direction="neutral",
strength="weak",
summary="机场锚点与周边站网基本同步,暂不构成单独上修或下修理由。",
summary_en="The airport anchor and nearby station network are broadly aligned, so this layer does not independently argue for upside or downside.",
)
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 = "峰值窗口已过,后续更偏向确认最终高点而非继续上修。"
headline_en = "The peak window has passed; the read now shifts toward confirming the final high rather than chasing further upside."
confidence = "high" if available_layers >= 2 else "medium"
elif suppress_score >= support_score + 2:
structural_cap = any(
signal.get("direction") == "suppress"
and signal.get("label") in {"边界层结构", "站网对比", "日内节奏"}
for signal in signals
)
if taf_has_cloud_rain_cap and structural_cap:
headline = "峰值同时存在 TAF 云雨扰动和结构压制,当前更偏防守高温上修。"
headline_en = "Both TAF cloud/rain disruption and structural signals are capping the peak; defend against aggressive high-temperature upside for now."
elif taf_has_cloud_rain_cap:
headline = "TAF 提示峰值窗口有云雨扰动,当前更偏防守高温上修。"
headline_en = "TAF flags cloud/rain disruption near the peak window; defend against aggressive high-temperature upside for now."
else:
headline = "峰值主要受结构信号压制,TAF 云雨层暂未构成主压温理由。"
headline_en = "The peak is mainly capped by structural signals; TAF cloud/rain is not the primary suppression reason for now."
confidence = "high" if available_layers >= 3 else "medium"
elif support_score >= suppress_score + 2:
headline = "峰值仍有上修空间,后续重点看峰值窗口内报文能否继续抬升。"
headline_en = "The peak still has upside room; the next check is whether reports keep lifting through the peak window."
confidence = "high" if available_layers >= 3 else "medium"
elif available_layers == 0:
headline = "关键日内层仍在补齐,先以观测锚点和下一次报文为主。"
headline_en = "Key intraday layers are still filling in; anchor the read on observations and the next report."
confidence = "low"
else:
headline = "当前处于分歧判断区,峰值窗口内的下一组观测将决定方向。"
headline_en = "The setup is in a split-decision zone; the next observations inside the peak window should decide direction."
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 = []
invalidation_rules_en = []
confirmation_rules = []
confirmation_rules_en = []
if peak_status == "past":
invalidation_rules.append("若后续官方结算源补录更高值,以结算源最终高点为准。")
invalidation_rules_en.append("If the official settlement source later backfills a higher reading, defer to the final settlement-source high.")
confirmation_rules.append("若峰值窗口后连续两次观测不再创新高,当前高点基本确认。")
confirmation_rules_en.append("If two consecutive post-peak observations fail to make a new high, the current high is broadly confirmed.")
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},上修路径降级。")
invalidation_rules_en.append(f"If observations are still not near {threshold:.0f}{unit} before {watch_clock}, downgrade the upside path.")
confirmation_rules.append(f"峰值窗口内任一结算源观测触达或超过 {threshold:.0f}{unit},基准路径确认度上升。")
confirmation_rules_en.append(f"If any settlement-source observation reaches or exceeds {threshold:.0f}{unit} inside the peak window, confidence in the base path rises.")
invalidation_rules.append("若 TAF 或实况报文出现阵雨、雷暴或低云/云雨压制,高温上沿需要下调。")
invalidation_rules_en.append("If TAF or live reports show showers, thunderstorms, or low-cloud/cloud-rain suppression, lower the upper temperature bound.")
confirmation_rules.append("若实测继续贴近 DEB 曲线且云雨信号不增强,维持当前主路径。")
confirmation_rules_en.append("If observations keep tracking the DEB curve and cloud/rain signals do not strengthen, maintain the current main path.")
if not signals:
_add_signal(
signals,
label="数据完整性",
label_en="Data completeness",
direction="neutral",
strength="weak",
summary="当前缺少足够的日内结构层,等待下一次观测刷新后再提高判断权重。",
summary_en="There are not enough intraday structure layers yet; wait for the next observation refresh before raising confidence.",
)
return {
"headline": headline,
"headline_en": headline_en,
"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],
"invalidation_rules_en": invalidation_rules_en[:4],
"confirmation_rules": confirmation_rules[:3],
"confirmation_rules_en": confirmation_rules_en[:3],
"signal_contributions": signals[:5],
}