2026-03-20 20:59:30 +08:00
from __future__ import annotations
2026-04-08 11:41:34 +08:00
import hashlib
import json
import os
2026-03-24 02:58:29 +08:00
import re
2026-03-20 20:59:30 +08:00
import time as _time
2026-04-08 06:53:49 +08:00
import threading
2026-04-11 20:11:01 +08:00
from concurrent.futures import ThreadPoolExecutor
2026-03-20 20:59:30 +08:00
from datetime import datetime , timezone , timedelta
from typing import Dict , Any , Optional
2026-04-08 11:41:34 +08:00
import httpx
2026-03-20 20:59:30 +08:00
from fastapi import HTTPException
from loguru import logger
from web.core import (
_cache ,
CACHE_TTL ,
CACHE_TTL_ANKARA ,
2026-05-11 17:28:53 +08:00
CACHE_TTL_KOREAN_AMOS ,
2026-03-20 20:59:30 +08:00
CITIES ,
CITY_RISK_PROFILES ,
SETTLEMENT_SOURCE_LABELS ,
_is_excluded_model_name ,
_market_layer ,
_sf ,
_weather ,
)
from src.analysis.deb_algorithm import calculate_dynamic_weights
from src.analysis.settlement_rounding import apply_city_settlement
2026-04-06 07:21:59 +08:00
from src.data_collection.country_networks import build_country_network_snapshot
2026-04-11 20:11:01 +08:00
from src.data_collection.city_registry import ALIASES , CITY_REGISTRY
2026-04-19 15:37:13 +08:00
from src.data_collection.city_time import get_city_utc_offset_seconds
2026-04-16 15:29:26 +08:00
from src.data_collection.nmc_sources import NMC_CITY_REFERENCES
2026-05-01 11:27:16 +08:00
from src.database.runtime_state import IntradayPathSnapshotRepository
2026-03-29 23:29:17 +08:00
from src.models.lgbm_daily_high import predict_lgbm_daily_high
2026-03-30 19:03:13 +08:00
TURKISH_MGM_CITIES = { "ankara" , "istanbul" }
2026-04-08 06:53:49 +08:00
_ANALYSIS_CACHE_STATS_LOCK = threading . Lock ()
_ANALYSIS_CACHE_STATS : Dict [ str , Any ] = {
"total_requests" : 0 ,
"cache_hits" : 0 ,
"cache_misses" : 0 ,
"force_refresh_requests" : 0 ,
"last_cache_hit_at" : None ,
"last_cache_miss_at" : None ,
"last_city" : None ,
}
2026-04-11 20:11:01 +08:00
_SUMMARY_CACHE_LOCK = threading . Lock ()
_SUMMARY_CACHE : Dict [ str , Dict [ str , Any ]] = {}
2026-04-08 11:41:34 +08:00
_GROQ_COMMENTARY_CACHE_LOCK = threading . Lock ()
_GROQ_COMMENTARY_CACHE : Dict [ str , Dict [ str , Any ]] = {}
_GROQ_COMMENTARY_CACHE_TTL_SEC = int (
os . getenv ( "POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC" , "1800" )
)
2026-04-08 06:53:49 +08:00
2026-04-26 08:12:31 +08:00
def _dedupe_forecast_daily ( rows : Any ) -> list [ Dict [ str , Any ]]:
if not isinstance ( rows , list ):
return []
seen = set ()
out = []
for row in rows :
if not isinstance ( row , dict ):
continue
date = str ( row . get ( "date" ) or "" ) . strip ()
if not date or date in seen :
continue
seen . add ( date )
out . append ( row )
return out
2026-04-16 15:29:26 +08:00
def _format_observation_time_local ( value : Any , utc_offset : int ) -> str :
raw = str ( value or "" ) . strip ()
if not raw :
return ""
if "T" in raw :
try :
dt = datetime . fromisoformat ( raw . replace ( "Z" , "+00:00" ))
if dt . tzinfo is None :
dt = dt . replace ( tzinfo = timezone . utc )
return dt . astimezone ( timezone ( timedelta ( seconds = utc_offset ))) . strftime ( "%H:%M" )
except Exception :
pass
match = re . search ( r "(\d{1,2}):(\d {2} )" , raw )
if match :
return f " { int ( match . group ( 1 )) : 02d } : { match . group ( 2 ) } "
return raw [: 16 ]
def _fetch_nmc_current_fallback ( city : str , * , use_fahrenheit : bool ) -> Dict [ str , Any ]:
city_key = str ( city or "" ) . strip () . lower ()
if city_key not in NMC_CITY_REFERENCES :
return {}
try :
payload = _weather . fetch_nmc_region_current (
city_key ,
use_fahrenheit = use_fahrenheit ,
)
return payload if isinstance ( payload , dict ) else {}
except Exception as exc :
logger . debug ( "NMC current fallback failed city= {} : {} " , city_key , exc )
return {}
2026-04-16 17:34:39 +08:00
def _is_plausible_city_temp ( city : str , value : Any , unit : str = "°C" ) -> bool :
temp = _sf ( value )
if temp is None :
return False
meta = CITY_REGISTRY . get ( str ( city or "" ) . strip () . lower (), {}) or {}
min_c = _sf ( meta . get ( "min_plausible_metar_temp_c" ))
if min_c is None :
return True
min_value = min_c * 9 / 5 + 32 if str ( unit or "" ) . upper () . endswith ( "F" ) else min_c
return temp >= min_value
2026-04-21 19:13:33 +08:00
def _parse_utc_datetime ( value : Any ) -> Optional [ datetime ]:
raw = str ( value or "" ) . strip ()
if not raw or "T" not in raw :
return None
try :
dt = datetime . fromisoformat ( raw . replace ( "Z" , "+00:00" ))
except Exception :
return None
if dt . tzinfo is None :
dt = dt . replace ( tzinfo = timezone . utc )
return dt . astimezone ( timezone . utc )
def _metar_is_current_local_day (
metar : Dict [ str , Any ],
* ,
local_date : str ,
utc_offset : int ,
) -> bool :
if not isinstance ( metar , dict ) or not metar :
return False
if metar . get ( "stale_for_today" ) is True :
return False
observation_local_date = str ( metar . get ( "observation_local_date" ) or "" ) . strip ()
if observation_local_date :
return observation_local_date == local_date
obs_dt = _parse_utc_datetime ( metar . get ( "observation_time" ))
if obs_dt is None :
return True
local_dt = obs_dt . astimezone ( timezone ( timedelta ( seconds = utc_offset )))
return local_dt . strftime ( "%Y-%m- %d " ) == local_date
2026-04-08 06:53:49 +08:00
def _record_analysis_cache_event ( * , city : str , hit : bool , force_refresh : bool ) -> None :
now = datetime . now ( timezone . utc ) . isoformat ()
with _ANALYSIS_CACHE_STATS_LOCK :
_ANALYSIS_CACHE_STATS [ "total_requests" ] = int ( _ANALYSIS_CACHE_STATS . get ( "total_requests" ) or 0 ) + 1
_ANALYSIS_CACHE_STATS [ "last_city" ] = str ( city or "" )
if force_refresh :
_ANALYSIS_CACHE_STATS [ "force_refresh_requests" ] = int ( _ANALYSIS_CACHE_STATS . get ( "force_refresh_requests" ) or 0 ) + 1
if hit :
_ANALYSIS_CACHE_STATS [ "cache_hits" ] = int ( _ANALYSIS_CACHE_STATS . get ( "cache_hits" ) or 0 ) + 1
_ANALYSIS_CACHE_STATS [ "last_cache_hit_at" ] = now
else :
_ANALYSIS_CACHE_STATS [ "cache_misses" ] = int ( _ANALYSIS_CACHE_STATS . get ( "cache_misses" ) or 0 ) + 1
_ANALYSIS_CACHE_STATS [ "last_cache_miss_at" ] = now
def get_analysis_cache_stats () -> Dict [ str , Any ]:
with _ANALYSIS_CACHE_STATS_LOCK :
stats = dict ( _ANALYSIS_CACHE_STATS )
hits = int ( stats . get ( "cache_hits" ) or 0 )
misses = int ( stats . get ( "cache_misses" ) or 0 )
eligible = hits + misses
hit_rate = ( hits / eligible ) if eligible > 0 else None
miss_rate = ( misses / eligible ) if eligible > 0 else None
stats [ "hit_rate" ] = round ( hit_rate , 4 ) if hit_rate is not None else None
stats [ "miss_rate" ] = round ( miss_rate , 4 ) if miss_rate is not None else None
return stats
2026-03-30 19:03:13 +08:00
2026-03-29 23:29:17 +08:00
2026-05-11 17:28:53 +08:00
KOREAN_AMOS_CITIES = { "seoul" , "busan" }
2026-04-11 20:11:01 +08:00
def _analysis_ttl_for_city ( city : str ) -> int :
2026-05-11 17:28:53 +08:00
city_lower = city . lower ()
if city_lower in TURKISH_MGM_CITIES :
return CACHE_TTL_ANKARA
if city_lower in KOREAN_AMOS_CITIES :
return CACHE_TTL_KOREAN_AMOS
return CACHE_TTL
2026-04-11 20:11:01 +08:00
2026-04-12 10:42:00 +08:00
def _analysis_cache_key ( city : str , detail_mode : str = "full" ) -> str :
2026-04-12 20:49:51 +08:00
normalized_raw = str ( detail_mode or "" ) . strip () . lower ()
if normalized_raw == "panel" :
normalized_mode = "panel"
2026-04-13 18:42:27 +08:00
elif normalized_raw == "market" :
normalized_mode = "market"
2026-04-12 20:49:51 +08:00
elif normalized_raw == "nearby" :
normalized_mode = "nearby"
else :
normalized_mode = "full"
2026-04-12 10:42:00 +08:00
return f " { city } :: { normalized_mode } "
def _get_cached_analysis (
city : str ,
ttl : int ,
2026-04-13 18:42:27 +08:00
detail_modes : tuple [ str , ... ] = ( "panel" , "market" , "nearby" , "full" ),
2026-04-12 10:42:00 +08:00
) -> Optional [ Dict [ str , Any ]]:
now_ts = _time . time ()
freshest_payload : Optional [ Dict [ str , Any ]] = None
freshest_ts = 0.0
for detail_mode in detail_modes :
cached = _cache . get ( _analysis_cache_key ( city , detail_mode ))
if not cached :
continue
cached_ts = float ( cached . get ( "t" , 0 ))
payload = cached . get ( "d" )
if (
cached_ts
and now_ts - cached_ts < ttl
and isinstance ( payload , dict )
and cached_ts >= freshest_ts
):
freshest_payload = payload
freshest_ts = cached_ts
return freshest_payload
2026-04-11 20:11:01 +08:00
def _get_cached_summary ( city : str , ttl : int ) -> Optional [ Dict [ str , Any ]]:
now_ts = _time . time ()
with _SUMMARY_CACHE_LOCK :
cached = _SUMMARY_CACHE . get ( city )
if cached and now_ts - float ( cached . get ( "t" , 0 )) < ttl :
payload = cached . get ( "d" )
if isinstance ( payload , dict ):
return dict ( payload )
return None
def _set_cached_summary ( city : str , payload : Dict [ str , Any ]) -> None :
with _SUMMARY_CACHE_LOCK :
_SUMMARY_CACHE [ city ] = { "t" : _time . time (), "d" : dict ( payload )}
2026-04-08 11:41:34 +08:00
def _groq_commentary_enabled () -> bool :
enabled = str (
os . getenv ( "POLYWEATHER_GROQ_COMMENTARY_ENABLED" , "false" )
) . strip () . lower ()
api_key = str ( os . getenv ( "GROQ_API_KEY" ) or "" ) . strip ()
return enabled in { "1" , "true" , "yes" , "on" } and bool ( api_key )
def _clean_commentary_text ( value : Any , * , limit : int = 240 ) -> str :
text = str ( value or "" ) . strip ()
if not text :
return ""
text = re . sub ( r "\s+" , " " , text )
return text [: limit ] . strip ()
def _build_groq_commentary_context ( result : Dict [ str , Any ]) -> Dict [ str , Any ]:
dynamic = result . get ( "dynamic_commentary" ) or {}
vertical = result . get ( "vertical_profile_signal" ) or {}
taf_signal = (( result . get ( "taf" ) or {}) . get ( "signal" ) or {}) if isinstance ( result . get ( "taf" ), dict ) else {}
network = result . get ( "network_lead_signal" ) or {}
peak = result . get ( "peak" ) or {}
current = result . get ( "current" ) or {}
airport_primary = result . get ( "airport_primary" ) or {}
notes = dynamic . get ( "notes" ) if isinstance ( dynamic . get ( "notes" ), list ) else []
compact_notes = [ _clean_commentary_text ( item , limit = 180 ) for item in notes ]
compact_notes = [ item for item in compact_notes if item ][: 4 ]
return {
"city" : result . get ( "display_name" ) or result . get ( "name" ),
"local_date" : result . get ( "local_date" ),
"local_time" : result . get ( "local_time" ),
"temp_symbol" : result . get ( "temp_symbol" ),
"current_temp" : current . get ( "temp" ),
"day_high_so_far" : current . get ( "max_so_far" ),
"airport_anchor_temp" : airport_primary . get ( "temp" ),
"airport_vs_network_delta" : result . get ( "airport_vs_network_delta" ),
"peak_hours" : peak . get ( "hours" ) or [],
"peak_status" : peak . get ( "status" ),
"network_lead_status" : network . get ( "status" ),
"network_lead_note" : _clean_commentary_text ( network . get ( "note" ), limit = 180 ),
"rules_summary" : _clean_commentary_text ( dynamic . get ( "summary" ), limit = 260 ),
"rules_notes" : compact_notes ,
"upper_air_summary_zh" : _clean_commentary_text ( vertical . get ( "summary_zh" ), limit = 260 ),
"upper_air_summary_en" : _clean_commentary_text ( vertical . get ( "summary_en" ), limit = 260 ),
"taf_summary_zh" : _clean_commentary_text ( taf_signal . get ( "summary_zh" ), limit = 220 ),
"taf_summary_en" : _clean_commentary_text ( taf_signal . get ( "summary_en" ), limit = 220 ),
"taf_peak_window" : _clean_commentary_text ( taf_signal . get ( "peak_window" ), limit = 80 ),
}
def _normalize_groq_commentary_payload ( payload : Dict [ str , Any ]) -> Dict [ str , Any ]:
def _headline ( value : Any , fallback : str ) -> str :
text = _clean_commentary_text ( value , limit = 90 )
return text or fallback
def _bullets ( value : Any ) -> list [ str ]:
items = value if isinstance ( value , list ) else []
cleaned = [ _clean_commentary_text ( item , limit = 120 ) for item in items ]
cleaned = [ item for item in cleaned if item ]
return cleaned [: 3 ]
zh_headline = _headline ( payload . get ( "headline_zh" ), "结构信号以现有规则结论为主。" )
en_headline = _headline ( payload . get ( "headline_en" ), "Structural read stays anchored to the existing rule-based signal." )
zh_bullets = _bullets ( payload . get ( "bullets_zh" ))
en_bullets = _bullets ( payload . get ( "bullets_en" ))
while len ( zh_bullets ) < 3 :
zh_bullets . append ( "继续结合当前节奏、边界风险和峰值窗口判断。" )
while len ( en_bullets ) < 3 :
en_bullets . append ( "Keep the read anchored to pace, boundary risk, and the peak window." )
return {
"headline_zh" : zh_headline ,
"headline_en" : en_headline ,
"bullets_zh" : zh_bullets [: 3 ],
"bullets_en" : en_bullets [: 3 ],
"source" : "groq" ,
}
def _request_groq_commentary ( context : Dict [ str , Any ]) -> Optional [ Dict [ str , Any ]]:
api_key = str ( os . getenv ( "GROQ_API_KEY" ) or "" ) . strip ()
if not api_key :
return None
model = str ( os . getenv ( "POLYWEATHER_GROQ_COMMENTARY_MODEL" ) or "openai/gpt-oss-20b" ) . strip ()
timeout_sec = float ( os . getenv ( "POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC" , "8" ))
payload = {
"model" : model ,
"temperature" : 0.2 ,
"max_tokens" : 400 ,
"messages" : [
{
"role" : "system" ,
"content" : (
"You rewrite weather-market structure commentary. "
"Never invent facts. Use only the provided context. "
"Return concise bilingual output for a dashboard: "
"one headline and exactly three bullets in Chinese, and the same in English. "
"Keep every bullet actionable and short."
),
},
{
"role" : "user" ,
"content" : json . dumps ( context , ensure_ascii = False ),
},
],
"response_format" : {
"type" : "json_schema" ,
"json_schema" : {
"name" : "polyweather_structure_commentary" ,
"strict" : True ,
"schema" : {
"type" : "object" ,
"additionalProperties" : False ,
"properties" : {
"headline_zh" : { "type" : "string" },
"bullets_zh" : {
"type" : "array" ,
"items" : { "type" : "string" },
"minItems" : 3 ,
"maxItems" : 3 ,
},
"headline_en" : { "type" : "string" },
"bullets_en" : {
"type" : "array" ,
"items" : { "type" : "string" },
"minItems" : 3 ,
"maxItems" : 3 ,
},
},
"required" : [
"headline_zh" ,
"bullets_zh" ,
"headline_en" ,
"bullets_en" ,
],
},
},
},
}
with httpx . Client ( timeout = timeout_sec ) as client :
response = client . post (
"https://api.groq.com/openai/v1/chat/completions" ,
headers = {
"Authorization" : f "Bearer { api_key } " ,
"Content-Type" : "application/json" ,
},
json = payload ,
)
response . raise_for_status ()
body = response . json ()
content = (
((( body . get ( "choices" ) or [{}])[ 0 ]) . get ( "message" ) or {}) . get ( "content" )
if isinstance ( body , dict )
else None
)
if not content :
return None
try :
return _normalize_groq_commentary_payload ( json . loads ( str ( content )))
except Exception :
logger . warning ( "Groq commentary returned non-JSON payload" )
return None
def _maybe_enrich_dynamic_commentary_with_groq (
city : str ,
result : Dict [ str , Any ],
) -> Dict [ str , Any ]:
dynamic = result . get ( "dynamic_commentary" ) or {}
if not _groq_commentary_enabled ():
return dynamic
if dynamic . get ( "headline_zh" ) and dynamic . get ( "bullets_zh" ):
return dynamic
context = _build_groq_commentary_context ( result )
if not context . get ( "rules_summary" ) and not context . get ( "rules_notes" ):
return dynamic
cache_key = hashlib . sha256 (
json . dumps ({ "city" : city , "context" : context }, sort_keys = True , ensure_ascii = False ) . encode ( "utf-8" )
) . hexdigest ()
now = _time . time ()
with _GROQ_COMMENTARY_CACHE_LOCK :
cached = _GROQ_COMMENTARY_CACHE . get ( cache_key )
if cached and now - float ( cached . get ( "t" ) or 0 ) < _GROQ_COMMENTARY_CACHE_TTL_SEC :
merged = dict ( dynamic )
merged . update ( cached . get ( "payload" ) or {})
return merged
try :
enriched = _request_groq_commentary ( context )
except Exception as exc :
logger . warning ( "Groq commentary skipped for {} : {} " , city , exc )
return dynamic
if not enriched :
return dynamic
with _GROQ_COMMENTARY_CACHE_LOCK :
_GROQ_COMMENTARY_CACHE [ cache_key ] = { "t" : now , "payload" : enriched }
merged = dict ( dynamic )
merged . update ( enriched )
return merged
2026-03-29 23:29:17 +08:00
def _interpolate_hourly_value (
times : list ,
values : list ,
local_date : str ,
target_hour_frac : float ,
) -> Optional [ float ]:
points = []
for ts , raw_value in zip ( times or [], values or []):
if not str ( ts ) . startswith ( local_date ):
continue
value = _sf ( raw_value )
if value is None :
continue
try :
hh_mm = str ( ts ) . split ( "T" )[ 1 ]
hour = int ( hh_mm [: 2 ])
minute = int ( hh_mm [ 3 : 5 ]) if len ( hh_mm ) >= 5 else 0
except Exception :
continue
points . append (( hour + minute / 60.0 , value ))
if not points :
return None
points . sort ( key = lambda item : item [ 0 ])
if target_hour_frac <= points [ 0 ][ 0 ]:
return float ( points [ 0 ][ 1 ])
if target_hour_frac >= points [ - 1 ][ 0 ]:
return float ( points [ - 1 ][ 1 ])
for idx in range ( 1 , len ( points )):
left_hour , left_value = points [ idx - 1 ]
right_hour , right_value = points [ idx ]
if target_hour_frac > right_hour :
continue
if right_hour == left_hour :
return float ( right_value )
ratio = ( target_hour_frac - left_hour ) / ( right_hour - left_hour )
return float ( left_value + ( right_value - left_value ) * ratio )
return float ( points [ - 1 ][ 1 ])
def _build_deviation_monitor (
* ,
current_temp : Optional [ float ],
deb_prediction : Optional [ float ],
om_today : Optional [ float ],
hourly_times : list ,
hourly_temps : list ,
local_date : str ,
local_hour_frac : float ,
observation_points : list ,
) -> Dict [ str , Any ]:
if current_temp is None or deb_prediction is None or om_today is None :
return {}
offset = _sf ( deb_prediction ) - _sf ( om_today )
if offset is None :
return {}
expected_now = _interpolate_hourly_value (
hourly_times ,
[( _sf ( value ) + offset ) if _sf ( value ) is not None else None for value in hourly_temps ],
local_date ,
local_hour_frac ,
)
if expected_now is None :
return {}
delta = float ( current_temp ) - expected_now
abs_delta = abs ( delta )
if abs_delta < 0.8 :
direction = "normal"
severity = "normal"
elif delta <= - 1.8 :
direction = "cold"
severity = "strong"
elif delta >= 1.8 :
direction = "hot"
severity = "strong"
elif delta < 0 :
direction = "cold"
severity = "light"
else :
direction = "hot"
severity = "light"
deviation_series = []
for item in observation_points or []:
if not isinstance ( item , dict ):
continue
obs_temp = _sf ( item . get ( "temp" ))
raw_time = str ( item . get ( "time" ) or "" ) . strip ()
if obs_temp is None :
continue
match = re . search ( r "(\d{1,2}):(\d {2} )" , raw_time )
if not match :
continue
obs_hour_frac = int ( match . group ( 1 )) + int ( match . group ( 2 )) / 60.0
ref_temp = _interpolate_hourly_value (
hourly_times ,
[( _sf ( value ) + offset ) if _sf ( value ) is not None else None for value in hourly_temps ],
local_date ,
obs_hour_frac ,
)
if ref_temp is None :
continue
deviation_series . append ( float ( obs_temp ) - ref_temp )
trend = "stable"
if len ( deviation_series ) >= 2 :
latest = deviation_series [ - 1 ]
previous = deviation_series [ - 2 ]
if latest * previous > 0 :
if abs ( latest ) - abs ( previous ) >= 0.3 :
trend = "expanding"
elif abs ( previous ) - abs ( latest ) >= 0.3 :
trend = "contracting"
if direction == "normal" :
label_zh = f "正常 ± { abs_delta : .1f } °C"
label_en = f "Normal ± { abs_delta : .1f } °C"
elif direction == "cold" :
label_zh = f "偏冷 { delta : .1f } °C"
label_en = f "Cool bias { delta : .1f } °C"
else :
label_zh = f "偏热 + { abs_delta : .1f } °C"
label_en = f "Warm bias + { abs_delta : .1f } °C"
trend_zh = {
"contracting" : "收敛中" ,
"expanding" : "扩大中" ,
"stable" : "稳定" ,
} . get ( trend , "稳定" )
trend_en = {
"contracting" : "contracting" ,
"expanding" : "expanding" ,
"stable" : "stable" ,
} . get ( trend , "stable" )
return {
"available" : True ,
"current_delta" : round ( delta , 1 ),
"reference_temp" : round ( expected_now , 1 ),
"direction" : direction ,
"severity" : severity ,
"trend" : trend ,
"label_zh" : label_zh ,
"label_en" : label_en ,
"trend_label_zh" : trend_zh ,
"trend_label_en" : trend_en ,
}
2026-03-20 20:59:30 +08:00
2026-03-24 01:28:45 +08:00
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 ,
2026-03-24 02:07:15 +08:00
first_peak_h : int ,
last_peak_h : int ,
2026-03-24 01:28:45 +08:00
) -> Dict [ str , Any ]:
times = hourly_next_48h . get ( "times" ) or []
if not times :
return {}
2026-03-24 02:07:15 +08:00
preferred_start = max ( local_hour , max ( 0 , first_peak_h - 2 ))
preferred_end = min ( 23 , last_peak_h + 1 )
2026-03-24 01:28:45 +08:00
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
2026-03-24 01:49:00 +08:00
def _level_label ( level : str , locale : str ) -> str :
mapping = {
"high" : { "zh" : "高" , "en" : "high" },
"medium" : { "zh" : "中" , "en" : "medium" },
"low" : { "zh" : "低" , "en" : "low" },
"strong" : { "zh" : "强" , "en" : "strong" },
"weak" : { "zh" : "弱" , "en" : "weak" },
}
return mapping . get ( level , {}) . get ( locale , level )
2026-03-24 01:28:45 +08:00
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"
2026-03-24 01:49:00 +08:00
if ( cape_max is not None and cape_max >= 700 ) or (
cin_min is not None and cin_min <= - 50
2026-03-24 01:28:45 +08:00
):
suppression_risk = "high"
2026-03-24 01:49:00 +08:00
elif ( cape_max is not None and cape_max >= 150 ) or (
cin_min is not None and cin_min <= - 15
2026-03-24 01:28:45 +08:00
):
suppression_risk = "medium"
trigger_risk = "low"
if (
cape_max is not None
2026-03-24 01:49:00 +08:00
and cape_max >= 550
2026-03-24 01:28:45 +08:00
and lifted_index_min is not None
2026-03-24 01:49:00 +08:00
and lifted_index_min <= - 1.5
2026-03-24 01:28:45 +08:00
):
trigger_risk = "high"
elif (
cape_max is not None
2026-03-24 01:49:00 +08:00
and cape_max >= 120
2026-03-24 01:28:45 +08:00
and lifted_index_min is not None
2026-03-24 01:49:00 +08:00
and lifted_index_min <= 0.5
2026-03-24 01:28:45 +08:00
):
trigger_risk = "medium"
mixing_strength = "weak"
2026-03-24 01:49:00 +08:00
if boundary_layer_height_max is not None and boundary_layer_height_max >= 1400 :
2026-03-24 01:28:45 +08:00
mixing_strength = "strong"
2026-03-24 01:49:00 +08:00
elif boundary_layer_height_max is not None and boundary_layer_height_max >= 700 :
2026-03-24 01:28:45 +08:00
mixing_strength = "medium"
shear_risk = "low"
2026-03-24 01:49:00 +08:00
if shear_10m_180m_max is not None and shear_10m_180m_max >= 8 :
2026-03-24 01:28:45 +08:00
shear_risk = "high"
2026-03-24 01:49:00 +08:00
elif shear_10m_180m_max is not None and shear_10m_180m_max >= 4 :
2026-03-24 01:28:45 +08:00
shear_risk = "medium"
2026-03-24 01:49:00 +08:00
heating_setup = "neutral"
heating_score = 0
if suppression_risk == "high" :
heating_score -= 2
elif suppression_risk == "medium" :
heating_score -= 1
if trigger_risk == "high" :
heating_score -= 2
elif trigger_risk == "medium" :
heating_score -= 1
if mixing_strength == "strong" :
heating_score += 2
elif mixing_strength == "medium" :
heating_score += 1
else :
heating_score -= 1
if shear_risk == "high" :
heating_score -= 1
if heating_score >= 2 :
heating_setup = "supportive"
elif heating_score <= - 2 :
heating_setup = "suppressed"
has_profile_data = any (
value is not None
for value in (
cape_max ,
cin_min ,
lifted_index_min ,
boundary_layer_height_max ,
shear_10m_180m_max ,
)
)
2026-03-24 01:28:45 +08:00
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." )
2026-03-24 01:49:00 +08:00
elif has_profile_data :
zh_parts . append ( "高空对流压温风险暂时不高。" )
en_parts . append ( "Upper-air suppression risk remains limited for now." )
2026-03-24 01:28:45 +08:00
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." )
2026-03-24 01:49:00 +08:00
elif has_profile_data :
zh_parts . append ( "边界层混合偏浅。" )
en_parts . append ( "Boundary-layer mixing remains shallow." )
2026-03-24 01:28:45 +08:00
if shear_risk == "high" :
zh_parts . append ( "高空风切变较强,午后结构波动可能加大。" )
en_parts . append ( "Upper-level shear is relatively strong and may increase afternoon volatility." )
2026-03-24 01:49:00 +08:00
elif shear_risk == "medium" :
zh_parts . append ( "高空风切变有一定存在感。" )
en_parts . append ( "Upper-level shear is noticeable." )
elif has_profile_data :
zh_parts . append ( "高空风切变扰动有限。" )
en_parts . append ( "Upper-level shear disruption remains limited." )
2026-03-24 01:28:45 +08:00
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." )
2026-03-24 01:49:00 +08:00
elif has_profile_data :
zh_parts . append ( "午后触发条件偏弱。" )
en_parts . append ( "Afternoon trigger potential remains weak." )
if not has_profile_data :
zh_parts . append ( "高空剖面字段暂缺,当前仅保留基础默认信号。" )
en_parts . append ( "Upper-air profile fields are currently unavailable, so only a fallback signal is shown." )
elif not zh_parts :
2026-03-24 01:28:45 +08:00
zh_parts . append ( "高空结构整体平稳,暂未看到明显压温信号。" )
if not en_parts :
en_parts . append ( "The upper-air structure looks fairly stable, without a strong suppression signal yet." )
2026-03-24 01:49:00 +08:00
if has_profile_data :
summary_tokens_zh = []
summary_tokens_en = []
window_start = str ( times [ candidate_indexes [ 0 ]]) . split ( "T" )[ 1 ][: 5 ]
window_end = str ( times [ candidate_indexes [ - 1 ]]) . split ( "T" )[ 1 ][: 5 ]
zh_parts . append ( f "判断窗口: { window_start } - { window_end } 。" )
en_parts . append ( f "Signal window: { window_start } - { window_end } ." )
if cape_max is not None :
summary_tokens_zh . append ( f "CAPE≈ { round ( cape_max ) } " )
summary_tokens_en . append ( f "CAPE≈ { round ( cape_max ) } " )
if cin_min is not None :
summary_tokens_zh . append ( f "CIN≈ { round ( cin_min ) } " )
summary_tokens_en . append ( f "CIN≈ { round ( cin_min ) } " )
if boundary_layer_height_max is not None :
summary_tokens_zh . append ( f "混合层≈ { round ( boundary_layer_height_max ) } m" )
summary_tokens_en . append ( f "mixing≈ { round ( boundary_layer_height_max ) } m" )
if shear_10m_180m_max is not None :
summary_tokens_zh . append ( f "切变≈ { shear_10m_180m_max : .1f } " )
summary_tokens_en . append ( f "shear≈ { shear_10m_180m_max : .1f } " )
zh_parts . append (
f "压温 { _level_label ( suppression_risk , 'zh' ) } 、触发 { _level_label ( trigger_risk , 'zh' ) } 、混合 { _level_label ( mixing_strength , 'zh' ) } 、切变 { _level_label ( shear_risk , 'zh' ) } 。"
)
en_parts . append (
f "Suppression { _level_label ( suppression_risk , 'en' ) } , trigger { _level_label ( trigger_risk , 'en' ) } , mixing { _level_label ( mixing_strength , 'en' ) } , shear { _level_label ( shear_risk , 'en' ) } ."
)
if heating_setup == "supportive" :
zh_parts . append ( "整体更偏向支持白天冲高。" )
en_parts . append ( "Overall, the profile is more supportive of daytime heating." )
elif heating_setup == "suppressed" :
zh_parts . append ( "整体更偏向抑制午后冲高。" )
en_parts . append ( "Overall, the profile leans more toward suppressing the afternoon peak." )
else :
zh_parts . append ( "整体更像中性环境,仍需结合地面信号。" )
en_parts . append ( "Overall, the profile looks fairly neutral and still needs surface confirmation." )
if summary_tokens_zh :
zh_parts . append ( " / " . join ( summary_tokens_zh ) + "。" )
if summary_tokens_en :
en_parts . append ( " / " . join ( summary_tokens_en ) + "." )
2026-03-24 01:28:45 +08:00
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 ,
2026-03-24 01:49:00 +08:00
"heating_setup" : heating_setup ,
"heating_score" : heating_score ,
2026-03-24 01:28:45 +08:00
"summary_zh" : "" . join ( zh_parts ),
"summary_en" : " " . join ( en_parts ),
}
2026-03-24 02:58:29 +08:00
def _build_taf_signal (
taf_data : Dict [ str , Any ],
city : str ,
2026-03-24 03:13:54 +08:00
local_date : str ,
utc_offset : int ,
2026-03-24 02:58:29 +08:00
first_peak_h : int ,
last_peak_h : int ,
) -> Dict [ str , Any ]:
if str ( city or "" ) . strip () . lower () == "hong kong" :
return {}
2026-03-24 03:13:54 +08:00
raw_taf = re . sub ( r "\s+" , " " , str (( taf_data or {}) . get ( "raw_taf" ) or "" ) . upper () . strip ())
2026-03-24 02:58:29 +08:00
if not raw_taf :
return {}
2026-03-24 03:13:54 +08:00
issue_raw = str (( taf_data or {}) . get ( "issue_time" ) or "" ) . strip ()
issue_dt = None
if issue_raw :
2026-03-24 02:58:29 +08:00
try :
2026-03-24 03:13:54 +08:00
issue_dt = datetime . fromisoformat ( issue_raw . replace ( "Z" , "+00:00" ))
2026-03-24 02:58:29 +08:00
except Exception :
2026-03-24 03:13:54 +08:00
issue_dt = None
if issue_dt is None :
issue_dt = datetime . now ( timezone . utc )
local_tz = timezone ( timedelta ( seconds = int ( utc_offset or 0 )))
valid_match = re . search ( r "\b(\d {2} )(\d {2} )/(\d {2} )(\d {2} )\b" , raw_taf )
tokens = raw_taf . split ()
if not valid_match :
return {}
def _infer_utc ( day : int , hour : int , minute : int = 0 ) -> datetime :
base = issue_dt
year = base . year
month = base . month
2026-03-24 03:27:50 +08:00
day_offset = 0
normalized_hour = hour
if normalized_hour >= 24 :
day_offset += normalized_hour // 24
normalized_hour = normalized_hour % 24
candidate = datetime (
year ,
month ,
day ,
normalized_hour ,
minute ,
tzinfo = timezone . utc ,
)
if day_offset :
candidate += timedelta ( days = day_offset )
2026-03-24 03:13:54 +08:00
if candidate < base - timedelta ( days = 20 ):
if month == 12 :
2026-03-24 03:27:50 +08:00
candidate = datetime (
year + 1 ,
1 ,
day ,
normalized_hour ,
minute ,
tzinfo = timezone . utc ,
) + timedelta ( days = day_offset )
2026-03-24 03:13:54 +08:00
else :
2026-03-24 03:27:50 +08:00
candidate = datetime (
year ,
month + 1 ,
day ,
normalized_hour ,
minute ,
tzinfo = timezone . utc ,
) + timedelta ( days = day_offset )
2026-03-24 03:13:54 +08:00
elif candidate > base + timedelta ( days = 20 ):
if month == 1 :
2026-03-24 03:27:50 +08:00
candidate = datetime (
year - 1 ,
12 ,
day ,
normalized_hour ,
minute ,
tzinfo = timezone . utc ,
) + timedelta ( days = day_offset )
2026-03-24 03:13:54 +08:00
else :
2026-03-24 03:27:50 +08:00
candidate = datetime (
year ,
month - 1 ,
day ,
normalized_hour ,
minute ,
tzinfo = timezone . utc ,
) + timedelta ( days = day_offset )
2026-03-24 03:13:54 +08:00
return candidate
def _parse_period ( token : str ) -> tuple [ Optional [ datetime ], Optional [ datetime ]]:
match = re . match ( r "^(\d {2} )(\d {2} )/(\d {2} )(\d {2} )$" , token )
if not match :
return None , None
start = _infer_utc ( int ( match . group ( 1 )), int ( match . group ( 2 )))
end = _infer_utc ( int ( match . group ( 3 )), int ( match . group ( 4 )))
if end <= start :
end += timedelta ( days = 1 )
return start , end
valid_start_utc , valid_end_utc = _parse_period ( valid_match . group ( 0 ))
if valid_start_utc is None or valid_end_utc is None :
return {}
segment_indexes : list [ int ] = []
for idx , token in enumerate ( tokens ):
if re . match ( r "^FM\d {6} $" , token ) or token in { "TEMPO" , "BECMG" , "PROB30" , "PROB40" }:
segment_indexes . append ( idx )
base_start_idx = 0
for idx , token in enumerate ( tokens ):
if token == valid_match . group ( 0 ):
base_start_idx = idx + 1
break
segments : list [ Dict [ str , Any ]] = []
first_segment_idx = segment_indexes [ 0 ] if segment_indexes else len ( tokens )
if base_start_idx < first_segment_idx :
segments . append (
{
"type" : "BASE" ,
"start_utc" : valid_start_utc ,
"end_utc" : valid_end_utc ,
"tokens" : tokens [ base_start_idx : first_segment_idx ],
}
)
idx_pos = 0
while idx_pos < len ( segment_indexes ):
start_idx = segment_indexes [ idx_pos ]
end_idx = segment_indexes [ idx_pos + 1 ] if idx_pos + 1 < len ( segment_indexes ) else len ( tokens )
token = tokens [ start_idx ]
seg_type = token
seg_start = valid_start_utc
seg_end = valid_end_utc
payload_start = start_idx + 1
if re . match ( r "^FM(\d {2} )(\d {2} )(\d {2} )$" , token ):
match = re . match ( r "^FM(\d {2} )(\d {2} )(\d {2} )$" , token )
seg_type = "FM"
seg_start = _infer_utc ( int ( match . group ( 1 )), int ( match . group ( 2 )), int ( match . group ( 3 )))
if idx_pos + 1 < len ( segment_indexes ):
next_token = tokens [ segment_indexes [ idx_pos + 1 ]]
next_match = re . match ( r "^FM(\d {2} )(\d {2} )(\d {2} )$" , next_token )
if next_match :
seg_end = _infer_utc ( int ( next_match . group ( 1 )), int ( next_match . group ( 2 )), int ( next_match . group ( 3 )))
else :
seg_end = valid_end_utc
else :
seg_end = valid_end_utc
elif token in { "TEMPO" , "BECMG" }:
seg_type = token
if payload_start < len ( tokens ):
seg_start , seg_end = _parse_period ( tokens [ payload_start ])
payload_start += 1
elif token in { "PROB30" , "PROB40" }:
seg_type = token
if payload_start < len ( tokens ) and tokens [ payload_start ] == "TEMPO" :
seg_type = f " { token } TEMPO"
payload_start += 1
if payload_start < len ( tokens ):
seg_start , seg_end = _parse_period ( tokens [ payload_start ])
payload_start += 1
if seg_start is None or seg_end is None :
idx_pos += 1
2026-03-24 02:58:29 +08:00
continue
2026-03-24 03:13:54 +08:00
if seg_end <= seg_start :
seg_end = seg_start + timedelta ( hours = 1 )
segments . append (
{
"type" : seg_type ,
"start_utc" : seg_start ,
"end_utc" : seg_end ,
"tokens" : tokens [ payload_start : end_idx ],
}
)
idx_pos += 1
2026-03-24 02:58:29 +08:00
2026-03-24 03:13:54 +08:00
peak_window_start = datetime . strptime ( f " { local_date } { max ( 0 , first_peak_h - 2 ) : 02d } :00" , "%Y-%m- %d %H:%M" ) . replace ( tzinfo = local_tz )
peak_window_end = datetime . strptime ( f " { local_date } { min ( 23 , last_peak_h + 1 ) : 02d } :00" , "%Y-%m- %d %H:%M" ) . replace ( tzinfo = local_tz )
2026-03-24 02:58:29 +08:00
2026-03-24 03:13:54 +08:00
precip_rank = { "low" : 0 , "medium" : 1 , "high" : 2 }
suppression_level = "low"
2026-03-24 02:58:29 +08:00
disruption_level = "low"
2026-03-24 03:13:54 +08:00
low_ceiling_ft = None
ceiling_cover = None
wind_regimes : list [ str ] = []
markers : list [ Dict [ str , Any ]] = []
active_segments : list [ Dict [ str , Any ]] = []
def _segment_precip_level ( tokens_block : list [ str ]) -> str :
joined = " " . join ( tokens_block )
if re . search ( r "\b(?:-|\+)?(?:TSRA|TS|VCTS|SHRA|SHSN|SHGS)\b" , joined ):
return "high"
if re . search ( r "\b(?:-|\+)?(?:RA|DZ|SN)\b" , joined ):
return "medium"
return "low"
for segment in segments :
start_local = segment [ "start_utc" ] . astimezone ( local_tz )
end_local = segment [ "end_utc" ] . astimezone ( local_tz )
2026-03-24 19:21:28 +08:00
overlap_start = max ( start_local , peak_window_start )
overlap_end = min ( end_local , peak_window_end )
if overlap_end <= overlap_start :
2026-03-24 03:13:54 +08:00
continue
active_segments . append ( segment )
joined = " " . join ( segment [ "tokens" ])
level = _segment_precip_level ( segment [ "tokens" ])
if precip_rank [ level ] > precip_rank [ suppression_level ]:
suppression_level = level
cloud_matches = re . findall ( r "\b(FEW|SCT|BKN|OVC)(\d {3} )\b" , joined )
for cover , base in cloud_matches :
if cover not in { "BKN" , "OVC" }:
continue
try :
base_ft = int ( base ) * 100
except Exception :
continue
if low_ceiling_ft is None or base_ft < low_ceiling_ft :
low_ceiling_ft = base_ft
ceiling_cover = cover
if low_ceiling_ft is not None and low_ceiling_ft <= 4000 and suppression_level == "low" :
suppression_level = "medium"
wind_matches = re . findall ( r "\b(\d {3} |VRB)(\d{2,3})(?:G\d{2,3})?KT\b" , joined )
segment_regimes = []
for direction , _speed in wind_matches :
if direction == "VRB" :
segment_regimes . append ( "variable" )
continue
deg = int ( direction )
if 135 <= deg <= 225 :
segment_regimes . append ( "southerly" )
elif deg >= 315 or deg <= 45 :
segment_regimes . append ( "northerly" )
else :
segment_regimes . append ( "cross" )
for item in segment_regimes :
if item not in wind_regimes :
wind_regimes . append ( item )
if segment [ "type" ] in { "TEMPO" , "BECMG" , "PROB30" , "PROB40" , "PROB30 TEMPO" , "PROB40 TEMPO" }:
disruption_level = "medium" if disruption_level == "low" else disruption_level
if segment [ "type" ] in { "PROB30 TEMPO" , "PROB40 TEMPO" } or level == "high" :
disruption_level = "high"
2026-03-24 19:21:28 +08:00
marker_time_local = overlap_start
2026-03-24 03:13:54 +08:00
marker_hour = marker_time_local . strftime ( "%H:00" )
hazards = []
if level != "low" :
hazards . append ( level )
if low_ceiling_ft is not None and segment_regimes is not None :
hazards . append ( "cloud" )
if segment_regimes :
hazards . append ( "wind" )
summary_zh = (
2026-03-24 19:21:28 +08:00
f " { segment [ 'type' ] } { overlap_start . strftime ( '%H:%M' ) } - { overlap_end . strftime ( '%H:%M' ) } "
2026-03-24 03:13:54 +08:00
f " { '有阵雨/雷暴扰动' if level == 'high' else '有云雨扰动' if level == 'medium' else '以稳定为主' } "
)
summary_en = (
2026-03-24 19:21:28 +08:00
f " { segment [ 'type' ] } { overlap_start . strftime ( '%H:%M' ) } - { overlap_end . strftime ( '%H:%M' ) } "
2026-03-24 03:13:54 +08:00
f " { 'shows shower/thunder disruption' if level == 'high' else 'shows cloud/rain disruption' if level == 'medium' else 'stays relatively stable' } "
)
markers . append (
{
"label_time" : marker_hour ,
"marker_type" : segment [ "type" ],
2026-03-24 19:21:28 +08:00
"start_local" : overlap_start . strftime ( "%H:%M" ),
"end_local" : overlap_end . strftime ( "%H:%M" ),
2026-03-24 03:13:54 +08:00
"suppression_level" : level ,
"summary_zh" : summary_zh ,
"summary_en" : summary_en ,
}
)
2026-03-24 02:58:29 +08:00
2026-03-24 03:13:54 +08:00
wind_shift = len ( wind_regimes ) >= 2 or "variable" in wind_regimes
peak_window = f " { peak_window_start . strftime ( '%H:%M' ) } - { peak_window_end . strftime ( '%H:%M' ) } "
2026-03-24 02:58:29 +08:00
if suppression_level == "high" :
2026-03-24 03:44:12 +08:00
summary_zh = f "TAF 在峰值窗口( { peak_window } )提示阵雨或雷暴扰动,机场最高温可能被云雨压低。"
summary_en = f "TAF flags shower or thunderstorm disruption around the peak window ( { peak_window } ), airport high may get capped by showers/storms."
2026-03-24 02:58:29 +08:00
elif suppression_level == "medium" :
summary_zh = f "TAF 在峰值窗口( { peak_window } )提示云量或弱降水扰动,需要防峰值被压低。"
summary_en = f "TAF points to cloud or light-precip disruption around the peak window ( { peak_window } ); the airport high may be capped."
else :
summary_zh = f "TAF 在峰值窗口( { peak_window } )暂未提示明显云雨压温。"
summary_en = f "TAF does not flag a strong cloud/rain suppression signal around the peak window ( { peak_window } )."
if wind_shift :
summary_zh += " 同时机场预报风向存在阶段性切换。"
summary_en += " Airport wind direction also shifts by regime during the window."
return {
"available" : True ,
"source" : "aviationweather-taf" ,
"raw_taf" : raw_taf ,
"issue_time" : ( taf_data or {}) . get ( "issue_time" ),
"valid_time_from" : ( taf_data or {}) . get ( "valid_time_from" ),
"valid_time_to" : ( taf_data or {}) . get ( "valid_time_to" ),
"peak_window" : peak_window ,
2026-03-24 03:13:54 +08:00
"segments" : [
{
"type" : seg [ "type" ],
"start_local" : seg [ "start_utc" ] . astimezone ( local_tz ) . strftime ( "%H:%M" ),
"end_local" : seg [ "end_utc" ] . astimezone ( local_tz ) . strftime ( "%H:%M" ),
"tokens" : seg [ "tokens" ],
}
for seg in active_segments
],
"markers" : markers ,
2026-03-24 02:58:29 +08:00
"low_ceiling_ft" : low_ceiling_ft ,
"ceiling_cover" : ceiling_cover ,
2026-03-24 03:13:54 +08:00
"wind_regimes" : wind_regimes ,
2026-03-24 02:58:29 +08:00
"wind_shift" : wind_shift ,
"suppression_level" : suppression_level ,
"disruption_level" : disruption_level ,
"summary_zh" : summary_zh ,
"summary_en" : summary_en ,
}
2026-04-16 17:13:53 +08:00
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 ,
2026-04-16 17:34:39 +08:00
label_en : Optional [ str ] = None ,
summary_en : Optional [ str ] = None ,
2026-04-16 17:13:53 +08:00
) -> None :
signals . append (
{
"label" : label ,
2026-04-16 17:34:39 +08:00
"label_en" : label_en or label ,
2026-04-16 17:13:53 +08:00
"direction" : direction ,
"strength" : strength ,
"summary" : summary ,
2026-04-16 17:34:39 +08:00
"summary_en" : summary_en or summary ,
2026-04-16 17:13:53 +08:00
}
)
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 = "日内节奏" ,
2026-04-16 17:34:39 +08:00
label_en = "Intraday pace" ,
2026-04-16 17:13:53 +08:00
direction = "support" ,
strength = strength ,
summary = f "实测较预期路径偏高 { abs ( delta or 0 ) : .1f }{ unit } ,峰值仍有上修空间。" ,
2026-04-16 17:34:39 +08:00
summary_en = f "Observed temperature is running { abs ( delta or 0 ) : .1f }{ unit } above the expected path; the peak still has upside room." ,
2026-04-16 17:13:53 +08:00
)
elif direction == "cold" :
suppress_score += 2 if strength == "strong" else 1
_add_signal (
signals ,
label = "日内节奏" ,
2026-04-16 17:34:39 +08:00
label_en = "Intraday pace" ,
2026-04-16 17:13:53 +08:00
direction = "suppress" ,
strength = strength ,
summary = f "实测较预期路径偏低 { abs ( delta or 0 ) : .1f }{ unit } ,追更高温档需要等待后续观测确认。" ,
2026-04-16 17:34:39 +08:00
summary_en = f "Observed temperature is running { abs ( delta or 0 ) : .1f }{ unit } below the expected path; higher buckets need confirmation from later observations." ,
2026-04-16 17:13:53 +08:00
)
else :
_add_signal (
signals ,
label = "日内节奏" ,
2026-04-16 17:34:39 +08:00
label_en = "Intraday pace" ,
2026-04-16 17:13:53 +08:00
direction = "neutral" ,
strength = "weak" ,
summary = "实测大体贴近当前预期路径,下一步主要看峰值窗口内是否继续抬升。" ,
2026-04-16 17:34:39 +08:00
summary_en = "Observed temperature is broadly tracking the expected path; the next question is whether it keeps lifting through the peak window." ,
2026-04-16 17:13:53 +08:00
)
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 = "边界层结构" ,
2026-04-16 17:34:39 +08:00
label_en = "Boundary-layer setup" ,
2026-04-16 17:13:53 +08:00
direction = "support" ,
strength = "strong" ,
summary = str ( vertical . get ( "summary_zh" ) or "边界层结构支持白天继续混合升温。" ),
2026-04-16 17:34:39 +08:00
summary_en = str ( vertical . get ( "summary_en" ) or "The boundary-layer setup supports continued daytime mixing and warming." ),
2026-04-16 17:13:53 +08:00
)
elif heating_setup == "suppressed" or suppression_risk == "high" :
suppress_score += 2
_add_signal (
signals ,
label = "边界层结构" ,
2026-04-16 17:34:39 +08:00
label_en = "Boundary-layer setup" ,
2026-04-16 17:13:53 +08:00
direction = "suppress" ,
strength = "strong" ,
summary = str ( vertical . get ( "summary_zh" ) or "边界层或云雨结构对午后峰值形成压制。" ),
2026-04-16 17:34:39 +08:00
summary_en = str ( vertical . get ( "summary_en" ) or "Boundary-layer or cloud/rain structure is capping the afternoon peak." ),
2026-04-16 17:13:53 +08:00
)
else :
_add_signal (
signals ,
label = "边界层结构" ,
2026-04-16 17:34:39 +08:00
label_en = "Boundary-layer setup" ,
2026-04-16 17:13:53 +08:00
direction = "neutral" ,
strength = "medium" ,
summary = str ( vertical . get ( "summary_zh" ) or "边界层结构暂未给出单边信号。" ),
2026-04-16 17:34:39 +08:00
summary_en = str ( vertical . get ( "summary_en" ) or "The boundary-layer setup does not yet provide a one-sided signal." ),
2026-04-16 17:13:53 +08:00
)
taf_suppression = str ( taf_signal . get ( "suppression_level" ) or "" ) . lower ()
taf_disruption = str ( taf_signal . get ( "disruption_level" ) or "" ) . lower ()
2026-04-19 14:15:46 +08:00
taf_has_cloud_rain_cap = taf_suppression in { "medium" , "high" } or taf_disruption in {
"medium" ,
"high" ,
}
structural_cap = False
2026-04-16 17:13:53 +08:00
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 云雨扰动" ,
2026-04-16 17:34:39 +08:00
label_en = "TAF cloud/rain disruption" ,
2026-04-16 17:13:53 +08:00
direction = direction_value ,
strength = strength ,
summary = str ( taf_signal . get ( "summary_zh" ) or "TAF 暂未提示强云雨压温信号。" ),
2026-04-16 17:34:39 +08:00
summary_en = str ( taf_signal . get ( "summary_en" ) or "TAF does not yet flag a strong cloud/rain temperature cap." ),
2026-04-16 17:13:53 +08:00
)
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 ()
2026-04-19 02:38:34 +08:00
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"
2026-04-16 17:13:53 +08:00
if airport_delta <= - 0.4 :
support_score += 1
_add_signal (
signals ,
label = "站网对比" ,
2026-04-16 17:34:39 +08:00
label_en = "Station-network comparison" ,
2026-04-16 17:13:53 +08:00
direction = "support" ,
2026-04-19 02:38:34 +08:00
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 } ." ,
2026-04-16 17:13:53 +08:00
)
elif airport_delta >= 0.4 :
suppress_score += 1
_add_signal (
signals ,
label = "站网对比" ,
2026-04-16 17:34:39 +08:00
label_en = "Station-network comparison" ,
2026-04-16 17:13:53 +08:00
direction = "suppress" ,
2026-04-19 02:38:34 +08:00
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 } ." ,
2026-04-16 17:13:53 +08:00
)
else :
_add_signal (
signals ,
label = "站网对比" ,
2026-04-16 17:34:39 +08:00
label_en = "Station-network comparison" ,
2026-04-16 17:13:53 +08:00
direction = "neutral" ,
strength = "weak" ,
summary = "机场锚点与周边站网基本同步,暂不构成单独上修或下修理由。" ,
2026-04-16 17:34:39 +08:00
summary_en = "The airport anchor and nearby station network are broadly aligned, so this layer does not independently argue for upside or downside." ,
2026-04-16 17:13:53 +08:00
)
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 = "峰值窗口已过,后续更偏向确认最终高点而非继续上修。"
2026-04-16 17:34:39 +08:00
headline_en = "The peak window has passed; the read now shifts toward confirming the final high rather than chasing further upside."
2026-04-16 17:13:53 +08:00
confidence = "high" if available_layers >= 2 else "medium"
elif suppress_score >= support_score + 2 :
2026-04-19 14:15:46 +08:00
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."
2026-04-16 17:13:53 +08:00
confidence = "high" if available_layers >= 3 else "medium"
elif support_score >= suppress_score + 2 :
headline = "峰值仍有上修空间,后续重点看峰值窗口内报文能否继续抬升。"
2026-04-16 17:34:39 +08:00
headline_en = "The peak still has upside room; the next check is whether reports keep lifting through the peak window."
2026-04-16 17:13:53 +08:00
confidence = "high" if available_layers >= 3 else "medium"
elif available_layers == 0 :
headline = "关键日内层仍在补齐,先以观测锚点和下一次报文为主。"
2026-04-16 17:34:39 +08:00
headline_en = "Key intraday layers are still filling in; anchor the read on observations and the next report."
2026-04-16 17:13:53 +08:00
confidence = "low"
else :
headline = "当前处于分歧判断区,峰值窗口内的下一组观测将决定方向。"
2026-04-16 17:34:39 +08:00
headline_en = "The setup is in a split-decision zone; the next observations inside the peak window should decide direction."
2026-04-16 17:13:53 +08:00
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 = []
2026-04-16 17:34:39 +08:00
invalidation_rules_en = []
2026-04-16 17:13:53 +08:00
confirmation_rules = []
2026-04-16 17:34:39 +08:00
confirmation_rules_en = []
2026-04-16 17:13:53 +08:00
if peak_status == "past" :
invalidation_rules . append ( "若后续官方结算源补录更高值,以结算源最终高点为准。" )
2026-04-16 17:34:39 +08:00
invalidation_rules_en . append ( "If the official settlement source later backfills a higher reading, defer to the final settlement-source high." )
2026-04-16 17:13:53 +08:00
confirmation_rules . append ( "若峰值窗口后连续两次观测不再创新高,当前高点基本确认。" )
2026-04-16 17:34:39 +08:00
confirmation_rules_en . append ( "If two consecutive post-peak observations fail to make a new high, the current high is broadly confirmed." )
2026-04-16 17:13:53 +08:00
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 } ,上修路径降级。" )
2026-04-16 17:34:39 +08:00
invalidation_rules_en . append ( f "If observations are still not near { threshold : .0f }{ unit } before { watch_clock } , downgrade the upside path." )
2026-04-16 17:13:53 +08:00
confirmation_rules . append ( f "峰值窗口内任一结算源观测触达或超过 { threshold : .0f }{ unit } ,基准路径确认度上升。" )
2026-04-16 17:34:39 +08:00
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." )
2026-04-16 17:13:53 +08:00
invalidation_rules . append ( "若 TAF 或实况报文出现阵雨、雷暴或低云/云雨压制,高温上沿需要下调。" )
2026-04-16 17:34:39 +08:00
invalidation_rules_en . append ( "If TAF or live reports show showers, thunderstorms, or low-cloud/cloud-rain suppression, lower the upper temperature bound." )
2026-04-16 17:13:53 +08:00
confirmation_rules . append ( "若实测继续贴近 DEB 曲线且云雨信号不增强,维持当前主路径。" )
2026-04-16 17:34:39 +08:00
confirmation_rules_en . append ( "If observations keep tracking the DEB curve and cloud/rain signals do not strengthen, maintain the current main path." )
2026-04-16 17:13:53 +08:00
if not signals :
_add_signal (
signals ,
label = "数据完整性" ,
2026-04-16 17:34:39 +08:00
label_en = "Data completeness" ,
2026-04-16 17:13:53 +08:00
direction = "neutral" ,
strength = "weak" ,
summary = "当前缺少足够的日内结构层,等待下一次观测刷新后再提高判断权重。" ,
2026-04-16 17:34:39 +08:00
summary_en = "There are not enough intraday structure layers yet; wait for the next observation refresh before raising confidence." ,
2026-04-16 17:13:53 +08:00
)
return {
"headline" : headline ,
2026-04-16 17:34:39 +08:00
"headline_en" : headline_en ,
2026-04-16 17:13:53 +08:00
"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 ],
2026-04-16 17:34:39 +08:00
"invalidation_rules_en" : invalidation_rules_en [: 4 ],
2026-04-16 17:13:53 +08:00
"confirmation_rules" : confirmation_rules [: 3 ],
2026-04-16 17:34:39 +08:00
"confirmation_rules_en" : confirmation_rules_en [: 3 ],
2026-04-16 17:13:53 +08:00
"signal_contributions" : signals [: 5 ],
}
2026-05-01 11:27:16 +08:00
def _archive_intraday_path_snapshot ( city : str , result : Dict [ str , Any ]) -> None :
"""Persist replayable intraday path inputs visible at analysis time."""
hourly = result . get ( "hourly" ) or {}
times = hourly . get ( "times" ) if isinstance ( hourly , dict ) else []
temps = hourly . get ( "temps" ) if isinstance ( hourly , dict ) else []
if not isinstance ( times , list ) or not isinstance ( temps , list ) or not times :
return
forecast = result . get ( "forecast" ) or {}
deb = result . get ( "deb" ) or {}
current = result . get ( "current" ) or {}
forecast_today_high = _sf ( forecast . get ( "today_high" ))
deb_prediction = _sf ( deb . get ( "prediction" ))
offset = (
deb_prediction - forecast_today_high
if deb_prediction is not None and forecast_today_high is not None
else 0.0
)
deb_base_temps = [
round ( float ( value ) + offset , 1 ) if _sf ( value ) is not None else None
for value in temps
]
utc_offset = int ( result . get ( "utc_offset_seconds" ) or 0 )
snapshot_time = datetime . now ( timezone . utc ) . astimezone (
timezone ( timedelta ( seconds = utc_offset ))
) . isoformat ( timespec = "seconds" )
payload = {
"schema_version" : 1 ,
"city" : city ,
"target_date" : str ( result . get ( "local_date" ) or "" ) . strip (),
"snapshot_time" : snapshot_time ,
"local_time" : str ( result . get ( "local_time" ) or "" ) . strip (),
"utc_offset_seconds" : utc_offset ,
"temp_symbol" : result . get ( "temp_symbol" ),
"deb_prediction" : deb_prediction ,
"forecast_today_high" : forecast_today_high ,
"deb_base_path" : {
"times" : [ str ( item ) for item in times ],
"temps" : deb_base_temps ,
"source" : "hourly_plus_deb_offset" ,
"offset" : round ( offset , 3 ),
},
"hourly" : {
"times" : [ str ( item ) for item in times ],
"temps" : temps ,
},
"metar_today_obs" : result . get ( "metar_today_obs" ) or [],
"settlement_today_obs" : result . get ( "settlement_today_obs" ) or [],
"current" : {
"temp" : _sf ( current . get ( "temp" )),
"max_so_far" : _sf ( current . get ( "max_so_far" )),
"obs_time" : current . get ( "obs_time" ),
"settlement_source" : current . get ( "settlement_source" ),
"settlement_source_label" : current . get ( "settlement_source_label" ),
},
"forecast" : {
"today_high" : forecast_today_high ,
"sunrise" : forecast . get ( "sunrise" ),
"sunset" : forecast . get ( "sunset" ),
},
"peak" : result . get ( "peak" ) or {},
"metar_status" : result . get ( "metar_status" ) or {},
}
try :
IntradayPathSnapshotRepository () . append_snapshot ( payload )
except Exception as exc :
logger . debug ( f "intraday path snapshot archive skipped for { city } : { exc } " )
2026-04-08 11:41:34 +08:00
def _analyze (
city : str ,
force_refresh : bool = False ,
include_llm_commentary : bool = False ,
2026-04-11 20:11:01 +08:00
detail_mode : str = "full" ,
2026-04-08 11:41:34 +08:00
) -> Dict [ str , Any ]:
2026-03-20 20:59:30 +08:00
"""Fetch, analyse, and return structured weather data for one city."""
# Check cache
2026-05-11 17:28:53 +08:00
ttl = _analysis_ttl_for_city ( city )
2026-04-12 20:49:51 +08:00
normalized_detail_mode_raw = str ( detail_mode or "full" ) . strip () . lower ()
if normalized_detail_mode_raw == "panel" :
normalized_detail_mode = "panel"
2026-04-13 18:42:27 +08:00
elif normalized_detail_mode_raw == "market" :
normalized_detail_mode = "market"
2026-04-12 20:49:51 +08:00
elif normalized_detail_mode_raw == "nearby" :
normalized_detail_mode = "nearby"
else :
normalized_detail_mode = "full"
2026-04-12 10:42:00 +08:00
cache_key = _analysis_cache_key ( city , normalized_detail_mode )
2026-03-20 20:59:30 +08:00
if not force_refresh :
2026-04-12 10:42:00 +08:00
cached = _cache . get ( cache_key )
2026-03-20 20:59:30 +08:00
if cached and _time . time () - cached [ "t" ] < ttl :
2026-04-08 11:41:34 +08:00
if include_llm_commentary :
cached_payload = cached [ "d" ]
dynamic = cached_payload . get ( "dynamic_commentary" ) or {}
if not dynamic . get ( "headline_zh" ):
cached_payload [ "dynamic_commentary" ] = _maybe_enrich_dynamic_commentary_with_groq (
city ,
cached_payload ,
)
2026-04-08 06:53:49 +08:00
_record_analysis_cache_event ( city = city , hit = True , force_refresh = False )
2026-03-20 20:59:30 +08:00
return cached [ "d" ]
2026-04-08 06:53:49 +08:00
_record_analysis_cache_event ( city = city , hit = False , force_refresh = force_refresh )
2026-03-20 20:59:30 +08:00
info = CITIES [ city ]
lat , lon , is_f = info [ "lat" ], info [ "lon" ], info [ "f" ]
sym = "°F" if is_f else "°C"
settlement_source = str ( info . get ( "settlement_source" ) or "metar" ) . strip () . lower () or "metar"
settlement_source_label = SETTLEMENT_SOURCE_LABELS . get (
settlement_source ,
settlement_source . upper (),
)
# ── 1. Fetch raw data ──
2026-04-12 10:42:00 +08:00
is_panel_mode = normalized_detail_mode == "panel"
2026-04-13 18:42:27 +08:00
is_market_mode = normalized_detail_mode == "market"
2026-04-12 20:49:51 +08:00
is_nearby_mode = normalized_detail_mode == "nearby"
2026-04-11 20:11:01 +08:00
2026-03-20 20:59:30 +08:00
raw = _weather . fetch_all_sources (
city ,
lat = lat ,
lon = lon ,
force_refresh = force_refresh ,
2026-04-13 18:42:27 +08:00
include_taf = not is_panel_mode and not is_nearby_mode and not is_market_mode ,
include_nearby = not is_panel_mode and not is_market_mode ,
include_ensemble = not is_panel_mode and not is_nearby_mode and not is_market_mode ,
2026-04-12 20:49:51 +08:00
include_multi_model = not is_panel_mode and not is_nearby_mode ,
2026-04-13 18:42:27 +08:00
include_mgm = not is_market_mode ,
2026-03-20 20:59:30 +08:00
)
om = raw . get ( "open-meteo" , {})
metar = raw . get ( "metar" , {})
2026-03-24 02:58:29 +08:00
taf = raw . get ( "taf" , {})
2026-03-20 20:59:30 +08:00
mgm = raw . get ( "mgm" ) or {}
settlement_current = raw . get ( "settlement_current" ) or {}
ens_raw = raw . get ( "ensemble" , {})
mm = raw . get ( "multi_model" , {})
if not isinstance ( om , dict ):
om = {}
if not isinstance ( metar , dict ):
metar = {}
if not isinstance ( mgm , dict ):
mgm = {}
if not isinstance ( settlement_current , dict ):
settlement_current = {}
if not isinstance ( ens_raw , dict ):
ens_raw = {}
if not isinstance ( mm , dict ):
mm = {}
risk = CITY_RISK_PROFILES . get ( city , {})
2026-04-11 20:11:01 +08:00
network_snapshot = (
build_country_network_snapshot ( city , raw )
2026-04-13 18:42:27 +08:00
if not is_panel_mode and not is_market_mode
2026-04-11 20:11:01 +08:00
else {}
)
2026-03-20 20:59:30 +08:00
2026-04-21 19:13:33 +08:00
# 优先从 API 获取偏移;若缺失则尝试 NWS 动态偏移;最后回退静态配置。
# 当前日期/时间必须来自运行时钟,不能使用 Open-Meteo 缓存里的 local_time。
utc_offset = om . get ( "utc_offset" )
if utc_offset is None :
try :
nws_periods = ( raw . get ( "nws" , {}) or {}) . get ( "forecast_periods" , []) or []
if nws_periods :
first_start = nws_periods [ 0 ] . get ( "start_time" )
if first_start :
maybe_dt = datetime . fromisoformat ( str ( first_start ))
if maybe_dt . utcoffset () is not None :
utc_offset = int ( maybe_dt . utcoffset () . total_seconds ())
except Exception :
utc_offset = None
if utc_offset is None :
utc_offset = get_city_utc_offset_seconds ( city )
try :
utc_offset = int ( utc_offset or 0 )
except Exception :
utc_offset = get_city_utc_offset_seconds ( city )
now_utc = datetime . now ( timezone . utc )
local_now = now_utc + timedelta ( seconds = utc_offset )
local_date_str = local_now . strftime ( "%Y-%m- %d " )
local_hour = local_now . hour
local_minute = local_now . minute
local_time_str = f " { local_hour : 02d } : { local_minute : 02d } "
local_hour_frac = local_hour + local_minute / 60
metar_current_is_today = _metar_is_current_local_day (
metar ,
local_date = local_date_str ,
utc_offset = int ( utc_offset or 0 ),
)
2026-05-10 18:16:10 +08:00
# ── 2. Current conditions (settlement > AMOS runway sensors > METAR > MGM > NMC fallback) ──
2026-03-20 20:59:30 +08:00
mc = metar . get ( "current" , {}) if metar else {}
mg_cur = mgm . get ( "current" , {}) if mgm else {}
sc_cur = settlement_current . get ( "current" , {}) if settlement_current else {}
2026-05-10 18:16:10 +08:00
amos_data = raw . get ( "amos" ) or {}
2026-05-10 19:46:26 +08:00
if amos_data :
logger . info ( "AMOS _analyze: found amos data for city= {} temp_c= {} source= {} " ,
city , amos_data . get ( "temp_c" ), amos_data . get ( "source" ))
2026-03-25 17:00:36 +08:00
use_settlement_current = settlement_source in { "hko" , "cwa" , "noaa" , "wunderground" } and bool ( sc_cur )
2026-04-21 19:13:33 +08:00
live_mc = mc if metar_current_is_today else {}
primary_current = sc_cur if use_settlement_current else live_mc
2026-04-16 15:29:26 +08:00
current_source = settlement_source
current_source_label = settlement_source_label
current_station_code = settlement_current . get ( "station_code" )
current_station_name = settlement_current . get ( "station_name" )
2026-03-20 20:59:30 +08:00
cur_temp = _sf ( primary_current . get ( "temp" ))
2026-04-16 17:34:39 +08:00
if cur_temp is not None and not _is_plausible_city_temp ( city , cur_temp , sym ):
cur_temp = None
2026-05-10 18:16:10 +08:00
# AMOS runway sensor: authoritative for Korean airports (RKSI/RKPK)
if cur_temp is None :
amos_temp = _sf ( amos_data . get ( "temp_c" ))
if amos_temp is not None and _is_plausible_city_temp ( city , amos_temp , sym ):
cur_temp = amos_temp
current_source = "amos"
current_source_label = amos_data . get ( "source_label" ) or "AMOS"
current_station_code = amos_data . get ( "icao" )
current_station_name = amos_data . get ( "station_label" )
2026-03-20 20:59:30 +08:00
if cur_temp is None :
2026-04-21 19:13:33 +08:00
cur_temp = _sf ( live_mc . get ( "temp" ))
2026-04-16 17:34:39 +08:00
if cur_temp is not None and not _is_plausible_city_temp ( city , cur_temp , sym ):
cur_temp = None
2026-03-20 20:59:30 +08:00
if cur_temp is None :
cur_temp = _sf ( mg_cur . get ( "temp" ))
2026-04-16 17:34:39 +08:00
if cur_temp is not None and not _is_plausible_city_temp ( city , cur_temp , sym ):
cur_temp = None
2026-04-16 15:29:26 +08:00
if cur_temp is None :
nmc_fallback = _fetch_nmc_current_fallback ( city , use_fahrenheit = is_f )
nmc_cur = nmc_fallback . get ( "current" ) or {}
nmc_temp = _sf ( nmc_cur . get ( "temp" ))
if nmc_temp is not None :
cur_temp = nmc_temp
current_source = "nmc"
current_source_label = "NMC"
current_station_code = nmc_fallback . get ( "station_code" )
current_station_name = nmc_fallback . get ( "station_name" )
2026-03-20 20:59:30 +08:00
max_so_far = _sf ( primary_current . get ( "max_temp_so_far" ))
2026-04-16 17:34:39 +08:00
if max_so_far is not None and not _is_plausible_city_temp ( city , max_so_far , sym ):
max_so_far = None
2026-03-20 20:59:30 +08:00
if max_so_far is None :
2026-04-21 19:13:33 +08:00
max_so_far = _sf ( live_mc . get ( "max_temp_so_far" ))
2026-04-16 17:34:39 +08:00
if max_so_far is not None and not _is_plausible_city_temp ( city , max_so_far , sym ):
max_so_far = None
2026-03-20 20:59:30 +08:00
if max_so_far is None :
max_so_far = _sf ( mg_cur . get ( "mgm_max_temp" ))
2026-04-16 17:34:39 +08:00
if max_so_far is not None and not _is_plausible_city_temp ( city , max_so_far , sym ):
max_so_far = None
2026-04-16 15:29:26 +08:00
if max_so_far is None :
max_so_far = cur_temp
2026-03-20 20:59:30 +08:00
max_temp_time = primary_current . get ( "max_temp_time" )
if not max_temp_time and not use_settlement_current :
2026-04-21 19:13:33 +08:00
max_temp_time = live_mc . get ( "max_temp_time" )
2026-03-20 20:59:30 +08:00
if not max_temp_time :
max_temp_time = mg_cur . get ( "time" , "" )
if " " in max_temp_time :
max_temp_time = max_temp_time . split ( " " )[ 1 ][: 5 ]
if max_temp_time == "" :
max_temp_time = None
2026-03-25 18:38:34 +08:00
raw_settlement_max = max_so_far
wu_settle = apply_city_settlement ( city . lower (), raw_settlement_max ) if raw_settlement_max is not None else None
display_settlement_max = wu_settle if settlement_source == "wunderground" and wu_settle is not None else raw_settlement_max
2026-03-20 20:59:30 +08:00
# Observation time → local
obs_time_str = ""
metar_age_min = None
obs_t = ""
if use_settlement_current :
obs_t = str ( settlement_current . get ( "observation_time" ) or "" ) . strip ()
2026-04-21 19:13:33 +08:00
if not obs_t and metar_current_is_today :
2026-03-20 20:59:30 +08:00
obs_t = metar . get ( "observation_time" , "" ) if metar else ""
if obs_t and "T" in obs_t :
try :
2026-04-21 19:13:33 +08:00
dt = _parse_utc_datetime ( obs_t )
if dt is None :
raise ValueError ( "invalid observation time" )
2026-03-20 20:59:30 +08:00
local_dt = dt . astimezone ( timezone ( timedelta ( seconds = utc_offset )))
obs_time_str = local_dt . strftime ( "%H:%M" )
metar_age_min = int (
( datetime . now ( timezone . utc ) - dt . astimezone ( timezone . utc )) . total_seconds () / 60
)
except Exception :
obs_time_str = str ( obs_t )[: 16 ]
2026-05-10 18:18:40 +08:00
if not obs_time_str and current_source == "amos" :
amos_obs_time = amos_data . get ( "observation_time" )
if amos_obs_time :
obs_time_str = _format_observation_time_local ( amos_obs_time , int ( utc_offset or 0 ))
2026-04-16 15:29:26 +08:00
if not obs_time_str and current_source == "nmc" :
nmc_fallback = _fetch_nmc_current_fallback ( city , use_fahrenheit = is_f )
obs_time_str = _format_observation_time_local (
nmc_fallback . get ( "publish_time" ) or nmc_fallback . get ( "timestamp" ),
int ( utc_offset or 0 ),
)
2026-03-20 20:59:30 +08:00
2026-04-16 18:21:51 +08:00
airport_primary_current = dict ( network_snapshot . get ( "airport_primary_current" ) or {})
2026-04-21 19:13:33 +08:00
if (
airport_primary_current . get ( "source_code" ) == "metar"
and metar
and not metar_current_is_today
):
airport_primary_current [ "temp" ] = None
airport_primary_current [ "stale_for_today" ] = True
airport_primary_current [ "last_observation_local_date" ] = metar . get ( "observation_local_date" )
airport_primary_current [ "current_local_date" ] = local_date_str
2026-04-16 18:21:51 +08:00
if (
airport_primary_current . get ( "source_code" ) == "metar"
and obs_time_str
and not use_settlement_current
):
airport_primary_current [ "obs_time" ] = obs_time_str
airport_primary_current [ "obs_age_min" ] = metar_age_min
2026-03-20 20:59:30 +08:00
settlement_today_obs = []
if use_settlement_current :
2026-03-23 21:22:41 +08:00
explicit_settlement_obs = settlement_current . get ( "today_obs" ) or []
normalized_obs = []
for item in explicit_settlement_obs :
if isinstance ( item , dict ):
raw_time = str ( item . get ( "time" ) or "" ) . strip ()
raw_temp = _sf ( item . get ( "temp" ))
elif isinstance ( item , ( list , tuple )) and len ( item ) >= 2 :
raw_time = str ( item [ 0 ] or "" ) . strip ()
raw_temp = _sf ( item [ 1 ])
else :
continue
if not raw_time or raw_temp is None :
continue
normalized_obs . append ({ "time" : raw_time , "temp" : raw_temp })
if normalized_obs :
settlement_today_obs = normalized_obs
else :
if obs_time_str and cur_temp is not None :
settlement_today_obs . append ({ "time" : obs_time_str , "temp" : cur_temp })
if (
max_temp_time
and max_so_far is not None
and str ( max_temp_time ) != str ( obs_time_str )
):
settlement_today_obs . append ({ "time" : str ( max_temp_time ), "temp" : max_so_far })
2026-03-20 20:59:30 +08:00
2026-03-22 22:43:06 +08:00
metar_today_obs_payload = [
{ "time" : t , "temp" : v }
2026-04-21 19:13:33 +08:00
for t , v in (
metar . get ( "today_obs" , []) if metar and metar_current_is_today else []
)
2026-04-16 17:34:39 +08:00
if _is_plausible_city_temp ( city , v , sym )
]
metar_recent_obs_payload = [
point
2026-04-21 19:13:33 +08:00
for point in (
metar . get ( "recent_obs" , []) if metar and metar_current_is_today else []
)
2026-04-16 17:34:39 +08:00
if isinstance ( point , dict )
and _is_plausible_city_temp ( city , point . get ( "temp" ), sym )
2026-03-22 22:43:06 +08:00
]
2026-03-25 18:03:56 +08:00
airport_max_so_far = None
airport_max_temp_time = None
for point in metar_today_obs_payload :
value = _sf ( point . get ( "temp" )) if isinstance ( point , dict ) else None
if value is None :
continue
if airport_max_so_far is None or value >= airport_max_so_far :
airport_max_so_far = value
airport_max_temp_time = str ( point . get ( "time" ) or "" ) or None
2026-03-20 20:59:30 +08:00
2026-04-21 19:13:33 +08:00
# ── 3. Daily forecast ──
2026-03-20 20:59:30 +08:00
daily = om . get ( "daily" , {})
dates = daily . get ( "time" , [])[: 5 ]
maxtemps = daily . get ( "temperature_2m_max" , [])[: 5 ]
sunrises = daily . get ( "sunrise" , [])
sunsets = daily . get ( "sunset" , [])
sunshine = daily . get ( "sunshine_duration" , [])
om_today = _sf ( maxtemps [ 0 ]) if maxtemps else None
2026-04-26 08:12:31 +08:00
forecast_daily = _dedupe_forecast_daily (
[{ "date" : d , "max_temp" : t } for d , t in zip ( dates , maxtemps )]
)
2026-03-20 20:59:30 +08:00
if om_today is None :
nws_high = _sf ( raw . get ( "nws" , {}) . get ( "today_high" ))
mgm_high = _sf ( mgm . get ( "today_high" )) if mgm else None
fallback_high = (
nws_high
if nws_high is not None
else mgm_high
if mgm_high is not None
else max_so_far
if max_so_far is not None
else cur_temp
)
if fallback_high is not None :
om_today = float ( fallback_high )
if not forecast_daily :
forecast_daily = [{ "date" : local_date_str , "max_temp" : om_today }]
sunrise = (
sunrises [ 0 ] . split ( "T" )[ 1 ][: 5 ]
if sunrises and "T" in str ( sunrises [ 0 ])
else ""
)
sunset = (
sunsets [ 0 ] . split ( "T" )[ 1 ][: 5 ]
if sunsets and "T" in str ( sunsets [ 0 ])
else ""
)
sunshine_h = round ( sunshine [ 0 ] / 3600 , 1 ) if sunshine else 0
# ── 5. Multi-model forecasts ──
current_forecasts : Dict [ str , float ] = {}
if om_today is not None :
current_forecasts [ "Open-Meteo" ] = om_today
for m , v in mm . get ( "forecasts" , {}) . items ():
if v is not None and not _is_excluded_model_name ( m ):
current_forecasts [ m ] = _sf ( v )
nws_high = _sf ( raw . get ( "nws" , {}) . get ( "today_high" ))
if nws_high is not None :
current_forecasts [ "NWS" ] = nws_high
mgm_high = _sf ( mgm . get ( "today_high" )) if mgm else None
if mgm_high is not None :
current_forecasts [ "MGM" ] = mgm_high
# ── 6. DEB fusion ──
deb_val , deb_weights = None , ""
if current_forecasts :
blended , winfo = calculate_dynamic_weights ( city , current_forecasts )
if blended is not None :
deb_val = blended
deb_weights = winfo
# ── 7. Ensemble stats ──
ens_data = {
"median" : _sf ( ens_raw . get ( "median" )),
"p10" : _sf ( ens_raw . get ( "p10" )),
"p90" : _sf ( ens_raw . get ( "p90" )),
}
# ── 8. METAR trend ──
recent_temps = metar . get ( "recent_temps" , []) if metar else []
trend_info = {
"direction" : "unknown" ,
"recent" : [{ "time" : t , "temp" : v } for t , v in recent_temps [: 6 ]],
"is_cooling" : False ,
"is_dead_market" : False ,
}
if len ( recent_temps ) >= 2 :
t_only = [ t for _ , t in recent_temps ]
latest , prev = t_only [ 0 ], t_only [ 1 ]
diff = latest - prev
if len ( t_only ) >= 3 :
n = min ( 3 , len ( t_only ))
all_same = all ( t == latest for t in t_only [: n ])
all_rising = all ( t_only [ i ] >= t_only [ i + 1 ] for i in range ( n - 1 ))
all_falling = all ( t_only [ i ] <= t_only [ i + 1 ] for i in range ( n - 1 ))
if all_same :
trend_info [ "direction" ] = "stagnant"
elif all_rising and diff > 0 :
trend_info [ "direction" ] = "rising"
elif all_falling and diff < 0 :
trend_info [ "direction" ] = "falling"
else :
trend_info [ "direction" ] = "mixed"
elif diff > 0 :
trend_info [ "direction" ] = "rising"
elif diff < 0 :
trend_info [ "direction" ] = "falling"
else :
trend_info [ "direction" ] = "stagnant"
trend_info [ "is_cooling" ] = trend_info [ "direction" ] in ( "falling" , "stagnant" )
# ── 9. Peak hour detection ──
hourly = om . get ( "hourly" , {})
h_times = hourly . get ( "time" , [])
h_temps = hourly . get ( "temperature_2m" , [])
h_rad = hourly . get ( "shortwave_radiation" , [])
h_dew = hourly . get ( "dew_point_2m" , [])
h_pressure = hourly . get ( "pressure_msl" , [])
h_wspd = hourly . get ( "wind_speed_10m" , [])
h_wdir = hourly . get ( "wind_direction_10m" , [])
2026-03-24 01:28:45 +08:00
h_wspd_180m = hourly . get ( "wind_speed_180m" , [])
h_wdir_180m = hourly . get ( "wind_direction_180m" , [])
2026-03-20 20:59:30 +08:00
h_precip_prob = hourly . get ( "precipitation_probability" , [])
h_cloud_cover = hourly . get ( "cloud_cover" , [])
2026-03-24 01:28:45 +08:00
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" , [])
2026-03-20 20:59:30 +08:00
if ( not h_times or not h_temps ) and metar :
metar_today_obs = metar . get ( "today_obs" , []) or []
parsed_obs = []
for item in metar_today_obs :
try :
t_str , t_val = item
if t_str is None or t_val is None :
continue
hh , minute_part = str ( t_str ) . split ( ":" )
parsed_obs . append (( int ( hh ), int ( minute_part ), float ( t_val )))
except Exception :
continue
if parsed_obs :
parsed_obs . sort ( key = lambda x : ( x [ 0 ], x [ 1 ]))
h_times = [ f " { local_date_str } T { hh : 02d } : { mm : 02d } " for hh , mm , _ in parsed_obs ]
h_temps = [ v for _ , _ , v in parsed_obs ]
h_rad = [ 0 for _ in parsed_obs ]
h_dew = [ None for _ in parsed_obs ]
h_pressure = [ None for _ in parsed_obs ]
h_wspd = [ None for _ in parsed_obs ]
h_wdir = [ None for _ in parsed_obs ]
2026-03-24 01:28:45 +08:00
h_wspd_180m = [ None for _ in parsed_obs ]
h_wdir_180m = [ None for _ in parsed_obs ]
2026-03-20 20:59:30 +08:00
h_precip_prob = [ None for _ in parsed_obs ]
h_cloud_cover = [ None for _ in parsed_obs ]
2026-03-24 01:28:45 +08:00
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 ]
2026-03-20 20:59:30 +08:00
peak_hours = []
if h_times and h_temps and om_today is not None :
for ts , tmp in zip ( h_times , h_temps ):
if ts . startswith ( local_date_str ) and abs ( tmp - om_today ) <= 0.2 :
hr = int ( ts . split ( "T" )[ 1 ][: 2 ])
if 8 <= hr <= 19 :
peak_hours . append ( ts . split ( "T" )[ 1 ][: 5 ])
first_peak_h = int ( peak_hours [ 0 ] . split ( ":" )[ 0 ]) if peak_hours else 13
last_peak_h = int ( peak_hours [ - 1 ] . split ( ":" )[ 0 ]) if peak_hours else 15
if local_hour_frac > last_peak_h :
peak_status = "past"
elif first_peak_h <= local_hour_frac <= last_peak_h :
peak_status = "in_window"
else :
peak_status = "before"
2026-05-10 16:48:28 +08:00
lgbm_val = None
2026-03-29 23:29:17 +08:00
if current_forecasts and deb_val is not None :
lgbm_val , _ = predict_lgbm_daily_high (
city_name = city ,
current_forecasts = current_forecasts ,
deb_prediction = deb_val ,
current_temp = cur_temp ,
max_so_far = max_so_far ,
humidity = _sf ( primary_current . get ( "humidity" )),
wind_speed_kt = _sf ( primary_current . get ( "wind_speed_kt" )),
visibility_mi = _sf ( primary_current . get ( "visibility_mi" )),
local_hour = local_hour ,
local_date = local_date_str ,
peak_status = peak_status ,
)
2026-05-10 16:54:48 +08:00
# LGBM is kept as an independent reference (lgbm.prediction),
# not fed back into DEB to avoid circular dependency
2026-03-29 23:29:17 +08:00
deviation_monitor = _build_deviation_monitor (
current_temp = cur_temp ,
deb_prediction = deb_val ,
om_today = om_today ,
hourly_times = h_times ,
hourly_temps = h_temps ,
local_date = local_date_str ,
local_hour_frac = local_hour_frac ,
observation_points = (
settlement_today_obs if settlement_today_obs else metar_today_obs_payload
),
)
2026-03-20 20:59:30 +08:00
# ── 10. Shared analysis (probability, trend, AI) via trend_engine ──
# This single call replaces the duplicate probability engine, dead market
# detection, forecast bust grading, and AI context building.
from src.analysis.trend_engine import analyze_weather_trend as _trend_analyze , calculate_prob_distribution
probabilities = []
2026-04-22 01:43:13 +08:00
probabilities_all = []
2026-03-20 20:59:30 +08:00
shadow_probabilities = []
2026-04-22 01:43:13 +08:00
shadow_probabilities_all = []
2026-03-20 20:59:30 +08:00
mu = None
probability_engine = "legacy"
probability_calibration_mode = "legacy"
probability_calibration_version = None
probability_raw_mu = None
probability_raw_sigma = None
probability_calibrated_mu = None
probability_calibrated_sigma = None
2026-03-21 18:54:23 +08:00
dynamic_commentary = { "summary" : "" , "notes" : []}
2026-03-20 20:59:30 +08:00
try :
_ , _ai_context , sd = _trend_analyze ( raw , sym , city )
# Use structured data from shared engine
mu = sd . get ( "mu" )
probabilities = sd . get ( "probabilities" , [])
2026-04-22 01:43:13 +08:00
probabilities_all = sd . get ( "probabilities_all" , probabilities )
2026-03-20 20:59:30 +08:00
shadow_probabilities = sd . get ( "shadow_probabilities" , [])
2026-04-22 01:43:13 +08:00
shadow_probabilities_all = sd . get ( "shadow_probabilities_all" , shadow_probabilities )
2026-03-20 20:59:30 +08:00
probability_engine = sd . get ( "probability_engine" , "legacy" )
probability_calibration_mode = sd . get ( "probability_calibration_mode" , "legacy" )
probability_calibration_version = sd . get ( "probability_calibration_version" )
probability_raw_mu = sd . get ( "probability_raw_mu" )
probability_raw_sigma = sd . get ( "probability_raw_sigma" )
probability_calibrated_mu = sd . get ( "probability_calibrated_mu" )
probability_calibrated_sigma = sd . get ( "probability_calibrated_sigma" )
2026-03-21 18:54:23 +08:00
dynamic_commentary = sd . get ( "dynamic_commentary" ) or dynamic_commentary
2026-03-20 20:59:30 +08:00
trend_info [ "is_dead_market" ] = sd . get ( "trend_info" , {}) . get ( "is_dead_market" , False )
trend_info [ "direction" ] = sd . get ( "trend_info" , {}) . get ( "direction" , trend_info . get ( "direction" , "unknown" ))
trend_info [ "is_cooling" ] = sd . get ( "trend_info" , {}) . get ( "is_cooling" , False )
peak_status = sd . get ( "peak_status" , peak_status )
# Use shared DEB if not already set
if deb_val is None and sd . get ( "deb_prediction" ) is not None :
deb_val = sd [ "deb_prediction" ]
deb_weights = sd . get ( "deb_weights" , "" )
except Exception as e :
logger . warning ( f "Structured analysis skipped for { city } : { e } " )
# ── 12. Hourly data (today only, for chart) ──
today_hourly : Dict [ str , list ] = { "times" : [], "temps" : [], "radiation" : []}
for i , ts in enumerate ( h_times ):
if ts . startswith ( local_date_str ):
today_hourly [ "times" ] . append ( ts . split ( "T" )[ 1 ][: 5 ])
today_hourly [ "temps" ] . append ( h_temps [ i ] if i < len ( h_temps ) else None )
today_hourly [ "radiation" ] . append ( h_rad [ i ] if i < len ( h_rad ) else None )
# ── 12b. Next 48h hourly block for future-date analysis modal ──
next_48h_hourly = {
"times" : [],
"temps" : [],
"radiation" : [],
"dew_point" : [],
"pressure_msl" : [],
"wind_speed_10m" : [],
"wind_direction_10m" : [],
2026-03-24 01:28:45 +08:00
"wind_speed_180m" : [],
"wind_direction_180m" : [],
2026-03-20 20:59:30 +08:00
"precipitation_probability" : [],
"cloud_cover" : [],
2026-03-24 01:28:45 +08:00
"cape" : [],
"convective_inhibition" : [],
"lifted_index" : [],
"boundary_layer_height" : [],
2026-03-20 20:59:30 +08:00
}
try :
local_anchor = datetime . strptime (
f " { local_date_str } { local_time_str } " , "%Y-%m- %d %H:%M"
)
except Exception :
local_anchor = None
if local_anchor is not None :
horizon = local_anchor + timedelta ( hours = 48 )
for i , ts in enumerate ( h_times ):
try :
ts_dt = datetime . fromisoformat ( ts )
except Exception :
continue
if ts_dt < local_anchor or ts_dt > horizon :
continue
next_48h_hourly [ "times" ] . append ( ts )
next_48h_hourly [ "temps" ] . append ( h_temps [ i ] if i < len ( h_temps ) else None )
next_48h_hourly [ "radiation" ] . append ( h_rad [ i ] if i < len ( h_rad ) else None )
next_48h_hourly [ "dew_point" ] . append ( h_dew [ i ] if i < len ( h_dew ) else None )
next_48h_hourly [ "pressure_msl" ] . append (
h_pressure [ i ] if i < len ( h_pressure ) else None
)
next_48h_hourly [ "wind_speed_10m" ] . append (
h_wspd [ i ] if i < len ( h_wspd ) else None
)
next_48h_hourly [ "wind_direction_10m" ] . append (
h_wdir [ i ] if i < len ( h_wdir ) else None
)
2026-03-24 01:28:45 +08:00
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
)
2026-03-20 20:59:30 +08:00
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
)
2026-03-24 01:28:45 +08:00
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
)
2026-04-11 20:11:01 +08:00
vertical_profile_signal = (
_build_vertical_profile_signal (
next_48h_hourly ,
local_date_str ,
local_hour ,
first_peak_h ,
last_peak_h ,
)
2026-04-13 18:42:27 +08:00
if not is_panel_mode and not is_nearby_mode and not is_market_mode
2026-04-11 20:11:01 +08:00
else {}
2026-03-24 01:28:45 +08:00
)
2026-04-11 20:11:01 +08:00
taf_signal = (
_build_taf_signal (
taf if isinstance ( taf , dict ) else {},
city ,
local_date_str ,
int ( utc_offset or 0 ),
first_peak_h ,
last_peak_h ,
)
2026-04-13 18:42:27 +08:00
if not is_panel_mode and not is_nearby_mode and not is_market_mode
2026-04-11 20:11:01 +08:00
else { "available" : False }
2026-03-24 02:58:29 +08:00
)
2026-03-20 20:59:30 +08:00
# ── 13. Cloud description (METAR primary, MGM fallback) ──
clouds = mc . get ( "clouds" , [])
cloud_desc = ""
if clouds :
c_map = {
"BKN" : "多云" ,
"OVC" : "阴天" ,
"FEW" : "少云" ,
"SCT" : "散云" ,
"SKC" : "晴" ,
"CLR" : "晴" ,
}
main = clouds [ - 1 ]
cloud_desc = c_map . get ( main . get ( "cover" ), main . get ( "cover" , "" ))
if not cloud_desc and mgm :
mgc_cover = mgm . get ( "current" , {}) . get ( "cloud_cover" )
if mgc_cover is not None :
cloud_desc_map = {
0 : "晴朗" ,
1 : "少云" ,
2 : "少云" ,
3 : "散云" ,
4 : "散云" ,
5 : "多云" ,
6 : "多云" ,
7 : "阴天" ,
8 : "阴天" ,
}
cloud_desc = cloud_desc_map . get ( mgc_cover , "" )
# Final fallback: If we have ANY actual observation but no cloud info, it's usually clear.
if not cloud_desc :
if mc . get ( "temp" ) is not None or ( mgm and mgm . get ( "current" , {}) . get ( "temp" ) is not None ):
# If weather phenomenon exists (e.g. rain), we'll let app.js handle wx_desc priority.
# Otherwise, clear skies.
if not mc . get ( "wx_desc" ):
cloud_desc = "晴朗"
2026-03-30 19:03:13 +08:00
# ── 14. MGM data (Turkish MGM-supported cities) ──
2026-03-20 20:59:30 +08:00
mgm_data = {}
if mgm :
mgc = mgm . get ( "current" , {})
mgm_time_str = mgc . get ( "time" , "" )
# MGM time is usually "2026-03-04T10:40:00.000Z" (UTC)
if mgm_time_str and "T" in mgm_time_str :
try :
# Handle ISO format with Z or +00:00
ts = mgm_time_str . replace ( "Z" , "+00:00" )
if "+" in ts :
base , offset_part = ts . split ( "+" , 1 )
if "." in base :
base = base . split ( "." )[ 0 ]
ts = base + "+" + offset_part
dt = datetime . fromisoformat ( ts )
local_dt = dt . astimezone ( timezone ( timedelta ( seconds = utc_offset or 0 )))
mgm_time_str = local_dt . strftime ( "%H:%M" )
except Exception as e :
logger . debug ( f "MGM time conversion failed: { e } " )
pass
mgm_data = {
"temp" : _sf ( mgc . get ( "temp" )),
"time" : mgm_time_str ,
"feels_like" : _sf ( mgc . get ( "feels_like" )),
"humidity" : _sf ( mgc . get ( "humidity" )),
"wind_dir" : _sf ( mgc . get ( "wind_dir" )),
"wind_speed_ms" : _sf ( mgc . get ( "wind_speed_ms" )),
"pressure" : _sf ( mgc . get ( "pressure" )),
"cloud_cover" : mgc . get ( "cloud_cover" ),
"rain_24h" : _sf ( mgc . get ( "rain_24h" )),
"today_high" : _sf ( mgm . get ( "today_high" )),
"today_low" : _sf ( mgm . get ( "today_low" )),
"hourly" : [],
}
mgm_hourly = mgm . get ( "hourly" , [])
for h in mgm_hourly :
dt_str = h . get ( "time" )
val = _sf ( h . get ( "temp" ))
if dt_str and "T" in dt_str and val is not None :
try :
dt = datetime . fromisoformat ( dt_str . replace ( "Z" , "+00:00" ))
local_dt = dt . astimezone ( timezone ( timedelta ( seconds = utc_offset )))
mgm_data [ "hourly" ] . append ({
"time" : local_dt . strftime ( "%Y-%m- %d T%H:%M" ),
"temp" : val
})
except Exception :
pass
# ── 15. Extended Multi-Model Daily ──
multi_model_daily = {}
mm_daily_raw = mm . get ( "daily_forecasts" , {})
for i , d_str in enumerate ( dates ):
if i == 0 :
day_m = current_forecasts . copy ()
d_val , d_winfo = deb_val , deb_weights
else :
day_m = mm_daily_raw . get ( d_str , {}) . copy ()
if i < len ( maxtemps ) and maxtemps [ i ] is not None :
day_m [ "Open-Meteo" ] = _sf ( maxtemps [ i ])
# Add MGM per-day forecast
mgm_daily = mgm . get ( "daily_forecasts" , {})
if d_str in mgm_daily :
day_m [ "MGM" ] = _sf ( mgm_daily [ d_str ])
day_m = {
m : v for m , v in day_m . items () if not _is_excluded_model_name ( m )
}
d_val , d_winfo = None , ""
d_probs = []
2026-04-23 21:10:22 +08:00
d_probs_all = []
2026-03-20 20:59:30 +08:00
if day_m :
try :
blended , winfo = calculate_dynamic_weights ( city , day_m )
if blended is not None :
d_val = blended
d_winfo = winfo
# Calculate future probability based on model divergence
m_vals = [ v for v in day_m . values () if v is not None ]
if len ( m_vals ) > 1 :
# Use spread as a proxy for sigma.
# sigma = (max-min)/2 with a floor of 0.6
d_sigma = max ( 0.6 , ( max ( m_vals ) - min ( m_vals )) / 2.0 )
else :
d_sigma = 1.0
prob_obj = calculate_prob_distribution ( d_val , d_sigma , None , sym )
d_probs = prob_obj . get ( "probabilities" , [])
2026-04-23 21:10:22 +08:00
d_probs_all = prob_obj . get ( "probabilities_all" , d_probs )
2026-03-20 20:59:30 +08:00
except Exception :
pass
if day_m :
multi_model_daily [ d_str ] = {
"models" : day_m ,
"deb" : { "prediction" : d_val , "weights_info" : d_winfo },
2026-04-23 21:10:22 +08:00
"probabilities" : d_probs if i > 0 else probabilities , # Use today's real prob for today
"probabilities_all" : d_probs_all if i > 0 else probabilities_all ,
2026-03-20 20:59:30 +08:00
}
# ── Assemble result ──
2026-04-03 01:47:03 +08:00
city_meta = CITIES . get ( city , {}) or {}
2026-03-20 20:59:30 +08:00
result = {
2026-04-13 18:42:27 +08:00
"detail_depth" : (
"panel"
if is_panel_mode
else "market"
if is_market_mode
else "nearby"
if is_nearby_mode
else "full"
),
2026-03-20 20:59:30 +08:00
"name" : city ,
2026-04-03 01:47:03 +08:00
"display_name" : str ( city_meta . get ( "display_name" ) or city_meta . get ( "name" ) or city . title ()),
2026-03-20 20:59:30 +08:00
"lat" : lat ,
"lon" : lon ,
2026-04-19 14:47:18 +08:00
"utc_offset_seconds" : int ( utc_offset or 0 ),
2026-03-20 20:59:30 +08:00
"temp_symbol" : sym ,
"local_time" : local_time_str ,
"local_date" : local_date_str ,
"risk" : {
"level" : risk . get ( "risk_level" , "low" ),
"emoji" : risk . get ( "risk_emoji" , "🟢" ),
"airport" : risk . get ( "airport_name" , "" ),
"icao" : risk . get ( "icao" , "" ),
"distance_km" : risk . get ( "distance_km" , 0 ),
"warning" : risk . get ( "warning" , "" ),
},
"current" : {
"temp" : cur_temp ,
2026-03-25 18:38:34 +08:00
"max_so_far" : display_settlement_max ,
2026-03-20 20:59:30 +08:00
"max_temp_time" : max_temp_time ,
2026-03-25 18:38:34 +08:00
"raw_max_so_far" : raw_settlement_max ,
2026-03-20 20:59:30 +08:00
"wu_settlement" : wu_settle ,
2026-04-16 15:29:26 +08:00
"settlement_source" : current_source ,
"settlement_source_label" : current_source_label ,
"station_code" : current_station_code ,
"station_name" : current_station_name ,
2026-03-20 20:59:30 +08:00
"obs_time" : obs_time_str ,
2026-03-25 18:03:56 +08:00
"obs_age_min" : None if use_settlement_current else metar_age_min ,
2026-04-21 19:13:33 +08:00
"observation_status" : "live" if cur_temp is not None else "missing" ,
2026-03-25 18:03:56 +08:00
"report_time" : primary_current . get ( "report_time" ),
"receipt_time" : primary_current . get ( "receipt_time" ),
"obs_time_epoch" : primary_current . get ( "obs_time_epoch" ),
2026-05-10 18:16:10 +08:00
"wind_speed_kt" : _sf ( amos_data . get ( "wind_kt" )) if current_source == "amos" else _sf ( primary_current . get ( "wind_speed_kt" )),
2026-03-20 20:59:30 +08:00
"wind_dir" : _sf ( primary_current . get ( "wind_dir" )),
"humidity" : _sf ( primary_current . get ( "humidity" )),
2026-05-10 18:16:10 +08:00
"pressure_hpa" : _sf ( amos_data . get ( "pressure_hpa" )) if current_source == "amos" else _sf ( primary_current . get ( "pressure_hpa" )),
2026-03-20 20:59:30 +08:00
"cloud_desc" : cloud_desc ,
"clouds_raw" : [
{ "cover" : c . get ( "cover" ), "base" : c . get ( "base" )} for c in clouds
],
"visibility_mi" : _sf ( primary_current . get ( "visibility_mi" )),
"wx_desc" : primary_current . get ( "wx_desc" ),
2026-05-10 18:16:10 +08:00
"raw_metar" : amos_data . get ( "raw_metar" ) if current_source == "amos" else primary_current . get ( "raw_metar" ),
2026-03-20 20:59:30 +08:00
},
2026-03-25 18:03:56 +08:00
"airport_current" : {
2026-04-21 19:13:33 +08:00
"temp" : _sf ( live_mc . get ( "temp" )),
2026-04-16 18:21:51 +08:00
"obs_time" : obs_time_str ,
2026-03-25 18:03:56 +08:00
"max_so_far" : airport_max_so_far ,
"max_temp_time" : airport_max_temp_time ,
"obs_age_min" : metar_age_min ,
"report_time" : metar . get ( "report_time" ) if metar else None ,
"receipt_time" : metar . get ( "receipt_time" ) if metar else None ,
"obs_time_epoch" : metar . get ( "obs_time_epoch" ) if metar else None ,
2026-05-10 18:18:40 +08:00
"wind_speed_kt" : _sf ( amos_data . get ( "wind_kt" )) if current_source == "amos" else _sf ( live_mc . get ( "wind_speed_kt" )),
2026-04-21 19:13:33 +08:00
"wind_dir" : _sf ( live_mc . get ( "wind_dir" )),
"humidity" : _sf ( live_mc . get ( "humidity" )),
2026-03-25 18:03:56 +08:00
"cloud_desc" : metar . get ( "cloud_desc" ) if metar else None ,
2026-04-21 19:13:33 +08:00
"visibility_mi" : _sf ( live_mc . get ( "visibility_mi" )),
"wx_desc" : live_mc . get ( "wx_desc" ),
2026-05-10 18:18:40 +08:00
"raw_metar" : amos_data . get ( "raw_metar" ) if current_source == "amos" else live_mc . get ( "raw_metar" ),
"source_label" : "AMOS" if current_source == "amos" else "METAR" ,
"stale_for_today" : False if current_source == "amos" else ( bool ( metar ) and not metar_current_is_today ),
2026-04-21 19:13:33 +08:00
"last_observation_local_date" : metar . get ( "observation_local_date" ) if metar else None ,
"current_local_date" : local_date_str ,
2026-03-25 18:03:56 +08:00
},
2026-04-06 07:21:59 +08:00
"settlement_station" : network_snapshot . get ( "settlement_station" ) or {},
2026-04-16 18:21:51 +08:00
"airport_primary" : airport_primary_current ,
2026-04-06 07:21:59 +08:00
"airport_primary_today_obs" : network_snapshot . get ( "airport_primary_today_obs" ) or [],
"official_nearby" : network_snapshot . get ( "official_nearby" ) or [],
"official_network_source" : network_snapshot . get ( "official_network_source" ),
"official_network_status" : network_snapshot . get ( "official_network_status" ) or {},
"network_lead_signal" : network_snapshot . get ( "network_lead_signal" ) or {},
"network_spread_signal" : network_snapshot . get ( "network_spread_signal" ) or {},
"center_station_candidate" : network_snapshot . get ( "center_station_candidate" ),
"airport_vs_network_delta" : network_snapshot . get ( "airport_vs_network_delta" ),
2026-03-20 20:59:30 +08:00
"mgm" : mgm_data ,
"mgm_nearby" : raw . get ( "mgm_nearby" , []),
2026-03-30 19:03:13 +08:00
"nearby_source" : raw . get ( "nearby_source" ) or ( "mgm" if city . lower () in TURKISH_MGM_CITIES else "metar_cluster" ),
2026-05-10 19:06:22 +08:00
"amos" : amos_data if amos_data and amos_data . get ( "source" ) else None ,
2026-03-20 20:59:30 +08:00
"forecast" : {
"today_high" : om_today ,
"daily" : forecast_daily ,
"sunrise" : sunrise ,
"sunset" : sunset ,
"sunshine_hours" : sunshine_h ,
},
"source_forecasts" : {
"weather_gov" : raw . get ( "nws" ) or {},
2026-04-17 00:27:57 +08:00
"open_meteo_multi_model" : {
"source" : mm . get ( "source" ),
"provider" : mm . get ( "provider" ),
"dates" : mm . get ( "dates" ) or [],
"model_metadata" : mm . get ( "model_metadata" ) or {},
"model_keys" : mm . get ( "model_keys" ) or {},
"attribution" : mm . get ( "attribution" ),
} if isinstance ( mm , dict ) and mm else {},
2026-03-20 20:59:30 +08:00
},
"multi_model" : { k : v for k , v in current_forecasts . items () if v is not None },
"multi_model_daily" : multi_model_daily ,
"deb" : { "prediction" : deb_val , "weights_info" : deb_weights },
2026-05-10 16:48:28 +08:00
"lgbm" : { "prediction" : lgbm_val },
2026-03-29 23:29:17 +08:00
"deviation_monitor" : deviation_monitor ,
2026-03-20 20:59:30 +08:00
"ensemble" : ens_data ,
"probabilities" : {
"mu" : round ( mu , 1 ) if mu is not None else None ,
"distribution" : probabilities ,
2026-04-22 01:43:13 +08:00
"distribution_all" : probabilities_all or probabilities ,
2026-03-20 20:59:30 +08:00
"engine" : probability_engine ,
"calibration_mode" : probability_calibration_mode ,
"calibration_version" : probability_calibration_version ,
"raw_mu" : probability_raw_mu ,
"raw_sigma" : probability_raw_sigma ,
"calibrated_mu" : probability_calibrated_mu ,
"calibrated_sigma" : probability_calibrated_sigma ,
"shadow_distribution" : shadow_probabilities ,
2026-04-22 01:43:13 +08:00
"shadow_distribution_all" : shadow_probabilities_all or shadow_probabilities ,
2026-03-20 20:59:30 +08:00
},
"trend" : trend_info ,
"peak" : {
"hours" : peak_hours ,
"first_h" : first_peak_h ,
"last_h" : last_peak_h ,
"status" : peak_status ,
},
2026-03-21 18:54:23 +08:00
"dynamic_commentary" : dynamic_commentary ,
2026-03-20 20:59:30 +08:00
"hourly" : today_hourly ,
"hourly_next_48h" : next_48h_hourly ,
2026-03-24 01:28:45 +08:00
"vertical_profile_signal" : vertical_profile_signal ,
2026-03-24 02:58:29 +08:00
"taf" : {
** ( taf if isinstance ( taf , dict ) else {}),
"signal" : taf_signal ,
}
if taf_signal or taf
else {},
2026-03-20 20:59:30 +08:00
"metar_today_obs" : metar_today_obs_payload ,
"metar_recent_obs" : metar_recent_obs_payload ,
2026-04-21 19:13:33 +08:00
"metar_status" : {
"available_for_today" : metar_current_is_today ,
"stale_for_today" : bool ( metar ) and not metar_current_is_today ,
"last_observation_time" : metar . get ( "observation_time" ) if metar else None ,
"last_observation_local_date" : metar . get ( "observation_local_date" ) if metar else None ,
"current_local_date" : local_date_str ,
"last_temp" : _sf ( mc . get ( "temp" )) if mc else None ,
},
2026-03-20 20:59:30 +08:00
"settlement_today_obs" : settlement_today_obs ,
2026-04-05 07:13:00 +08:00
"ai_analysis" : "" ,
2026-03-20 20:59:30 +08:00
"updated_at" : datetime . now ( timezone . utc ) . isoformat (),
}
2026-04-16 17:13:53 +08:00
result [ "intraday_meteorology" ] = _build_intraday_meteorology ( result )
2026-05-01 11:27:16 +08:00
if normalized_detail_mode == "full" :
_archive_intraday_path_snapshot ( city , result )
2026-03-20 20:59:30 +08:00
2026-04-08 11:41:34 +08:00
if include_llm_commentary :
result [ "dynamic_commentary" ] = _maybe_enrich_dynamic_commentary_with_groq (
city ,
result ,
)
2026-04-12 10:42:00 +08:00
_cache [ cache_key ] = { "t" : _time . time (), "d" : result }
2026-03-20 20:59:30 +08:00
return result
def _normalize_city_or_404 ( name : str ) -> str :
city = name . lower () . strip () . replace ( "-" , " " )
city = ALIASES . get ( city , city )
if city not in CITIES :
raise HTTPException ( 404 , detail = f "Unknown city: { city } " )
return city
2026-04-11 20:11:01 +08:00
def _analyze_summary ( city : str , force_refresh : bool = False ) -> Dict [ str , Any ]:
ttl = _analysis_ttl_for_city ( city )
if not force_refresh :
2026-04-12 10:42:00 +08:00
cached_detail = _get_cached_analysis ( city , ttl )
if cached_detail :
return cached_detail
2026-04-11 20:11:01 +08:00
cached_summary = _get_cached_summary ( city , ttl )
if cached_summary :
return cached_summary
info = CITIES [ city ]
lat , lon , is_f = info [ "lat" ], info [ "lon" ], info [ "f" ]
sym = "°F" if is_f else "°C"
settlement_source = str ( info . get ( "settlement_source" ) or "metar" ) . strip () . lower () or "metar"
settlement_source_label = SETTLEMENT_SOURCE_LABELS . get (
settlement_source ,
settlement_source . upper (),
)
if force_refresh :
try :
_weather . _evict_city_caches ( # type: ignore[attr-defined]
city = city ,
lat = lat ,
lon = lon ,
use_fahrenheit = is_f ,
)
except Exception :
pass
2026-04-19 15:37:13 +08:00
default_utc_offset = get_city_utc_offset_seconds ( city )
2026-04-11 20:11:01 +08:00
def _safe_call ( fn ):
try :
return fn ()
except Exception :
return None
jobs = {
"settlement_current" : lambda : _weather . fetch_settlement_current ( city ) or {},
"open_meteo" : lambda : _weather . fetch_from_open_meteo ( lat , lon , use_fahrenheit = is_f ) or {},
}
if _weather . _supports_aviationweather ( city ): # type: ignore[attr-defined]
jobs [ "metar" ] = lambda : _weather . fetch_metar (
city ,
use_fahrenheit = is_f ,
utc_offset = default_utc_offset ,
) or {}
if city in TURKISH_MGM_CITIES :
istno , _province = _weather . TURKISH_PROVINCES . get ( city , ( None , None )) # type: ignore[attr-defined]
if istno :
jobs [ "mgm" ] = lambda istno = istno : _weather . fetch_from_mgm ( str ( istno )) or {}
if is_f :
jobs [ "nws" ] = lambda : _weather . fetch_nws ( lat , lon ) or {}
if settlement_source == "hko" :
jobs [ "hko_forecast" ] = lambda : _weather . fetch_hko_forecast ()
fetched : Dict [ str , Any ] = {}
with ThreadPoolExecutor ( max_workers = min ( 6 , len ( jobs ))) as executor :
future_map = {
executor . submit ( _safe_call , fn ): key
for key , fn in jobs . items ()
}
for future , key in [( future , key ) for future , key in future_map . items ()]:
fetched [ key ] = future . result ()
settlement_current = fetched . get ( "settlement_current" ) or {}
open_meteo = fetched . get ( "open_meteo" ) or {}
utc_offset = open_meteo . get ( "utc_offset" )
if utc_offset is None :
utc_offset = default_utc_offset
try :
utc_offset = int ( utc_offset or 0 )
except Exception :
utc_offset = default_utc_offset
2026-04-21 19:13:33 +08:00
now_utc = datetime . now ( timezone . utc )
local_now = now_utc + timedelta ( seconds = utc_offset )
local_date_str = local_now . strftime ( "%Y-%m- %d " )
local_hour = local_now . hour
local_minute = local_now . minute
local_time_str = f " { local_hour : 02d } : { local_minute : 02d } "
local_hour_frac = local_hour + local_minute / 60.0
2026-04-11 20:11:01 +08:00
metar = fetched . get ( "metar" ) or {}
mgm = fetched . get ( "mgm" ) or {}
nws = fetched . get ( "nws" ) or {}
hko_forecast = fetched . get ( "hko_forecast" )
2026-04-21 19:13:33 +08:00
metar_current_is_today = _metar_is_current_local_day (
metar ,
local_date = local_date_str ,
utc_offset = int ( utc_offset or 0 ),
)
2026-04-11 20:11:01 +08:00
sc_cur = settlement_current . get ( "current" ) or {}
mc = metar . get ( "current" ) or {}
2026-04-21 19:13:33 +08:00
live_mc = mc if metar_current_is_today else {}
2026-04-11 20:11:01 +08:00
mg_cur = mgm . get ( "current" ) or {}
use_settlement_current = settlement_source in { "hko" , "cwa" , "noaa" , "wunderground" } and bool ( sc_cur )
2026-04-21 19:13:33 +08:00
primary_current = sc_cur if use_settlement_current else live_mc
2026-04-11 20:11:01 +08:00
2026-04-16 15:29:26 +08:00
current_source = settlement_source
current_source_label = settlement_source_label
nmc_fallback : Dict [ str , Any ] = {}
2026-04-11 20:11:01 +08:00
cur_temp = _sf ( primary_current . get ( "temp" ))
2026-04-16 17:34:39 +08:00
if cur_temp is not None and not _is_plausible_city_temp ( city , cur_temp , sym ):
cur_temp = None
2026-04-11 20:11:01 +08:00
if cur_temp is None :
2026-04-21 19:13:33 +08:00
cur_temp = _sf ( live_mc . get ( "temp" ))
2026-04-16 17:34:39 +08:00
if cur_temp is not None and not _is_plausible_city_temp ( city , cur_temp , sym ):
cur_temp = None
2026-04-11 20:11:01 +08:00
if cur_temp is None :
cur_temp = _sf ( mg_cur . get ( "temp" ))
2026-04-16 17:34:39 +08:00
if cur_temp is not None and not _is_plausible_city_temp ( city , cur_temp , sym ):
cur_temp = None
2026-04-16 15:29:26 +08:00
if cur_temp is None :
nmc_fallback = _fetch_nmc_current_fallback ( city , use_fahrenheit = is_f )
nmc_cur = nmc_fallback . get ( "current" ) or {}
nmc_temp = _sf ( nmc_cur . get ( "temp" ))
if nmc_temp is not None :
cur_temp = nmc_temp
current_source = "nmc"
current_source_label = "NMC"
2026-04-11 20:11:01 +08:00
max_so_far = _sf ( primary_current . get ( "max_temp_so_far" ))
2026-04-16 17:34:39 +08:00
if max_so_far is not None and not _is_plausible_city_temp ( city , max_so_far , sym ):
max_so_far = None
2026-04-11 20:11:01 +08:00
if max_so_far is None :
2026-04-21 19:13:33 +08:00
max_so_far = _sf ( live_mc . get ( "max_temp_so_far" ))
2026-04-16 17:34:39 +08:00
if max_so_far is not None and not _is_plausible_city_temp ( city , max_so_far , sym ):
max_so_far = None
2026-04-11 20:11:01 +08:00
if max_so_far is None :
max_so_far = _sf ( mg_cur . get ( "mgm_max_temp" ))
2026-04-16 17:34:39 +08:00
if max_so_far is not None and not _is_plausible_city_temp ( city , max_so_far , sym ):
max_so_far = None
2026-04-16 15:29:26 +08:00
if max_so_far is None :
max_so_far = cur_temp
2026-04-11 20:11:01 +08:00
max_temp_time = primary_current . get ( "max_temp_time" )
if not max_temp_time and not use_settlement_current :
2026-04-21 19:13:33 +08:00
max_temp_time = live_mc . get ( "max_temp_time" )
2026-04-11 20:11:01 +08:00
if not max_temp_time :
mgm_time = str ( mg_cur . get ( "time" ) or "" )
if " " in mgm_time :
max_temp_time = mgm_time . split ( " " )[ 1 ][: 5 ]
raw_settlement_max = max_so_far
wu_settle = (
apply_city_settlement ( city . lower (), raw_settlement_max )
if raw_settlement_max is not None
else None
)
display_settlement_max = (
wu_settle
if settlement_source == "wunderground" and wu_settle is not None
else raw_settlement_max
)
obs_time_str = ""
obs_age_min = None
obs_t = ""
if use_settlement_current :
obs_t = str ( settlement_current . get ( "observation_time" ) or "" ) . strip ()
2026-04-21 19:13:33 +08:00
if not obs_t and metar_current_is_today :
2026-04-11 20:11:01 +08:00
obs_t = str ( metar . get ( "observation_time" ) or "" ) . strip ()
if obs_t and "T" in obs_t :
try :
2026-04-21 19:13:33 +08:00
dt = _parse_utc_datetime ( obs_t )
if dt is None :
raise ValueError ( "invalid observation time" )
2026-04-11 20:11:01 +08:00
local_dt = dt . astimezone ( timezone ( timedelta ( seconds = utc_offset )))
obs_time_str = local_dt . strftime ( "%H:%M" )
obs_age_min = int (
( datetime . now ( timezone . utc ) - dt . astimezone ( timezone . utc )) . total_seconds () / 60
)
except Exception :
obs_time_str = str ( obs_t )[: 16 ]
2026-04-16 15:29:26 +08:00
if not obs_time_str and current_source == "nmc" :
if not nmc_fallback :
nmc_fallback = _fetch_nmc_current_fallback ( city , use_fahrenheit = is_f )
obs_time_str = _format_observation_time_local (
nmc_fallback . get ( "publish_time" ) or nmc_fallback . get ( "timestamp" ),
int ( utc_offset or 0 ),
)
2026-04-11 20:11:01 +08:00
om_daily = ( open_meteo . get ( "daily" ) or {}) if isinstance ( open_meteo , dict ) else {}
om_hourly = ( open_meteo . get ( "hourly" ) or {}) if isinstance ( open_meteo , dict ) else {}
maxtemps = om_daily . get ( "temperature_2m_max" , [])[: 5 ]
om_today = _sf ( maxtemps [ 0 ]) if maxtemps else None
nws_high = _sf (( nws or {}) . get ( "today_high" )) if isinstance ( nws , dict ) else None
mgm_high = _sf (( mgm or {}) . get ( "today_high" )) if isinstance ( mgm , dict ) else None
if om_today is None :
fallback_high = (
nws_high
if nws_high is not None
else mgm_high
if mgm_high is not None
else max_so_far
if max_so_far is not None
else cur_temp
)
if fallback_high is not None :
om_today = float ( fallback_high )
current_forecasts : Dict [ str , float ] = {}
if om_today is not None :
current_forecasts [ "Open-Meteo" ] = om_today
if nws_high is not None :
current_forecasts [ "NWS" ] = nws_high
if mgm_high is not None :
current_forecasts [ "MGM" ] = mgm_high
if hko_forecast is not None :
current_forecasts [ "HKO" ] = _sf ( hko_forecast )
current_forecasts = {
model_name : value
for model_name , value in current_forecasts . items ()
if value is not None and not _is_excluded_model_name ( model_name )
}
deb_val = None
if current_forecasts :
blended , _weights_info = calculate_dynamic_weights ( city , current_forecasts )
if blended is not None :
deb_val = blended
if deb_val is None :
deb_val = om_today
settlement_today_obs = []
if use_settlement_current :
explicit_obs = settlement_current . get ( "today_obs" ) or []
for item in explicit_obs :
if isinstance ( item , dict ):
raw_time = str ( item . get ( "time" ) or "" ) . strip ()
raw_temp = _sf ( item . get ( "temp" ))
elif isinstance ( item , ( list , tuple )) and len ( item ) >= 2 :
raw_time = str ( item [ 0 ] or "" ) . strip ()
raw_temp = _sf ( item [ 1 ])
else :
continue
if raw_time and raw_temp is not None :
settlement_today_obs . append ({ "time" : raw_time , "temp" : raw_temp })
if not settlement_today_obs and obs_time_str and cur_temp is not None :
settlement_today_obs . append ({ "time" : obs_time_str , "temp" : cur_temp })
if max_temp_time and max_so_far is not None and str ( max_temp_time ) != str ( obs_time_str ):
settlement_today_obs . append ({ "time" : str ( max_temp_time ), "temp" : max_so_far })
metar_today_obs_payload = [
{ "time" : obs_time , "temp" : obs_temp }
2026-04-21 19:13:33 +08:00
for obs_time , obs_temp in (
( metar . get ( "today_obs" ) or [])
if isinstance ( metar , dict ) and metar_current_is_today
else []
)
2026-04-11 20:11:01 +08:00
]
deviation_monitor = _build_deviation_monitor (
current_temp = cur_temp ,
deb_prediction = deb_val ,
om_today = om_today ,
hourly_times = om_hourly . get ( "time" , []) if isinstance ( om_hourly , dict ) else [],
hourly_temps = om_hourly . get ( "temperature_2m" , []) if isinstance ( om_hourly , dict ) else [],
local_date = local_date_str ,
local_hour_frac = local_hour_frac ,
observation_points = (
settlement_today_obs if settlement_today_obs else metar_today_obs_payload
),
)
risk = CITY_RISK_PROFILES . get ( city , {})
city_meta = CITY_REGISTRY . get ( city , {}) or {}
result = {
"name" : city ,
"display_name" : str ( city_meta . get ( "display_name" ) or city_meta . get ( "name" ) or city . title ()),
"temp_symbol" : sym ,
2026-04-19 14:47:18 +08:00
"utc_offset_seconds" : int ( utc_offset or 0 ),
2026-04-11 20:11:01 +08:00
"local_time" : local_time_str ,
"local_date" : local_date_str ,
"risk" : {
"level" : risk . get ( "risk_level" , "low" ),
"warning" : risk . get ( "warning" , "" ),
"icao" : risk . get ( "icao" , "" ),
},
"current" : {
"temp" : _sf ( cur_temp ),
"max_so_far" : _sf ( display_settlement_max ),
"max_temp_time" : max_temp_time ,
"wu_settlement" : _sf ( wu_settle ),
2026-04-16 15:29:26 +08:00
"settlement_source" : current_source ,
"settlement_source_label" : current_source_label ,
2026-04-11 20:11:01 +08:00
"obs_time" : obs_time_str or None ,
"obs_age_min" : obs_age_min ,
2026-04-21 19:13:33 +08:00
"observation_status" : "live" if cur_temp is not None else "missing" ,
2026-04-11 20:11:01 +08:00
},
"deb" : { "prediction" : _sf ( deb_val )},
"deviation_monitor" : deviation_monitor or {},
"updated_at" : datetime . now ( timezone . utc ) . isoformat (),
}
_set_cached_summary ( city , result )
return result
2026-03-20 20:59:30 +08:00
def _build_city_summary_payload ( data : Dict [ str , Any ]) -> Dict [ str , Any ]:
return {
"name" : data . get ( "name" ),
"display_name" : data . get ( "display_name" ),
"icao" : data . get ( "risk" , {}) . get ( "icao" ),
2026-04-19 14:47:18 +08:00
"utc_offset_seconds" : data . get ( "utc_offset_seconds" ),
2026-03-20 20:59:30 +08:00
"local_time" : data . get ( "local_time" ),
"temp_symbol" : data . get ( "temp_symbol" ),
"current" : {
"temp" : data . get ( "current" , {}) . get ( "temp" ),
"obs_time" : data . get ( "current" , {}) . get ( "obs_time" ),
"settlement_source" : data . get ( "current" , {}) . get ( "settlement_source" ),
"settlement_source_label" : data . get ( "current" , {}) . get ( "settlement_source_label" ),
},
"deb" : { "prediction" : data . get ( "deb" , {}) . get ( "prediction" )},
2026-03-29 23:29:17 +08:00
"deviation_monitor" : data . get ( "deviation_monitor" ) or {},
2026-03-20 20:59:30 +08:00
"risk" : {
"level" : data . get ( "risk" , {}) . get ( "level" ),
"warning" : data . get ( "risk" , {}) . get ( "warning" ),
},
"updated_at" : data . get ( "updated_at" ),
}
2026-04-13 18:42:27 +08:00
def _build_city_market_scan_payload (
2026-03-20 20:59:30 +08:00
data : Dict [ str , Any ],
market_slug : Optional [ str ] = None ,
target_date : Optional [ str ] = None ,
2026-04-23 20:45:32 +08:00
lite : bool = False ,
2026-04-23 23:49:52 +08:00
scan_filters : Optional [ Dict [ str , Any ]] = None ,
2026-03-20 20:59:30 +08:00
) -> Dict [ str , Any ]:
city = str ( data . get ( "name" ) or "" ) . strip () . lower ()
local_date = str ( data . get ( "local_date" ) or "" ) . strip ()
requested_date = str ( target_date or "" ) . strip ()
selected_date = requested_date or local_date
multi_model_daily = data . get ( "multi_model_daily" ) or {}
selected_daily = (
multi_model_daily . get ( selected_date )
if isinstance ( multi_model_daily , dict )
else None
)
if not isinstance ( selected_daily , dict ):
selected_daily = {}
selected_date = local_date
distribution = selected_daily . get ( "probabilities" )
if not isinstance ( distribution , list ) or not distribution :
distribution = data . get ( "probabilities" , {}) . get ( "distribution" , []) or []
2026-04-23 21:10:22 +08:00
distribution_all = selected_daily . get ( "probabilities_all" )
if not isinstance ( distribution_all , list ) or not distribution_all :
distribution_all = data . get ( "probabilities" , {}) . get ( "distribution_all" , []) or []
if not distribution_all :
distribution_all = distribution
2026-03-20 20:59:30 +08:00
model_map = selected_daily . get ( "models" ) or data . get ( "multi_model" ) or {}
if not isinstance ( model_map , dict ):
model_map = {}
anchor_temp = None
anchor_model = None
for model_name , raw_value in model_map . items ():
value = _sf ( raw_value )
if value is None :
continue
if anchor_temp is None or value > anchor_temp :
anchor_temp = value
anchor_model = str ( model_name or "" ) . strip () or None
anchor_temp_c = anchor_temp
temp_symbol = str ( data . get ( "temp_symbol" ) or "" )
if anchor_temp_c is not None and "F" in temp_symbol . upper ():
anchor_temp_c = ( anchor_temp_c - 32.0 ) * 5.0 / 9.0
anchor_settlement = apply_city_settlement ( city , anchor_temp_c ) if anchor_temp_c is not None else None
primary_bucket = None
if isinstance ( distribution , list ) and distribution :
2026-04-23 21:10:22 +08:00
ranked_buckets = []
2026-04-26 10:02:54 +08:00
temp_symbol_upper = str ( temp_symbol or "" ) . upper ()
max_primary_bucket_delta = 16.0 if "F" in temp_symbol_upper else 8.0
2026-04-23 21:10:22 +08:00
for idx , row in enumerate ( distribution_all ):
if not isinstance ( row , dict ):
continue
2026-04-26 10:02:54 +08:00
bucket_value = _sf (
row . get ( "temp" )
if row . get ( "temp" ) is not None
else row . get ( "value" )
if row . get ( "value" ) is not None
else row . get ( "lower" )
)
if (
anchor_temp is not None
and bucket_value is not None
and abs ( float ( bucket_value ) - float ( anchor_temp )) > max_primary_bucket_delta
):
continue
2026-04-23 21:10:22 +08:00
bucket_prob = _sf ( row . get ( "probability" ))
prob_rank = bucket_prob if bucket_prob is not None else - 1.0
ranked_buckets . append (( - prob_rank , idx , row ))
if ranked_buckets :
ranked_buckets . sort ( key = lambda x : ( x [ 0 ], x [ 1 ]))
primary_bucket = ranked_buckets [ 0 ][ 2 ]
2026-04-26 10:02:54 +08:00
elif anchor_temp is None :
2026-04-23 21:10:22 +08:00
primary_bucket = distribution [ 0 ]
2026-03-20 20:59:30 +08:00
model_probability = None
if isinstance ( primary_bucket , dict ) and primary_bucket . get ( "probability" ) is not None :
try :
raw_probability = float ( primary_bucket . get ( "probability" ))
model_probability = raw_probability / 100.0 if raw_probability > 1.0 else raw_probability
except Exception :
model_probability = None
fallback_sparkline = [
p . get ( "probability" , 0 )
2026-04-23 21:10:22 +08:00
for p in distribution_all [: 8 ]
2026-03-20 20:59:30 +08:00
if isinstance ( p , dict )
]
2026-04-23 23:49:52 +08:00
current = data . get ( "current" ) or {}
2026-04-24 15:12:53 +08:00
selected_deb = selected_daily . get ( "deb" ) if isinstance ( selected_daily . get ( "deb" ), dict ) else {}
current_deb = data . get ( "deb" ) if isinstance ( data . get ( "deb" ), dict ) else {}
2026-04-23 23:49:52 +08:00
scan_context = {
"local_date" : data . get ( "local_date" ),
"local_time" : data . get ( "local_time" ),
"peak" : data . get ( "peak" ) or {},
"current_max_so_far" : current . get ( "max_so_far" ),
"current_temp" : current . get ( "temp" ),
"trend" : data . get ( "trend" ) or {},
"network_lead_signal" : data . get ( "network_lead_signal" ) or {},
2026-04-24 15:12:53 +08:00
"models" : model_map ,
"deb_prediction" : selected_deb . get ( "prediction" ) or current_deb . get ( "prediction" ),
2026-04-23 23:49:52 +08:00
}
2026-03-20 20:59:30 +08:00
market_scan = _market_layer . build_market_scan (
city = data . get ( "name" ),
target_date = selected_date or data . get ( "local_date" ),
temperature_bucket = primary_bucket if isinstance ( primary_bucket , dict ) else None ,
model_probability = model_probability ,
2026-04-23 21:10:22 +08:00
probability_distribution = distribution_all ,
temp_symbol = temp_symbol ,
2026-03-20 20:59:30 +08:00
fallback_sparkline = fallback_sparkline ,
forced_market_slug = market_slug ,
2026-04-23 20:45:32 +08:00
include_related_buckets = not lite ,
2026-04-23 23:49:52 +08:00
scan_filters = scan_filters ,
scan_context = scan_context ,
2026-03-20 20:59:30 +08:00
)
if isinstance ( market_scan , dict ):
market_scan [ "anchor_model" ] = anchor_model
market_scan [ "anchor_high" ] = anchor_temp
market_scan [ "anchor_settlement" ] = anchor_settlement
market_scan [ "open_meteo_settlement" ] = anchor_settlement
2026-04-23 20:45:32 +08:00
probabilities = data . get ( "probabilities" ) or {}
market_scan [ "probability_engine" ] = str (
probabilities . get ( "engine" ) or "legacy"
) . strip () or "legacy"
market_scan [ "probability_calibration_mode" ] = str (
probabilities . get ( "calibration_mode" ) or "legacy"
) . strip () or "legacy"
2026-04-13 18:42:27 +08:00
return {
"market_scan" : market_scan ,
"selected_date" : selected_date or data . get ( "local_date" ),
"fetched_at" : data . get ( "updated_at" ),
}
def _build_city_detail_payload (
data : Dict [ str , Any ],
market_slug : Optional [ str ] = None ,
target_date : Optional [ str ] = None ,
) -> Dict [ str , Any ]:
market_payload = _build_city_market_scan_payload (
data ,
market_slug = market_slug ,
target_date = target_date ,
)
market_scan = market_payload . get ( "market_scan" )
2026-03-20 20:59:30 +08:00
return {
"city" : data . get ( "name" ),
"fetched_at" : data . get ( "updated_at" ),
"overview" : {
"name" : data . get ( "name" ),
"display_name" : data . get ( "display_name" ),
"icao" : data . get ( "risk" , {}) . get ( "icao" ),
"airport" : data . get ( "risk" , {}) . get ( "airport" ),
"lat" : data . get ( "lat" ),
"lon" : data . get ( "lon" ),
"local_time" : data . get ( "local_time" ),
"local_date" : data . get ( "local_date" ),
"temp_symbol" : data . get ( "temp_symbol" ),
"current_temp" : data . get ( "current" , {}) . get ( "temp" ),
"settlement_source" : data . get ( "current" , {}) . get ( "settlement_source" ),
"settlement_source_label" : data . get ( "current" , {}) . get ( "settlement_source_label" ),
2026-04-06 07:21:59 +08:00
"settlement_station" : data . get ( "settlement_station" ) or {},
2026-03-20 20:59:30 +08:00
"deb_prediction" : data . get ( "deb" , {}) . get ( "prediction" ),
"risk_level" : data . get ( "risk" , {}) . get ( "level" ),
"risk_warning" : data . get ( "risk" , {}) . get ( "warning" ),
"updated_at" : data . get ( "updated_at" ),
},
"official" : {
"available" : bool ( data . get ( "current" , {}) . get ( "temp" ) is not None ),
"metar" : {
2026-03-25 18:03:56 +08:00
"observation_time" : data . get ( "airport_current" , {}) . get ( "obs_time" ),
"obs_age_min" : data . get ( "airport_current" , {}) . get ( "obs_age_min" ),
"report_time" : data . get ( "airport_current" , {}) . get ( "report_time" ),
"receipt_time" : data . get ( "airport_current" , {}) . get ( "receipt_time" ),
"raw_metar" : data . get ( "airport_current" , {}) . get ( "raw_metar" ),
"current" : data . get ( "airport_current" ) or {},
2026-03-20 20:59:30 +08:00
},
2026-03-24 02:58:29 +08:00
"taf" : data . get ( "taf" ) or {},
2026-03-20 20:59:30 +08:00
"weather_gov" : {},
"mgm" : data . get ( "mgm" ) or {},
"mgm_nearby" : data . get ( "mgm_nearby" ) or [],
2026-03-30 19:03:13 +08:00
"nearby_source" : data . get ( "nearby_source" ) or ( "mgm" if str ( data . get ( "name" ) or "" ) . lower () in TURKISH_MGM_CITIES else "metar_cluster" ),
2026-04-06 07:21:59 +08:00
"airport_primary" : data . get ( "airport_primary" ) or {},
"airport_primary_today_obs" : data . get ( "airport_primary_today_obs" ) or [],
"official_nearby" : data . get ( "official_nearby" ) or [],
"official_network_source" : data . get ( "official_network_source" ),
"official_network_status" : data . get ( "official_network_status" ) or {},
"network_lead_signal" : data . get ( "network_lead_signal" ) or {},
"network_spread_signal" : data . get ( "network_spread_signal" ) or {},
"center_station_candidate" : data . get ( "center_station_candidate" ),
"airport_vs_network_delta" : data . get ( "airport_vs_network_delta" ),
2026-03-20 20:59:30 +08:00
},
"timeseries" : {
"metar_recent_obs" : data . get ( "metar_recent_obs" ) or [],
"metar_today_obs" : data . get ( "metar_today_obs" ) or [],
"settlement_today_obs" : data . get ( "settlement_today_obs" ) or [],
"hourly" : data . get ( "hourly" ) or {},
"mgm_hourly" : ( data . get ( "mgm" ) or {}) . get ( "hourly" , []),
"forecast_daily" : ( data . get ( "forecast" ) or {}) . get ( "daily" , []),
},
"models" : {
k : v
for k , v in ( data . get ( "multi_model" ) or {}) . items ()
if not _is_excluded_model_name ( k )
},
2026-05-06 18:24:15 +08:00
"deb" : data . get ( "deb" ) or {},
"multi_model_daily" : data . get ( "multi_model_daily" ) or {},
2026-03-20 20:59:30 +08:00
"probabilities" : data . get ( "probabilities" ) or { "mu" : None , "distribution" : []},
2026-03-21 18:54:23 +08:00
"dynamic_commentary" : data . get ( "dynamic_commentary" ) or { "summary" : "" , "notes" : []},
2026-04-16 17:13:53 +08:00
"intraday_meteorology" : data . get ( "intraday_meteorology" )
or _build_intraday_meteorology ( data ),
2026-03-24 01:28:45 +08:00
"vertical_profile_signal" : data . get ( "vertical_profile_signal" ) or {},
2026-03-24 02:58:29 +08:00
"taf" : data . get ( "taf" ) or {},
2026-03-20 20:59:30 +08:00
"market_scan" : market_scan ,
"risk" : data . get ( "risk" ),
2026-04-06 07:21:59 +08:00
"settlement_station" : data . get ( "settlement_station" ) or {},
"airport_primary" : data . get ( "airport_primary" ) or {},
"official_nearby" : data . get ( "official_nearby" ) or [],
"official_network_source" : data . get ( "official_network_source" ),
"official_network_status" : data . get ( "official_network_status" ) or {},
"network_lead_signal" : data . get ( "network_lead_signal" ) or {},
"network_spread_signal" : data . get ( "network_spread_signal" ) or {},
"center_station_candidate" : data . get ( "center_station_candidate" ),
"airport_vs_network_delta" : data . get ( "airport_vs_network_delta" ),
2026-03-25 18:03:56 +08:00
"airport_current" : data . get ( "airport_current" ) or {},
2026-03-30 19:03:13 +08:00
"nearby_source" : data . get ( "nearby_source" ) or ( "mgm" if str ( data . get ( "name" ) or "" ) . lower () in TURKISH_MGM_CITIES else "metar_cluster" ),
2026-03-20 20:59:30 +08:00
"ai_analysis" : data . get ( "ai_analysis" ) or "" ,
"errors" : {},
}
# ──────────────────────────────────────────────────────────
# Routes
# ──────────────────────────────────────────────────────────