dca4f2d618
- 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
312 lines
16 KiB
Python
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],
|
|
}
|