2026-03-20 20:59:30 +08:00
from __future__ import annotations
2026-05-25 17:54:44 +08:00
import re
2026-03-20 20:59:30 +08:00
import time as _time
2026-05-25 17:54:44 +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
from fastapi import HTTPException
from loguru import logger
from web.core import (
2026-05-25 17:53:25 +08:00
LRUDict ,
2026-03-20 20:59:30 +08:00
_cache ,
2026-05-25 02:20:15 +08:00
_CACHE_LOCK ,
2026-03-20 20:59:30 +08:00
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 ,
_sf ,
_weather ,
)
2026-05-27 21:40:26 +08:00
from src.analysis.deb_algorithm import calculate_deb_prediction
2026-05-28 10:44:48 +08:00
from src.analysis.deb_hourly_consensus import build_deb_hourly_consensus_path
2026-05-27 22:41:16 +08:00
from src.analysis.deb_hourly_correction import (
build_deb_hourly_path ,
get_cached_hourly_peak_corrector ,
)
2026-03-20 20:59:30 +08:00
from src.analysis.settlement_rounding import apply_city_settlement
2026-05-28 10:03:29 +08:00
from src.analysis.trend_engine import _resolve_peak_hours
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-05-01 11:27:16 +08:00
from src.database.runtime_state import IntradayPathSnapshotRepository
2026-05-14 20:01:26 +08:00
from web.services.city_payloads import (
2026-06-01 12:06:46 +08:00
build_city_chart_detail_payload as _city_chart_payload_detail ,
2026-05-14 20:01:26 +08:00
build_city_detail_payload as _city_payload_detail ,
2026-05-25 20:15:43 +08:00
build_city_summary_payload as _city_payload_summary
2026-05-14 20:01:26 +08:00
)
2026-05-25 17:31:26 +08:00
from web.services.observation_freshness import (
build_observation_freshness as _build_observation_freshness ,
observation_age_min as _observation_age_min ,
2026-05-25 20:03:06 +08:00
)
2026-05-25 17:40:50 +08:00
from web.services.analysis_utils import (
add_signal as _add_signal ,
bucket_label as _bucket_label ,
bucket_label_from_value as _bucket_label_from_value ,
format_clock_minutes as _format_clock_minutes ,
next_observation_clock as _next_observation_clock ,
top_probability_bucket as _top_probability_bucket ,
2026-05-25 20:03:06 +08:00
)
2026-05-18 23:11:56 +08:00
from web.services.analysis_signals import (
_build_deviation_monitor ,
_build_taf_signal ,
_build_vertical_profile_signal ,
_interpolate_hourly_value , # noqa: F401 - compatibility re-export
_wind_components , # noqa: F401 - compatibility re-export
2026-05-25 20:03:06 +08:00
)
2026-03-29 23:29:17 +08:00
2026-03-30 19:03:13 +08:00
TURKISH_MGM_CITIES = { "ankara" , "istanbul" }
2026-05-17 13:41:44 +08:00
HIGH_FREQ_AIRPORT_ANALYSIS_CITIES = {
"seoul" ,
"singapore" ,
"busan" ,
"tokyo" ,
"ankara" ,
"helsinki" ,
"amsterdam" ,
"istanbul" ,
"paris" ,
"hong kong" ,
2026-05-25 06:10:54 +08:00
"shenzhen" ,
2026-05-17 13:41:44 +08:00
"taipei" ,
"beijing" ,
"shanghai" ,
"guangzhou" ,
"shenzhen" ,
"qingdao" ,
"chengdu" ,
"chongqing" ,
"wuhan" ,
}
2026-05-17 19:19:12 +08:00
2026-05-28 10:59:54 +08:00
AMSC_SETTLEMENT_RUNWAY_PAIRS : Dict [ str , tuple [ str , str ]] = {
"shanghai" : ( "17L" , "35R" ),
"chengdu" : ( "02L" , "20R" ),
"chongqing" : ( "20R" , "02L" ),
"guangzhou" : ( "02L" , "20R" ),
"wuhan" : ( "04" , "22" ),
"beijing" : ( "19" , "01" ),
"qingdao" : ( "16" , "34" ),
}
AMSC_SETTLEMENT_RUNWAY_TARGETS : Dict [ str , str ] = {
"shanghai" : "35R" ,
"chengdu" : "02L" ,
"chongqing" : "02L" ,
"guangzhou" : "02L" ,
"wuhan" : "04" ,
"beijing" : "01" ,
"qingdao" : "34" ,
}
2026-05-25 17:54:44 +08:00
2026-05-29 21:56:57 +08:00
def _should_build_country_network_snapshot (
city : str ,
raw : Dict [ str , Any ],
* ,
is_panel_mode : bool ,
is_market_mode : bool ,
) -> bool :
if is_market_mode :
return False
if not is_panel_mode :
return True
2026-05-29 22:40:33 +08:00
city_lower = ( city or "" ) . strip () . lower ()
2026-05-29 21:56:57 +08:00
if city_lower not in TURKISH_MGM_CITIES :
return False
return bool (
( raw or {}) . get ( "mgm" )
or ( raw or {}) . get ( "mgm_today_obs" )
or ( raw or {}) . get ( "mgm_nearby" )
)
2026-05-25 20:03:06 +08:00
def _mgm_hourly_high ( mgm : Dict [ str , Any ]) -> Optional [ float ]:
hourly = mgm . get ( "hourly" ) if isinstance ( mgm , dict ) else []
if not isinstance ( hourly , list ):
2026-05-25 17:54:44 +08:00
return None
values = []
for row in hourly :
2026-05-25 20:03:06 +08:00
if not isinstance ( row , dict ):
2026-05-25 17:54:44 +08:00
continue
2026-05-25 20:03:06 +08:00
value = _sf ( row . get ( "temp" ))
2026-05-25 17:54:44 +08:00
if value is not None :
2026-05-25 20:03:06 +08:00
values . append ( value )
return max ( values ) if values else None
2026-05-28 10:59:54 +08:00
def _normalize_runway_label ( value : Any ) -> str :
return re . sub ( r "[^0-9A-Z]+" , "" , str ( value or "" ) . strip () . upper ())
def _split_runway_pair_label ( value : Any ) -> tuple [ str , str ]:
2026-05-29 22:40:33 +08:00
parts = [ _normalize_runway_label ( part ) for part in str ( value or "" ) . split ( "/" ) if part . strip ()]
2026-05-28 10:59:54 +08:00
if len ( parts ) >= 2 :
return parts [ 0 ], parts [ 1 ]
runway = _normalize_runway_label ( value )
return runway , runway
def _runway_pair_matches ( left : tuple [ str , str ], right : tuple [ str , str ]) -> bool :
return tuple ( sorted ( left )) == tuple ( sorted ( right ))
def _settlement_runway_endpoint_temp ( city : str , row : Dict [ str , Any ]) -> Optional [ float ]:
city_key = ( city or "" ) . strip () . lower ()
configured_pair = AMSC_SETTLEMENT_RUNWAY_PAIRS . get ( city_key )
target = _normalize_runway_label ( AMSC_SETTLEMENT_RUNWAY_TARGETS . get ( city_key ))
if not configured_pair or not target :
return None
pair = _split_runway_pair_label ( row . get ( "runway" ))
2026-05-29 22:40:33 +08:00
configured = (
_normalize_runway_label ( configured_pair [ 0 ]),
_normalize_runway_label ( configured_pair [ 1 ]),
)
2026-05-28 10:59:54 +08:00
if not _runway_pair_matches ( pair , configured ):
return None
tdz_temp = _sf ( row . get ( "tdz_temp" ))
end_temp = _sf ( row . get ( "end_temp" ))
if target == pair [ 0 ]:
return tdz_temp if tdz_temp is not None else end_temp
if target == pair [ 1 ]:
return end_temp if end_temp is not None else tdz_temp
return None
def _runway_history_temp_for_city ( city : str , row : Dict [ str , Any ]) -> Optional [ float ]:
endpoint_temp = _settlement_runway_endpoint_temp ( city , row )
if endpoint_temp is not None :
return endpoint_temp
target_runway_max = _sf ( row . get ( "target_runway_max" ))
if target_runway_max is not None :
return target_runway_max
return _sf ( row . get ( "tdz_temp" ))
2026-05-25 20:03:06 +08:00
_ANALYSIS_CACHE_STATS_LOCK = threading . Lock ()
2026-05-25 17:53:25 +08:00
_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-05-25 20:03:06 +08:00
_SUMMARY_CACHE_LOCK = threading . Lock ()
2026-05-25 17:54:44 +08:00
_SUMMARY_CACHE_MAXSIZE = 128
2026-05-25 20:03:06 +08:00
_SUMMARY_CACHE = LRUDict ( maxsize = _SUMMARY_CACHE_MAXSIZE )
def _dedupe_forecast_daily ( rows : Any ) -> list [ Dict [ str , Any ]]:
if not isinstance ( rows , list ):
2026-05-25 17:54:44 +08:00
return []
2026-05-25 20:03:06 +08:00
seen = set ()
2026-05-25 17:54:44 +08:00
out = []
for row in rows :
2026-05-25 20:03:06 +08:00
if not isinstance ( row , dict ):
2026-05-25 17:54:44 +08:00
continue
2026-05-25 20:03:06 +08:00
date = str ( row . get ( "date" ) or "" ) . strip ()
2026-05-25 17:54:44 +08:00
if not date or date in seen :
continue
2026-05-25 20:03:06 +08:00
seen . add ( date )
out . append ( row )
2026-05-25 17:54:44 +08:00
return out
2026-05-25 20:03:06 +08:00
def _format_observation_time_local ( value : Any , utc_offset : int ) -> str :
raw = str ( value or "" ) . strip ()
2026-05-25 17:54:44 +08:00
if not raw :
return ""
if "T" in raw :
try :
2026-05-25 20:03:06 +08:00
dt = datetime . fromisoformat ( raw . replace ( "Z" , "+00:00" ))
2026-05-25 17:54:44 +08:00
if dt . tzinfo is None :
2026-05-25 20:03:06 +08:00
dt = dt . replace ( tzinfo = timezone . utc )
return dt . astimezone ( timezone ( timedelta ( seconds = utc_offset ))) . strftime ( "%H:%M" )
2026-05-25 17:54:44 +08:00
except Exception :
pass
2026-05-25 20:03:06 +08:00
match = re . search ( r "(\d{1,2}):(\d {2} )" , raw )
2026-05-25 17:54:44 +08:00
if match :
2026-05-25 20:03:06 +08:00
return f " { int ( match . group ( 1 )) : 02d } : { match . group ( 2 ) } "
2026-05-25 17:54:44 +08:00
return raw [: 16 ]
2026-05-25 17:53:25 +08:00
2026-05-25 20:03:06 +08:00
def _fetch_nmc_current_fallback ( city : str , * , use_fahrenheit : bool ) -> Dict [ str , Any ]:
2026-05-25 17:53:25 +08:00
return {}
2026-05-25 20:03:06 +08:00
def _is_plausible_city_temp ( city : str , value : Any , unit : str = "°C" ) -> bool :
temp = _sf ( value )
2026-05-25 17:54:44 +08:00
if temp is None :
return False
2026-05-26 22:27:55 +08:00
meta = CITY_REGISTRY . get (( city or "" ) . strip () . lower (), {}) or {}
2026-05-25 20:03:06 +08:00
min_c = _sf ( meta . get ( "min_plausible_metar_temp_c" ))
2026-05-25 17:54:44 +08:00
if min_c is None :
return True
2026-05-26 22:27:55 +08:00
min_value = min_c * 9 / 5 + 32 if ( unit or "" ) . upper () . endswith ( "F" ) else min_c
2026-05-25 17:54:44 +08:00
return temp >= min_value
2026-05-25 20:03:06 +08:00
def _parse_local_hour ( local_time_str : Optional [ str ]) -> Optional [ int ]:
2026-05-25 17:54:44 +08:00
if not local_time_str :
return None
try :
2026-05-26 22:27:55 +08:00
parts = local_time_str . strip () . split ( ":" )
2026-05-25 20:03:06 +08:00
hour = int ( parts [ 0 ])
2026-05-25 17:54:44 +08:00
if 0 <= hour <= 23 :
return hour
except Exception :
pass
return None
2026-05-25 20:03:06 +08:00
def _parse_utc_datetime ( value : Any ) -> Optional [ datetime ]:
raw = str ( value or "" ) . strip ()
2026-05-25 17:54:44 +08:00
if not raw or "T" not in raw :
return None
try :
2026-05-25 20:03:06 +08:00
dt = datetime . fromisoformat ( raw . replace ( "Z" , "+00:00" ))
2026-05-25 17:54:44 +08:00
except Exception :
return None
if dt . tzinfo is None :
2026-05-25 20:03:06 +08:00
dt = dt . replace ( tzinfo = timezone . utc )
return dt . astimezone ( timezone . utc )
2026-05-25 17:54:44 +08:00
def _metar_is_current_local_day (
metar : Dict [ str , Any ],
* ,
local_date : str ,
utc_offset : int ,
2026-05-25 20:03:06 +08:00
) -> bool :
if not isinstance ( metar , dict ) or not metar :
2026-05-25 17:54:44 +08:00
return False
2026-05-25 20:03:06 +08:00
if metar . get ( "stale_for_today" ) is True :
2026-05-25 17:54:44 +08:00
return False
2026-05-25 20:03:06 +08:00
observation_local_date = str ( metar . get ( "observation_local_date" ) or "" ) . strip ()
2026-05-25 17:54:44 +08:00
if observation_local_date :
return observation_local_date == local_date
2026-05-25 20:03:06 +08:00
obs_dt = _parse_utc_datetime ( metar . get ( "observation_time" ))
2026-05-25 17:54:44 +08:00
if obs_dt is None :
return True
2026-05-25 20:03:06 +08:00
local_dt = obs_dt . astimezone ( timezone ( timedelta ( seconds = utc_offset )))
return local_dt . strftime ( "%Y-%m- %d " ) == local_date
2026-05-25 17:54:44 +08:00
2026-05-25 20:03:06 +08:00
def _record_analysis_cache_event ( * , city : str , hit : bool , force_refresh : bool ) -> None :
now = datetime . now ( timezone . utc ) . isoformat ()
2026-04-08 06:53:49 +08:00
with _ANALYSIS_CACHE_STATS_LOCK :
2026-05-25 20:03:06 +08:00
_ANALYSIS_CACHE_STATS [ "total_requests" ] = int ( _ANALYSIS_CACHE_STATS . get ( "total_requests" ) or 0 ) + 1
2026-05-26 22:27:55 +08:00
_ANALYSIS_CACHE_STATS [ "last_city" ] = city or ""
2026-04-08 06:53:49 +08:00
if force_refresh :
2026-05-25 20:03:06 +08:00
_ANALYSIS_CACHE_STATS [ "force_refresh_requests" ] = int ( _ANALYSIS_CACHE_STATS . get ( "force_refresh_requests" ) or 0 ) + 1
2026-04-08 06:53:49 +08:00
if hit :
2026-05-25 20:03:06 +08:00
_ANALYSIS_CACHE_STATS [ "cache_hits" ] = int ( _ANALYSIS_CACHE_STATS . get ( "cache_hits" ) or 0 ) + 1
2026-04-08 06:53:49 +08:00
_ANALYSIS_CACHE_STATS [ "last_cache_hit_at" ] = now
else :
2026-05-25 20:03:06 +08:00
_ANALYSIS_CACHE_STATS [ "cache_misses" ] = int ( _ANALYSIS_CACHE_STATS . get ( "cache_misses" ) or 0 ) + 1
2026-04-08 06:53:49 +08:00
_ANALYSIS_CACHE_STATS [ "last_cache_miss_at" ] = now
2026-05-25 20:03:06 +08:00
def get_analysis_cache_stats () -> Dict [ str , Any ]:
2026-04-08 06:53:49 +08:00
with _ANALYSIS_CACHE_STATS_LOCK :
2026-05-25 20:03:06 +08:00
stats = dict ( _ANALYSIS_CACHE_STATS )
hits = int ( stats . get ( "cache_hits" ) or 0 )
misses = int ( stats . get ( "cache_misses" ) or 0 )
2026-04-08 06:53:49 +08:00
eligible = hits + misses
2026-05-25 20:03:06 +08:00
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
2026-04-08 06:53:49 +08:00
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-05-25 20:03:06 +08:00
def _analysis_ttl_for_city ( city : str ) -> int :
city_lower = city . lower ()
2026-05-11 17:28:53 +08:00
if city_lower in TURKISH_MGM_CITIES :
return CACHE_TTL_ANKARA
if city_lower in KOREAN_AMOS_CITIES :
return CACHE_TTL_KOREAN_AMOS
2026-05-17 13:41:44 +08:00
if city_lower in HIGH_FREQ_AIRPORT_ANALYSIS_CITIES :
return 60
2026-05-11 17:28:53 +08:00
return CACHE_TTL
2026-04-11 20:11:01 +08:00
2026-05-25 20:03:06 +08:00
def _analysis_cache_key ( city : str , detail_mode : str = "full" ) -> str :
2026-05-26 22:27:55 +08:00
normalized_raw = ( detail_mode or "" ) . strip () . lower ()
2026-04-12 20:49:51 +08:00
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-05-25 20:03:06 +08:00
detail_modes : tuple [ str , ... ] = ( "panel" , "market" , "nearby" , "full" ),
) -> Optional [ Dict [ str , Any ]]:
now_ts = _time . time ()
2026-04-12 10:42:00 +08:00
freshest_payload : Optional [ Dict [ str , Any ]] = None
freshest_ts = 0.0
2026-05-25 02:20:15 +08:00
with _CACHE_LOCK :
for detail_mode in detail_modes :
2026-05-25 20:03:06 +08:00
cached = _cache . get ( _analysis_cache_key ( city , detail_mode ))
2026-05-25 02:20:15 +08:00
if not cached :
continue
2026-05-25 20:03:06 +08:00
cached_ts = float ( cached . get ( "t" , 0 ))
payload = cached . get ( "d" )
2026-05-25 02:20:15 +08:00
if (
cached_ts
and now_ts - cached_ts < ttl
2026-05-25 20:03:06 +08:00
and isinstance ( payload , dict )
2026-05-25 02:20:15 +08:00
and cached_ts >= freshest_ts
2026-05-25 20:03:06 +08:00
):
2026-05-25 02:20:15 +08:00
freshest_payload = payload
freshest_ts = cached_ts
2026-04-12 10:42:00 +08:00
return freshest_payload
2026-05-25 20:03:06 +08:00
def _get_cached_summary ( city : str , ttl : int ) -> Optional [ Dict [ str , Any ]]:
now_ts = _time . time ()
2026-04-11 20:11:01 +08:00
with _SUMMARY_CACHE_LOCK :
2026-05-25 20:03:06 +08:00
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 )
2026-04-11 20:11:01 +08:00
return None
2026-05-25 20:03:06 +08:00
def _set_cached_summary ( city : str , payload : Dict [ str , Any ]) -> None :
2026-04-11 20:11:01 +08:00
with _SUMMARY_CACHE_LOCK :
2026-05-25 20:03:06 +08:00
_SUMMARY_CACHE [ city ] = { "t" : _time . time (), "d" : dict ( payload )}
2026-04-11 20:11:01 +08:00
2026-03-24 01:28:45 +08:00
2026-03-24 02:58:29 +08:00
2026-05-25 20:03:06 +08:00
def _build_intraday_meteorology ( data : Dict [ str , Any ]) -> Dict [ str , Any ]:
2026-04-16 17:13:53 +08:00
"""Build a paid-product intraday meteorology read from existing layers."""
2026-05-25 20:03:06 +08:00
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 {}
2026-04-16 17:13:53 +08:00
taf_signal = (
2026-05-25 20:03:06 +08:00
(( data . get ( "taf" ) or {}) . get ( "signal" ) or {})
if isinstance ( data . get ( "taf" ), dict )
2026-04-16 17:13:53 +08:00
else {}
2026-05-25 20:03:06 +08:00
)
vertical = data . get ( "vertical_profile_signal" ) or {}
2026-04-16 17:13:53 +08:00
2026-05-25 20:03:06 +08:00
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
2026-04-16 17:13:53 +08:00
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
2026-05-25 20:03:06 +08:00
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
2026-04-16 17:13:53 +08:00
signals : list = []
support_score = 0
suppress_score = 0
available_layers = 0
2026-05-25 20:03:06 +08:00
direction = str ( deviation . get ( "direction" ) or "" ) . lower ()
severity = str ( deviation . get ( "severity" ) or "normal" ) . lower ()
delta = _sf ( deviation . get ( "current_delta" ))
2026-04-16 17:13:53 +08:00
if direction :
available_layers += 1
2026-05-25 20:03:06 +08:00
strength = "strong" if severity == "strong" else ( "medium" if severity == "light" else "weak" )
2026-04-16 17:13:53 +08:00
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 ,
2026-05-25 20:03:06 +08:00
summary = f "实测较预期路径偏高 { abs ( delta or 0 ) : .1f }{ unit } ,峰值仍有上修空间。" ,
summary_en = f "Observed temperature is running { abs ( delta or 0 ) : .1f }{ unit } above the expected path; the peak still has upside room." ,
)
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 ,
2026-05-25 20:03:06 +08:00
summary = f "实测较预期路径偏低 { abs ( delta or 0 ) : .1f }{ unit } ,追更高温档需要等待后续观测确认。" ,
summary_en = f "Observed temperature is running { abs ( delta or 0 ) : .1f }{ unit } below the expected path; higher buckets need confirmation from later observations." ,
)
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-05-25 20:03:06 +08:00
)
2026-04-16 17:13:53 +08:00
2026-05-25 20:03:06 +08:00
heating_setup = str ( vertical . get ( "heating_setup" ) or "" ) . lower ()
suppression_risk = str ( vertical . get ( "suppression_risk" ) or "" ) . lower ()
2026-04-16 17:13:53 +08:00
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" ,
2026-05-25 20:03:06 +08:00
summary = str ( vertical . get ( "summary_zh" ) or "边界层结构支持白天继续混合升温。" ),
summary_en = str ( vertical . get ( "summary_en" ) or "The boundary-layer setup supports continued daytime mixing and warming." ),
)
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" ,
2026-05-25 20:03:06 +08:00
summary = str ( vertical . get ( "summary_zh" ) or "边界层或云雨结构对午后峰值形成压制。" ),
summary_en = str ( vertical . get ( "summary_en" ) or "Boundary-layer or cloud/rain structure is capping the afternoon peak." ),
)
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" ,
2026-05-25 20:03:06 +08:00
summary = str ( vertical . get ( "summary_zh" ) or "边界层结构暂未给出单边信号。" ),
summary_en = str ( vertical . get ( "summary_en" ) or "The boundary-layer setup does not yet provide a one-sided signal." ),
)
2026-04-16 17:13:53 +08:00
2026-05-25 20:03:06 +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" ,
}
2026-05-29 21:56:57 +08:00
2026-04-19 14:15:46 +08:00
structural_cap = False
2026-05-25 20:03:06 +08:00
if taf_signal . get ( "available" ) or taf_suppression :
2026-04-16 17:13:53 +08:00
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 ,
2026-05-25 20:03:06 +08:00
summary = str ( taf_signal . get ( "summary_zh" ) or "TAF 暂未提示强云雨压温信号。" ),
summary_en = str ( taf_signal . get ( "summary_en" ) or "TAF does not yet flag a strong cloud/rain temperature cap." ),
)
2026-04-16 17:13:53 +08:00
2026-05-25 20:03:06 +08:00
airport_delta = _sf ( data . get ( "airport_vs_network_delta" ))
lead_signal = data . get ( "network_lead_signal" ) or {}
2026-04-16 17:13:53 +08:00
if airport_delta is not None :
available_layers += 1
2026-05-25 20:03:06 +08:00
leader = str ( lead_signal . get ( "leader_station_label" ) or lead_signal . get ( "leader_station_code" ) or "" ) . strip ()
sync_status = str ( lead_signal . get ( "leader_sync_status" ) or "" ) . strip () . lower ()
sync_delta = _sf ( lead_signal . get ( "leader_time_delta_vs_anchor_minutes" ))
2026-04-19 02:38:34 +08:00
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" ,
2026-05-25 20:03:06 +08:00
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" ,
2026-05-25 20:03:06 +08:00
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-05-25 20:03:06 +08:00
)
2026-04-16 17:13:53 +08:00
2026-05-25 20:03:06 +08:00
peak_status = str ( peak . get ( "status" ) or "" ) . lower ()
first_h = _sf ( peak . get ( "first_h" ))
last_h = _sf ( peak . get ( "last_h" ))
2026-04-16 17:13:53 +08:00
peak_window = (
2026-05-25 20:03:06 +08:00
f " { int ( first_h ) : 02d } :00- { int ( last_h ) : 02d } :59"
2026-04-16 17:13:53 +08:00
if first_h is not None and last_h is not None
else "--"
2026-05-25 20:03:06 +08:00
)
2026-04-16 17:13:53 +08:00
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 (
2026-05-25 20:03:06 +08:00
signal . get ( "direction" ) == "suppress"
and signal . get ( "label" ) in { "边界层结构" , "站网对比" , "日内节奏" }
2026-04-19 14:15:46 +08:00
for signal in signals
2026-05-25 20:03:06 +08:00
)
2026-04-19 14:15:46 +08:00
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"
2026-05-25 20:03:06 +08:00
next_observation = _next_observation_clock ( data . get ( "local_time" ) or current . get ( "obs_time" ))
2026-04-16 17:13:53 +08:00
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" :
2026-05-25 20:03:06 +08:00
invalidation_rules . append ( "若后续官方结算源补录更高值,以结算源最终高点为准。" )
invalidation_rules_en . append ( "If the official settlement source later backfills a higher reading, defer to the final settlement-source high." )
confirmation_rules . append ( "若峰值窗口后连续两次观测不再创新高,当前高点基本确认。" )
confirmation_rules_en . append ( "If two consecutive post-peak observations fail to make a new high, the current high is broadly confirmed." )
2026-04-16 17:13:53 +08:00
else :
2026-05-25 20:03:06 +08:00
watch_clock = _format_clock_minutes ( int ( first_h or 13 ) * 60 + 30 )
2026-04-16 17:13:53 +08:00
if threshold is not None :
2026-05-25 20:03:06 +08:00
invalidation_rules . append ( f " { watch_clock } 前若仍未接近 { threshold : .0f }{ unit } ,上修路径降级。" )
invalidation_rules_en . append ( f "If observations are still not near { threshold : .0f }{ unit } before { watch_clock } , downgrade the upside path." )
confirmation_rules . append ( f "峰值窗口内任一结算源观测触达或超过 { threshold : .0f }{ unit } ,基准路径确认度上升。" )
confirmation_rules_en . append ( f "If any settlement-source observation reaches or exceeds { threshold : .0f }{ unit } inside the peak window, confidence in the base path rises." )
invalidation_rules . append ( "若 TAF 或实况报文出现阵雨、雷暴或低云/云雨压制,高温上沿需要下调。" )
invalidation_rules_en . append ( "If TAF or live reports show showers, thunderstorms, or low-cloud/cloud-rain suppression, lower the upper temperature bound." )
confirmation_rules . append ( "若实测继续贴近 DEB 曲线且云雨信号不增强,维持当前主路径。" )
confirmation_rules_en . append ( "If observations keep tracking the DEB curve and cloud/rain signals do not strengthen, maintain the current main path." )
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-05-25 20:03:06 +08:00
)
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-25 20:03:06 +08:00
def _archive_intraday_path_snapshot ( city : str , result : Dict [ str , Any ]) -> None :
2026-05-01 11:27:16 +08:00
"""Persist replayable intraday path inputs visible at analysis time."""
2026-05-25 20:03:06 +08:00
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 :
2026-05-01 11:27:16 +08:00
return
2026-05-25 20:03:06 +08:00
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" ))
2026-05-01 11:27:16 +08:00
offset = (
deb_prediction - forecast_today_high
if deb_prediction is not None and forecast_today_high is not None
else 0.0
2026-05-25 20:03:06 +08:00
)
2026-05-01 11:27:16 +08:00
deb_base_temps = [
2026-05-25 20:03:06 +08:00
round ( float ( value ) + offset , 1 ) if _sf ( value ) is not None else None
2026-05-01 11:27:16 +08:00
for value in temps
]
2026-05-25 20:03:06 +08:00
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" )
2026-05-01 11:27:16 +08:00
payload = {
"schema_version" : 1 ,
"city" : city ,
2026-05-25 20:03:06 +08:00
"target_date" : str ( result . get ( "local_date" ) or "" ) . strip (),
2026-05-01 11:27:16 +08:00
"snapshot_time" : snapshot_time ,
2026-05-25 20:03:06 +08:00
"local_time" : str ( result . get ( "local_time" ) or "" ) . strip (),
2026-05-01 11:27:16 +08:00
"utc_offset_seconds" : utc_offset ,
2026-05-25 20:03:06 +08:00
"temp_symbol" : result . get ( "temp_symbol" ),
2026-05-01 11:27:16 +08:00
"deb_prediction" : deb_prediction ,
"forecast_today_high" : forecast_today_high ,
"deb_base_path" : {
2026-05-25 20:03:06 +08:00
"times" : [ str ( item ) for item in times ],
2026-05-01 11:27:16 +08:00
"temps" : deb_base_temps ,
"source" : "hourly_plus_deb_offset" ,
2026-05-25 20:03:06 +08:00
"offset" : round ( offset , 3 ),
2026-05-01 11:27:16 +08:00
},
"hourly" : {
2026-05-25 20:03:06 +08:00
"times" : [ str ( item ) for item in times ],
2026-05-01 11:27:16 +08:00
"temps" : temps ,
},
2026-05-25 20:03:06 +08:00
"metar_today_obs" : result . get ( "metar_today_obs" ) or [],
"settlement_today_obs" : result . get ( "settlement_today_obs" ) or [],
2026-05-01 11:27:16 +08:00
"current" : {
2026-05-25 20:03:06 +08:00
"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" ),
2026-05-01 11:27:16 +08:00
},
"forecast" : {
"today_high" : forecast_today_high ,
2026-05-25 20:03:06 +08:00
"sunrise" : forecast . get ( "sunrise" ),
"sunset" : forecast . get ( "sunset" ),
2026-05-01 11:27:16 +08:00
},
2026-05-25 20:03:06 +08:00
"peak" : result . get ( "peak" ) or {},
"metar_status" : result . get ( "metar_status" ) or {},
2026-05-01 11:27:16 +08:00
}
try :
2026-05-25 20:03:06 +08:00
IntradayPathSnapshotRepository () . append_snapshot ( payload )
2026-05-01 11:27:16 +08:00
except Exception as exc :
2026-05-25 20:03:06 +08:00
logger . debug ( f "intraday path snapshot archive skipped for { city } : { exc } " )
2026-05-01 11:27:16 +08:00
2026-04-08 11:41:34 +08:00
def _analyze (
city : str ,
force_refresh : bool = False ,
2026-05-17 18:04:05 +08:00
force_refresh_observations_only : bool = False ,
2026-04-11 20:11:01 +08:00
detail_mode : str = "full" ,
2026-05-25 20:03:06 +08:00
) -> Dict [ str , Any ]:
2026-05-17 18:04:05 +08:00
"""Fetch, analyse, and return structured weather data for one city.
Set *force_refresh_observations_only* to True for high-frequency
observation loops that need fresh METAR/AMOS/runway data but should
keep the longer-lived multi-model forecast caches intact so the DEB
blending does not fall back to the current observed temperature.
"""
# Check cache – skip when explicitly refreshing observations
2026-05-25 20:03:06 +08:00
ttl = _analysis_ttl_for_city ( city )
2026-05-26 22:27:55 +08:00
normalized_detail_mode_raw = ( detail_mode or "full" ) . strip () . lower ()
2026-04-12 20:49:51 +08:00
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-05-25 20:03:06 +08:00
cache_key = _analysis_cache_key ( city , normalized_detail_mode )
2026-05-17 18:04:05 +08:00
if not force_refresh and not force_refresh_observations_only :
2026-05-25 20:03:06 +08:00
cached = _cache . get ( cache_key )
if cached and _time . time () - cached [ "t" ] < ttl :
_record_analysis_cache_event ( city = city , hit = True , force_refresh = False )
2026-03-20 20:59:30 +08:00
return cached [ "d" ]
2026-05-25 20:03:06 +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"
2026-05-25 20:03:06 +08:00
settlement_source = str ( info . get ( "settlement_source" ) or "metar" ) . strip () . lower () or "metar"
2026-03-20 20:59:30 +08:00
settlement_source_label = SETTLEMENT_SOURCE_LABELS . get (
settlement_source ,
2026-05-25 20:03:06 +08:00
settlement_source . upper (),
)
2026-03-20 20:59:30 +08:00
# ── 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-05-17 18:04:05 +08:00
force_refresh_observations_only = force_refresh_observations_only ,
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-05-25 18:17:03 +08:00
include_multi_model = not is_nearby_mode ,
2026-04-13 18:42:27 +08:00
include_mgm = not is_market_mode ,
2026-05-25 20:03:06 +08:00
)
om = raw . get ( "open-meteo" , {})
metar = raw . get ( "metar" , {})
taf = raw . get ( "taf" , {})
mgm = raw . get ( "mgm" ) or {}
settlement_current = raw . get ( "settlement_current" ) or {}
2026-05-29 17:20:07 +08:00
wunderground_current = raw . get ( "wunderground_current" ) or {}
2026-05-25 20:03:06 +08:00
ens_raw = raw . get ( "ensemble" , {})
mm = raw . get ( "multi_model" , {})
if not isinstance ( om , dict ):
2026-03-20 20:59:30 +08:00
om = {}
2026-05-25 20:03:06 +08:00
if not isinstance ( metar , dict ):
2026-03-20 20:59:30 +08:00
metar = {}
2026-05-25 20:03:06 +08:00
if not isinstance ( mgm , dict ):
2026-03-20 20:59:30 +08:00
mgm = {}
2026-05-25 20:03:06 +08:00
if not isinstance ( settlement_current , dict ):
2026-03-20 20:59:30 +08:00
settlement_current = {}
2026-05-29 17:20:07 +08:00
if not isinstance ( wunderground_current , dict ):
wunderground_current = {}
2026-05-25 20:03:06 +08:00
if not isinstance ( ens_raw , dict ):
2026-03-20 20:59:30 +08:00
ens_raw = {}
2026-05-25 20:03:06 +08:00
if not isinstance ( mm , dict ):
2026-03-20 20:59:30 +08:00
mm = {}
2026-05-25 20:03:06 +08:00
if not mm . get ( "hourly_times" ):
mm_hourly = _weather . fetch_multi_model ( lat , lon , city = city , use_fahrenheit = is_f )
if mm_hourly and mm_hourly . get ( "hourly_times" ):
2026-05-25 18:39:54 +08:00
mm = { ** mm , ** mm_hourly }
2026-05-28 09:44:42 +08:00
raw [ "multi_model" ] = mm
2026-05-25 20:03:06 +08:00
risk = CITY_RISK_PROFILES . get ( city , {})
2026-04-11 20:11:01 +08:00
network_snapshot = (
2026-05-25 20:03:06 +08:00
build_country_network_snapshot ( city , raw )
2026-05-29 21:56:57 +08:00
if _should_build_country_network_snapshot (
city ,
raw ,
is_panel_mode = is_panel_mode ,
is_market_mode = is_market_mode ,
)
2026-04-11 20:11:01 +08:00
else {}
2026-05-25 20:03:06 +08:00
)
2026-03-20 20:59:30 +08:00
2026-04-21 19:13:33 +08:00
# 优先从 API 获取偏移;若缺失则尝试 NWS 动态偏移;最后回退静态配置。
# 当前日期/时间必须来自运行时钟,不能使用 Open-Meteo 缓存里的 local_time。
2026-05-25 20:03:06 +08:00
utc_offset = om . get ( "utc_offset" )
2026-04-21 19:13:33 +08:00
if utc_offset is None :
try :
2026-05-25 20:03:06 +08:00
nws_periods = ( raw . get ( "nws" , {}) or {}) . get ( "forecast_periods" , []) or []
2026-04-21 19:13:33 +08:00
if nws_periods :
2026-05-25 20:03:06 +08:00
first_start = nws_periods [ 0 ] . get ( "start_time" )
2026-04-21 19:13:33 +08:00
if first_start :
2026-05-25 20:03:06 +08:00
maybe_dt = datetime . fromisoformat ( str ( first_start ))
2026-05-26 22:27:55 +08:00
offset_td = maybe_dt . utcoffset ()
if offset_td is not None :
utc_offset = int ( offset_td . total_seconds ())
2026-04-21 19:13:33 +08:00
except Exception :
utc_offset = None
if utc_offset is None :
2026-05-25 20:03:06 +08:00
utc_offset = get_city_utc_offset_seconds ( city )
2026-04-21 19:13:33 +08:00
try :
2026-05-25 20:03:06 +08:00
utc_offset = int ( utc_offset or 0 )
2026-04-21 19:13:33 +08:00
except Exception :
2026-05-25 20:03:06 +08:00
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 " )
2026-04-21 19:13:33 +08:00
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 ,
2026-05-26 22:27:55 +08:00
utc_offset = utc_offset ,
2026-05-25 20:03:06 +08:00
)
# ── 2. Current conditions (settlement > AMOS runway sensors > METAR > MGM > NMC fallback) ──
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 {}
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= {} " ,
2026-05-25 20:03:06 +08:00
city , amos_data . get ( "temp_c" ), amos_data . get ( "source" ))
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
2026-05-25 20:03:06 +08:00
current_station_code = settlement_current . get ( "station_code" )
current_station_name = settlement_current . get ( "station_name" )
cur_temp = _sf ( primary_current . get ( "temp" ))
if cur_temp is not None and not _is_plausible_city_temp ( city , cur_temp , sym ):
2026-04-16 17:34:39 +08:00
cur_temp = None
2026-05-25 20:03:06 +08:00
# AMOS runway sensor: authoritative for Korean airports (RKSI/RKPK)
2026-05-10 18:16:10 +08:00
if cur_temp is None :
2026-05-25 20:03:06 +08:00
amos_temp = _sf ( amos_data . get ( "temp_c" ))
if amos_temp is not None and _is_plausible_city_temp ( city , amos_temp , sym ):
2026-05-10 18:16:10 +08:00
cur_temp = amos_temp
current_source = "amos"
2026-05-25 20:03:06 +08:00
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-05-25 20:03:06 +08:00
cur_temp = _sf ( live_mc . get ( "temp" ))
if cur_temp is not None and not _is_plausible_city_temp ( city , cur_temp , sym ):
2026-04-16 17:34:39 +08:00
cur_temp = None
2026-05-25 20:58:18 +08:00
# Official settlement station: e.g. CWA for Taipei, HKO for Hong Kong
if cur_temp is None :
cur_temp = _sf (( settlement_current . get ( "current" ) or {}) . get ( "temp" ))
if cur_temp is not None :
current_source = settlement_source or "settlement"
current_source_label = settlement_source_label or "Settlement Station"
current_station_code = settlement_current . get ( "station_code" ) or current_station_code
current_station_name = settlement_current . get ( "station_name" ) or current_station_name
2026-03-20 20:59:30 +08:00
if cur_temp is None :
2026-05-25 20:03:06 +08:00
cur_temp = _sf ( mg_cur . get ( "temp" ))
if cur_temp is not None and not _is_plausible_city_temp ( city , cur_temp , sym ):
2026-04-16 17:34:39 +08:00
cur_temp = None
2026-04-16 15:29:26 +08:00
if cur_temp is None :
2026-05-25 20:03:06 +08:00
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" ))
2026-04-16 15:29:26 +08:00
if nmc_temp is not None :
cur_temp = nmc_temp
current_source = "nmc"
current_source_label = "NMC"
2026-05-25 20:03:06 +08:00
current_station_code = nmc_fallback . get ( "station_code" )
current_station_name = nmc_fallback . get ( "station_name" )
2026-03-20 20:59:30 +08:00
2026-05-25 20:03:06 +08:00
max_so_far = _sf ( primary_current . get ( "max_temp_so_far" ))
if max_so_far is not None and not _is_plausible_city_temp ( city , max_so_far , sym ):
2026-04-16 17:34:39 +08:00
max_so_far = None
2026-03-20 20:59:30 +08:00
if max_so_far is None :
2026-05-25 20:03:06 +08:00
max_so_far = _sf ( live_mc . get ( "max_temp_so_far" ))
if max_so_far is not None and not _is_plausible_city_temp ( city , max_so_far , sym ):
2026-04-16 17:34:39 +08:00
max_so_far = None
2026-03-20 20:59:30 +08:00
if max_so_far is None :
2026-05-25 20:03:06 +08:00
max_so_far = _sf ( mg_cur . get ( "mgm_max_temp" ))
if max_so_far is not None and not _is_plausible_city_temp ( city , max_so_far , sym ):
2026-04-16 17:34:39 +08:00
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
2026-05-25 20:03:06 +08:00
max_temp_time = primary_current . get ( "max_temp_time" )
2026-03-20 20:59:30 +08:00
if not max_temp_time and not use_settlement_current :
2026-05-25 20:03:06 +08:00
max_temp_time = live_mc . get ( "max_temp_time" )
2026-03-20 20:59:30 +08:00
if not max_temp_time :
2026-05-25 20:03:06 +08:00
max_temp_time = mg_cur . get ( "time" , "" )
2026-03-20 20:59:30 +08:00
if " " in max_temp_time :
2026-05-25 20:03:06 +08:00
max_temp_time = max_temp_time . split ( " " )[ 1 ][: 5 ]
2026-03-20 20:59:30 +08:00
if max_temp_time == "" :
max_temp_time = None
2026-03-25 18:38:34 +08:00
raw_settlement_max = max_so_far
2026-05-25 20:03:06 +08:00
wu_settle = apply_city_settlement ( city . lower (), raw_settlement_max ) if raw_settlement_max is not None else None
2026-03-25 18:38:34 +08:00
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 :
2026-05-25 20:03:06 +08:00
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-05-25 20:03:06 +08:00
obs_t = metar . get ( "observation_time" , "" ) if metar else ""
2026-03-20 20:59:30 +08:00
if obs_t and "T" in obs_t :
try :
2026-05-25 20:03:06 +08:00
dt = _parse_utc_datetime ( obs_t )
2026-04-21 19:13:33 +08:00
if dt is None :
2026-05-25 20:03:06 +08:00
raise ValueError ( "invalid observation time" )
local_dt = dt . astimezone ( timezone ( timedelta ( seconds = utc_offset )))
obs_time_str = local_dt . strftime ( "%H:%M" )
2026-03-20 20:59:30 +08:00
metar_age_min = int (
2026-05-25 20:03:06 +08:00
( datetime . now ( timezone . utc ) - dt . astimezone ( timezone . utc )) . total_seconds () / 60
)
2026-03-20 20:59:30 +08:00
except Exception :
2026-05-25 20:03:06 +08:00
obs_time_str = str ( obs_t )[: 16 ]
2026-05-10 18:18:40 +08:00
if not obs_time_str and current_source == "amos" :
2026-05-25 20:03:06 +08:00
amos_obs_time = amos_data . get ( "observation_time" )
2026-05-10 18:18:40 +08:00
if amos_obs_time :
2026-05-26 22:27:55 +08:00
obs_time_str = _format_observation_time_local ( amos_obs_time , utc_offset )
nmc_fallback = None
2026-04-16 15:29:26 +08:00
if not obs_time_str and current_source == "nmc" :
2026-05-25 20:03:06 +08:00
nmc_fallback = _fetch_nmc_current_fallback ( city , use_fahrenheit = is_f )
2026-04-16 15:29:26 +08:00
obs_time_str = _format_observation_time_local (
2026-05-25 20:03:06 +08:00
nmc_fallback . get ( "publish_time" ) or nmc_fallback . get ( "timestamp" ),
2026-05-26 22:27:55 +08:00
utc_offset ,
2026-05-25 20:03:06 +08:00
)
2026-03-20 20:59:30 +08:00
2026-05-14 14:21:28 +08:00
current_obs_raw = obs_t
if current_source == "amos" :
2026-05-25 20:03:06 +08:00
current_obs_raw = amos_data . get ( "observation_time" )
2026-05-14 14:21:28 +08:00
elif current_source == "nmc" :
current_obs_raw = (
2026-05-25 20:03:06 +08:00
nmc_fallback . get ( "publish_time" )
or nmc_fallback . get ( "timestamp" )
if isinstance ( nmc_fallback , dict )
2026-05-14 14:21:28 +08:00
else None
2026-05-25 20:03:06 +08:00
)
2026-05-14 14:21:28 +08:00
current_age_min = metar_age_min
if current_obs_raw :
2026-05-25 20:03:06 +08:00
current_age_min = _observation_age_min ( current_obs_raw , now_utc ) or current_age_min
2026-05-14 14:21:28 +08:00
current_freshness = _build_observation_freshness (
source_code = current_source ,
source_label = current_source_label ,
observed_at = current_obs_raw ,
observed_at_local = obs_time_str ,
2026-05-25 20:03:06 +08:00
ingested_at = primary_current . get ( "receipt_time" ) or primary_current . get ( "report_time" ),
2026-05-14 14:21:28 +08:00
age_min = current_age_min ,
now_utc = now_utc ,
2026-05-25 20:03:06 +08:00
)
airport_source_code = amos_data . get ( "source" ) if current_source == "amos" else "metar"
airport_source_code = airport_source_code or ( "amos" if current_source == "amos" else "metar" )
airport_source_label = amos_data . get ( "source_label" ) if current_source == "amos" else "METAR"
airport_source_label = airport_source_label or ( "AMOS" if current_source == "amos" else "METAR" )
airport_obs_raw = amos_data . get ( "observation_time" ) if current_source == "amos" else ( metar . get ( "observation_time" ) if metar else None )
airport_age_min = _observation_age_min ( airport_obs_raw , now_utc ) if airport_obs_raw else metar_age_min
2026-05-14 14:21:28 +08:00
if airport_age_min is None :
airport_age_min = metar_age_min
2026-05-25 20:03:06 +08:00
airport_temp = _sf ( amos_data . get ( "temp_c" )) if current_source == "amos" else _sf ( live_mc . get ( "temp" ))
if airport_temp is not None and not _is_plausible_city_temp ( city , airport_temp , sym ):
2026-05-14 14:21:28 +08:00
airport_temp = None
airport_freshness = _build_observation_freshness (
source_code = airport_source_code ,
source_label = airport_source_label ,
observed_at = airport_obs_raw ,
observed_at_local = obs_time_str ,
2026-05-25 20:03:06 +08:00
ingested_at = metar . get ( "receipt_time" ) if metar else None ,
2026-05-14 14:21:28 +08:00
age_min = airport_age_min ,
now_utc = now_utc ,
2026-05-25 20:03:06 +08:00
)
2026-05-14 14:21:28 +08:00
2026-05-25 20:03:06 +08:00
airport_primary_current = dict ( network_snapshot . get ( "airport_primary_current" ) or {})
2026-04-21 19:13:33 +08:00
if (
2026-05-25 20:03:06 +08:00
airport_primary_current . get ( "source_code" ) == "metar"
2026-04-21 19:13:33 +08:00
and metar
and not metar_current_is_today
2026-05-25 20:03:06 +08:00
):
2026-04-21 19:13:33 +08:00
airport_primary_current [ "temp" ] = None
airport_primary_current [ "stale_for_today" ] = True
2026-05-25 20:03:06 +08:00
airport_primary_current [ "last_observation_local_date" ] = metar . get ( "observation_local_date" )
2026-04-21 19:13:33 +08:00
airport_primary_current [ "current_local_date" ] = local_date_str
2026-04-16 18:21:51 +08:00
if (
2026-05-25 20:03:06 +08:00
airport_primary_current . get ( "source_code" ) == "metar"
2026-04-16 18:21:51 +08:00
and obs_time_str
and not use_settlement_current
2026-05-25 20:03:06 +08:00
):
2026-04-16 18:21:51 +08:00
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-05-25 20:03:06 +08:00
explicit_settlement_obs = settlement_current . get ( "today_obs" ) or []
2026-03-23 21:22:41 +08:00
normalized_obs = []
for item in explicit_settlement_obs :
2026-05-25 20:03:06 +08:00
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 ])
2026-03-23 21:22:41 +08:00
else :
continue
if not raw_time or raw_temp is None :
continue
2026-05-25 20:03:06 +08:00
normalized_obs . append ({ "time" : raw_time , "temp" : raw_temp })
2026-03-23 21:22:41 +08:00
if normalized_obs :
settlement_today_obs = normalized_obs
else :
if obs_time_str and cur_temp is not None :
2026-05-25 20:03:06 +08:00
settlement_today_obs . append ({ "time" : obs_time_str , "temp" : cur_temp })
2026-03-23 21:22:41 +08:00
if (
max_temp_time
and max_so_far is not None
2026-05-26 22:27:55 +08:00
and max_temp_time != obs_time_str
2026-05-25 20:03:06 +08:00
):
2026-05-26 22:27:55 +08:00
settlement_today_obs . append ({ "time" : 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 (
2026-05-25 20:03:06 +08:00
metar . get ( "today_obs" , []) if metar and metar_current_is_today else []
)
if _is_plausible_city_temp ( city , v , sym )
2026-04-16 17:34:39 +08:00
]
metar_recent_obs_payload = [
point
2026-04-21 19:13:33 +08:00
for point in (
2026-05-25 20:03:06 +08:00
metar . get ( "recent_obs" , []) if metar and metar_current_is_today else []
)
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 :
2026-05-25 20:03:06 +08:00
value = _sf ( point . get ( "temp" )) if isinstance ( point , dict ) else None
2026-03-25 18:03:56 +08:00
if value is None :
continue
if airport_max_so_far is None or value >= airport_max_so_far :
airport_max_so_far = value
2026-05-25 20:03:06 +08:00
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-05-25 20:03:06 +08:00
daily = om . get ( "daily" , {})
all_dates = daily . get ( "time" , [])
all_maxtemps = daily . get ( "temperature_2m_max" , [])
all_sunrises = daily . get ( "sunrise" , [])
all_sunsets = daily . get ( "sunset" , [])
all_sunshine = daily . get ( "sunshine_duration" , [])
2026-05-20 07:55:47 +08:00
start_idx = 0
if local_date_str in all_dates :
2026-05-25 20:03:06 +08:00
start_idx = all_dates . index ( local_date_str )
2026-05-20 07:55:47 +08:00
else :
2026-05-25 20:03:06 +08:00
for idx , d in enumerate ( all_dates ):
2026-05-20 07:55:47 +08:00
if d >= local_date_str :
start_idx = idx
break
dates = all_dates [ start_idx : start_idx + 5 ]
maxtemps = all_maxtemps [ start_idx : start_idx + 5 ]
sunrises = all_sunrises [ start_idx : start_idx + 5 ]
sunsets = all_sunsets [ start_idx : start_idx + 5 ]
sunshine = all_sunshine [ start_idx : start_idx + 5 ]
2026-05-25 20:03:06 +08:00
om_today = _sf ( maxtemps [ 0 ]) if maxtemps else None
2026-03-20 20:59:30 +08:00
2026-04-26 08:12:31 +08:00
forecast_daily = _dedupe_forecast_daily (
2026-05-25 20:03:06 +08:00
[{ "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 :
2026-05-25 20:03:06 +08:00
nws_high = _sf ( raw . get ( "nws" , {}) . get ( "today_high" ))
mgm_high = _sf ( mgm . get ( "today_high" )) if mgm else None
mgm_hourly_high = _mgm_hourly_high ( mgm )
2026-03-20 20:59:30 +08:00
fallback_high = (
nws_high
if nws_high is not None
else mgm_high
if mgm_high is not None
2026-05-17 19:19:12 +08:00
else mgm_hourly_high
if mgm_hourly_high is not None
2026-03-20 20:59:30 +08:00
else max_so_far
if max_so_far is not None
else cur_temp
2026-05-25 20:03:06 +08:00
)
2026-03-20 20:59:30 +08:00
if fallback_high is not None :
2026-05-26 22:27:55 +08:00
om_today = fallback_high
2026-03-20 20:59:30 +08:00
if not forecast_daily :
forecast_daily = [{ "date" : local_date_str , "max_temp" : om_today }]
sunrise = (
2026-05-25 20:03:06 +08:00
sunrises [ 0 ] . split ( "T" )[ 1 ][: 5 ]
if sunrises and "T" in str ( sunrises [ 0 ])
2026-03-20 20:59:30 +08:00
else ""
2026-05-25 20:03:06 +08:00
)
2026-03-20 20:59:30 +08:00
sunset = (
2026-05-25 20:03:06 +08:00
sunsets [ 0 ] . split ( "T" )[ 1 ][: 5 ]
if sunsets and "T" in str ( sunsets [ 0 ])
2026-03-20 20:59:30 +08:00
else ""
2026-05-25 20:03:06 +08:00
)
sunshine_h = round ( sunshine [ 0 ] / 3600 , 1 ) if sunshine else 0
2026-03-20 20:59:30 +08:00
# ── 5. Multi-model forecasts ──
current_forecasts : Dict [ str , float ] = {}
if om_today is not None :
current_forecasts [ "Open-Meteo" ] = om_today
2026-05-25 20:03:06 +08:00
for m , v in mm . get ( "forecasts" , {}) . items ():
if v is not None and not _is_excluded_model_name ( m ):
2026-05-26 22:27:55 +08:00
temp_val = _sf ( v )
if temp_val is not None :
current_forecasts [ m ] = temp_val
2026-05-25 20:03:06 +08:00
nws_high = _sf ( raw . get ( "nws" , {}) . get ( "today_high" ))
2026-03-20 20:59:30 +08:00
if nws_high is not None :
current_forecasts [ "NWS" ] = nws_high
2026-05-25 20:03:06 +08:00
mgm_high = _sf ( mgm . get ( "today_high" )) if mgm else None
mgm_hourly_high = _mgm_hourly_high ( mgm )
2026-03-20 20:59:30 +08:00
if mgm_high is not None :
current_forecasts [ "MGM" ] = mgm_high
2026-05-17 19:19:12 +08:00
elif mgm_hourly_high is not None :
current_forecasts [ "MGM Hourly" ] = mgm_hourly_high
2026-03-20 20:59:30 +08:00
# ── 6. DEB fusion ──
deb_val , deb_weights = None , ""
2026-05-27 21:40:26 +08:00
deb_raw_val , deb_version = None , None
deb_bias_adjustment , deb_bias_samples = 0.0 , 0
2026-06-08 00:59:16 +08:00
deb_selected_version , deb_guard_reason = None , None
2026-05-27 21:40:26 +08:00
deb_intraday_adjustment = 0.0
2026-05-28 10:44:48 +08:00
deb_hourly_consensus = None
2026-06-07 23:57:47 +08:00
deb_quality = {}
2026-03-20 20:59:30 +08:00
if current_forecasts :
2026-05-27 21:40:26 +08:00
deb_result = calculate_deb_prediction ( city , current_forecasts )
if deb_result . get ( "prediction" ) is not None :
deb_val = deb_result . get ( "prediction" )
deb_raw_val = deb_result . get ( "raw_prediction" )
deb_version = deb_result . get ( "version" )
2026-06-08 00:59:16 +08:00
deb_selected_version = deb_result . get ( "selected_version" )
deb_guard_reason = deb_result . get ( "guard_reason" )
2026-05-27 21:40:26 +08:00
deb_bias_adjustment = deb_result . get ( "bias_adjustment" ) or 0.0
deb_bias_samples = deb_result . get ( "bias_samples" ) or 0
deb_weights = deb_result . get ( "weights_info" ) or ""
2026-06-07 23:57:47 +08:00
deb_quality = {
"quality_tier" : deb_result . get ( "quality_tier" ),
"recommendation" : deb_result . get ( "recommendation" ),
"recent_hit_rate" : deb_result . get ( "recent_hit_rate" ),
"recent_samples" : deb_result . get ( "recent_samples" ),
"recent_hits" : deb_result . get ( "recent_hits" ),
"recent_mae" : deb_result . get ( "recent_mae" ),
}
2026-05-28 10:44:48 +08:00
deb_hourly_consensus = build_deb_hourly_consensus_path (
city = city ,
hourly_times = mm . get ( "hourly_times" ) or [],
hourly_forecasts = mm . get ( "hourly_forecasts" ) or {},
daily_forecasts = current_forecasts ,
deb_prediction = deb_val ,
local_date = local_date_str ,
)
2026-03-20 20:59:30 +08:00
# ── 7. Ensemble stats ──
ens_data = {
2026-05-25 20:03:06 +08:00
"median" : _sf ( ens_raw . get ( "median" )),
"p10" : _sf ( ens_raw . get ( "p10" )),
"p90" : _sf ( ens_raw . get ( "p90" )),
2026-03-20 20:59:30 +08:00
}
# ── 8. METAR trend ──
2026-05-25 20:03:06 +08:00
recent_temps = metar . get ( "recent_temps" , []) if metar else []
2026-03-20 20:59:30 +08:00
trend_info = {
"direction" : "unknown" ,
"recent" : [{ "time" : t , "temp" : v } for t , v in recent_temps [: 6 ]],
"is_cooling" : False ,
"is_dead_market" : False ,
}
2026-05-25 20:03:06 +08:00
if len ( recent_temps ) >= 2 :
2026-03-20 20:59:30 +08:00
t_only = [ t for _ , t in recent_temps ]
latest , prev = t_only [ 0 ], t_only [ 1 ]
diff = latest - prev
2026-05-25 20:03:06 +08:00
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 ))
2026-03-20 20:59:30 +08:00
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"
2026-05-25 20:03:06 +08:00
trend_info [ "is_cooling" ] = trend_info [ "direction" ] in ( "falling" , "stagnant" )
2026-03-20 20:59:30 +08:00
# ── 9. Peak hour detection ──
2026-05-25 20:03:06 +08:00
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" , [])
h_wspd_180m = hourly . get ( "wind_speed_180m" , [])
h_wdir_180m = hourly . get ( "wind_direction_180m" , [])
h_precip_prob = hourly . get ( "precipitation_probability" , [])
h_cloud_cover = hourly . get ( "cloud_cover" , [])
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" , [])
if ( not h_times or not h_temps ) and metar :
metar_today_obs = metar . get ( "today_obs" , []) or []
2026-03-20 20:59:30 +08:00
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
2026-05-25 20:03:06 +08:00
hh , minute_part = str ( t_str ) . split ( ":" )
parsed_obs . append (( int ( hh ), int ( minute_part ), float ( t_val )))
2026-03-20 20:59:30 +08:00
except Exception :
continue
if parsed_obs :
2026-05-25 20:03:06 +08:00
parsed_obs . sort ( key = lambda x : ( x [ 0 ], x [ 1 ]))
2026-03-20 20:59:30 +08:00
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
2026-05-28 10:44:48 +08:00
peak_source = raw
if deb_hourly_consensus :
peak_source = {
** raw ,
"deb" : {
** ( raw . get ( "deb" ) or {}),
"hourly_consensus" : deb_hourly_consensus ,
},
}
peak_hours = _resolve_peak_hours ( peak_source , local_date_str , h_times , h_temps , om_today )
2026-03-20 20:59:30 +08:00
2026-05-25 20:03:06 +08:00
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
2026-03-20 20:59:30 +08:00
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-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-05-25 20:03:06 +08:00
),
)
2026-03-29 23:29:17 +08:00
2026-05-25 20:03:06 +08:00
# ── 10. Shared analysis (probability, trend, AI) via trend_engine ──
2026-03-20 20:59:30 +08:00
# 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
mu = None
2026-03-21 18:54:23 +08:00
dynamic_commentary = { "summary" : "" , "notes" : []}
2026-03-20 20:59:30 +08:00
try :
2026-05-25 20:03:06 +08:00
_ , _ai_context , sd = _trend_analyze ( raw , sym , city )
2026-03-20 20:59:30 +08:00
2026-05-25 20:03:06 +08:00
mu = sd . get ( "mu" )
probabilities = sd . get ( "probabilities" , [])
probabilities_all = sd . get ( "probabilities_all" , probabilities )
dynamic_commentary = sd . get ( "dynamic_commentary" ) or dynamic_commentary
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 )
2026-03-20 20:59:30 +08:00
# Use shared DEB if not already set
2026-05-25 20:03:06 +08:00
if deb_val is None and sd . get ( "deb_prediction" ) is not None :
2026-03-20 20:59:30 +08:00
deb_val = sd [ "deb_prediction" ]
2026-05-27 21:40:26 +08:00
deb_raw_val = sd . get ( "deb_raw_prediction" ) or deb_val
deb_version = sd . get ( "deb_version" )
deb_bias_adjustment = sd . get ( "deb_bias_adjustment" ) or 0.0
deb_bias_samples = sd . get ( "deb_bias_samples" ) or 0
2026-05-25 20:03:06 +08:00
deb_weights = sd . get ( "deb_weights" , "" )
2026-06-07 23:57:47 +08:00
deb_quality = sd . get ( "deb_quality" ) or deb_quality
2026-05-28 10:44:48 +08:00
if deb_hourly_consensus is None and sd . get ( "deb_hourly_consensus" ):
deb_hourly_consensus = sd . get ( "deb_hourly_consensus" )
2026-03-20 20:59:30 +08:00
except Exception as e :
2026-05-25 20:03:06 +08:00
logger . warning ( f "Structured analysis skipped for { city } : { e } " )
2026-03-20 20:59:30 +08:00
2026-05-25 20:03:06 +08:00
# ── 12. Hourly data (today only, for chart) ──
2026-03-20 20:59:30 +08:00
today_hourly : Dict [ str , list ] = { "times" : [], "temps" : [], "radiation" : []}
2026-05-25 20:03:06 +08:00
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 )
2026-03-20 20:59:30 +08:00
2026-05-25 03:18:00 +08:00
# ── 12a-b. Intraday bias correction ──────────────────────────────────
# Nudge the DEB high-temp forecast and probability mu using the gap
# between the current observed temperature and the model's hourly path.
2026-05-25 03:29:04 +08:00
# Uses cur_temp / max_so_far already resolved at lines 1052-1095 above.
2026-05-25 20:03:06 +08:00
_local_hour = _parse_local_hour ( local_time_str )
2026-05-26 22:27:55 +08:00
peak_first = first_peak_h or 14
peak_last_h = last_peak_h or 17
2026-05-25 03:18:00 +08:00
if (
deb_val is not None
2026-05-25 03:29:04 +08:00
and cur_temp is not None
2026-05-25 03:18:00 +08:00
and _local_hour is not None
and 6 <= _local_hour <= 22
2026-05-25 20:03:06 +08:00
):
hourly_times_list = today_hourly . get ( "times" ) or []
hourly_temps_list = today_hourly . get ( "temps" ) or []
2026-05-25 03:18:00 +08:00
model_hourly_temp = None
current_hour_str = f " { _local_hour : 02d } :00"
2026-05-25 20:03:06 +08:00
for idx , t_str in enumerate ( hourly_times_list ):
if str ( t_str or "" ) . startswith ( current_hour_str ) and idx < len ( hourly_temps_list ):
candidate = _sf ( hourly_temps_list [ idx ])
2026-05-25 03:18:00 +08:00
if candidate is not None :
model_hourly_temp = candidate
break
2026-05-25 03:29:04 +08:00
reference_temp = model_hourly_temp if model_hourly_temp is not None else cur_temp
2026-05-25 03:18:00 +08:00
if reference_temp is not None :
2026-05-25 03:29:04 +08:00
hourly_bias = cur_temp - reference_temp
2026-05-25 03:18:00 +08:00
if _local_hour < peak_first :
2026-05-25 20:03:06 +08:00
progress = max ( 0.0 , ( _local_hour - 6 ) / max ( 1 , peak_first - 6 ))
2026-05-25 03:18:00 +08:00
weight = 0.15 + 0.20 * progress
elif peak_first <= _local_hour <= peak_last_h :
2026-05-25 20:03:06 +08:00
progress = ( _local_hour - peak_first ) / max ( 1 , peak_last_h - peak_first )
2026-05-25 03:18:00 +08:00
weight = 0.40 + 0.35 * progress
else :
weight = 0.80
2026-05-25 20:03:06 +08:00
max_correction = 5.0 if str ( sym or "" ) . upper () == "F" else 3.0
hourly_correction = max ( - max_correction , min ( max_correction , hourly_bias * weight ))
2026-05-25 03:18:00 +08:00
2026-05-25 03:29:04 +08:00
_msf = max_so_far if max_so_far is not None else cur_temp
max_so_far_excess = _msf - deb_val
2026-05-25 20:03:06 +08:00
max_correction_clamped = max ( - max_correction , min ( max_correction , max_so_far_excess * max ( 0.3 , weight )))
2026-05-25 03:18:00 +08:00
blended_correction = hourly_correction * 0.6 + max_correction_clamped * 0.4
2026-05-27 21:40:26 +08:00
deb_intraday_adjustment = round ( blended_correction , 1 )
2026-05-25 20:03:06 +08:00
deb_val = round ( deb_val + blended_correction , 1 )
2026-05-25 03:18:00 +08:00
if mu is not None :
2026-05-25 20:03:06 +08:00
mu = round ( mu + blended_correction , 1 )
2026-05-27 21:40:26 +08:00
deb_weights = f " { deb_weights or 'DEB' } + intraday_bias( { deb_intraday_adjustment : +.1f } )"
2026-05-25 03:18:00 +08:00
2026-05-27 22:41:16 +08:00
deb_hourly_path = None
2026-05-28 10:44:48 +08:00
deb_base_source = "hourly_plus_deb_offset"
deb_base_times = [ str ( item ) for item in today_hourly . get ( "times" ) or []]
deb_base_temps = today_hourly . get ( "temps" ) or []
if isinstance ( deb_hourly_consensus , dict ):
consensus_times = deb_hourly_consensus . get ( "times" ) or []
consensus_temps = deb_hourly_consensus . get ( "temps" ) or []
if consensus_times and consensus_temps :
deb_base_source = "deb_hourly_consensus"
deb_base_times = [ str ( item ) for item in consensus_times ]
deb_base_temps = consensus_temps
if deb_val is not None and deb_base_times and deb_base_temps :
2026-05-27 22:41:16 +08:00
try :
deb_hourly_path = build_deb_hourly_path (
city = city ,
2026-05-28 10:44:48 +08:00
hourly_times = deb_base_times ,
hourly_temps = deb_base_temps ,
2026-05-27 22:41:16 +08:00
deb_prediction = deb_val ,
peak_first_h = first_peak_h ,
peak_last_h = last_peak_h ,
corrector = get_cached_hourly_peak_corrector (),
2026-05-28 10:44:48 +08:00
base_source = deb_base_source ,
2026-05-27 22:41:16 +08:00
)
except Exception as exc :
logger . debug ( f "DEB hourly path correction skipped for { city } : { exc } " )
2026-03-20 20:59:30 +08:00
# ── 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"
2026-05-25 20:03:06 +08:00
)
2026-03-20 20:59:30 +08:00
except Exception :
local_anchor = None
if local_anchor is not None :
2026-05-25 20:03:06 +08:00
horizon = local_anchor + timedelta ( hours = 48 )
for i , ts in enumerate ( h_times ):
2026-03-20 20:59:30 +08:00
try :
2026-05-25 20:03:06 +08:00
ts_dt = datetime . fromisoformat ( ts )
2026-03-20 20:59:30 +08:00
except Exception :
continue
if ts_dt < local_anchor or ts_dt > horizon :
continue
2026-05-25 20:03:06 +08:00
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 )
2026-03-20 20:59:30 +08:00
next_48h_hourly [ "pressure_msl" ] . append (
2026-05-25 20:03:06 +08:00
h_pressure [ i ] if i < len ( h_pressure ) else None
)
2026-03-20 20:59:30 +08:00
next_48h_hourly [ "wind_speed_10m" ] . append (
2026-05-25 20:03:06 +08:00
h_wspd [ i ] if i < len ( h_wspd ) else None
)
2026-03-20 20:59:30 +08:00
next_48h_hourly [ "wind_direction_10m" ] . append (
2026-05-25 20:03:06 +08:00
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 (
2026-05-25 20:03:06 +08:00
h_wspd_180m [ i ] if i < len ( h_wspd_180m ) else None
)
2026-03-24 01:28:45 +08:00
next_48h_hourly [ "wind_direction_180m" ] . append (
2026-05-25 20:03:06 +08:00
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 (
2026-05-25 20:03:06 +08:00
h_precip_prob [ i ] if i < len ( h_precip_prob ) else None
)
2026-03-20 20:59:30 +08:00
next_48h_hourly [ "cloud_cover" ] . append (
2026-05-25 20:03:06 +08:00
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 (
2026-05-25 20:03:06 +08:00
h_cape [ i ] if i < len ( h_cape ) else None
)
2026-03-24 01:28:45 +08:00
next_48h_hourly [ "convective_inhibition" ] . append (
2026-05-25 20:03:06 +08:00
h_cin [ i ] if i < len ( h_cin ) else None
)
2026-03-24 01:28:45 +08:00
next_48h_hourly [ "lifted_index" ] . append (
2026-05-25 20:03:06 +08:00
h_lifted_index [ i ] if i < len ( h_lifted_index ) else None
)
2026-03-24 01:28:45 +08:00
next_48h_hourly [ "boundary_layer_height" ] . append (
2026-05-25 20:03:06 +08:00
h_boundary_layer_height [ i ] if i < len ( h_boundary_layer_height ) else None
)
2026-03-24 01:28:45 +08:00
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-05-25 20:03:06 +08:00
)
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-05-25 20:03:06 +08:00
)
2026-04-11 20:11:01 +08:00
taf_signal = (
_build_taf_signal (
2026-05-25 20:03:06 +08:00
taf if isinstance ( taf , dict ) else {},
2026-04-11 20:11:01 +08:00
city ,
local_date_str ,
2026-05-26 22:27:55 +08:00
utc_offset ,
2026-04-11 20:11:01 +08:00
first_peak_h ,
last_peak_h ,
2026-05-25 20:03:06 +08:00
)
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-05-25 20:03:06 +08:00
)
2026-03-20 20:59:30 +08:00
2026-05-25 20:03:06 +08:00
# ── 13. Cloud description (METAR primary, MGM fallback) ──
clouds = mc . get ( "clouds" , [])
2026-03-20 20:59:30 +08:00
cloud_desc = ""
if clouds :
c_map = {
"BKN" : "多云" ,
"OVC" : "阴天" ,
"FEW" : "少云" ,
"SCT" : "散云" ,
"SKC" : "晴" ,
"CLR" : "晴" ,
}
main = clouds [ - 1 ]
2026-05-25 20:03:06 +08:00
cloud_desc = c_map . get ( main . get ( "cover" ), main . get ( "cover" , "" ))
2026-03-20 20:59:30 +08:00
if not cloud_desc and mgm :
2026-05-25 20:03:06 +08:00
mgc_cover = mgm . get ( "current" , {}) . get ( "cloud_cover" )
2026-03-20 20:59:30 +08:00
if mgc_cover is not None :
cloud_desc_map = {
0 : "晴朗" ,
1 : "少云" ,
2 : "少云" ,
3 : "散云" ,
4 : "散云" ,
5 : "多云" ,
6 : "多云" ,
7 : "阴天" ,
8 : "阴天" ,
}
2026-05-25 20:03:06 +08:00
cloud_desc = cloud_desc_map . get ( mgc_cover , "" )
2026-03-20 20:59:30 +08:00
# Final fallback: If we have ANY actual observation but no cloud info, it's usually clear.
if not cloud_desc :
2026-05-25 20:03:06 +08:00
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.
2026-03-20 20:59:30 +08:00
# Otherwise, clear skies.
2026-05-25 20:03:06 +08:00
if not mc . get ( "wx_desc" ):
2026-03-20 20:59:30 +08:00
cloud_desc = "晴朗"
2026-05-25 20:03:06 +08:00
# ── 14. MGM data (Turkish MGM-supported cities) ──
2026-03-20 20:59:30 +08:00
mgm_data = {}
if mgm :
2026-05-25 20:03:06 +08:00
mgc = mgm . get ( "current" , {})
mgm_time_str = mgc . get ( "time" , "" )
# MGM time is usually "2026-03-04T10:40:00.000Z" (UTC)
2026-03-20 20:59:30 +08:00
if mgm_time_str and "T" in mgm_time_str :
try :
# Handle ISO format with Z or +00:00
2026-05-25 20:03:06 +08:00
ts = mgm_time_str . replace ( "Z" , "+00:00" )
2026-03-20 20:59:30 +08:00
if "+" in ts :
2026-05-25 20:03:06 +08:00
base , offset_part = ts . split ( "+" , 1 )
2026-03-20 20:59:30 +08:00
if "." in base :
2026-05-25 20:03:06 +08:00
base = base . split ( "." )[ 0 ]
2026-03-20 20:59:30 +08:00
ts = base + "+" + offset_part
2026-05-25 20:03:06 +08:00
dt = datetime . fromisoformat ( ts )
local_dt = dt . astimezone ( timezone ( timedelta ( seconds = utc_offset or 0 )))
mgm_time_str = local_dt . strftime ( "%H:%M" )
2026-03-20 20:59:30 +08:00
except Exception as e :
2026-05-25 20:03:06 +08:00
logger . debug ( f "MGM time conversion failed: { e } " )
2026-03-20 20:59:30 +08:00
pass
mgm_data = {
2026-05-25 20:03:06 +08:00
"temp" : _sf ( mgc . get ( "temp" )),
2026-03-20 20:59:30 +08:00
"time" : mgm_time_str ,
2026-05-25 20:03:06 +08:00
"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" )),
2026-03-20 20:59:30 +08:00
"hourly" : [],
}
2026-05-25 20:03:06 +08:00
mgm_hourly = mgm . get ( "hourly" , [])
2026-03-20 20:59:30 +08:00
for h in mgm_hourly :
2026-05-25 20:03:06 +08:00
dt_str = h . get ( "time" )
val = _sf ( h . get ( "temp" ))
2026-03-20 20:59:30 +08:00
if dt_str and "T" in dt_str and val is not None :
try :
2026-05-25 20:03:06 +08:00
dt = datetime . fromisoformat ( dt_str . replace ( "Z" , "+00:00" ))
local_dt = dt . astimezone ( timezone ( timedelta ( seconds = utc_offset )))
2026-03-20 20:59:30 +08:00
mgm_data [ "hourly" ] . append ({
2026-05-25 20:03:06 +08:00
"time" : local_dt . strftime ( "%Y-%m- %d T%H:%M" ),
2026-03-20 20:59:30 +08:00
"temp" : val
2026-05-25 20:03:06 +08:00
})
2026-03-20 20:59:30 +08:00
except Exception :
pass
# ── 15. Extended Multi-Model Daily ──
multi_model_daily = {}
2026-05-25 20:03:06 +08:00
mm_daily_raw = mm . get ( "daily_forecasts" , {})
for i , d_str in enumerate ( dates ):
2026-05-26 22:27:55 +08:00
d_probs = []
d_probs_all = []
2026-03-20 20:59:30 +08:00
if i == 0 :
2026-05-25 20:03:06 +08:00
day_m = current_forecasts . copy ()
2026-03-20 20:59:30 +08:00
d_val , d_winfo = deb_val , deb_weights
2026-05-27 21:40:26 +08:00
d_raw_val = deb_raw_val
d_version = deb_version
2026-06-08 00:59:16 +08:00
d_selected_version = deb_selected_version
d_guard_reason = deb_guard_reason
2026-05-27 21:40:26 +08:00
d_bias_adjustment = deb_bias_adjustment
d_bias_samples = deb_bias_samples
2026-06-08 00:12:14 +08:00
d_quality = dict ( deb_quality )
2026-03-20 20:59:30 +08:00
else :
2026-05-25 20:03:06 +08:00
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 ])
2026-03-20 20:59:30 +08:00
# Add MGM per-day forecast
2026-05-25 20:03:06 +08:00
mgm_daily = mgm . get ( "daily_forecasts" , {})
2026-03-20 20:59:30 +08:00
if d_str in mgm_daily :
2026-05-25 20:03:06 +08:00
day_m [ "MGM" ] = _sf ( mgm_daily [ d_str ])
2026-03-20 20:59:30 +08:00
day_m = {
2026-05-25 20:03:06 +08:00
m : v for m , v in day_m . items () if not _is_excluded_model_name ( m )
2026-03-20 20:59:30 +08:00
}
d_val , d_winfo = None , ""
2026-05-27 21:40:26 +08:00
d_raw_val , d_version = None , None
2026-06-08 00:59:16 +08:00
d_selected_version , d_guard_reason = None , None
2026-05-27 21:40:26 +08:00
d_bias_adjustment , d_bias_samples = 0.0 , 0
2026-06-07 23:57:47 +08:00
d_quality = {}
2026-03-20 20:59:30 +08:00
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 :
2026-05-27 21:40:26 +08:00
deb_result = calculate_deb_prediction ( city , day_m )
2026-05-29 22:40:33 +08:00
d_prediction = _sf ( deb_result . get ( "prediction" ))
if d_prediction is not None :
d_val = d_prediction
2026-05-27 21:40:26 +08:00
d_raw_val = deb_result . get ( "raw_prediction" )
d_version = deb_result . get ( "version" )
2026-06-08 00:59:16 +08:00
d_selected_version = deb_result . get ( "selected_version" )
d_guard_reason = deb_result . get ( "guard_reason" )
2026-05-27 21:40:26 +08:00
d_bias_adjustment = deb_result . get ( "bias_adjustment" ) or 0.0
d_bias_samples = deb_result . get ( "bias_samples" ) or 0
d_winfo = deb_result . get ( "weights_info" ) or ""
2026-06-07 23:57:47 +08:00
d_quality = {
"quality_tier" : deb_result . get ( "quality_tier" ),
"recommendation" : deb_result . get ( "recommendation" ),
"recent_hit_rate" : deb_result . get ( "recent_hit_rate" ),
"recent_samples" : deb_result . get ( "recent_samples" ),
"recent_hits" : deb_result . get ( "recent_hits" ),
"recent_mae" : deb_result . get ( "recent_mae" ),
}
2026-03-20 20:59:30 +08:00
# Calculate future probability based on model divergence
2026-05-25 20:03:06 +08:00
m_vals = [ v for v in day_m . values () if v is not None ]
if len ( m_vals ) > 1 :
2026-03-20 20:59:30 +08:00
# Use spread as a proxy for sigma.
2026-05-25 20:03:06 +08:00
# sigma = (max-min)/2 with a floor of 0.6
d_sigma = max ( 0.6 , ( max ( m_vals ) - min ( m_vals )) / 2.0 )
2026-03-20 20:59:30 +08:00
else :
d_sigma = 1.0
2026-05-25 20:03:06 +08:00
prob_obj = calculate_prob_distribution ( d_val , d_sigma , None , sym )
d_probs = prob_obj . get ( "probabilities" , [])
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 ,
2026-05-27 21:40:26 +08:00
"deb" : {
"prediction" : d_val ,
"raw_prediction" : d_raw_val ,
"version" : d_version ,
2026-06-08 00:59:16 +08:00
"selected_version" : d_selected_version ,
"guard_reason" : d_guard_reason ,
2026-05-27 21:40:26 +08:00
"weights_info" : d_winfo ,
"bias_adjustment" : d_bias_adjustment ,
"bias_samples" : d_bias_samples ,
2026-06-07 23:57:47 +08:00
** d_quality ,
2026-05-27 21:40:26 +08:00
},
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-05-26 09:13:39 +08:00
runway_plate_history = {}
icao = risk . get ( "icao" , "" )
2026-05-26 22:27:55 +08:00
if isinstance ( icao , str ) and icao :
2026-05-26 09:13:39 +08:00
try :
from src.database.db_manager import DBManager
raw_runway_obs = DBManager () . get_runway_obs_recent ( icao , minutes = 36 * 60 )
for r in raw_runway_obs :
rw = r . get ( "runway" )
if not rw :
continue
2026-05-28 10:59:54 +08:00
temp_val = _runway_history_temp_for_city ( city , r )
2026-05-26 09:13:39 +08:00
if temp_val is not None :
if is_f :
temp_val = round ( temp_val * 9.0 / 5.0 + 32.0 , 1 )
else :
temp_val = round ( temp_val , 1 )
time_val = r . get ( "otime_utc" ) or r . get ( "created_at" )
if not time_val :
continue
if rw not in runway_plate_history :
runway_plate_history [ rw ] = []
runway_plate_history [ rw ] . append ({
"time" : time_val ,
"temp" : temp_val
})
except Exception :
logger . exception ( "Failed to fetch runway plate history for icao= {} " , icao )
2026-05-25 20:03:06 +08:00
city_meta = CITIES . get ( city , {}) or {}
2026-03-20 20:59:30 +08:00
result = {
2026-05-26 09:13:39 +08:00
"runway_plate_history" : runway_plate_history ,
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-05-25 20:03:06 +08:00
),
2026-03-20 20:59:30 +08:00
"name" : city ,
2026-05-25 20:03:06 +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-05-26 22:27:55 +08:00
"utc_offset_seconds" : utc_offset ,
2026-03-20 20:59:30 +08:00
"temp_symbol" : sym ,
"local_time" : local_time_str ,
"local_date" : local_date_str ,
"risk" : {
2026-05-25 20:03:06 +08:00
"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" , "" ),
2026-03-20 20:59:30 +08:00
},
"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-05-14 14:21:28 +08:00
"source_code" : current_source ,
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-05-14 14:21:28 +08:00
"freshness" : current_freshness ,
2026-04-21 19:13:33 +08:00
"observation_status" : "live" if cur_temp is not None else "missing" ,
2026-05-25 20:03:06 +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" ),
"wind_speed_kt" : _sf ( amos_data . get ( "wind_kt" )) if current_source == "amos" else _sf ( primary_current . get ( "wind_speed_kt" )),
"wind_dir" : _sf ( primary_current . get ( "wind_dir" )),
"humidity" : _sf ( primary_current . get ( "humidity" )),
"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" : [
2026-05-25 20:03:06 +08:00
{ "cover" : c . get ( "cover" ), "base" : c . get ( "base" )} for c in clouds
2026-03-20 20:59:30 +08:00
],
2026-05-25 20:03:06 +08:00
"visibility_mi" : _sf ( primary_current . get ( "visibility_mi" )),
"wx_desc" : primary_current . get ( "wx_desc" ),
"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-05-14 14:21:28 +08:00
"temp" : airport_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 ,
2026-05-14 14:21:28 +08:00
"obs_age_min" : airport_age_min ,
2026-05-25 20:03:06 +08:00
"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 ,
"wind_speed_kt" : _sf ( amos_data . get ( "wind_kt" )) if current_source == "amos" else _sf ( live_mc . get ( "wind_speed_kt" )),
"wind_dir" : _sf ( live_mc . get ( "wind_dir" )),
"humidity" : _sf ( live_mc . get ( "humidity" )),
"cloud_desc" : metar . get ( "cloud_desc" ) if metar else None ,
"visibility_mi" : _sf ( live_mc . get ( "visibility_mi" )),
"wx_desc" : live_mc . get ( "wx_desc" ),
"raw_metar" : amos_data . get ( "raw_metar" ) if current_source == "amos" else live_mc . get ( "raw_metar" ),
2026-05-14 14:21:28 +08:00
"source_code" : airport_source_code ,
"source_label" : airport_source_label ,
"freshness" : airport_freshness ,
2026-05-25 20:03:06 +08:00
"stale_for_today" : False if current_source == "amos" else ( bool ( metar ) and not metar_current_is_today ),
"last_observation_local_date" : metar . get ( "observation_local_date" ) if metar else None ,
2026-04-21 19:13:33 +08:00
"current_local_date" : local_date_str ,
2026-03-25 18:03:56 +08:00
},
2026-05-29 17:20:07 +08:00
"wunderground_current" : wunderground_current ,
2026-05-25 20:03:06 +08:00
"settlement_station" : network_snapshot . get ( "settlement_station" ) or {},
2026-04-16 18:21:51 +08:00
"airport_primary" : airport_primary_current ,
2026-05-25 20:03:06 +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 ,
2026-05-25 20:03:06 +08:00
"mgm_nearby" : raw . get ( "mgm_nearby" , []),
"nearby_source" : raw . get ( "nearby_source" ) or ( "mgm" if city . lower () in TURKISH_MGM_CITIES else "metar_cluster" ),
"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" : {
2026-05-25 20:03:06 +08:00
"weather_gov" : raw . get ( "nws" ) or {},
2026-04-17 00:27:57 +08:00
"open_meteo_multi_model" : {
2026-05-25 20:03:06 +08:00
"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
},
2026-05-25 18:44:03 +08:00
"multi_model" : {
** mm ,
2026-05-25 20:03:06 +08:00
"forecasts" : { k : v for k , v in current_forecasts . items () if v is not None },
2026-05-25 18:44:03 +08:00
},
2026-03-20 20:59:30 +08:00
"multi_model_daily" : multi_model_daily ,
2026-05-27 21:40:26 +08:00
"deb" : {
"prediction" : deb_val ,
"raw_prediction" : deb_raw_val ,
"version" : deb_version ,
"weights_info" : deb_weights ,
"bias_adjustment" : deb_bias_adjustment ,
"bias_samples" : deb_bias_samples ,
"intraday_adjustment" : deb_intraday_adjustment ,
2026-05-28 10:44:48 +08:00
"hourly_consensus" : deb_hourly_consensus ,
2026-05-27 22:41:16 +08:00
"hourly_path" : deb_hourly_path ,
"hourly_correction" : ( deb_hourly_path or {}) . get ( "correction" ) if isinstance ( deb_hourly_path , dict ) else None ,
2026-06-07 23:57:47 +08:00
** deb_quality ,
2026-05-27 21:40:26 +08:00
},
2026-03-29 23:29:17 +08:00
"deviation_monitor" : deviation_monitor ,
2026-03-20 20:59:30 +08:00
"ensemble" : ens_data ,
"probabilities" : {
2026-05-25 20:03:06 +08:00
"mu" : round ( mu , 1 ) if mu is not None else None ,
2026-03-20 20:59:30 +08:00
"distribution" : probabilities ,
2026-04-22 01:43:13 +08:00
"distribution_all" : probabilities_all or probabilities ,
2026-05-18 22:05:55 +08:00
"engine" : "legacy" ,
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" : {
2026-05-25 20:03:06 +08:00
** ( taf if isinstance ( taf , dict ) else {}),
2026-03-24 02:58:29 +08:00
"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 ,
2026-05-25 20:03:06 +08:00
"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 ,
2026-04-21 19:13:33 +08:00
"current_local_date" : local_date_str ,
2026-05-25 20:03:06 +08:00
"last_temp" : _sf ( mc . get ( "temp" )) if mc else None ,
2026-04-21 19:13:33 +08:00
},
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-05-25 20:03:06 +08:00
"updated_at" : datetime . now ( timezone . utc ) . isoformat (),
2026-03-20 20:59:30 +08:00
}
2026-05-25 20:03:06 +08:00
result [ "intraday_meteorology" ] = _build_intraday_meteorology ( result )
2026-05-01 11:27:16 +08:00
if normalized_detail_mode == "full" :
2026-05-25 20:03:06 +08:00
_archive_intraday_path_snapshot ( city , result )
2026-03-20 20:59:30 +08:00
2026-05-25 02:20:15 +08:00
with _CACHE_LOCK :
2026-05-25 20:03:06 +08:00
_cache [ cache_key ] = { "t" : _time . time (), "d" : result }
2026-03-20 20:59:30 +08:00
return result
2026-05-25 20:03:06 +08:00
def _normalize_city_or_404 ( name : str ) -> str :
city = name . lower () . strip () . replace ( "-" , " " )
city = ALIASES . get ( city , city )
2026-03-20 20:59:30 +08:00
if city not in CITIES :
2026-05-25 20:03:06 +08:00
raise HTTPException ( 404 , detail = f "Unknown city: { city } " )
2026-03-20 20:59:30 +08:00
return city
2026-05-25 20:03:06 +08:00
def _analyze_summary ( city : str , force_refresh : bool = False ) -> Dict [ str , Any ]:
ttl = _analysis_ttl_for_city ( city )
2026-04-11 20:11:01 +08:00
if not force_refresh :
2026-05-25 20:03:06 +08:00
cached_detail = _get_cached_analysis ( city , ttl )
2026-04-12 10:42:00 +08:00
if cached_detail :
return cached_detail
2026-05-25 20:03:06 +08:00
cached_summary = _get_cached_summary ( city , ttl )
2026-04-11 20:11:01 +08:00
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"
2026-05-25 20:03:06 +08:00
settlement_source = str ( info . get ( "settlement_source" ) or "metar" ) . strip () . lower () or "metar"
2026-04-11 20:11:01 +08:00
settlement_source_label = SETTLEMENT_SOURCE_LABELS . get (
settlement_source ,
2026-05-25 20:03:06 +08:00
settlement_source . upper (),
)
2026-04-11 20:11:01 +08:00
if force_refresh :
try :
_weather . _evict_city_caches ( # type: ignore[attr-defined]
city = city ,
lat = lat ,
lon = lon ,
use_fahrenheit = is_f ,
2026-05-25 20:03:06 +08:00
)
2026-04-11 20:11:01 +08:00
except Exception :
pass
2026-05-25 20:03:06 +08:00
default_utc_offset = get_city_utc_offset_seconds ( city )
2026-04-11 20:11:01 +08:00
2026-05-25 20:03:06 +08:00
def _safe_call ( fn ):
2026-04-11 20:11:01 +08:00
try :
2026-05-25 20:03:06 +08:00
return fn ()
2026-04-11 20:11:01 +08:00
except Exception :
return None
2026-05-26 22:27:55 +08:00
jobs : Dict [ str , Any ] = {
2026-05-25 20:03:06 +08:00
"settlement_current" : lambda : _weather . fetch_settlement_current ( city ) or {},
2026-05-29 17:20:07 +08:00
"wunderground_current" : lambda : _weather . fetch_wunderground_historical (
city ,
use_fahrenheit = is_f ,
utc_offset = default_utc_offset ,
) or {},
2026-05-25 20:03:06 +08:00
"open_meteo" : lambda : _weather . fetch_from_open_meteo ( lat , lon , use_fahrenheit = is_f ) or {},
"multi_model" : lambda : _weather . fetch_multi_model ( lat , lon , city = city , use_fahrenheit = is_f ) or {},
2026-04-11 20:11:01 +08:00
}
2026-05-25 20:03:06 +08:00
if _weather . _supports_aviationweather ( city ): # type: ignore[attr-defined]
2026-04-11 20:11:01 +08:00
jobs [ "metar" ] = lambda : _weather . fetch_metar (
city ,
use_fahrenheit = is_f ,
utc_offset = default_utc_offset ,
2026-05-25 20:03:06 +08:00
) or {}
2026-04-11 20:11:01 +08:00
if city in TURKISH_MGM_CITIES :
2026-05-25 20:03:06 +08:00
istno , _province = _weather . TURKISH_PROVINCES . get ( city , ( None , None )) # type: ignore[attr-defined]
2026-04-11 20:11:01 +08:00
if istno :
2026-05-25 20:03:06 +08:00
jobs [ "mgm" ] = lambda istno = istno : _weather . fetch_from_mgm ( str ( istno )) or {}
2026-04-11 20:11:01 +08:00
if is_f :
2026-05-25 20:03:06 +08:00
jobs [ "nws" ] = lambda : _weather . fetch_nws ( lat , lon ) or {}
2026-04-11 20:11:01 +08:00
if settlement_source == "hko" :
2026-05-25 20:03:06 +08:00
jobs [ "hko_forecast" ] = lambda : _weather . fetch_hko_forecast ()
2026-04-11 20:11:01 +08:00
fetched : Dict [ str , Any ] = {}
2026-05-25 20:03:06 +08:00
with ThreadPoolExecutor ( max_workers = min ( 6 , len ( jobs ))) as executor :
2026-04-11 20:11:01 +08:00
future_map = {
2026-05-25 20:03:06 +08:00
executor . submit ( _safe_call , fn ): key
for key , fn in jobs . items ()
2026-04-11 20:11:01 +08:00
}
2026-05-25 20:03:06 +08:00
for future , key in [( future , key ) for future , key in future_map . items ()]:
fetched [ key ] = future . result ()
2026-04-11 20:11:01 +08:00
2026-05-25 20:03:06 +08:00
settlement_current = fetched . get ( "settlement_current" ) or {}
2026-05-29 17:20:07 +08:00
wunderground_current = fetched . get ( "wunderground_current" ) or {}
2026-05-25 20:03:06 +08:00
open_meteo = fetched . get ( "open_meteo" ) or {}
mm = fetched . get ( "multi_model" ) or {}
2026-05-29 17:20:07 +08:00
if not isinstance ( wunderground_current , dict ):
wunderground_current = {}
2026-05-25 20:03:06 +08:00
utc_offset = open_meteo . get ( "utc_offset" )
2026-04-11 20:11:01 +08:00
if utc_offset is None :
utc_offset = default_utc_offset
try :
2026-05-25 20:03:06 +08:00
utc_offset = int ( utc_offset or 0 )
2026-04-11 20:11:01 +08:00
except Exception :
utc_offset = default_utc_offset
2026-05-25 20:03:06 +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 " )
2026-04-21 19:13:33 +08:00
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-05-25 20:03:06 +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 ,
2026-05-26 22:27:55 +08:00
utc_offset = utc_offset ,
2026-05-25 20:03:06 +08:00
)
2026-04-11 20:11:01 +08:00
2026-05-25 20:03:06 +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-05-25 20:03:06 +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-05-25 20:03:06 +08:00
cur_temp = _sf ( primary_current . get ( "temp" ))
if cur_temp is not None and not _is_plausible_city_temp ( city , cur_temp , sym ):
2026-04-16 17:34:39 +08:00
cur_temp = None
2026-04-11 20:11:01 +08:00
if cur_temp is None :
2026-05-25 20:03:06 +08:00
cur_temp = _sf ( live_mc . get ( "temp" ))
if cur_temp is not None and not _is_plausible_city_temp ( city , cur_temp , sym ):
2026-04-16 17:34:39 +08:00
cur_temp = None
2026-05-25 20:58:18 +08:00
if cur_temp is None :
cur_temp = _sf (( settlement_current . get ( "current" ) or {}) . get ( "temp" ))
if cur_temp is not None :
current_source = settlement_source or "settlement"
current_source_label = settlement_source_label or "Settlement Station"
2026-04-11 20:11:01 +08:00
if cur_temp is None :
2026-05-25 20:03:06 +08:00
cur_temp = _sf ( mg_cur . get ( "temp" ))
if cur_temp is not None and not _is_plausible_city_temp ( city , cur_temp , sym ):
2026-04-16 17:34:39 +08:00
cur_temp = None
2026-04-16 15:29:26 +08:00
if cur_temp is None :
2026-05-25 20:03:06 +08:00
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" ))
2026-04-16 15:29:26 +08:00
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
2026-05-25 20:03:06 +08:00
max_so_far = _sf ( primary_current . get ( "max_temp_so_far" ))
if max_so_far is not None and not _is_plausible_city_temp ( city , max_so_far , sym ):
2026-04-16 17:34:39 +08:00
max_so_far = None
2026-04-11 20:11:01 +08:00
if max_so_far is None :
2026-05-25 20:03:06 +08:00
max_so_far = _sf ( live_mc . get ( "max_temp_so_far" ))
if max_so_far is not None and not _is_plausible_city_temp ( city , max_so_far , sym ):
2026-04-16 17:34:39 +08:00
max_so_far = None
2026-04-11 20:11:01 +08:00
if max_so_far is None :
2026-05-25 20:03:06 +08:00
max_so_far = _sf ( mg_cur . get ( "mgm_max_temp" ))
if max_so_far is not None and not _is_plausible_city_temp ( city , max_so_far , sym ):
2026-04-16 17:34:39 +08:00
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
2026-05-25 20:03:06 +08:00
max_temp_time = primary_current . get ( "max_temp_time" )
2026-04-11 20:11:01 +08:00
if not max_temp_time and not use_settlement_current :
2026-05-25 20:03:06 +08:00
max_temp_time = live_mc . get ( "max_temp_time" )
2026-04-11 20:11:01 +08:00
if not max_temp_time :
2026-05-25 20:03:06 +08:00
mgm_time = str ( mg_cur . get ( "time" ) or "" )
2026-04-11 20:11:01 +08:00
if " " in mgm_time :
2026-05-25 20:03:06 +08:00
max_temp_time = mgm_time . split ( " " )[ 1 ][: 5 ]
2026-04-11 20:11:01 +08:00
raw_settlement_max = max_so_far
wu_settle = (
2026-05-25 20:03:06 +08:00
apply_city_settlement ( city . lower (), raw_settlement_max )
2026-04-11 20:11:01 +08:00
if raw_settlement_max is not None
else None
2026-05-25 20:03:06 +08:00
)
2026-04-11 20:11:01 +08:00
display_settlement_max = (
wu_settle
if settlement_source == "wunderground" and wu_settle is not None
else raw_settlement_max
2026-05-25 20:03:06 +08:00
)
2026-04-11 20:11:01 +08:00
obs_time_str = ""
obs_age_min = None
obs_t = ""
if use_settlement_current :
2026-05-25 20:03:06 +08:00
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-05-25 20:03:06 +08:00
obs_t = str ( metar . get ( "observation_time" ) or "" ) . strip ()
2026-04-11 20:11:01 +08:00
if obs_t and "T" in obs_t :
try :
2026-05-25 20:03:06 +08:00
dt = _parse_utc_datetime ( obs_t )
2026-04-21 19:13:33 +08:00
if dt is None :
2026-05-25 20:03:06 +08:00
raise ValueError ( "invalid observation time" )
local_dt = dt . astimezone ( timezone ( timedelta ( seconds = utc_offset )))
obs_time_str = local_dt . strftime ( "%H:%M" )
2026-04-11 20:11:01 +08:00
obs_age_min = int (
2026-05-25 20:03:06 +08:00
( datetime . now ( timezone . utc ) - dt . astimezone ( timezone . utc )) . total_seconds () / 60
)
2026-04-11 20:11:01 +08:00
except Exception :
2026-05-26 22:27:55 +08:00
obs_time_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 :
2026-05-25 20:03:06 +08:00
nmc_fallback = _fetch_nmc_current_fallback ( city , use_fahrenheit = is_f )
2026-04-16 15:29:26 +08:00
obs_time_str = _format_observation_time_local (
2026-05-25 20:03:06 +08:00
nmc_fallback . get ( "publish_time" ) or nmc_fallback . get ( "timestamp" ),
2026-05-26 22:27:55 +08:00
utc_offset ,
2026-05-25 20:03:06 +08:00
)
2026-04-11 20:11:01 +08:00
2026-05-25 20:03:06 +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 {}
2026-05-20 07:55:47 +08:00
2026-05-25 20:03:06 +08:00
all_dates = om_daily . get ( "time" , [])
all_maxtemps = om_daily . get ( "temperature_2m_max" , [])
2026-05-20 07:55:47 +08:00
start_idx = 0
if local_date_str in all_dates :
2026-05-25 20:03:06 +08:00
start_idx = all_dates . index ( local_date_str )
2026-05-20 07:55:47 +08:00
else :
2026-05-25 20:03:06 +08:00
for idx , d in enumerate ( all_dates ):
2026-05-20 07:55:47 +08:00
if d >= local_date_str :
start_idx = idx
break
maxtemps = all_maxtemps [ start_idx : start_idx + 5 ]
2026-05-25 20:03:06 +08:00
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
mgm_hourly_high = _mgm_hourly_high ( mgm )
2026-04-11 20:11:01 +08:00
if om_today is None :
fallback_high = (
nws_high
if nws_high is not None
else mgm_high
if mgm_high is not None
2026-05-17 19:19:12 +08:00
else mgm_hourly_high
if mgm_hourly_high is not None
2026-04-11 20:11:01 +08:00
else max_so_far
if max_so_far is not None
else cur_temp
2026-05-25 20:03:06 +08:00
)
2026-04-11 20:11:01 +08:00
if fallback_high is not None :
2026-05-26 22:27:55 +08:00
om_today = fallback_high
2026-04-11 20:11:01 +08:00
current_forecasts : Dict [ str , float ] = {}
if om_today is not None :
current_forecasts [ "Open-Meteo" ] = om_today
2026-05-25 20:03:06 +08:00
for m , v in mm . get ( "forecasts" , {}) . items ():
if v is not None and not _is_excluded_model_name ( m ):
2026-05-26 22:27:55 +08:00
temp_val = _sf ( v )
if temp_val is not None :
current_forecasts [ m ] = temp_val
2026-04-11 20:11:01 +08:00
if nws_high is not None :
current_forecasts [ "NWS" ] = nws_high
if mgm_high is not None :
current_forecasts [ "MGM" ] = mgm_high
2026-05-17 19:19:12 +08:00
elif mgm_hourly_high is not None :
current_forecasts [ "MGM Hourly" ] = mgm_hourly_high
2026-04-11 20:11:01 +08:00
if hko_forecast is not None :
2026-05-26 22:27:55 +08:00
temp_hko = _sf ( hko_forecast )
if temp_hko is not None :
current_forecasts [ "HKO" ] = temp_hko
2026-04-11 20:11:01 +08:00
current_forecasts = {
model_name : value
2026-05-25 20:03:06 +08:00
for model_name , value in current_forecasts . items ()
if value is not None and not _is_excluded_model_name ( model_name )
2026-04-11 20:11:01 +08:00
}
deb_val = None
2026-05-27 21:40:26 +08:00
deb_raw_val = None
deb_version = None
deb_bias_adjustment = 0.0
deb_bias_samples = 0
2026-06-08 00:59:16 +08:00
deb_selected_version , deb_guard_reason = None , None
2026-06-07 23:57:47 +08:00
deb_quality = {}
2026-04-11 20:11:01 +08:00
if current_forecasts :
2026-05-27 21:40:26 +08:00
deb_result = calculate_deb_prediction ( city , current_forecasts )
if deb_result . get ( "prediction" ) is not None :
deb_val = deb_result . get ( "prediction" )
deb_raw_val = deb_result . get ( "raw_prediction" )
deb_version = deb_result . get ( "version" )
2026-06-08 00:59:16 +08:00
deb_selected_version = deb_result . get ( "selected_version" )
deb_guard_reason = deb_result . get ( "guard_reason" )
2026-05-27 21:40:26 +08:00
deb_bias_adjustment = deb_result . get ( "bias_adjustment" ) or 0.0
deb_bias_samples = deb_result . get ( "bias_samples" ) or 0
2026-06-07 23:57:47 +08:00
deb_quality = {
"quality_tier" : deb_result . get ( "quality_tier" ),
"recommendation" : deb_result . get ( "recommendation" ),
"recent_hit_rate" : deb_result . get ( "recent_hit_rate" ),
"recent_samples" : deb_result . get ( "recent_samples" ),
"recent_hits" : deb_result . get ( "recent_hits" ),
"recent_mae" : deb_result . get ( "recent_mae" ),
}
2026-04-11 20:11:01 +08:00
if deb_val is None :
deb_val = om_today
settlement_today_obs = []
if use_settlement_current :
2026-05-25 20:03:06 +08:00
explicit_obs = settlement_current . get ( "today_obs" ) or []
2026-04-11 20:11:01 +08:00
for item in explicit_obs :
2026-05-25 20:03:06 +08:00
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 ])
2026-04-11 20:11:01 +08:00
else :
continue
if raw_time and raw_temp is not None :
2026-05-25 20:03:06 +08:00
settlement_today_obs . append ({ "time" : raw_time , "temp" : raw_temp })
2026-04-11 20:11:01 +08:00
if not settlement_today_obs and obs_time_str and cur_temp is not None :
2026-05-25 20:03:06 +08:00
settlement_today_obs . append ({ "time" : obs_time_str , "temp" : cur_temp })
2026-05-26 22:27:55 +08:00
if max_temp_time and max_so_far is not None and max_temp_time != obs_time_str :
settlement_today_obs . append ({ "time" : max_temp_time , "temp" : max_so_far })
2026-04-11 20:11:01 +08:00
metar_today_obs_payload = [
{ "time" : obs_time , "temp" : obs_temp }
2026-04-21 19:13:33 +08:00
for obs_time , obs_temp in (
2026-05-25 20:03:06 +08:00
( metar . get ( "today_obs" ) or [])
if isinstance ( metar , dict ) and metar_current_is_today
2026-04-21 19:13:33 +08:00
else []
2026-05-25 20:03:06 +08:00
)
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 ,
2026-05-25 20:03:06 +08:00
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 [],
2026-04-11 20:11:01 +08:00
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-05-25 20:03:06 +08:00
),
)
2026-04-11 20:11:01 +08:00
2026-05-25 20:03:06 +08:00
risk = CITY_RISK_PROFILES . get ( city , {})
city_meta = CITY_REGISTRY . get ( city , {}) or {}
2026-04-11 20:11:01 +08:00
result = {
"name" : city ,
2026-05-25 20:03:06 +08:00
"display_name" : str ( city_meta . get ( "display_name" ) or city_meta . get ( "name" ) or city . title ()),
2026-04-11 20:11:01 +08:00
"temp_symbol" : sym ,
2026-05-26 22:27:55 +08:00
"utc_offset_seconds" : utc_offset ,
2026-04-11 20:11:01 +08:00
"local_time" : local_time_str ,
"local_date" : local_date_str ,
"risk" : {
2026-05-25 20:03:06 +08:00
"level" : risk . get ( "risk_level" , "low" ),
"warning" : risk . get ( "warning" , "" ),
"icao" : risk . get ( "icao" , "" ),
2026-04-11 20:11:01 +08:00
},
"current" : {
2026-05-25 20:03:06 +08:00
"temp" : _sf ( cur_temp ),
"max_so_far" : _sf ( display_settlement_max ),
2026-04-11 20:11:01 +08:00
"max_temp_time" : max_temp_time ,
2026-05-25 20:03:06 +08:00
"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
},
2026-05-29 17:20:07 +08:00
"wunderground_current" : wunderground_current ,
2026-05-27 21:40:26 +08:00
"deb" : {
"prediction" : _sf ( deb_val ),
"raw_prediction" : _sf ( deb_raw_val ),
"version" : deb_version ,
2026-06-08 00:59:16 +08:00
"selected_version" : deb_selected_version ,
"guard_reason" : deb_guard_reason ,
2026-05-27 21:40:26 +08:00
"bias_adjustment" : deb_bias_adjustment ,
"bias_samples" : deb_bias_samples ,
2026-06-07 23:57:47 +08:00
** deb_quality ,
2026-05-27 21:40:26 +08:00
},
2026-04-11 20:11:01 +08:00
"deviation_monitor" : deviation_monitor or {},
2026-05-25 20:03:06 +08:00
"updated_at" : datetime . now ( timezone . utc ) . isoformat (),
2026-04-11 20:11:01 +08:00
}
2026-05-25 20:03:06 +08:00
_set_cached_summary ( city , result )
2026-04-11 20:11:01 +08:00
return result
2026-05-25 20:03:06 +08:00
def _build_city_summary_payload ( data : Dict [ str , Any ]) -> Dict [ str , Any ]:
return _city_payload_summary ( data )
2026-03-20 20:59:30 +08:00
2026-04-13 18:42:27 +08:00
def _build_city_detail_payload (
data : Dict [ str , Any ],
market_slug : Optional [ str ] = None ,
target_date : Optional [ str ] = None ,
2026-05-26 20:37:00 +08:00
resolution : Optional [ str ] = "10m" ,
2026-05-25 20:03:06 +08:00
) -> Dict [ str , Any ]:
2026-05-14 20:01:26 +08:00
return _city_payload_detail (
2026-04-13 18:42:27 +08:00
data ,
market_slug = market_slug ,
target_date = target_date ,
2026-05-26 20:37:00 +08:00
resolution = resolution ,
2026-05-25 20:03:06 +08:00
)
2026-05-14 20:01:26 +08:00
2026-03-20 20:59:30 +08:00
2026-06-01 12:06:46 +08:00
def _build_city_chart_detail_payload (
data : Dict [ str , Any ],
resolution : Optional [ str ] = "10m" ,
) -> Dict [ str , Any ]:
return _city_chart_payload_detail ( data , resolution = resolution )
2026-03-20 20:59:30 +08:00
# ──────────────────────────────────────────────────────────
# Routes
# ──────────────────────────────────────────────────────────
2026-05-25 20:15:43 +08:00
def _build_city_market_scan_payload (
data ,
market_slug = None ,
target_date = None ,
lite = False ,
scan_filters = None ,
):
local_date = str ( data . get ( "local_date" ) or "" ) . strip ()
return {
"market_scan" : { "available" : False },
"selected_date" : target_date or local_date ,
"fetched_at" : data . get ( "updated_at" ),
}