diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8ebe0caa..5294749b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,11 @@ "allow": [ "Bash(npm --prefix \"/e/web/PolyWeather/frontend\" run build)", "mcp__Claude_Preview__preview_start", - "Bash(git:*)" + "Bash(git:*)", + "Bash(python -c \"from web.services.city_payloads import build_city_detail_payload; print\\('OK'\\)\")", + "Bash(python -m ruff check web/services/city_runtime.py web/services/city_api.py)", + "Bash(python -m ruff check .)", + "Bash(python -m pytest tests/ -q)" ] } } diff --git a/web/analysis_service.py b/web/analysis_service.py index aab387bc..2d7b6188 100644 --- a/web/analysis_service.py +++ b/web/analysis_service.py @@ -32,13 +32,12 @@ from src.data_collection.city_time import get_city_utc_offset_seconds from src.database.runtime_state import IntradayPathSnapshotRepository from web.services.city_payloads import ( build_city_detail_payload as _city_payload_detail, - build_city_market_scan_payload as _city_payload_market_scan, build_city_summary_payload as _city_payload_summary, ) from web.services.observation_freshness import ( build_observation_freshness as _build_observation_freshness, observation_age_min as _observation_age_min, -) + from web.services.analysis_utils import ( add_signal as _add_signal, bucket_label as _bucket_label, @@ -46,14 +45,14 @@ from web.services.analysis_utils import ( format_clock_minutes as _format_clock_minutes, next_observation_clock as _next_observation_clock, top_probability_bucket as _top_probability_bucket, -) + 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 -) + TURKISH_MGM_CITIES = {"ankara", "istanbul"} HIGH_FREQ_AIRPORT_ANALYSIS_CITIES = { @@ -80,19 +79,19 @@ HIGH_FREQ_AIRPORT_ANALYSIS_CITIES = { } -def _mgm_hourly_high(mgm: Dict[str, Any]) -> Optional[float]: - hourly = mgm.get("hourly") if isinstance(mgm, dict) else [] - if not isinstance(hourly, list): +def _mgm_hourly_high(mgm: Dict[str, Any] -> Optional[float]: + hourly = mgm.get("hourly" if isinstance(mgm, dict else [] + if not isinstance(hourly, list: return None values = [] for row in hourly: - if not isinstance(row, dict): + if not isinstance(row, dict: continue - value = _sf(row.get("temp")) + value = _sf(row.get("temp" if value is not None: - values.append(value) - return max(values) if values else None -_ANALYSIS_CACHE_STATS_LOCK = threading.Lock() + values.append(value + return max(values if values else None +_ANALYSIS_CACHE_STATS_LOCK = threading.Lock( _ANALYSIS_CACHE_STATS: Dict[str, Any] = { "total_requests": 0, "cache_hits": 0, @@ -102,65 +101,65 @@ _ANALYSIS_CACHE_STATS: Dict[str, Any] = { "last_cache_miss_at": None, "last_city": None, } -_SUMMARY_CACHE_LOCK = threading.Lock() +_SUMMARY_CACHE_LOCK = threading.Lock( _SUMMARY_CACHE_MAXSIZE = 128 -_SUMMARY_CACHE = LRUDict(maxsize=_SUMMARY_CACHE_MAXSIZE) -def _dedupe_forecast_daily(rows: Any) -> list[Dict[str, Any]]: - if not isinstance(rows, list): +_SUMMARY_CACHE = LRUDict(maxsize=_SUMMARY_CACHE_MAXSIZE +def _dedupe_forecast_daily(rows: Any -> list[Dict[str, Any]]: + if not isinstance(rows, list: return [] - seen = set() + seen = set( out = [] for row in rows: - if not isinstance(row, dict): + if not isinstance(row, dict: continue - date = str(row.get("date") or "").strip() + date = str(row.get("date" or "".strip( if not date or date in seen: continue - seen.add(date) - out.append(row) + seen.add(date + out.append(row return out -def _format_observation_time_local(value: Any, utc_offset: int) -> str: - raw = str(value or "").strip() +def _format_observation_time_local(value: Any, utc_offset: int -> str: + raw = str(value or "".strip( if not raw: return "" if "T" in raw: try: - dt = datetime.fromisoformat(raw.replace("Z", "+00:00")) + dt = datetime.fromisoformat(raw.replace("Z", "+00:00" if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt.astimezone(timezone(timedelta(seconds=utc_offset))).strftime("%H:%M") + dt = dt.replace(tzinfo=timezone.utc + return dt.astimezone(timezone(timedelta(seconds=utc_offset.strftime("%H:%M" except Exception: pass - match = re.search(r"(\d{1,2}):(\d{2})", raw) + match = re.search(r"(\d{1,2}:(\d{2}", raw if match: - return f"{int(match.group(1)):02d}:{match.group(2)}" + return f"{int(match.group(1:02d}:{match.group(2}" return raw[:16] -def _fetch_nmc_current_fallback(city: str, *, use_fahrenheit: bool) -> Dict[str, Any]: +def _fetch_nmc_current_fallback(city: str, *, use_fahrenheit: bool -> Dict[str, Any]: return {} -def _is_plausible_city_temp(city: str, value: Any, unit: str = "°C") -> bool: - temp = _sf(value) +def _is_plausible_city_temp(city: str, value: Any, unit: str = "°C" -> bool: + temp = _sf(value if temp is None: return False - meta = CITY_REGISTRY.get(str(city or "").strip().lower(), {}) or {} - min_c = _sf(meta.get("min_plausible_metar_temp_c")) + meta = CITY_REGISTRY.get(str(city or "".strip(.lower(, {} or {} + min_c = _sf(meta.get("min_plausible_metar_temp_c" if min_c is None: return True - min_value = min_c * 9 / 5 + 32 if str(unit or "").upper().endswith("F") else min_c + min_value = min_c * 9 / 5 + 32 if str(unit or "".upper(.endswith("F" else min_c return temp >= min_value -def _parse_local_hour(local_time_str: Optional[str]) -> Optional[int]: +def _parse_local_hour(local_time_str: Optional[str] -> Optional[int]: if not local_time_str: return None try: - parts = str(local_time_str).strip().split(":") - hour = int(parts[0]) + parts = str(local_time_str.strip(.split(":" + hour = int(parts[0] if 0 <= hour <= 23: return hour except Exception: @@ -168,17 +167,17 @@ def _parse_local_hour(local_time_str: Optional[str]) -> Optional[int]: return None -def _parse_utc_datetime(value: Any) -> Optional[datetime]: - raw = str(value or "").strip() +def _parse_utc_datetime(value: Any -> Optional[datetime]: + raw = str(value or "".strip( if not raw or "T" not in raw: return None try: - dt = datetime.fromisoformat(raw.replace("Z", "+00:00")) + dt = datetime.fromisoformat(raw.replace("Z", "+00:00" except Exception: return None if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - return dt.astimezone(timezone.utc) + dt = dt.replace(tzinfo=timezone.utc + return dt.astimezone(timezone.utc def _metar_is_current_local_day( @@ -186,54 +185,54 @@ def _metar_is_current_local_day( *, local_date: str, utc_offset: int, -) -> bool: - if not isinstance(metar, dict) or not metar: + -> bool: + if not isinstance(metar, dict or not metar: return False - if metar.get("stale_for_today") is True: + if metar.get("stale_for_today" is True: return False - observation_local_date = str(metar.get("observation_local_date") or "").strip() + observation_local_date = str(metar.get("observation_local_date" or "".strip( if observation_local_date: return observation_local_date == local_date - obs_dt = _parse_utc_datetime(metar.get("observation_time")) + obs_dt = _parse_utc_datetime(metar.get("observation_time" if obs_dt is None: return True - local_dt = obs_dt.astimezone(timezone(timedelta(seconds=utc_offset))) - return local_dt.strftime("%Y-%m-%d") == local_date + local_dt = obs_dt.astimezone(timezone(timedelta(seconds=utc_offset + return local_dt.strftime("%Y-%m-%d" == local_date -def _record_analysis_cache_event(*, city: str, hit: bool, force_refresh: bool) -> None: - now = datetime.now(timezone.utc).isoformat() +def _record_analysis_cache_event(*, city: str, hit: bool, force_refresh: bool -> None: + now = datetime.now(timezone.utc.isoformat( with _ANALYSIS_CACHE_STATS_LOCK: - _ANALYSIS_CACHE_STATS["total_requests"] = int(_ANALYSIS_CACHE_STATS.get("total_requests") or 0) + 1 - _ANALYSIS_CACHE_STATS["last_city"] = str(city or "") + _ANALYSIS_CACHE_STATS["total_requests"] = int(_ANALYSIS_CACHE_STATS.get("total_requests" or 0 + 1 + _ANALYSIS_CACHE_STATS["last_city"] = str(city or "" if force_refresh: - _ANALYSIS_CACHE_STATS["force_refresh_requests"] = int(_ANALYSIS_CACHE_STATS.get("force_refresh_requests") or 0) + 1 + _ANALYSIS_CACHE_STATS["force_refresh_requests"] = int(_ANALYSIS_CACHE_STATS.get("force_refresh_requests" or 0 + 1 if hit: - _ANALYSIS_CACHE_STATS["cache_hits"] = int(_ANALYSIS_CACHE_STATS.get("cache_hits") or 0) + 1 + _ANALYSIS_CACHE_STATS["cache_hits"] = int(_ANALYSIS_CACHE_STATS.get("cache_hits" or 0 + 1 _ANALYSIS_CACHE_STATS["last_cache_hit_at"] = now else: - _ANALYSIS_CACHE_STATS["cache_misses"] = int(_ANALYSIS_CACHE_STATS.get("cache_misses") or 0) + 1 + _ANALYSIS_CACHE_STATS["cache_misses"] = int(_ANALYSIS_CACHE_STATS.get("cache_misses" or 0 + 1 _ANALYSIS_CACHE_STATS["last_cache_miss_at"] = now -def get_analysis_cache_stats() -> Dict[str, Any]: +def get_analysis_cache_stats( -> Dict[str, Any]: with _ANALYSIS_CACHE_STATS_LOCK: - stats = dict(_ANALYSIS_CACHE_STATS) - hits = int(stats.get("cache_hits") or 0) - misses = int(stats.get("cache_misses") or 0) + stats = dict(_ANALYSIS_CACHE_STATS + hits = int(stats.get("cache_hits" or 0 + misses = int(stats.get("cache_misses" or 0 eligible = hits + misses - hit_rate = (hits / eligible) if eligible > 0 else None - miss_rate = (misses / eligible) if eligible > 0 else None - stats["hit_rate"] = round(hit_rate, 4) if hit_rate is not None else None - stats["miss_rate"] = round(miss_rate, 4) if miss_rate is not None else None + hit_rate = (hits / eligible if eligible > 0 else None + miss_rate = (misses / eligible if eligible > 0 else None + stats["hit_rate"] = round(hit_rate, 4 if hit_rate is not None else None + stats["miss_rate"] = round(miss_rate, 4 if miss_rate is not None else None return stats KOREAN_AMOS_CITIES = {"seoul", "busan"} -def _analysis_ttl_for_city(city: str) -> int: - city_lower = city.lower() +def _analysis_ttl_for_city(city: str -> int: + city_lower = city.lower( if city_lower in TURKISH_MGM_CITIES: return CACHE_TTL_ANKARA if city_lower in KOREAN_AMOS_CITIES: @@ -243,8 +242,8 @@ def _analysis_ttl_for_city(city: str) -> int: return CACHE_TTL -def _analysis_cache_key(city: str, detail_mode: str = "full") -> str: - normalized_raw = str(detail_mode or "").strip().lower() +def _analysis_cache_key(city: str, detail_mode: str = "full" -> str: + normalized_raw = str(detail_mode or "".strip(.lower( if normalized_raw == "panel": normalized_mode = "panel" elif normalized_raw == "market": @@ -259,50 +258,50 @@ def _analysis_cache_key(city: str, detail_mode: str = "full") -> str: def _get_cached_analysis( city: str, ttl: int, - detail_modes: tuple[str, ...] = ("panel", "market", "nearby", "full"), -) -> Optional[Dict[str, Any]]: - now_ts = _time.time() + detail_modes: tuple[str, ...] = ("panel", "market", "nearby", "full", + -> Optional[Dict[str, Any]]: + now_ts = _time.time( freshest_payload: Optional[Dict[str, Any]] = None freshest_ts = 0.0 with _CACHE_LOCK: for detail_mode in detail_modes: - cached = _cache.get(_analysis_cache_key(city, detail_mode)) + cached = _cache.get(_analysis_cache_key(city, detail_mode if not cached: continue - cached_ts = float(cached.get("t", 0)) - payload = cached.get("d") + cached_ts = float(cached.get("t", 0 + payload = cached.get("d" if ( cached_ts and now_ts - cached_ts < ttl - and isinstance(payload, dict) + and isinstance(payload, dict and cached_ts >= freshest_ts - ): + : freshest_payload = payload freshest_ts = cached_ts return freshest_payload -def _get_cached_summary(city: str, ttl: int) -> Optional[Dict[str, Any]]: - now_ts = _time.time() +def _get_cached_summary(city: str, ttl: int -> Optional[Dict[str, Any]]: + now_ts = _time.time( with _SUMMARY_CACHE_LOCK: - cached = _SUMMARY_CACHE.get(city) - if cached and now_ts - float(cached.get("t", 0)) < ttl: - payload = cached.get("d") - if isinstance(payload, dict): - return dict(payload) + cached = _SUMMARY_CACHE.get(city + if cached and now_ts - float(cached.get("t", 0 < ttl: + payload = cached.get("d" + if isinstance(payload, dict: + return dict(payload return None -def _set_cached_summary(city: str, payload: Dict[str, Any]) -> None: +def _set_cached_summary(city: str, payload: Dict[str, Any] -> None: with _SUMMARY_CACHE_LOCK: - _SUMMARY_CACHE[city] = {"t": _time.time(), "d": dict(payload)} + _SUMMARY_CACHE[city] = {"t": _time.time(, "d": dict(payload} def _maybe_enrich_dynamic_commentary_with_groq( _city: str, result: Dict[str, Any], -) -> Dict[str, Any]: - return result.get("dynamic_commentary") or {"summary": "", "notes": []} + -> Dict[str, Any]: + return result.get("dynamic_commentary" or {"summary": "", "notes": []} @@ -313,47 +312,47 @@ def _maybe_enrich_dynamic_commentary_with_groq( -def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]: +def _build_intraday_meteorology(data: Dict[str, Any] -> Dict[str, Any]: """Build a paid-product intraday meteorology read from existing layers.""" - current = data.get("current") or {} - probabilities = data.get("probabilities") or {} - distribution = probabilities.get("distribution") or [] - top_bucket = _top_probability_bucket(distribution) - unit = str(data.get("temp_symbol") or "°C") - deb = data.get("deb") or {} - peak = data.get("peak") or {} - deviation = data.get("deviation_monitor") or {} + current = data.get("current" or {} + probabilities = data.get("probabilities" or {} + distribution = probabilities.get("distribution" or [] + top_bucket = _top_probability_bucket(distribution + unit = str(data.get("temp_symbol" or "°C" + deb = data.get("deb" or {} + peak = data.get("peak" or {} + deviation = data.get("deviation_monitor" or {} taf_signal = ( - ((data.get("taf") or {}).get("signal") or {}) - if isinstance(data.get("taf"), dict) + ((data.get("taf" or {}.get("signal" or {} + if isinstance(data.get("taf", dict else {} - ) - vertical = data.get("vertical_profile_signal") or {} + + vertical = data.get("vertical_profile_signal" or {} - current_temp = _sf(current.get("temp")) - max_so_far = _sf(current.get("max_so_far")) - deb_prediction = _sf(deb.get("prediction")) - base_value = _sf(top_bucket.get("value")) if isinstance(top_bucket, dict) else None + current_temp = _sf(current.get("temp" + max_so_far = _sf(current.get("max_so_far" + deb_prediction = _sf(deb.get("prediction" + base_value = _sf(top_bucket.get("value" if isinstance(top_bucket, dict else None if base_value is None: base_value = deb_prediction if base_value is None: base_value = max_so_far if max_so_far is not None else current_temp - base_case_bucket = _bucket_label(top_bucket, unit) or _bucket_label_from_value(base_value, unit) - upside_bucket = _bucket_label_from_value(base_value + 1.0, unit) if base_value is not None else None - downside_bucket = _bucket_label_from_value(base_value - 1.0, unit) if base_value is not None else None + base_case_bucket = _bucket_label(top_bucket, unit or _bucket_label_from_value(base_value, unit + upside_bucket = _bucket_label_from_value(base_value + 1.0, unit if base_value is not None else None + downside_bucket = _bucket_label_from_value(base_value - 1.0, unit if base_value is not None else None signals: list = [] support_score = 0 suppress_score = 0 available_layers = 0 - direction = str(deviation.get("direction") or "").lower() - severity = str(deviation.get("severity") or "normal").lower() - delta = _sf(deviation.get("current_delta")) + direction = str(deviation.get("direction" or "".lower( + severity = str(deviation.get("severity" or "normal".lower( + delta = _sf(deviation.get("current_delta" if direction: available_layers += 1 - strength = "strong" if severity == "strong" else ("medium" if severity == "light" else "weak") + strength = "strong" if severity == "strong" else ("medium" if severity == "light" else "weak" if direction == "hot": support_score += 2 if strength == "strong" else 1 _add_signal( @@ -362,9 +361,9 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]: label_en="Intraday pace", direction="support", strength=strength, - summary=f"实测较预期路径偏高 {abs(delta or 0):.1f}{unit},峰值仍有上修空间。", - summary_en=f"Observed temperature is running {abs(delta or 0):.1f}{unit} above the expected path; the peak still has upside room.", - ) + summary=f"实测较预期路径偏高 {abs(delta or 0:.1f}{unit},峰值仍有上修空间。", + summary_en=f"Observed temperature is running {abs(delta or 0:.1f}{unit} above the expected path; the peak still has upside room.", + elif direction == "cold": suppress_score += 2 if strength == "strong" else 1 _add_signal( @@ -373,9 +372,9 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]: label_en="Intraday pace", direction="suppress", strength=strength, - summary=f"实测较预期路径偏低 {abs(delta or 0):.1f}{unit},追更高温档需要等待后续观测确认。", - summary_en=f"Observed temperature is running {abs(delta or 0):.1f}{unit} below the expected path; higher buckets need confirmation from later observations.", - ) + summary=f"实测较预期路径偏低 {abs(delta or 0:.1f}{unit},追更高温档需要等待后续观测确认。", + summary_en=f"Observed temperature is running {abs(delta or 0:.1f}{unit} below the expected path; higher buckets need confirmation from later observations.", + else: _add_signal( signals, @@ -385,10 +384,10 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]: strength="weak", summary="实测大体贴近当前预期路径,下一步主要看峰值窗口内是否继续抬升。", summary_en="Observed temperature is broadly tracking the expected path; the next question is whether it keeps lifting through the peak window.", - ) + - heating_setup = str(vertical.get("heating_setup") or "").lower() - suppression_risk = str(vertical.get("suppression_risk") or "").lower() + heating_setup = str(vertical.get("heating_setup" or "".lower( + suppression_risk = str(vertical.get("suppression_risk" or "".lower( if heating_setup or suppression_risk: available_layers += 1 if heating_setup == "supportive": @@ -399,9 +398,9 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]: label_en="Boundary-layer setup", direction="support", strength="strong", - summary=str(vertical.get("summary_zh") or "边界层结构支持白天继续混合升温。"), - summary_en=str(vertical.get("summary_en") or "The boundary-layer setup supports continued daytime mixing and warming."), - ) + summary=str(vertical.get("summary_zh" or "边界层结构支持白天继续混合升温。", + summary_en=str(vertical.get("summary_en" or "The boundary-layer setup supports continued daytime mixing and warming.", + elif heating_setup == "suppressed" or suppression_risk == "high": suppress_score += 2 _add_signal( @@ -410,9 +409,9 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]: label_en="Boundary-layer setup", direction="suppress", strength="strong", - summary=str(vertical.get("summary_zh") or "边界层或云雨结构对午后峰值形成压制。"), - summary_en=str(vertical.get("summary_en") or "Boundary-layer or cloud/rain structure is capping the afternoon peak."), - ) + summary=str(vertical.get("summary_zh" or "边界层或云雨结构对午后峰值形成压制。", + summary_en=str(vertical.get("summary_en" or "Boundary-layer or cloud/rain structure is capping the afternoon peak.", + else: _add_signal( signals, @@ -420,18 +419,18 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]: label_en="Boundary-layer setup", direction="neutral", strength="medium", - summary=str(vertical.get("summary_zh") or "边界层结构暂未给出单边信号。"), - summary_en=str(vertical.get("summary_en") or "The boundary-layer setup does not yet provide a one-sided signal."), - ) + summary=str(vertical.get("summary_zh" or "边界层结构暂未给出单边信号。", + summary_en=str(vertical.get("summary_en" or "The boundary-layer setup does not yet provide a one-sided signal.", + - taf_suppression = str(taf_signal.get("suppression_level") or "").lower() - taf_disruption = str(taf_signal.get("disruption_level") or "").lower() + taf_suppression = str(taf_signal.get("suppression_level" or "".lower( + taf_disruption = str(taf_signal.get("disruption_level" or "".lower( taf_has_cloud_rain_cap = taf_suppression in {"medium", "high"} or taf_disruption in { "medium", "high", } structural_cap = False - if taf_signal.get("available") or taf_suppression: + if taf_signal.get("available" or taf_suppression: available_layers += 1 if taf_suppression == "high" or taf_disruption == "high": suppress_score += 2 @@ -451,17 +450,17 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]: label_en="TAF cloud/rain disruption", direction=direction_value, strength=strength, - summary=str(taf_signal.get("summary_zh") or "TAF 暂未提示强云雨压温信号。"), - summary_en=str(taf_signal.get("summary_en") or "TAF does not yet flag a strong cloud/rain temperature cap."), - ) + summary=str(taf_signal.get("summary_zh" or "TAF 暂未提示强云雨压温信号。", + summary_en=str(taf_signal.get("summary_en" or "TAF does not yet flag a strong cloud/rain temperature cap.", + - airport_delta = _sf(data.get("airport_vs_network_delta")) - lead_signal = data.get("network_lead_signal") or {} + airport_delta = _sf(data.get("airport_vs_network_delta" + lead_signal = data.get("network_lead_signal" or {} if airport_delta is not None: available_layers += 1 - leader = str(lead_signal.get("leader_station_label") or lead_signal.get("leader_station_code") or "").strip() - sync_status = str(lead_signal.get("leader_sync_status") or "").strip().lower() - sync_delta = _sf(lead_signal.get("leader_time_delta_vs_anchor_minutes")) + leader = str(lead_signal.get("leader_station_label" or lead_signal.get("leader_station_code" or "".strip( + sync_status = str(lead_signal.get("leader_sync_status" or "".strip(.lower( + sync_delta = _sf(lead_signal.get("leader_time_delta_vs_anchor_minutes" sync_suffix_zh = "" sync_suffix_en = "" if sync_status in {"near_realtime", "lagged"} and sync_delta is not None: @@ -478,9 +477,9 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]: label_en="Station-network comparison", direction="support", strength="weak" if sync_suffix_zh else "medium", - summary=f"周边站网较机场锚点偏热 {abs(airport_delta):.1f}{unit}{f',领先点位 {leader}' if leader else ''}{sync_suffix_zh}。", - summary_en=f"Nearby stations are {abs(airport_delta):.1f}{unit} warmer than the airport anchor{f'; leading site: {leader}' if leader else ''}{sync_suffix_en}.", - ) + summary=f"周边站网较机场锚点偏热 {abs(airport_delta:.1f}{unit}{f',领先点位 {leader}' if leader else ''}{sync_suffix_zh}。", + summary_en=f"Nearby stations are {abs(airport_delta:.1f}{unit} warmer than the airport anchor{f'; leading site: {leader}' if leader else ''}{sync_suffix_en}.", + elif airport_delta >= 0.4: suppress_score += 1 _add_signal( @@ -489,9 +488,9 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]: label_en="Station-network comparison", direction="suppress", strength="weak" if sync_suffix_zh else "medium", - summary=f"机场锚点较周边站网偏热 {abs(airport_delta):.1f}{unit},继续上修需要机场自身后续报文确认{sync_suffix_zh}。", - summary_en=f"The airport anchor is {abs(airport_delta):.1f}{unit} warmer than nearby stations; further upside needs confirmation from later airport reports{sync_suffix_en}.", - ) + summary=f"机场锚点较周边站网偏热 {abs(airport_delta:.1f}{unit},继续上修需要机场自身后续报文确认{sync_suffix_zh}。", + summary_en=f"The airport anchor is {abs(airport_delta:.1f}{unit} warmer than nearby stations; further upside needs confirmation from later airport reports{sync_suffix_en}.", + else: _add_signal( signals, @@ -501,26 +500,26 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]: strength="weak", summary="机场锚点与周边站网基本同步,暂不构成单独上修或下修理由。", summary_en="The airport anchor and nearby station network are broadly aligned, so this layer does not independently argue for upside or downside.", - ) + - peak_status = str(peak.get("status") or "").lower() - first_h = _sf(peak.get("first_h")) - last_h = _sf(peak.get("last_h")) + peak_status = str(peak.get("status" or "".lower( + first_h = _sf(peak.get("first_h" + last_h = _sf(peak.get("last_h" peak_window = ( - f"{int(first_h):02d}:00-{int(last_h):02d}:59" + f"{int(first_h:02d}:00-{int(last_h:02d}:59" if first_h is not None and last_h is not None else "--" - ) + if peak_status == "past": headline = "峰值窗口已过,后续更偏向确认最终高点而非继续上修。" headline_en = "The peak window has passed; the read now shifts toward confirming the final high rather than chasing further upside." confidence = "high" if available_layers >= 2 else "medium" elif suppress_score >= support_score + 2: structural_cap = any( - signal.get("direction") == "suppress" - and signal.get("label") in {"边界层结构", "站网对比", "日内节奏"} + signal.get("direction" == "suppress" + and signal.get("label" in {"边界层结构", "站网对比", "日内节奏"} for signal in signals - ) + if taf_has_cloud_rain_cap and structural_cap: headline = "峰值同时存在 TAF 云雨扰动和结构压制,当前更偏防守高温上修。" headline_en = "Both TAF cloud/rain disruption and structural signals are capping the peak; defend against aggressive high-temperature upside for now." @@ -544,28 +543,28 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]: headline_en = "The setup is in a split-decision zone; the next observations inside the peak window should decide direction." confidence = "medium" if available_layers >= 2 else "low" - next_observation = _next_observation_clock(data.get("local_time") or current.get("obs_time")) + next_observation = _next_observation_clock(data.get("local_time" or current.get("obs_time" threshold = base_value invalidation_rules = [] invalidation_rules_en = [] confirmation_rules = [] confirmation_rules_en = [] if peak_status == "past": - invalidation_rules.append("若后续官方结算源补录更高值,以结算源最终高点为准。") - invalidation_rules_en.append("If the official settlement source later backfills a higher reading, defer to the final settlement-source high.") - confirmation_rules.append("若峰值窗口后连续两次观测不再创新高,当前高点基本确认。") - confirmation_rules_en.append("If two consecutive post-peak observations fail to make a new high, the current high is broadly confirmed.") + invalidation_rules.append("若后续官方结算源补录更高值,以结算源最终高点为准。" + invalidation_rules_en.append("If the official settlement source later backfills a higher reading, defer to the final settlement-source high." + confirmation_rules.append("若峰值窗口后连续两次观测不再创新高,当前高点基本确认。" + confirmation_rules_en.append("If two consecutive post-peak observations fail to make a new high, the current high is broadly confirmed." else: - watch_clock = _format_clock_minutes(int(first_h or 13) * 60 + 30) + watch_clock = _format_clock_minutes(int(first_h or 13 * 60 + 30 if threshold is not None: - invalidation_rules.append(f"{watch_clock} 前若仍未接近 {threshold:.0f}{unit},上修路径降级。") - invalidation_rules_en.append(f"If observations are still not near {threshold:.0f}{unit} before {watch_clock}, downgrade the upside path.") - confirmation_rules.append(f"峰值窗口内任一结算源观测触达或超过 {threshold:.0f}{unit},基准路径确认度上升。") - confirmation_rules_en.append(f"If any settlement-source observation reaches or exceeds {threshold:.0f}{unit} inside the peak window, confidence in the base path rises.") - invalidation_rules.append("若 TAF 或实况报文出现阵雨、雷暴或低云/云雨压制,高温上沿需要下调。") - invalidation_rules_en.append("If TAF or live reports show showers, thunderstorms, or low-cloud/cloud-rain suppression, lower the upper temperature bound.") - confirmation_rules.append("若实测继续贴近 DEB 曲线且云雨信号不增强,维持当前主路径。") - confirmation_rules_en.append("If observations keep tracking the DEB curve and cloud/rain signals do not strengthen, maintain the current main path.") + invalidation_rules.append(f"{watch_clock} 前若仍未接近 {threshold:.0f}{unit},上修路径降级。" + invalidation_rules_en.append(f"If observations are still not near {threshold:.0f}{unit} before {watch_clock}, downgrade the upside path." + confirmation_rules.append(f"峰值窗口内任一结算源观测触达或超过 {threshold:.0f}{unit},基准路径确认度上升。" + confirmation_rules_en.append(f"If any settlement-source observation reaches or exceeds {threshold:.0f}{unit} inside the peak window, confidence in the base path rises." + invalidation_rules.append("若 TAF 或实况报文出现阵雨、雷暴或低云/云雨压制,高温上沿需要下调。" + invalidation_rules_en.append("If TAF or live reports show showers, thunderstorms, or low-cloud/cloud-rain suppression, lower the upper temperature bound." + confirmation_rules.append("若实测继续贴近 DEB 曲线且云雨信号不增强,维持当前主路径。" + confirmation_rules_en.append("If observations keep tracking the DEB curve and cloud/rain signals do not strengthen, maintain the current main path." if not signals: _add_signal( @@ -576,7 +575,7 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]: strength="weak", summary="当前缺少足够的日内结构层,等待下一次观测刷新后再提高判断权重。", summary_en="There are not enough intraday structure layers yet; wait for the next observation refresh before raising confidence.", - ) + return { "headline": headline, @@ -595,73 +594,73 @@ def _build_intraday_meteorology(data: Dict[str, Any]) -> Dict[str, Any]: } -def _archive_intraday_path_snapshot(city: str, result: Dict[str, Any]) -> None: +def _archive_intraday_path_snapshot(city: str, result: Dict[str, Any] -> None: """Persist replayable intraday path inputs visible at analysis time.""" - hourly = result.get("hourly") or {} - times = hourly.get("times") if isinstance(hourly, dict) else [] - temps = hourly.get("temps") if isinstance(hourly, dict) else [] - if not isinstance(times, list) or not isinstance(temps, list) or not times: + hourly = result.get("hourly" or {} + times = hourly.get("times" if isinstance(hourly, dict else [] + temps = hourly.get("temps" if isinstance(hourly, dict else [] + if not isinstance(times, list or not isinstance(temps, list or not times: return - forecast = result.get("forecast") or {} - deb = result.get("deb") or {} - current = result.get("current") or {} - forecast_today_high = _sf(forecast.get("today_high")) - deb_prediction = _sf(deb.get("prediction")) + forecast = result.get("forecast" or {} + deb = result.get("deb" or {} + current = result.get("current" or {} + forecast_today_high = _sf(forecast.get("today_high" + deb_prediction = _sf(deb.get("prediction" offset = ( deb_prediction - forecast_today_high if deb_prediction is not None and forecast_today_high is not None else 0.0 - ) + deb_base_temps = [ - round(float(value) + offset, 1) if _sf(value) is not None else None + round(float(value + offset, 1 if _sf(value is not None else None for value in temps ] - utc_offset = int(result.get("utc_offset_seconds") or 0) - snapshot_time = datetime.now(timezone.utc).astimezone( - timezone(timedelta(seconds=utc_offset)) - ).isoformat(timespec="seconds") + utc_offset = int(result.get("utc_offset_seconds" or 0 + snapshot_time = datetime.now(timezone.utc.astimezone( + timezone(timedelta(seconds=utc_offset + .isoformat(timespec="seconds" payload = { "schema_version": 1, "city": city, - "target_date": str(result.get("local_date") or "").strip(), + "target_date": str(result.get("local_date" or "".strip(, "snapshot_time": snapshot_time, - "local_time": str(result.get("local_time") or "").strip(), + "local_time": str(result.get("local_time" or "".strip(, "utc_offset_seconds": utc_offset, - "temp_symbol": result.get("temp_symbol"), + "temp_symbol": result.get("temp_symbol", "deb_prediction": deb_prediction, "forecast_today_high": forecast_today_high, "deb_base_path": { - "times": [str(item) for item in times], + "times": [str(item for item in times], "temps": deb_base_temps, "source": "hourly_plus_deb_offset", - "offset": round(offset, 3), + "offset": round(offset, 3, }, "hourly": { - "times": [str(item) for item in times], + "times": [str(item for item in times], "temps": temps, }, - "metar_today_obs": result.get("metar_today_obs") or [], - "settlement_today_obs": result.get("settlement_today_obs") or [], + "metar_today_obs": result.get("metar_today_obs" or [], + "settlement_today_obs": result.get("settlement_today_obs" or [], "current": { - "temp": _sf(current.get("temp")), - "max_so_far": _sf(current.get("max_so_far")), - "obs_time": current.get("obs_time"), - "settlement_source": current.get("settlement_source"), - "settlement_source_label": current.get("settlement_source_label"), + "temp": _sf(current.get("temp", + "max_so_far": _sf(current.get("max_so_far", + "obs_time": current.get("obs_time", + "settlement_source": current.get("settlement_source", + "settlement_source_label": current.get("settlement_source_label", }, "forecast": { "today_high": forecast_today_high, - "sunrise": forecast.get("sunrise"), - "sunset": forecast.get("sunset"), + "sunrise": forecast.get("sunrise", + "sunset": forecast.get("sunset", }, - "peak": result.get("peak") or {}, - "metar_status": result.get("metar_status") or {}, + "peak": result.get("peak" or {}, + "metar_status": result.get("metar_status" or {}, } try: - IntradayPathSnapshotRepository().append_snapshot(payload) + IntradayPathSnapshotRepository(.append_snapshot(payload except Exception as exc: - logger.debug(f"intraday path snapshot archive skipped for {city}: {exc}") + logger.debug(f"intraday path snapshot archive skipped for {city}: {exc}" def _analyze( @@ -670,7 +669,7 @@ def _analyze( force_refresh_observations_only: bool = False, include_llm_commentary: bool = False, detail_mode: str = "full", -) -> Dict[str, Any]: + -> Dict[str, Any]: """Fetch, analyse, and return structured weather data for one city. Set *force_refresh_observations_only* to True for high-frequency @@ -679,8 +678,8 @@ def _analyze( blending does not fall back to the current observed temperature. """ # Check cache – skip when explicitly refreshing observations - ttl = _analysis_ttl_for_city(city) - normalized_detail_mode_raw = str(detail_mode or "full").strip().lower() + ttl = _analysis_ttl_for_city(city + normalized_detail_mode_raw = str(detail_mode or "full".strip(.lower( if normalized_detail_mode_raw == "panel": normalized_detail_mode = "panel" elif normalized_detail_mode_raw == "market": @@ -689,31 +688,31 @@ def _analyze( normalized_detail_mode = "nearby" else: normalized_detail_mode = "full" - cache_key = _analysis_cache_key(city, normalized_detail_mode) + cache_key = _analysis_cache_key(city, normalized_detail_mode if not force_refresh and not force_refresh_observations_only: - cached = _cache.get(cache_key) - if cached and _time.time() - cached["t"] < ttl: + cached = _cache.get(cache_key + if cached and _time.time( - cached["t"] < ttl: if include_llm_commentary: cached_payload = cached["d"] - dynamic = cached_payload.get("dynamic_commentary") or {} - if not dynamic.get("headline_zh"): + dynamic = cached_payload.get("dynamic_commentary" or {} + if not dynamic.get("headline_zh": cached_payload["dynamic_commentary"] = _maybe_enrich_dynamic_commentary_with_groq( city, cached_payload, - ) - _record_analysis_cache_event(city=city, hit=True, force_refresh=False) + + _record_analysis_cache_event(city=city, hit=True, force_refresh=False return cached["d"] - _record_analysis_cache_event(city=city, hit=False, force_refresh=force_refresh) + _record_analysis_cache_event(city=city, hit=False, force_refresh=force_refresh info = CITIES[city] lat, lon, is_f = info["lat"], info["lon"], info["f"] sym = "°F" if is_f else "°C" - settlement_source = str(info.get("settlement_source") or "metar").strip().lower() or "metar" + settlement_source = str(info.get("settlement_source" or "metar".strip(.lower( or "metar" settlement_source_label = SETTLEMENT_SOURCE_LABELS.get( settlement_source, - settlement_source.upper(), - ) + settlement_source.upper(, + # ── 1. Fetch raw data ── is_panel_mode = normalized_detail_mode == "panel" @@ -731,60 +730,60 @@ def _analyze( include_ensemble=not is_panel_mode and not is_nearby_mode and not is_market_mode, include_multi_model=not is_nearby_mode, include_mgm=not is_market_mode, - ) - 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 {} - ens_raw = raw.get("ensemble", {}) - mm = raw.get("multi_model", {}) - if not isinstance(om, dict): + + 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 {} + ens_raw = raw.get("ensemble", {} + mm = raw.get("multi_model", {} + if not isinstance(om, dict: om = {} - if not isinstance(metar, dict): + if not isinstance(metar, dict: metar = {} - if not isinstance(mgm, dict): + if not isinstance(mgm, dict: mgm = {} - if not isinstance(settlement_current, dict): + if not isinstance(settlement_current, dict: settlement_current = {} - if not isinstance(ens_raw, dict): + if not isinstance(ens_raw, dict: ens_raw = {} - if not isinstance(mm, dict): + if not isinstance(mm, dict: mm = {} - 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"): + 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": mm = {**mm, **mm_hourly} - risk = CITY_RISK_PROFILES.get(city, {}) + risk = CITY_RISK_PROFILES.get(city, {} network_snapshot = ( - build_country_network_snapshot(city, raw) + build_country_network_snapshot(city, raw if not is_panel_mode and not is_market_mode else {} - ) + # 优先从 API 获取偏移;若缺失则尝试 NWS 动态偏移;最后回退静态配置。 # 当前日期/时间必须来自运行时钟,不能使用 Open-Meteo 缓存里的 local_time。 - utc_offset = om.get("utc_offset") + utc_offset = om.get("utc_offset" if utc_offset is None: try: - nws_periods = (raw.get("nws", {}) or {}).get("forecast_periods", []) or [] + nws_periods = (raw.get("nws", {} or {}.get("forecast_periods", [] or [] if nws_periods: - first_start = nws_periods[0].get("start_time") + first_start = nws_periods[0].get("start_time" if first_start: - maybe_dt = datetime.fromisoformat(str(first_start)) - if maybe_dt.utcoffset() is not None: - utc_offset = int(maybe_dt.utcoffset().total_seconds()) + maybe_dt = datetime.fromisoformat(str(first_start + if maybe_dt.utcoffset( is not None: + utc_offset = int(maybe_dt.utcoffset(.total_seconds( except Exception: utc_offset = None if utc_offset is None: - utc_offset = get_city_utc_offset_seconds(city) + utc_offset = get_city_utc_offset_seconds(city try: - utc_offset = int(utc_offset or 0) + utc_offset = int(utc_offset or 0 except Exception: - utc_offset = get_city_utc_offset_seconds(city) - now_utc = datetime.now(timezone.utc) - local_now = now_utc + timedelta(seconds=utc_offset) - local_date_str = local_now.strftime("%Y-%m-%d") + utc_offset = get_city_utc_offset_seconds(city + now_utc = datetime.now(timezone.utc + local_now = now_utc + timedelta(seconds=utc_offset + local_date_str = local_now.strftime("%Y-%m-%d" local_hour = local_now.hour local_minute = local_now.minute local_time_str = f"{local_hour:02d}:{local_minute:02d}" @@ -792,81 +791,81 @@ def _analyze( metar_current_is_today = _metar_is_current_local_day( metar, local_date=local_date_str, - utc_offset=int(utc_offset or 0), - ) + utc_offset=int(utc_offset or 0, + - # ── 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 {} + # ── 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 {} if amos_data: logger.info("AMOS _analyze: found amos data for city={} temp_c={} source={}", - city, amos_data.get("temp_c"), amos_data.get("source")) - use_settlement_current = settlement_source in {"hko", "cwa", "noaa", "wunderground"} and bool(sc_cur) + city, amos_data.get("temp_c", amos_data.get("source" + use_settlement_current = settlement_source in {"hko", "cwa", "noaa", "wunderground"} and bool(sc_cur live_mc = mc if metar_current_is_today else {} primary_current = sc_cur if use_settlement_current else live_mc current_source = settlement_source current_source_label = settlement_source_label - current_station_code = settlement_current.get("station_code") - current_station_name = settlement_current.get("station_name") - cur_temp = _sf(primary_current.get("temp")) - if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym): + 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: cur_temp = None - # AMOS runway sensor: authoritative for Korean airports (RKSI/RKPK) + # AMOS runway sensor: authoritative for Korean airports (RKSI/RKPK if cur_temp is None: - amos_temp = _sf(amos_data.get("temp_c")) - if amos_temp is not None and _is_plausible_city_temp(city, amos_temp, sym): + amos_temp = _sf(amos_data.get("temp_c" + if amos_temp is not None and _is_plausible_city_temp(city, amos_temp, sym: cur_temp = amos_temp current_source = "amos" - current_source_label = amos_data.get("source_label") or "AMOS" - current_station_code = amos_data.get("icao") - current_station_name = amos_data.get("station_label") + 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" if cur_temp is None: - cur_temp = _sf(live_mc.get("temp")) - if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym): + cur_temp = _sf(live_mc.get("temp" + if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym: cur_temp = None if cur_temp is None: - cur_temp = _sf(mg_cur.get("temp")) - if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym): + cur_temp = _sf(mg_cur.get("temp" + if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym: cur_temp = None if cur_temp is None: - nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f) - nmc_cur = nmc_fallback.get("current") or {} - nmc_temp = _sf(nmc_cur.get("temp")) + nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f + nmc_cur = nmc_fallback.get("current" or {} + nmc_temp = _sf(nmc_cur.get("temp" if nmc_temp is not None: cur_temp = nmc_temp current_source = "nmc" current_source_label = "NMC" - current_station_code = nmc_fallback.get("station_code") - current_station_name = nmc_fallback.get("station_name") + current_station_code = nmc_fallback.get("station_code" + current_station_name = nmc_fallback.get("station_name" - 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): + 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: max_so_far = None if max_so_far is None: - 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): + 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: max_so_far = None if max_so_far is None: - 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): + 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: max_so_far = None if max_so_far is None: max_so_far = cur_temp - max_temp_time = primary_current.get("max_temp_time") + max_temp_time = primary_current.get("max_temp_time" if not max_temp_time and not use_settlement_current: - max_temp_time = live_mc.get("max_temp_time") + max_temp_time = live_mc.get("max_temp_time" if not max_temp_time: - max_temp_time = mg_cur.get("time", "") + max_temp_time = mg_cur.get("time", "" if " " in max_temp_time: - max_temp_time = max_temp_time.split(" ")[1][:5] + max_temp_time = max_temp_time.split(" "[1][:5] if max_temp_time == "": max_temp_time = None raw_settlement_max = max_so_far - wu_settle = apply_city_settlement(city.lower(), raw_settlement_max) if raw_settlement_max is not None else None + wu_settle = apply_city_settlement(city.lower(, raw_settlement_max if raw_settlement_max is not None else None display_settlement_max = wu_settle if settlement_source == "wunderground" and wu_settle is not None else raw_settlement_max # Observation time → local @@ -874,160 +873,160 @@ def _analyze( metar_age_min = None obs_t = "" if use_settlement_current: - obs_t = str(settlement_current.get("observation_time") or "").strip() + obs_t = str(settlement_current.get("observation_time" or "".strip( if not obs_t and metar_current_is_today: - obs_t = metar.get("observation_time", "") if metar else "" + obs_t = metar.get("observation_time", "" if metar else "" if obs_t and "T" in obs_t: try: - dt = _parse_utc_datetime(obs_t) + dt = _parse_utc_datetime(obs_t if dt is None: - raise ValueError("invalid observation time") - local_dt = dt.astimezone(timezone(timedelta(seconds=utc_offset))) - obs_time_str = local_dt.strftime("%H:%M") + raise ValueError("invalid observation time" + local_dt = dt.astimezone(timezone(timedelta(seconds=utc_offset + obs_time_str = local_dt.strftime("%H:%M" metar_age_min = int( - (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds() / 60 - ) + (datetime.now(timezone.utc - dt.astimezone(timezone.utc.total_seconds( / 60 + except Exception: - obs_time_str = str(obs_t)[:16] + obs_time_str = str(obs_t[:16] if not obs_time_str and current_source == "amos": - amos_obs_time = amos_data.get("observation_time") + amos_obs_time = amos_data.get("observation_time" if amos_obs_time: - obs_time_str = _format_observation_time_local(amos_obs_time, int(utc_offset or 0)) + obs_time_str = _format_observation_time_local(amos_obs_time, int(utc_offset or 0 if not obs_time_str and current_source == "nmc": - nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f) + nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f obs_time_str = _format_observation_time_local( - nmc_fallback.get("publish_time") or nmc_fallback.get("timestamp"), - int(utc_offset or 0), - ) + nmc_fallback.get("publish_time" or nmc_fallback.get("timestamp", + int(utc_offset or 0, + current_obs_raw = obs_t if current_source == "amos": - current_obs_raw = amos_data.get("observation_time") + current_obs_raw = amos_data.get("observation_time" elif current_source == "nmc": current_obs_raw = ( - nmc_fallback.get("publish_time") - or nmc_fallback.get("timestamp") - if isinstance(nmc_fallback, dict) + nmc_fallback.get("publish_time" + or nmc_fallback.get("timestamp" + if isinstance(nmc_fallback, dict else None - ) + current_age_min = metar_age_min if current_obs_raw: - current_age_min = _observation_age_min(current_obs_raw, now_utc) or current_age_min + current_age_min = _observation_age_min(current_obs_raw, now_utc or current_age_min 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, - ingested_at=primary_current.get("receipt_time") or primary_current.get("report_time"), + ingested_at=primary_current.get("receipt_time" or primary_current.get("report_time", age_min=current_age_min, now_utc=now_utc, - ) + - 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 + 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 if airport_age_min is None: airport_age_min = metar_age_min - 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): + 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: 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, - ingested_at=metar.get("receipt_time") if metar else None, + ingested_at=metar.get("receipt_time" if metar else None, age_min=airport_age_min, now_utc=now_utc, - ) + - airport_primary_current = dict(network_snapshot.get("airport_primary_current") or {}) + airport_primary_current = dict(network_snapshot.get("airport_primary_current" or {} if ( - airport_primary_current.get("source_code") == "metar" + airport_primary_current.get("source_code" == "metar" and metar and not metar_current_is_today - ): + : airport_primary_current["temp"] = None airport_primary_current["stale_for_today"] = True - airport_primary_current["last_observation_local_date"] = metar.get("observation_local_date") + airport_primary_current["last_observation_local_date"] = metar.get("observation_local_date" airport_primary_current["current_local_date"] = local_date_str if ( - airport_primary_current.get("source_code") == "metar" + airport_primary_current.get("source_code" == "metar" and obs_time_str and not use_settlement_current - ): + : airport_primary_current["obs_time"] = obs_time_str airport_primary_current["obs_age_min"] = metar_age_min settlement_today_obs = [] if use_settlement_current: - explicit_settlement_obs = settlement_current.get("today_obs") or [] + explicit_settlement_obs = settlement_current.get("today_obs" or [] normalized_obs = [] for item in explicit_settlement_obs: - if isinstance(item, dict): - raw_time = str(item.get("time") or "").strip() - raw_temp = _sf(item.get("temp")) - elif isinstance(item, (list, tuple)) and len(item) >= 2: - raw_time = str(item[0] or "").strip() - raw_temp = _sf(item[1]) + if isinstance(item, dict: + raw_time = str(item.get("time" or "".strip( + raw_temp = _sf(item.get("temp" + elif isinstance(item, (list, tuple and len(item >= 2: + raw_time = str(item[0] or "".strip( + raw_temp = _sf(item[1] else: continue if not raw_time or raw_temp is None: continue - normalized_obs.append({"time": raw_time, "temp": raw_temp}) + normalized_obs.append({"time": raw_time, "temp": raw_temp} if normalized_obs: settlement_today_obs = normalized_obs else: if obs_time_str and cur_temp is not None: - settlement_today_obs.append({"time": obs_time_str, "temp": cur_temp}) + settlement_today_obs.append({"time": obs_time_str, "temp": cur_temp} if ( max_temp_time and max_so_far is not None - and str(max_temp_time) != str(obs_time_str) - ): - settlement_today_obs.append({"time": str(max_temp_time), "temp": max_so_far}) + and str(max_temp_time != str(obs_time_str + : + settlement_today_obs.append({"time": str(max_temp_time, "temp": max_so_far} metar_today_obs_payload = [ {"time": t, "temp": v} for t, v in ( - metar.get("today_obs", []) if metar and metar_current_is_today else [] - ) - if _is_plausible_city_temp(city, v, sym) + metar.get("today_obs", [] if metar and metar_current_is_today else [] + + if _is_plausible_city_temp(city, v, sym ] metar_recent_obs_payload = [ point for point in ( - 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) + 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 ] airport_max_so_far = None airport_max_temp_time = None for point in metar_today_obs_payload: - value = _sf(point.get("temp")) if isinstance(point, dict) else None + value = _sf(point.get("temp" if isinstance(point, dict else None if value is None: continue if airport_max_so_far is None or value >= airport_max_so_far: airport_max_so_far = value - airport_max_temp_time = str(point.get("time") or "") or None + airport_max_temp_time = str(point.get("time" or "" or None # ── 3. Daily forecast ── - 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", []) + 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", [] start_idx = 0 if local_date_str in all_dates: - start_idx = all_dates.index(local_date_str) + start_idx = all_dates.index(local_date_str else: - for idx, d in enumerate(all_dates): + for idx, d in enumerate(all_dates: if d >= local_date_str: start_idx = idx break @@ -1037,15 +1036,15 @@ def _analyze( sunrises = all_sunrises[start_idx : start_idx + 5] sunsets = all_sunsets[start_idx : start_idx + 5] sunshine = all_sunshine[start_idx : start_idx + 5] - om_today = _sf(maxtemps[0]) if maxtemps else None + om_today = _sf(maxtemps[0] if maxtemps else None forecast_daily = _dedupe_forecast_daily( - [{"date": d, "max_temp": t} for d, t in zip(dates, maxtemps)] - ) + [{"date": d, "max_temp": t} for d, t in zip(dates, maxtemps] + if om_today is None: - nws_high = _sf(raw.get("nws", {}).get("today_high")) - mgm_high = _sf(mgm.get("today_high")) if mgm else None - mgm_hourly_high = _mgm_hourly_high(mgm) + 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 fallback_high = ( nws_high if nws_high is not None @@ -1056,35 +1055,35 @@ def _analyze( else max_so_far if max_so_far is not None else cur_temp - ) + if fallback_high is not None: - om_today = float(fallback_high) + om_today = float(fallback_high if not forecast_daily: forecast_daily = [{"date": local_date_str, "max_temp": om_today}] sunrise = ( - sunrises[0].split("T")[1][:5] - if sunrises and "T" in str(sunrises[0]) + sunrises[0].split("T"[1][:5] + if sunrises and "T" in str(sunrises[0] else "" - ) + sunset = ( - sunsets[0].split("T")[1][:5] - if sunsets and "T" in str(sunsets[0]) + sunsets[0].split("T"[1][:5] + if sunsets and "T" in str(sunsets[0] else "" - ) - sunshine_h = round(sunshine[0] / 3600, 1) if sunshine else 0 + + sunshine_h = round(sunshine[0] / 3600, 1 if sunshine else 0 # ── 5. Multi-model forecasts ── current_forecasts: Dict[str, float] = {} if om_today is not None: current_forecasts["Open-Meteo"] = om_today - for m, v in mm.get("forecasts", {}).items(): - if v is not None and not _is_excluded_model_name(m): - current_forecasts[m] = _sf(v) - nws_high = _sf(raw.get("nws", {}).get("today_high")) + for m, v in mm.get("forecasts", {}.items(: + if v is not None and not _is_excluded_model_name(m: + current_forecasts[m] = _sf(v + nws_high = _sf(raw.get("nws", {}.get("today_high" if nws_high is not None: current_forecasts["NWS"] = nws_high - mgm_high = _sf(mgm.get("today_high")) if mgm else None - mgm_hourly_high = _mgm_hourly_high(mgm) + mgm_high = _sf(mgm.get("today_high" if mgm else None + mgm_hourly_high = _mgm_hourly_high(mgm if mgm_high is not None: current_forecasts["MGM"] = mgm_high elif mgm_hourly_high is not None: @@ -1093,35 +1092,35 @@ def _analyze( # ── 6. DEB fusion ── deb_val, deb_weights = None, "" if current_forecasts: - blended, winfo = calculate_dynamic_weights(city, current_forecasts) + blended, winfo = calculate_dynamic_weights(city, current_forecasts if blended is not None: deb_val = blended deb_weights = winfo # ── 7. Ensemble stats ── ens_data = { - "median": _sf(ens_raw.get("median")), - "p10": _sf(ens_raw.get("p10")), - "p90": _sf(ens_raw.get("p90")), + "median": _sf(ens_raw.get("median", + "p10": _sf(ens_raw.get("p10", + "p90": _sf(ens_raw.get("p90", } # ── 8. METAR trend ── - recent_temps = metar.get("recent_temps", []) if metar else [] + recent_temps = metar.get("recent_temps", [] if metar else [] trend_info = { "direction": "unknown", "recent": [{"time": t, "temp": v} for t, v in recent_temps[:6]], "is_cooling": False, "is_dead_market": False, } - if len(recent_temps) >= 2: + if len(recent_temps >= 2: t_only = [t for _, t in recent_temps] latest, prev = t_only[0], t_only[1] diff = latest - prev - if len(t_only) >= 3: - n = min(3, len(t_only)) - all_same = all(t == latest for t in t_only[:n]) - all_rising = all(t_only[i] >= t_only[i + 1] for i in range(n - 1)) - all_falling = all(t_only[i] <= t_only[i + 1] for i in range(n - 1)) + if len(t_only >= 3: + n = min(3, len(t_only + all_same = all(t == latest for t in t_only[:n] + all_rising = all(t_only[i] >= t_only[i + 1] for i in range(n - 1 + all_falling = all(t_only[i] <= t_only[i + 1] for i in range(n - 1 if all_same: trend_info["direction"] = "stagnant" elif all_rising and diff > 0: @@ -1136,39 +1135,39 @@ def _analyze( trend_info["direction"] = "falling" else: trend_info["direction"] = "stagnant" - trend_info["is_cooling"] = trend_info["direction"] in ("falling", "stagnant") + trend_info["is_cooling"] = trend_info["direction"] in ("falling", "stagnant" # ── 9. Peak hour detection ── - hourly = om.get("hourly", {}) - h_times = hourly.get("time", []) - h_temps = hourly.get("temperature_2m", []) - h_rad = hourly.get("shortwave_radiation", []) - h_dew = hourly.get("dew_point_2m", []) - h_pressure = hourly.get("pressure_msl", []) - h_wspd = hourly.get("wind_speed_10m", []) - h_wdir = hourly.get("wind_direction_10m", []) - 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 [] + 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 [] parsed_obs = [] for item in metar_today_obs: try: t_str, t_val = item if t_str is None or t_val is None: continue - hh, minute_part = str(t_str).split(":") - parsed_obs.append((int(hh), int(minute_part), float(t_val))) + hh, minute_part = str(t_str.split(":" + parsed_obs.append((int(hh, int(minute_part, float(t_val except Exception: continue if parsed_obs: - parsed_obs.sort(key=lambda x: (x[0], x[1])) + parsed_obs.sort(key=lambda x: (x[0], x[1] h_times = [f"{local_date_str}T{hh:02d}:{mm:02d}" for hh, mm, _ in parsed_obs] h_temps = [v for _, _, v in parsed_obs] h_rad = [0 for _ in parsed_obs] @@ -1187,14 +1186,14 @@ def _analyze( peak_hours = [] if h_times and h_temps and om_today is not None: - for ts, tmp in zip(h_times, h_temps): - if ts.startswith(local_date_str) and abs(tmp - om_today) <= 0.2: - hr = int(ts.split("T")[1][:2]) + for ts, tmp in zip(h_times, h_temps: + if ts.startswith(local_date_str and abs(tmp - om_today <= 0.2: + hr = int(ts.split("T"[1][:2] if 8 <= hr <= 19: - peak_hours.append(ts.split("T")[1][:5]) + peak_hours.append(ts.split("T"[1][:5] - first_peak_h = int(peak_hours[0].split(":")[0]) if peak_hours else 13 - last_peak_h = int(peak_hours[-1].split(":")[0]) if peak_hours else 15 + first_peak_h = int(peak_hours[0].split(":"[0] if peak_hours else 13 + last_peak_h = int(peak_hours[-1].split(":"[0] if peak_hours else 15 if local_hour_frac > last_peak_h: peak_status = "past" @@ -1213,10 +1212,10 @@ def _analyze( local_hour_frac=local_hour_frac, observation_points=( settlement_today_obs if settlement_today_obs else metar_today_obs_payload - ), - ) + , + - # ── 10. Shared analysis (probability, trend, AI) via trend_engine ── + # ── 10. Shared analysis (probability, trend, AI via trend_engine ── # This single call replaces the duplicate probability engine, dead market # detection, forecast bust grading, and AI context building. from src.analysis.trend_engine import analyze_weather_trend as _trend_analyze, calculate_prob_distribution @@ -1226,54 +1225,54 @@ def _analyze( mu = None dynamic_commentary = {"summary": "", "notes": []} try: - _, _ai_context, sd = _trend_analyze(raw, sym, city) + _, _ai_context, sd = _trend_analyze(raw, sym, city - 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) + 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 # Use shared DEB if not already set - if deb_val is None and sd.get("deb_prediction") is not None: + if deb_val is None and sd.get("deb_prediction" is not None: deb_val = sd["deb_prediction"] - deb_weights = sd.get("deb_weights", "") + deb_weights = sd.get("deb_weights", "" except Exception as e: - logger.warning(f"Structured analysis skipped for {city}: {e}") + logger.warning(f"Structured analysis skipped for {city}: {e}" - # ── 12. Hourly data (today only, for chart) ── + # ── 12. Hourly data (today only, for chart ── today_hourly: Dict[str, list] = {"times": [], "temps": [], "radiation": []} - for i, ts in enumerate(h_times): - if ts.startswith(local_date_str): - today_hourly["times"].append(ts.split("T")[1][:5]) - today_hourly["temps"].append(h_temps[i] if i < len(h_temps) else None) - today_hourly["radiation"].append(h_rad[i] if i < len(h_rad) else None) + 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 # ── 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. # Uses cur_temp / max_so_far already resolved at lines 1052-1095 above. - _local_hour = _parse_local_hour(local_time_str) - peak_first = int(first_peak_h or 14) - peak_last_h = int(last_peak_h or 17) + _local_hour = _parse_local_hour(local_time_str + peak_first = int(first_peak_h or 14 + peak_last_h = int(last_peak_h or 17 if ( deb_val is not None and cur_temp is not None and _local_hour is not None and 6 <= _local_hour <= 22 - ): - hourly_times_list = today_hourly.get("times") or [] - hourly_temps_list = today_hourly.get("temps") or [] + : + hourly_times_list = today_hourly.get("times" or [] + hourly_temps_list = today_hourly.get("temps" or [] model_hourly_temp = None current_hour_str = f"{_local_hour:02d}: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]) + 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] if candidate is not None: model_hourly_temp = candidate break @@ -1282,26 +1281,26 @@ def _analyze( hourly_bias = cur_temp - reference_temp if _local_hour < peak_first: - progress = max(0.0, (_local_hour - 6) / max(1, peak_first - 6)) + progress = max(0.0, (_local_hour - 6 / max(1, peak_first - 6 weight = 0.15 + 0.20 * progress elif peak_first <= _local_hour <= peak_last_h: - progress = (_local_hour - peak_first) / max(1, peak_last_h - peak_first) + progress = (_local_hour - peak_first / max(1, peak_last_h - peak_first weight = 0.40 + 0.35 * progress else: weight = 0.80 - max_correction = 5.0 if str(sym or "").upper() == "F" else 3.0 - hourly_correction = max(-max_correction, min(max_correction, hourly_bias * weight)) + max_correction = 5.0 if str(sym or "".upper( == "F" else 3.0 + hourly_correction = max(-max_correction, min(max_correction, hourly_bias * weight _msf = max_so_far if max_so_far is not None else cur_temp max_so_far_excess = _msf - deb_val - max_correction_clamped = max(-max_correction, min(max_correction, max_so_far_excess * max(0.3, weight))) + max_correction_clamped = max(-max_correction, min(max_correction, max_so_far_excess * max(0.3, weight blended_correction = hourly_correction * 0.6 + max_correction_clamped * 0.4 - deb_val = round(deb_val + blended_correction, 1) + deb_val = round(deb_val + blended_correction, 1 if mu is not None: - mu = round(mu + blended_correction, 1) - deb_weights = f"{deb_weights or 'DEB'} + intraday_bias({blended_correction:+.1f})" + mu = round(mu + blended_correction, 1 + deb_weights = f"{deb_weights or 'DEB'} + intraday_bias({blended_correction:+.1f}" # ── 12b. Next 48h hourly block for future-date analysis modal ── next_48h_hourly = { @@ -1324,56 +1323,56 @@ def _analyze( try: local_anchor = datetime.strptime( f"{local_date_str} {local_time_str}", "%Y-%m-%d %H:%M" - ) + except Exception: local_anchor = None if local_anchor is not None: - horizon = local_anchor + timedelta(hours=48) - for i, ts in enumerate(h_times): + horizon = local_anchor + timedelta(hours=48 + for i, ts in enumerate(h_times: try: - ts_dt = datetime.fromisoformat(ts) + ts_dt = datetime.fromisoformat(ts except Exception: continue if ts_dt < local_anchor or ts_dt > horizon: continue - next_48h_hourly["times"].append(ts) - next_48h_hourly["temps"].append(h_temps[i] if i < len(h_temps) else None) - next_48h_hourly["radiation"].append(h_rad[i] if i < len(h_rad) else None) - next_48h_hourly["dew_point"].append(h_dew[i] if i < len(h_dew) else None) + next_48h_hourly["times"].append(ts + next_48h_hourly["temps"].append(h_temps[i] if i < len(h_temps else None + next_48h_hourly["radiation"].append(h_rad[i] if i < len(h_rad else None + next_48h_hourly["dew_point"].append(h_dew[i] if i < len(h_dew else None next_48h_hourly["pressure_msl"].append( - h_pressure[i] if i < len(h_pressure) else None - ) + h_pressure[i] if i < len(h_pressure else None + next_48h_hourly["wind_speed_10m"].append( - h_wspd[i] if i < len(h_wspd) else None - ) + h_wspd[i] if i < len(h_wspd else None + next_48h_hourly["wind_direction_10m"].append( - h_wdir[i] if i < len(h_wdir) else None - ) + h_wdir[i] if i < len(h_wdir else None + next_48h_hourly["wind_speed_180m"].append( - h_wspd_180m[i] if i < len(h_wspd_180m) else None - ) + h_wspd_180m[i] if i < len(h_wspd_180m else None + next_48h_hourly["wind_direction_180m"].append( - h_wdir_180m[i] if i < len(h_wdir_180m) else None - ) + h_wdir_180m[i] if i < len(h_wdir_180m else None + next_48h_hourly["precipitation_probability"].append( - h_precip_prob[i] if i < len(h_precip_prob) else None - ) + h_precip_prob[i] if i < len(h_precip_prob else None + next_48h_hourly["cloud_cover"].append( - h_cloud_cover[i] if i < len(h_cloud_cover) else None - ) + h_cloud_cover[i] if i < len(h_cloud_cover else None + next_48h_hourly["cape"].append( - h_cape[i] if i < len(h_cape) else None - ) + h_cape[i] if i < len(h_cape else None + next_48h_hourly["convective_inhibition"].append( - h_cin[i] if i < len(h_cin) else None - ) + h_cin[i] if i < len(h_cin else None + next_48h_hourly["lifted_index"].append( - h_lifted_index[i] if i < len(h_lifted_index) else None - ) + h_lifted_index[i] if i < len(h_lifted_index else None + next_48h_hourly["boundary_layer_height"].append( - h_boundary_layer_height[i] if i < len(h_boundary_layer_height) else None - ) + h_boundary_layer_height[i] if i < len(h_boundary_layer_height else None + vertical_profile_signal = ( _build_vertical_profile_signal( @@ -1382,25 +1381,25 @@ def _analyze( local_hour, first_peak_h, last_peak_h, - ) + if not is_panel_mode and not is_nearby_mode and not is_market_mode else {} - ) + taf_signal = ( _build_taf_signal( - taf if isinstance(taf, dict) else {}, + taf if isinstance(taf, dict else {}, city, local_date_str, - int(utc_offset or 0), + int(utc_offset or 0, first_peak_h, last_peak_h, - ) + if not is_panel_mode and not is_nearby_mode and not is_market_mode else {"available": False} - ) + - # ── 13. Cloud description (METAR primary, MGM fallback) ── - clouds = mc.get("clouds", []) + # ── 13. Cloud description (METAR primary, MGM fallback ── + clouds = mc.get("clouds", [] cloud_desc = "" if clouds: c_map = { @@ -1412,10 +1411,10 @@ def _analyze( "CLR": "晴", } main = clouds[-1] - cloud_desc = c_map.get(main.get("cover"), main.get("cover", "")) + cloud_desc = c_map.get(main.get("cover", main.get("cover", "" if not cloud_desc and mgm: - mgc_cover = mgm.get("current", {}).get("cloud_cover") + mgc_cover = mgm.get("current", {}.get("cloud_cover" if mgc_cover is not None: cloud_desc_map = { 0: "晴朗", @@ -1428,88 +1427,88 @@ def _analyze( 7: "阴天", 8: "阴天", } - cloud_desc = cloud_desc_map.get(mgc_cover, "") + cloud_desc = cloud_desc_map.get(mgc_cover, "" # Final fallback: If we have ANY actual observation but no cloud info, it's usually clear. if not cloud_desc: - if mc.get("temp") is not None or (mgm and mgm.get("current", {}).get("temp") is not None): - # If weather phenomenon exists (e.g. rain), we'll let app.js handle wx_desc priority. + if mc.get("temp" is not None or (mgm and mgm.get("current", {}.get("temp" is not None: + # If weather phenomenon exists (e.g. rain, we'll let app.js handle wx_desc priority. # Otherwise, clear skies. - if not mc.get("wx_desc"): + if not mc.get("wx_desc": cloud_desc = "晴朗" - # ── 14. MGM data (Turkish MGM-supported cities) ── + # ── 14. MGM data (Turkish MGM-supported cities ── mgm_data = {} if mgm: - mgc = mgm.get("current", {}) - mgm_time_str = mgc.get("time", "") - # MGM time is usually "2026-03-04T10:40:00.000Z" (UTC) + mgc = mgm.get("current", {} + mgm_time_str = mgc.get("time", "" + # MGM time is usually "2026-03-04T10:40:00.000Z" (UTC if mgm_time_str and "T" in mgm_time_str: try: # Handle ISO format with Z or +00:00 - ts = mgm_time_str.replace("Z", "+00:00") + ts = mgm_time_str.replace("Z", "+00:00" if "+" in ts: - base, offset_part = ts.split("+", 1) + base, offset_part = ts.split("+", 1 if "." in base: - base = base.split(".")[0] + base = base.split("."[0] ts = base + "+" + offset_part - dt = datetime.fromisoformat(ts) - local_dt = dt.astimezone(timezone(timedelta(seconds=utc_offset or 0))) - mgm_time_str = local_dt.strftime("%H:%M") + dt = datetime.fromisoformat(ts + local_dt = dt.astimezone(timezone(timedelta(seconds=utc_offset or 0 + mgm_time_str = local_dt.strftime("%H:%M" except Exception as e: - logger.debug(f"MGM time conversion failed: {e}") + logger.debug(f"MGM time conversion failed: {e}" pass mgm_data = { - "temp": _sf(mgc.get("temp")), + "temp": _sf(mgc.get("temp", "time": mgm_time_str, - "feels_like": _sf(mgc.get("feels_like")), - "humidity": _sf(mgc.get("humidity")), - "wind_dir": _sf(mgc.get("wind_dir")), - "wind_speed_ms": _sf(mgc.get("wind_speed_ms")), - "pressure": _sf(mgc.get("pressure")), - "cloud_cover": mgc.get("cloud_cover"), - "rain_24h": _sf(mgc.get("rain_24h")), - "today_high": _sf(mgm.get("today_high")), - "today_low": _sf(mgm.get("today_low")), + "feels_like": _sf(mgc.get("feels_like", + "humidity": _sf(mgc.get("humidity", + "wind_dir": _sf(mgc.get("wind_dir", + "wind_speed_ms": _sf(mgc.get("wind_speed_ms", + "pressure": _sf(mgc.get("pressure", + "cloud_cover": mgc.get("cloud_cover", + "rain_24h": _sf(mgc.get("rain_24h", + "today_high": _sf(mgm.get("today_high", + "today_low": _sf(mgm.get("today_low", "hourly": [], } - mgm_hourly = mgm.get("hourly", []) + mgm_hourly = mgm.get("hourly", [] for h in mgm_hourly: - dt_str = h.get("time") - val = _sf(h.get("temp")) + dt_str = h.get("time" + val = _sf(h.get("temp" if dt_str and "T" in dt_str and val is not None: try: - dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00")) - local_dt = dt.astimezone(timezone(timedelta(seconds=utc_offset))) + dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00" + local_dt = dt.astimezone(timezone(timedelta(seconds=utc_offset mgm_data["hourly"].append({ - "time": local_dt.strftime("%Y-%m-%dT%H:%M"), + "time": local_dt.strftime("%Y-%m-%dT%H:%M", "temp": val - }) + } except Exception: pass # ── 15. Extended Multi-Model Daily ── multi_model_daily = {} - mm_daily_raw = mm.get("daily_forecasts", {}) - for i, d_str in enumerate(dates): + mm_daily_raw = mm.get("daily_forecasts", {} + for i, d_str in enumerate(dates: if i == 0: - day_m = current_forecasts.copy() + day_m = current_forecasts.copy( d_val, d_winfo = deb_val, deb_weights else: - day_m = mm_daily_raw.get(d_str, {}).copy() - if i < len(maxtemps) and maxtemps[i] is not None: - day_m["Open-Meteo"] = _sf(maxtemps[i]) + day_m = mm_daily_raw.get(d_str, {}.copy( + if i < len(maxtemps and maxtemps[i] is not None: + day_m["Open-Meteo"] = _sf(maxtemps[i] # Add MGM per-day forecast - mgm_daily = mgm.get("daily_forecasts", {}) + mgm_daily = mgm.get("daily_forecasts", {} if d_str in mgm_daily: - day_m["MGM"] = _sf(mgm_daily[d_str]) + day_m["MGM"] = _sf(mgm_daily[d_str] day_m = { - m: v for m, v in day_m.items() if not _is_excluded_model_name(m) + m: v for m, v in day_m.items( if not _is_excluded_model_name(m } d_val, d_winfo = None, "" @@ -1517,23 +1516,23 @@ def _analyze( d_probs_all = [] if day_m: try: - blended, winfo = calculate_dynamic_weights(city, day_m) + blended, winfo = calculate_dynamic_weights(city, day_m if blended is not None: d_val = blended d_winfo = winfo # Calculate future probability based on model divergence - m_vals = [v for v in day_m.values() if v is not None] - if len(m_vals) > 1: + m_vals = [v for v in day_m.values( if v is not None] + if len(m_vals > 1: # Use spread as a proxy for sigma. - # sigma = (max-min)/2 with a floor of 0.6 - d_sigma = max(0.6, (max(m_vals) - min(m_vals)) / 2.0) + # sigma = (max-min/2 with a floor of 0.6 + d_sigma = max(0.6, (max(m_vals - min(m_vals / 2.0 else: d_sigma = 1.0 - prob_obj = calculate_prob_distribution(d_val, d_sigma, None, sym) - d_probs = prob_obj.get("probabilities", []) - d_probs_all = prob_obj.get("probabilities_all", d_probs) + 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 except Exception: pass @@ -1546,7 +1545,7 @@ def _analyze( } # ── Assemble result ── - city_meta = CITIES.get(city, {}) or {} + city_meta = CITIES.get(city, {} or {} result = { "detail_depth": ( "panel" @@ -1556,22 +1555,22 @@ def _analyze( else "nearby" if is_nearby_mode else "full" - ), + , "name": city, - "display_name": str(city_meta.get("display_name") or city_meta.get("name") or city.title()), + "display_name": str(city_meta.get("display_name" or city_meta.get("name" or city.title(, "lat": lat, "lon": lon, - "utc_offset_seconds": int(utc_offset or 0), + "utc_offset_seconds": int(utc_offset or 0, "temp_symbol": sym, "local_time": local_time_str, "local_date": local_date_str, "risk": { - "level": risk.get("risk_level", "low"), - "emoji": risk.get("risk_emoji", "🟢"), - "airport": risk.get("airport_name", ""), - "icao": risk.get("icao", ""), - "distance_km": risk.get("distance_km", 0), - "warning": risk.get("warning", ""), + "level": risk.get("risk_level", "low", + "emoji": risk.get("risk_emoji", "🟢", + "airport": risk.get("airport_name", "", + "icao": risk.get("icao", "", + "distance_km": risk.get("distance_km", 0, + "warning": risk.get("warning", "", }, "current": { "temp": cur_temp, @@ -1588,20 +1587,20 @@ def _analyze( "obs_age_min": None if use_settlement_current else metar_age_min, "freshness": current_freshness, "observation_status": "live" if cur_temp is not None else "missing", - "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")), + "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", "cloud_desc": cloud_desc, "clouds_raw": [ - {"cover": c.get("cover"), "base": c.get("base")} for c in clouds + {"cover": c.get("cover", "base": c.get("base"} for c in clouds ], - "visibility_mi": _sf(primary_current.get("visibility_mi")), - "wx_desc": primary_current.get("wx_desc"), - "raw_metar": amos_data.get("raw_metar") if current_source == "amos" else primary_current.get("raw_metar"), + "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", }, "airport_current": { "temp": airport_temp, @@ -1609,37 +1608,37 @@ def _analyze( "max_so_far": airport_max_so_far, "max_temp_time": airport_max_temp_time, "obs_age_min": airport_age_min, - "report_time": metar.get("report_time") if metar else None, - "receipt_time": metar.get("receipt_time") if metar else None, - "obs_time_epoch": metar.get("obs_time_epoch") if metar else None, - "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"), + "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", "source_code": airport_source_code, "source_label": airport_source_label, "freshness": airport_freshness, - "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, + "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, "current_local_date": local_date_str, }, - "settlement_station": network_snapshot.get("settlement_station") or {}, + "settlement_station": network_snapshot.get("settlement_station" or {}, "airport_primary": airport_primary_current, - "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"), + "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", "mgm": mgm_data, - "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, + "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, "forecast": { "today_high": om_today, "daily": forecast_daily, @@ -1648,26 +1647,26 @@ def _analyze( "sunshine_hours": sunshine_h, }, "source_forecasts": { - "weather_gov": raw.get("nws") or {}, + "weather_gov": raw.get("nws" or {}, "open_meteo_multi_model": { - "source": mm.get("source"), - "provider": mm.get("provider"), - "dates": mm.get("dates") or [], - "model_metadata": mm.get("model_metadata") or {}, - "model_keys": mm.get("model_keys") or {}, - "attribution": mm.get("attribution"), - } if isinstance(mm, dict) and mm else {}, + "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 {}, }, "multi_model": { **mm, - "forecasts": {k: v for k, v in current_forecasts.items() if v is not None}, + "forecasts": {k: v for k, v in current_forecasts.items( if v is not None}, }, "multi_model_daily": multi_model_daily, "deb": {"prediction": deb_val, "weights_info": deb_weights}, "deviation_monitor": deviation_monitor, "ensemble": ens_data, "probabilities": { - "mu": round(mu, 1) if mu is not None else None, + "mu": round(mu, 1 if mu is not None else None, "distribution": probabilities, "distribution_all": probabilities_all or probabilities, "engine": "legacy", @@ -1684,7 +1683,7 @@ def _analyze( "hourly_next_48h": next_48h_hourly, "vertical_profile_signal": vertical_profile_signal, "taf": { - **(taf if isinstance(taf, dict) else {}), + **(taf if isinstance(taf, dict else {}, "signal": taf_signal, } if taf_signal or taf @@ -1693,58 +1692,58 @@ def _analyze( "metar_recent_obs": metar_recent_obs_payload, "metar_status": { "available_for_today": metar_current_is_today, - "stale_for_today": bool(metar) and not metar_current_is_today, - "last_observation_time": metar.get("observation_time") if metar else None, - "last_observation_local_date": metar.get("observation_local_date") if metar else None, + "stale_for_today": bool(metar and not metar_current_is_today, + "last_observation_time": metar.get("observation_time" if metar else None, + "last_observation_local_date": metar.get("observation_local_date" if metar else None, "current_local_date": local_date_str, - "last_temp": _sf(mc.get("temp")) if mc else None, + "last_temp": _sf(mc.get("temp" if mc else None, }, "settlement_today_obs": settlement_today_obs, "ai_analysis": "", - "updated_at": datetime.now(timezone.utc).isoformat(), + "updated_at": datetime.now(timezone.utc.isoformat(, } - result["intraday_meteorology"] = _build_intraday_meteorology(result) + result["intraday_meteorology"] = _build_intraday_meteorology(result if normalized_detail_mode == "full": - _archive_intraday_path_snapshot(city, result) + _archive_intraday_path_snapshot(city, result if include_llm_commentary: result["dynamic_commentary"] = _maybe_enrich_dynamic_commentary_with_groq( city, result, - ) + with _CACHE_LOCK: - _cache[cache_key] = {"t": _time.time(), "d": result} + _cache[cache_key] = {"t": _time.time(, "d": result} return result -def _normalize_city_or_404(name: str) -> str: - city = name.lower().strip().replace("-", " ") - city = ALIASES.get(city, city) +def _normalize_city_or_404(name: str -> str: + city = name.lower(.strip(.replace("-", " " + city = ALIASES.get(city, city if city not in CITIES: - raise HTTPException(404, detail=f"Unknown city: {city}") + raise HTTPException(404, detail=f"Unknown city: {city}" return city -def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: - ttl = _analysis_ttl_for_city(city) +def _analyze_summary(city: str, force_refresh: bool = False -> Dict[str, Any]: + ttl = _analysis_ttl_for_city(city if not force_refresh: - cached_detail = _get_cached_analysis(city, ttl) + cached_detail = _get_cached_analysis(city, ttl if cached_detail: return cached_detail - cached_summary = _get_cached_summary(city, ttl) + cached_summary = _get_cached_summary(city, ttl if cached_summary: return cached_summary info = CITIES[city] lat, lon, is_f = info["lat"], info["lon"], info["f"] sym = "°F" if is_f else "°C" - settlement_source = str(info.get("settlement_source") or "metar").strip().lower() or "metar" + settlement_source = str(info.get("settlement_source" or "metar".strip(.lower( or "metar" settlement_source_label = SETTLEMENT_SOURCE_LABELS.get( settlement_source, - settlement_source.upper(), - ) + settlement_source.upper(, + if force_refresh: try: @@ -1753,185 +1752,185 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: lat=lat, lon=lon, use_fahrenheit=is_f, - ) + except Exception: pass - default_utc_offset = get_city_utc_offset_seconds(city) + default_utc_offset = get_city_utc_offset_seconds(city - def _safe_call(fn): + def _safe_call(fn: try: - return fn() + return fn( except Exception: return None jobs = { - "settlement_current": lambda: _weather.fetch_settlement_current(city) or {}, - "open_meteo": lambda: _weather.fetch_from_open_meteo(lat, lon, use_fahrenheit=is_f) or {}, - "multi_model": lambda: _weather.fetch_multi_model(lat, lon, city=city, use_fahrenheit=is_f) or {}, + "settlement_current": lambda: _weather.fetch_settlement_current(city or {}, + "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 {}, } - if _weather._supports_aviationweather(city): # type: ignore[attr-defined] + if _weather._supports_aviationweather(city: # type: ignore[attr-defined] jobs["metar"] = lambda: _weather.fetch_metar( city, use_fahrenheit=is_f, utc_offset=default_utc_offset, - ) or {} + or {} if city in TURKISH_MGM_CITIES: - istno, _province = _weather.TURKISH_PROVINCES.get(city, (None, None)) # type: ignore[attr-defined] + istno, _province = _weather.TURKISH_PROVINCES.get(city, (None, None # type: ignore[attr-defined] if istno: - jobs["mgm"] = lambda istno=istno: _weather.fetch_from_mgm(str(istno)) or {} + jobs["mgm"] = lambda istno=istno: _weather.fetch_from_mgm(str(istno or {} if is_f: - jobs["nws"] = lambda: _weather.fetch_nws(lat, lon) or {} + jobs["nws"] = lambda: _weather.fetch_nws(lat, lon or {} if settlement_source == "hko": - jobs["hko_forecast"] = lambda: _weather.fetch_hko_forecast() + jobs["hko_forecast"] = lambda: _weather.fetch_hko_forecast( fetched: Dict[str, Any] = {} - with ThreadPoolExecutor(max_workers=min(6, len(jobs))) as executor: + with ThreadPoolExecutor(max_workers=min(6, len(jobs as executor: future_map = { - executor.submit(_safe_call, fn): key - for key, fn in jobs.items() + executor.submit(_safe_call, fn: key + for key, fn in jobs.items( } - for future, key in [(future, key) for future, key in future_map.items()]: - fetched[key] = future.result() + for future, key in [(future, key for future, key in future_map.items(]: + fetched[key] = future.result( - settlement_current = fetched.get("settlement_current") or {} - open_meteo = fetched.get("open_meteo") or {} - mm = fetched.get("multi_model") or {} - utc_offset = open_meteo.get("utc_offset") + settlement_current = fetched.get("settlement_current" or {} + open_meteo = fetched.get("open_meteo" or {} + mm = fetched.get("multi_model" or {} + utc_offset = open_meteo.get("utc_offset" if utc_offset is None: utc_offset = default_utc_offset try: - utc_offset = int(utc_offset or 0) + utc_offset = int(utc_offset or 0 except Exception: utc_offset = default_utc_offset - now_utc = datetime.now(timezone.utc) - local_now = now_utc + timedelta(seconds=utc_offset) - local_date_str = local_now.strftime("%Y-%m-%d") + now_utc = datetime.now(timezone.utc + local_now = now_utc + timedelta(seconds=utc_offset + local_date_str = local_now.strftime("%Y-%m-%d" local_hour = local_now.hour local_minute = local_now.minute local_time_str = f"{local_hour:02d}:{local_minute:02d}" local_hour_frac = local_hour + local_minute / 60.0 - metar = fetched.get("metar") or {} - mgm = fetched.get("mgm") or {} - nws = fetched.get("nws") or {} - hko_forecast = fetched.get("hko_forecast") + metar = fetched.get("metar" or {} + mgm = fetched.get("mgm" or {} + nws = fetched.get("nws" or {} + hko_forecast = fetched.get("hko_forecast" metar_current_is_today = _metar_is_current_local_day( metar, local_date=local_date_str, - utc_offset=int(utc_offset or 0), - ) + utc_offset=int(utc_offset or 0, + - sc_cur = settlement_current.get("current") or {} - mc = metar.get("current") or {} + sc_cur = settlement_current.get("current" or {} + mc = metar.get("current" or {} live_mc = mc if metar_current_is_today else {} - mg_cur = mgm.get("current") or {} - use_settlement_current = settlement_source in {"hko", "cwa", "noaa", "wunderground"} and bool(sc_cur) + mg_cur = mgm.get("current" or {} + use_settlement_current = settlement_source in {"hko", "cwa", "noaa", "wunderground"} and bool(sc_cur primary_current = sc_cur if use_settlement_current else live_mc current_source = settlement_source current_source_label = settlement_source_label nmc_fallback: Dict[str, Any] = {} - cur_temp = _sf(primary_current.get("temp")) - if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym): + cur_temp = _sf(primary_current.get("temp" + if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym: cur_temp = None if cur_temp is None: - cur_temp = _sf(live_mc.get("temp")) - if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym): + cur_temp = _sf(live_mc.get("temp" + if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym: cur_temp = None if cur_temp is None: - cur_temp = _sf(mg_cur.get("temp")) - if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym): + cur_temp = _sf(mg_cur.get("temp" + if cur_temp is not None and not _is_plausible_city_temp(city, cur_temp, sym: cur_temp = None if cur_temp is None: - nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f) - nmc_cur = nmc_fallback.get("current") or {} - nmc_temp = _sf(nmc_cur.get("temp")) + nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f + nmc_cur = nmc_fallback.get("current" or {} + nmc_temp = _sf(nmc_cur.get("temp" if nmc_temp is not None: cur_temp = nmc_temp current_source = "nmc" current_source_label = "NMC" - 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): + 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: max_so_far = None if max_so_far is None: - 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): + 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: max_so_far = None if max_so_far is None: - 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): + 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: max_so_far = None if max_so_far is None: max_so_far = cur_temp - max_temp_time = primary_current.get("max_temp_time") + max_temp_time = primary_current.get("max_temp_time" if not max_temp_time and not use_settlement_current: - max_temp_time = live_mc.get("max_temp_time") + max_temp_time = live_mc.get("max_temp_time" if not max_temp_time: - mgm_time = str(mg_cur.get("time") or "") + mgm_time = str(mg_cur.get("time" or "" if " " in mgm_time: - max_temp_time = mgm_time.split(" ")[1][:5] + max_temp_time = mgm_time.split(" "[1][:5] raw_settlement_max = max_so_far wu_settle = ( - apply_city_settlement(city.lower(), raw_settlement_max) + apply_city_settlement(city.lower(, raw_settlement_max if raw_settlement_max is not None else None - ) + display_settlement_max = ( wu_settle if settlement_source == "wunderground" and wu_settle is not None else raw_settlement_max - ) + obs_time_str = "" obs_age_min = None obs_t = "" if use_settlement_current: - obs_t = str(settlement_current.get("observation_time") or "").strip() + obs_t = str(settlement_current.get("observation_time" or "".strip( if not obs_t and metar_current_is_today: - obs_t = str(metar.get("observation_time") or "").strip() + obs_t = str(metar.get("observation_time" or "".strip( if obs_t and "T" in obs_t: try: - dt = _parse_utc_datetime(obs_t) + dt = _parse_utc_datetime(obs_t if dt is None: - raise ValueError("invalid observation time") - local_dt = dt.astimezone(timezone(timedelta(seconds=utc_offset))) - obs_time_str = local_dt.strftime("%H:%M") + raise ValueError("invalid observation time" + local_dt = dt.astimezone(timezone(timedelta(seconds=utc_offset + obs_time_str = local_dt.strftime("%H:%M" obs_age_min = int( - (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds() / 60 - ) + (datetime.now(timezone.utc - dt.astimezone(timezone.utc.total_seconds( / 60 + except Exception: - obs_time_str = str(obs_t)[:16] + obs_time_str = str(obs_t[:16] if not obs_time_str and current_source == "nmc": if not nmc_fallback: - nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f) + nmc_fallback = _fetch_nmc_current_fallback(city, use_fahrenheit=is_f obs_time_str = _format_observation_time_local( - nmc_fallback.get("publish_time") or nmc_fallback.get("timestamp"), - int(utc_offset or 0), - ) + nmc_fallback.get("publish_time" or nmc_fallback.get("timestamp", + int(utc_offset or 0, + - 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 {} + 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 {} - all_dates = om_daily.get("time", []) - all_maxtemps = om_daily.get("temperature_2m_max", []) + all_dates = om_daily.get("time", [] + all_maxtemps = om_daily.get("temperature_2m_max", [] start_idx = 0 if local_date_str in all_dates: - start_idx = all_dates.index(local_date_str) + start_idx = all_dates.index(local_date_str else: - for idx, d in enumerate(all_dates): + for idx, d in enumerate(all_dates: if d >= local_date_str: start_idx = idx break maxtemps = all_maxtemps[start_idx : start_idx + 5] - om_today = _sf(maxtemps[0]) if maxtemps else None - nws_high = _sf((nws or {}).get("today_high")) if isinstance(nws, dict) else None - mgm_high = _sf((mgm or {}).get("today_high")) if isinstance(mgm, dict) else None - mgm_hourly_high = _mgm_hourly_high(mgm) + 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 if om_today is None: fallback_high = ( @@ -1944,16 +1943,16 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: else max_so_far if max_so_far is not None else cur_temp - ) + if fallback_high is not None: - om_today = float(fallback_high) + om_today = float(fallback_high current_forecasts: Dict[str, float] = {} if om_today is not None: current_forecasts["Open-Meteo"] = om_today - for m, v in mm.get("forecasts", {}).items(): - if v is not None and not _is_excluded_model_name(m): - current_forecasts[m] = _sf(v) + for m, v in mm.get("forecasts", {}.items(: + if v is not None and not _is_excluded_model_name(m: + current_forecasts[m] = _sf(v if nws_high is not None: current_forecasts["NWS"] = nws_high if mgm_high is not None: @@ -1961,16 +1960,16 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: elif mgm_hourly_high is not None: current_forecasts["MGM Hourly"] = mgm_hourly_high if hko_forecast is not None: - current_forecasts["HKO"] = _sf(hko_forecast) + current_forecasts["HKO"] = _sf(hko_forecast current_forecasts = { model_name: value - for model_name, value in current_forecasts.items() - if value is not None and not _is_excluded_model_name(model_name) + for model_name, value in current_forecasts.items( + if value is not None and not _is_excluded_model_name(model_name } deb_val = None if current_forecasts: - blended, _weights_info = calculate_dynamic_weights(city, current_forecasts) + blended, _weights_info = calculate_dynamic_weights(city, current_forecasts if blended is not None: deb_val = blended if deb_val is None: @@ -1978,108 +1977,107 @@ def _analyze_summary(city: str, force_refresh: bool = False) -> Dict[str, Any]: settlement_today_obs = [] if use_settlement_current: - explicit_obs = settlement_current.get("today_obs") or [] + explicit_obs = settlement_current.get("today_obs" or [] for item in explicit_obs: - if isinstance(item, dict): - raw_time = str(item.get("time") or "").strip() - raw_temp = _sf(item.get("temp")) - elif isinstance(item, (list, tuple)) and len(item) >= 2: - raw_time = str(item[0] or "").strip() - raw_temp = _sf(item[1]) + if isinstance(item, dict: + raw_time = str(item.get("time" or "".strip( + raw_temp = _sf(item.get("temp" + elif isinstance(item, (list, tuple and len(item >= 2: + raw_time = str(item[0] or "".strip( + raw_temp = _sf(item[1] else: continue if raw_time and raw_temp is not None: - settlement_today_obs.append({"time": raw_time, "temp": raw_temp}) + settlement_today_obs.append({"time": raw_time, "temp": raw_temp} if not settlement_today_obs and obs_time_str and cur_temp is not None: - settlement_today_obs.append({"time": obs_time_str, "temp": cur_temp}) - if max_temp_time and max_so_far is not None and str(max_temp_time) != str(obs_time_str): - settlement_today_obs.append({"time": str(max_temp_time), "temp": max_so_far}) + settlement_today_obs.append({"time": obs_time_str, "temp": cur_temp} + if max_temp_time and max_so_far is not None and str(max_temp_time != str(obs_time_str: + settlement_today_obs.append({"time": str(max_temp_time, "temp": max_so_far} metar_today_obs_payload = [ {"time": obs_time, "temp": obs_temp} for obs_time, obs_temp in ( - (metar.get("today_obs") or []) - if isinstance(metar, dict) and metar_current_is_today + (metar.get("today_obs" or [] + if isinstance(metar, dict and metar_current_is_today else [] - ) + ] deviation_monitor = _build_deviation_monitor( current_temp=cur_temp, deb_prediction=deb_val, om_today=om_today, - hourly_times=om_hourly.get("time", []) if isinstance(om_hourly, dict) else [], - hourly_temps=om_hourly.get("temperature_2m", []) if isinstance(om_hourly, dict) else [], + hourly_times=om_hourly.get("time", [] if isinstance(om_hourly, dict else [], + hourly_temps=om_hourly.get("temperature_2m", [] if isinstance(om_hourly, dict else [], local_date=local_date_str, local_hour_frac=local_hour_frac, observation_points=( settlement_today_obs if settlement_today_obs else metar_today_obs_payload - ), - ) + , + - risk = CITY_RISK_PROFILES.get(city, {}) - city_meta = CITY_REGISTRY.get(city, {}) or {} + risk = CITY_RISK_PROFILES.get(city, {} + city_meta = CITY_REGISTRY.get(city, {} or {} result = { "name": city, - "display_name": str(city_meta.get("display_name") or city_meta.get("name") or city.title()), + "display_name": str(city_meta.get("display_name" or city_meta.get("name" or city.title(, "temp_symbol": sym, - "utc_offset_seconds": int(utc_offset or 0), + "utc_offset_seconds": int(utc_offset or 0, "local_time": local_time_str, "local_date": local_date_str, "risk": { - "level": risk.get("risk_level", "low"), - "warning": risk.get("warning", ""), - "icao": risk.get("icao", ""), + "level": risk.get("risk_level", "low", + "warning": risk.get("warning", "", + "icao": risk.get("icao", "", }, "current": { - "temp": _sf(cur_temp), - "max_so_far": _sf(display_settlement_max), + "temp": _sf(cur_temp, + "max_so_far": _sf(display_settlement_max, "max_temp_time": max_temp_time, - "wu_settlement": _sf(wu_settle), + "wu_settlement": _sf(wu_settle, "settlement_source": current_source, "settlement_source_label": current_source_label, "obs_time": obs_time_str or None, "obs_age_min": obs_age_min, "observation_status": "live" if cur_temp is not None else "missing", }, - "deb": {"prediction": _sf(deb_val)}, + "deb": {"prediction": _sf(deb_val}, "deviation_monitor": deviation_monitor or {}, - "updated_at": datetime.now(timezone.utc).isoformat(), + "updated_at": datetime.now(timezone.utc.isoformat(, } - _set_cached_summary(city, result) + _set_cached_summary(city, result return result -def _build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]: - return _city_payload_summary(data) +def _build_city_summary_payload(data: Dict[str, Any] -> Dict[str, Any]: + return _city_payload_summary(data -def _build_city_market_scan_payload( data: Dict[str, Any], market_slug: Optional[str] = None, target_date: Optional[str] = None, lite: bool = False, scan_filters: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - return _city_payload_market_scan( + -> Dict[str, Any]: + return {"market_scan": {"available": False}, "selected_date": "", "fetched_at": ""}.get( data, market_slug=market_slug, target_date=target_date, lite=lite, scan_filters=scan_filters, - ) + def _build_city_detail_payload( data: Dict[str, Any], market_slug: Optional[str] = None, target_date: Optional[str] = None, -) -> Dict[str, Any]: + -> Dict[str, Any]: return _city_payload_detail( data, market_slug=market_slug, target_date=target_date, - ) + diff --git a/web/routers/city.py b/web/routers/city.py index d876ff7d..fc1d687b 100644 --- a/web/routers/city.py +++ b/web/routers/city.py @@ -7,7 +7,6 @@ from fastapi import APIRouter, BackgroundTasks, Query, Request from web.services.city_api import ( get_city_detail_aggregate_payload, get_city_detail_payload, - get_city_market_scan_payload, get_city_summary_payload, list_cities_payload, ) @@ -152,65 +151,6 @@ async def city_detail_aggregate( ) -@router.get("/api/city/{name}/market-scan") -async def city_market_scan( - request: Request, - background_tasks: BackgroundTasks, - name: str, - force_refresh: bool = False, - market_slug: Optional[str] = None, - target_date: Optional[str] = None, - lite: bool = False, -): - return await get_city_market_scan_payload( - request, - background_tasks, - name, - force_refresh=force_refresh, - market_slug=market_slug, - target_date=target_date, - lite=lite, - ) - - -@router.get("/api/city/{name}/holders") -async def city_holders( - name: str, - limit: int = 10, -): - """Return top token holders for a city's primary Polymarket market.""" - from web.services.city_payloads import _get_polymarket_layer - from web.analysis_service import _analyze - - layer = _get_polymarket_layer() - if not layer.enabled: - return {"holders": [], "available": False} - - data = _analyze(name, force_refresh=False, include_llm_commentary=False, detail_mode="market") - local_date = str(data.get("local_date") or "").strip() - if not local_date: - return {"holders": [], "available": False} - - scan = layer.build_market_scan( - city=name, - target_date=local_date, - include_related_buckets=False, - ) - if not scan.get("available"): - return {"holders": [], "available": False} - - condition_id = scan.get("selected_condition_id") - if not condition_id: - return {"holders": [], "available": False} - - holders = layer.get_market_holders(condition_id, limit=limit) - return { - "holders": holders, - "available": True, - "condition_id": condition_id, - } - - @router.get("/api/city/{name}/realtime-stream") async def city_realtime_stream(name: str): """Return a rolling window of recent temperature readings + market diff --git a/web/routers/scan.py b/web/routers/scan.py index 1be1eace..a4d5723c 100644 --- a/web/routers/scan.py +++ b/web/routers/scan.py @@ -30,7 +30,6 @@ async def scan_terminal( force_refresh: bool = False, region: str = "", trading_region: str = "", - skip_polymarket: bool = False, timezone_offset_seconds: int | None = None, ): return await get_scan_terminal_payload( @@ -46,8 +45,7 @@ async def scan_terminal( limit=limit, force_refresh=force_refresh, region=region or trading_region or None, - skip_polymarket=skip_polymarket, - timezone_offset_seconds=timezone_offset_seconds, + timezone_offset_seconds=timezone_offset_seconds, ) diff --git a/web/scan_terminal_city_row.py b/web/scan_terminal_city_row.py index 37515d06..d88f82f6 100644 --- a/web/scan_terminal_city_row.py +++ b/web/scan_terminal_city_row.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta from typing import Any, Dict, List, Optional from web.core import CITIES -from web.analysis_service import _analyze, _build_city_market_scan_payload +from web.analysis_service import _analyze from web.scan_city_ai_helpers import _safe_float from web.scan_terminal_ai_compact import _build_metar_decision_context from web.scan_terminal_filters import ( @@ -164,14 +164,7 @@ def _scan_city_terminal_rows( candidate_total = 0 for target_date in target_dates: - payload = _build_city_market_scan_payload( - data, - market_slug=None, - target_date=target_date, - lite=True, - scan_filters=filters, - ) - scan = payload.get("market_scan") or {} + scan = {"available": False} candidate_total += int(scan.get("candidate_count") or 0) raw_rows = scan.get("scan_rows") if not isinstance(raw_rows, list) or not raw_rows: diff --git a/web/scan_terminal_filters.py b/web/scan_terminal_filters.py index b66c7640..c1cc274f 100644 --- a/web/scan_terminal_filters.py +++ b/web/scan_terminal_filters.py @@ -48,7 +48,7 @@ def normalize_scan_terminal_filters( or "today", "limit": max(1, min(safe_int(raw.get("limit"), 25), 200)), "max_spread": max(0.0, _safe_float(raw.get("max_spread")) or 0.03), - "skip_polymarket": str(raw.get("skip_polymarket") or "false").lower() + in {"1", "true", "yes", "on"}, } trading_region = str(raw.get("trading_region") or "").strip().lower() diff --git a/web/services/city_api.py b/web/services/city_api.py index efe79256..07a0bded 100644 --- a/web/services/city_api.py +++ b/web/services/city_api.py @@ -222,10 +222,4 @@ async def get_city_market_scan_payload( ) if cached_scan is not None: return cached_scan - return await run_in_threadpool( - legacy_routes._build_city_market_scan_payload, - data, - market_slug, - target_date, - lite, - ) + return {"market_scan": {"available": False}, "selected_date": target_date or str(data.get("local_date", "")), "fetched_at": data.get("updated_at")} diff --git a/web/services/city_payloads.py b/web/services/city_payloads.py index c48e4167..7de68749 100644 --- a/web/services/city_payloads.py +++ b/web/services/city_payloads.py @@ -8,27 +8,6 @@ from web.core import _is_excluded_model_name TURKISH_MGM_CITIES = {"ankara", "istanbul"} -_polymarket_layer = None - - -def _get_polymarket_layer(): - global _polymarket_layer - if _polymarket_layer is None: - from src.data_collection.polymarket_readonly import PolymarketReadOnlyLayer - - _polymarket_layer = PolymarketReadOnlyLayer() - return _polymarket_layer - - -def _top_probability_bucket(distribution: Any) -> Optional[Dict[str, Any]]: - if not isinstance(distribution, list): - return None - candidates = [row for row in distribution if isinstance(row, dict)] - if not candidates: - return None - return max(candidates, key=lambda row: float(row.get("probability") or -1.0)) - - def build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]: return { "name": data.get("name"), @@ -54,66 +33,11 @@ def build_city_summary_payload(data: Dict[str, Any]) -> Dict[str, Any]: "updated_at": data.get("updated_at"), } - -def build_city_market_scan_payload( - data: Dict[str, Any], - market_slug: Optional[str] = None, - target_date: Optional[str] = None, - lite: bool = False, - scan_filters: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - local_date = str(data.get("local_date") or "").strip() - requested_date = str(target_date or "").strip() - selected_date = requested_date or local_date - - try: - layer = _get_polymarket_layer() - probabilities = data.get("probabilities") or {} - distribution = probabilities.get("distribution") or [] - top_bucket = _top_probability_bucket(distribution) - model_probability = ( - float(top_bucket.get("probability")) - if (isinstance(top_bucket, dict) and top_bucket.get("probability") is not None) - else None - ) - - scan = layer.build_market_scan( - city=data.get("name"), - target_date=selected_date, - temperature_bucket=top_bucket, - model_probability=model_probability, - probability_distribution=distribution, - temp_symbol=str(data.get("temp_symbol") or ""), - forced_market_slug=market_slug, - include_related_buckets=not lite, - scan_filters=scan_filters, - ) - return { - "market_scan": scan, - "selected_date": selected_date, - "fetched_at": data.get("updated_at"), - } - except Exception: - import traceback - traceback.print_exc() - return { - "market_scan": {"available": False}, - "selected_date": selected_date, - "fetched_at": data.get("updated_at"), - } - - def build_city_detail_payload( data: Dict[str, Any], market_slug: Optional[str] = None, target_date: Optional[str] = None, ) -> Dict[str, Any]: - market_payload = build_city_market_scan_payload( - data, - market_slug=market_slug, - target_date=target_date, - ) - market_scan = market_payload.get("market_scan") return { "city": data.get("name"), "fetched_at": data.get("updated_at"), @@ -200,7 +124,7 @@ def build_city_detail_payload( or _build_intraday_meteorology(data), "vertical_profile_signal": data.get("vertical_profile_signal") or {}, "taf": data.get("taf") or {}, - "market_scan": market_scan, + "market_scan": {"available": False}, "risk": data.get("risk"), "settlement_station": data.get("settlement_station") or {}, "airport_primary": data.get("airport_primary") or {}, diff --git a/web/services/city_runtime.py b/web/services/city_runtime.py index 8b73f3dc..daa74d2a 100644 --- a/web/services/city_runtime.py +++ b/web/services/city_runtime.py @@ -24,7 +24,6 @@ from web.analysis_service import ( _analyze, _analyze_summary, _build_city_detail_payload, # noqa: F401 - compatibility export for tests and transitional routers - _build_city_market_scan_payload, _build_city_summary_payload, ) from web.scan_terminal_service import ( @@ -195,12 +194,7 @@ def _attach_market_scan_payload( ) -> dict: if not isinstance(payload, dict): return payload - scan_payload = _build_city_market_scan_payload( - payload, - market_slug=market_slug, - target_date=target_date, - lite=lite, - ) + scan_payload = {"market_scan": {"available": False}, "selected_date": target_date or "", "fetched_at": payload.get("updated_at")} now_ts = time.time() payload["market_scan_payload"] = scan_payload payload["market_scan_updated_at"] = datetime.now().isoformat() diff --git a/web/services/ops_api.py b/web/services/ops_api.py index 66e8c63b..39a84a26 100644 --- a/web/services/ops_api.py +++ b/web/services/ops_api.py @@ -920,7 +920,7 @@ def get_ops_health_check(request: Request) -> dict[str, Any]: else: results["cwa"] = {"ok": False, "error": "not configured"} - # Polymarket health checks have been removed from the project + # AMOS (Korea runway sensors) try: diff --git a/web/services/scan_api.py b/web/services/scan_api.py index 4fe3f4dd..5e215790 100644 --- a/web/services/scan_api.py +++ b/web/services/scan_api.py @@ -47,7 +47,6 @@ async def get_scan_terminal_payload( limit: int = 25, force_refresh: bool = False, region: str = "", - skip_polymarket: bool = False, timezone_offset_seconds: int | None = None, ) -> Dict[str, Any]: legacy_routes._assert_entitlement(request) @@ -61,7 +60,6 @@ async def get_scan_terminal_payload( "market_type": market_type, "time_range": time_range, "limit": limit, - "skip_polymarket": skip_polymarket, } if timezone_offset_seconds is not None: filters["timezone_offset_seconds"] = timezone_offset_seconds