Decouple live weather reads from source collection
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
_SETTLEMENT_PROXY_SOURCES = {"amsc_awos", "amos", "runway", "awos"}
|
||||
_SETTLEMENT_OFFICIAL_SOURCES = {"hko", "cwa", "noaa", "wunderground", "mgm", "knmi", "ims"}
|
||||
_AIRPORT_OFFICIAL_SOURCES = {"metar", "madis_hfmetar", "aeroweb"}
|
||||
|
||||
|
||||
def _to_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _to_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _source_role(source: str) -> str:
|
||||
normalized = str(source or "").strip().lower()
|
||||
if normalized in _SETTLEMENT_PROXY_SOURCES or "amsc" in normalized or "runway" in normalized:
|
||||
return "settlement_proxy"
|
||||
if normalized in _SETTLEMENT_OFFICIAL_SOURCES:
|
||||
return "settlement_official"
|
||||
if normalized in _AIRPORT_OFFICIAL_SOURCES:
|
||||
return "airport_official"
|
||||
if "model" in normalized or normalized in {"deb", "open_meteo"}:
|
||||
return "model_blend"
|
||||
return "fallback"
|
||||
|
||||
|
||||
def _confidence(source_role: str, freshness_status: str) -> float:
|
||||
base = {
|
||||
"settlement_proxy": 0.92,
|
||||
"settlement_official": 0.9,
|
||||
"airport_official": 0.78,
|
||||
"model_blend": 0.55,
|
||||
"fallback": 0.42,
|
||||
}.get(source_role, 0.42)
|
||||
penalty = {
|
||||
"fresh": 0.0,
|
||||
"expected_wait": 0.03,
|
||||
"delayed": 0.1,
|
||||
"stale": 0.18,
|
||||
"expired": 0.28,
|
||||
"missing": 0.35,
|
||||
"unknown": 0.12,
|
||||
}.get(str(freshness_status or "").strip().lower(), 0.12)
|
||||
return round(max(0.05, min(0.99, base - penalty)), 2)
|
||||
|
||||
|
||||
def build_canonical_temperature(
|
||||
city: str,
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
fetched_at: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
current = payload.get("current") or {}
|
||||
if not isinstance(current, dict):
|
||||
return None
|
||||
value = _to_float(current.get("temp"))
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
freshness = current.get("freshness") or {}
|
||||
if not isinstance(freshness, dict):
|
||||
freshness = {}
|
||||
source = str(
|
||||
current.get("source_code")
|
||||
or current.get("settlement_source")
|
||||
or current.get("source")
|
||||
or freshness.get("source_code")
|
||||
or ""
|
||||
).strip().lower()
|
||||
source_label = str(
|
||||
current.get("settlement_source_label")
|
||||
or current.get("source_label")
|
||||
or freshness.get("source_label")
|
||||
or source.upper()
|
||||
).strip()
|
||||
observed_at = str(
|
||||
current.get("observed_at")
|
||||
or current.get("observation_time")
|
||||
or freshness.get("observed_at")
|
||||
or ""
|
||||
).strip()
|
||||
observed_at_local = str(
|
||||
current.get("observed_at_local")
|
||||
or freshness.get("observed_at_local")
|
||||
or current.get("obs_time")
|
||||
or ""
|
||||
).strip()
|
||||
freshness_sec = _to_int(freshness.get("age_sec"))
|
||||
if freshness_sec is None:
|
||||
age_min = _to_float(current.get("obs_age_min"))
|
||||
if age_min is not None:
|
||||
freshness_sec = int(age_min * 60)
|
||||
freshness_status = str(
|
||||
freshness.get("freshness_status")
|
||||
or current.get("observation_status")
|
||||
or "unknown"
|
||||
).strip().lower()
|
||||
role = _source_role(source)
|
||||
canonical = {
|
||||
"city": str(city or payload.get("name") or payload.get("city") or "").strip().lower(),
|
||||
"value": round(value, 2),
|
||||
"temp_symbol": str(payload.get("temp_symbol") or "°C"),
|
||||
"source": source,
|
||||
"source_label": source_label,
|
||||
"source_role": role,
|
||||
"station_code": current.get("station_code"),
|
||||
"station_name": current.get("station_name"),
|
||||
"observed_at": observed_at or None,
|
||||
"observed_at_local": observed_at_local or None,
|
||||
"fetched_at": str(fetched_at or payload.get("updated_at") or _now_iso()),
|
||||
"freshness_sec": freshness_sec,
|
||||
"freshness_status": freshness_status,
|
||||
"confidence": _confidence(role, freshness_status),
|
||||
}
|
||||
age_text = f" updated {freshness_sec}s ago" if freshness_sec is not None else ""
|
||||
canonical["explanation"] = f"{source_label or source or 'Source'}{age_text}."
|
||||
return canonical
|
||||
|
||||
|
||||
def attach_canonical_temperature(
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
city: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
return payload
|
||||
next_payload = payload
|
||||
canonical = build_canonical_temperature(
|
||||
city or str(payload.get("name") or payload.get("city") or ""),
|
||||
next_payload,
|
||||
)
|
||||
if canonical:
|
||||
next_payload["canonical_temperature"] = canonical
|
||||
return next_payload
|
||||
|
||||
|
||||
def store_canonical_temperature_from_payload(db: Any, city: str, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
canonical = build_canonical_temperature(city, payload)
|
||||
if not canonical:
|
||||
return None
|
||||
setter = getattr(db, "set_canonical_temperature", None)
|
||||
if callable(setter):
|
||||
setter(city, canonical)
|
||||
return canonical
|
||||
|
||||
|
||||
def build_city_weather_from_canonical(city: str, canonical: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
if not isinstance(canonical, dict):
|
||||
return None
|
||||
value = _to_float(canonical.get("value"))
|
||||
if value is None:
|
||||
return None
|
||||
observed_at = str(canonical.get("observed_at") or "").strip()
|
||||
observed_at_local = str(canonical.get("observed_at_local") or "").strip()
|
||||
source = str(canonical.get("source") or "").strip().lower()
|
||||
source_label = str(canonical.get("source_label") or source.upper()).strip()
|
||||
freshness = {
|
||||
"freshness_status": canonical.get("freshness_status") or "unknown",
|
||||
"age_sec": canonical.get("freshness_sec"),
|
||||
"observed_at": observed_at or None,
|
||||
"observed_at_local": observed_at_local or None,
|
||||
}
|
||||
current = {
|
||||
"temp": value,
|
||||
"source_code": source,
|
||||
"settlement_source": source,
|
||||
"settlement_source_label": source_label,
|
||||
"observed_at": observed_at or None,
|
||||
"observed_at_local": observed_at_local or None,
|
||||
"obs_time": observed_at_local or observed_at,
|
||||
"freshness": freshness,
|
||||
"observation_status": "live",
|
||||
}
|
||||
airport_primary = {
|
||||
"temp": value,
|
||||
"source_code": source,
|
||||
"source_label": source_label,
|
||||
"obs_time": observed_at or observed_at_local,
|
||||
"freshness": freshness,
|
||||
}
|
||||
payload = {
|
||||
"name": str(city or canonical.get("city") or "").strip().lower(),
|
||||
"temp_symbol": str(canonical.get("temp_symbol") or "°C"),
|
||||
"current": current,
|
||||
"airport_primary": airport_primary,
|
||||
"airport_current": deepcopy(airport_primary),
|
||||
"canonical_temperature": dict(canonical),
|
||||
"deb": {"prediction": canonical.get("deb_prediction")},
|
||||
"updated_at": canonical.get("fetched_at"),
|
||||
}
|
||||
return payload
|
||||
+195
-13
@@ -15,6 +15,7 @@ from loguru import logger
|
||||
|
||||
import web.routes as legacy_routes
|
||||
from web.analysis_service import _runway_history_temp_for_city
|
||||
from web.services.canonical_temperature import build_city_weather_from_canonical
|
||||
from web.services.request_timing import ServerTimingRecorder
|
||||
|
||||
_RECENT_DEB_CACHE: Optional[Dict[str, Dict[str, object]]] = None
|
||||
@@ -129,6 +130,132 @@ async def _get_cached_city_payload(city: str, kind: str) -> Dict[str, Any]:
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
|
||||
async def _get_canonical_city_payload(city: str, *, detail_depth: str = "panel") -> Dict[str, Any]:
|
||||
try:
|
||||
row = await run_in_threadpool(legacy_routes._CACHE_DB.get_canonical_temperature, city)
|
||||
except Exception:
|
||||
return {}
|
||||
if not isinstance(row, dict):
|
||||
return {}
|
||||
canonical = row.get("payload") or row
|
||||
if not isinstance(canonical, dict):
|
||||
return {}
|
||||
payload = build_city_weather_from_canonical(city, canonical)
|
||||
if not isinstance(payload, dict) or not payload:
|
||||
return {}
|
||||
city_meta = legacy_routes.CITY_REGISTRY.get(city, {}) or {}
|
||||
city_info = legacy_routes.CITIES.get(city, {}) or {}
|
||||
risk = legacy_routes.CITY_RISK_PROFILES.get(city, {}) or {}
|
||||
payload.update(
|
||||
{
|
||||
"detail_depth": detail_depth,
|
||||
"display_name": str(city_meta.get("display_name") or city_meta.get("name") or city.title()),
|
||||
"lat": city_info.get("lat"),
|
||||
"lon": city_info.get("lon"),
|
||||
"temp_symbol": canonical.get("temp_symbol") or payload.get("temp_symbol") or ("°F" if city_info.get("f") else "°C"),
|
||||
"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", ""),
|
||||
},
|
||||
"probabilities": {"mu": None, "distribution": []},
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _enqueue_collector_refresh_request(
|
||||
city: str,
|
||||
kind: str,
|
||||
*,
|
||||
reason: str = "canonical_fallback",
|
||||
) -> bool:
|
||||
try:
|
||||
enqueue = getattr(legacy_routes._CACHE_DB, "enqueue_observation_refresh_request", None)
|
||||
if not callable(enqueue):
|
||||
return False
|
||||
return bool(
|
||||
enqueue(
|
||||
city=city,
|
||||
kind=kind,
|
||||
priority="high",
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("collector refresh enqueue failed city={} kind={}: {}", city, kind, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _request_city_cache_refresh(
|
||||
city: str,
|
||||
kind: str,
|
||||
refresh_fn: Callable[[str, bool], Dict[str, Any]],
|
||||
) -> None:
|
||||
if _enqueue_collector_refresh_request(city, kind):
|
||||
return
|
||||
_start_city_cache_stale_refresh(city, kind, refresh_fn)
|
||||
|
||||
|
||||
def _request_city_full_refresh(city: str) -> None:
|
||||
if _enqueue_collector_refresh_request(city, "full"):
|
||||
return
|
||||
_start_city_full_stale_refresh(city)
|
||||
|
||||
|
||||
def _build_initializing_city_payload(city: str, *, detail_depth: str) -> Dict[str, Any]:
|
||||
city_meta = legacy_routes.CITY_REGISTRY.get(city, {}) or {}
|
||||
city_info = legacy_routes.CITIES.get(city, {}) or {}
|
||||
risk = legacy_routes.CITY_RISK_PROFILES.get(city, {}) or {}
|
||||
return {
|
||||
"city": city,
|
||||
"name": city,
|
||||
"display_name": str(city_meta.get("display_name") or city_meta.get("name") or city.title()),
|
||||
"detail_depth": detail_depth,
|
||||
"status": "initializing",
|
||||
"stale": True,
|
||||
"stale_reason": "collector_refresh_queued",
|
||||
"lat": city_info.get("lat"),
|
||||
"lon": city_info.get("lon"),
|
||||
"temp_symbol": "°F" if city_info.get("f") else "°C",
|
||||
"risk": {
|
||||
"level": risk.get("risk_level", "low"),
|
||||
"emoji": risk.get("risk_emoji", "🟢"),
|
||||
"airport": risk.get("airport_name", ""),
|
||||
"icao": risk.get("icao", ""),
|
||||
"distance_km": risk.get("distance_km", 0),
|
||||
"warning": risk.get("warning", ""),
|
||||
},
|
||||
"current": {
|
||||
"temp": None,
|
||||
"source_code": None,
|
||||
"settlement_source": None,
|
||||
"settlement_source_label": None,
|
||||
"obs_time": None,
|
||||
"freshness": {
|
||||
"freshness_status": "missing",
|
||||
"freshness_reason": "collector_refresh_queued",
|
||||
},
|
||||
"observation_status": "initializing",
|
||||
},
|
||||
"airport_current": {},
|
||||
"airport_primary": {},
|
||||
"canonical_temperature": None,
|
||||
"deb": {"prediction": None},
|
||||
"probabilities": {"mu": None, "distribution": []},
|
||||
"hourly": {"times": [], "temps": []},
|
||||
"multi_model_daily": {},
|
||||
}
|
||||
|
||||
|
||||
def _queue_and_build_initializing_city_payload(city: str, *, kind: str) -> Dict[str, Any]:
|
||||
_enqueue_collector_refresh_request(city, kind, reason="cold_start")
|
||||
return _build_initializing_city_payload(city, detail_depth=kind)
|
||||
|
||||
|
||||
async def _get_or_start_city_force_refresh_task(
|
||||
key: str,
|
||||
refresh_factory: Callable[[], Awaitable[Dict[str, Any]]],
|
||||
@@ -158,16 +285,37 @@ async def _refresh_city_payload_with_stale_timeout(
|
||||
kind: str,
|
||||
refresh_factory: Callable[[], Awaitable[Dict[str, Any]]],
|
||||
) -> Dict[str, Any]:
|
||||
cached_before_refresh = await _get_cached_city_payload(city, kind)
|
||||
if not cached_before_refresh:
|
||||
canonical_payload = await _get_canonical_city_payload(city, detail_depth=kind)
|
||||
if canonical_payload:
|
||||
_enqueue_collector_refresh_request(city, kind, reason="force_refresh")
|
||||
logger.warning(
|
||||
"city force refresh returning canonical latest without sync refresh city={} kind={}",
|
||||
city,
|
||||
kind,
|
||||
)
|
||||
return canonical_payload
|
||||
return _queue_and_build_initializing_city_payload(city, kind=kind)
|
||||
|
||||
task, started = await _get_or_start_city_force_refresh_task(f"{kind}:{city}", refresh_factory)
|
||||
if not started:
|
||||
cached_payload = await _get_cached_city_payload(city, kind)
|
||||
if cached_payload:
|
||||
if cached_before_refresh:
|
||||
logger.warning(
|
||||
"city force refresh already running city={} kind={}; returning stale cache",
|
||||
city,
|
||||
kind,
|
||||
)
|
||||
return await _overlay_cached_wunderground(city, cached_payload)
|
||||
return await _overlay_cached_wunderground(city, cached_before_refresh)
|
||||
canonical_payload = await _get_canonical_city_payload(city, detail_depth=kind)
|
||||
if canonical_payload:
|
||||
_enqueue_collector_refresh_request(city, kind, reason="force_refresh")
|
||||
logger.warning(
|
||||
"city force refresh returning canonical latest while refresh runs city={} kind={}",
|
||||
city,
|
||||
kind,
|
||||
)
|
||||
return canonical_payload
|
||||
timeout_sec = _city_force_refresh_timeout_sec()
|
||||
try:
|
||||
return await asyncio.wait_for(asyncio.shield(task), timeout=timeout_sec)
|
||||
@@ -384,9 +532,17 @@ async def _get_city_full_data(city: str, *, force_refresh: bool) -> Dict[str, An
|
||||
if payload:
|
||||
_start_city_full_stale_refresh(city)
|
||||
return await _overlay_cached_wunderground(city, payload)
|
||||
return await _refresh_city_full_data(city, False)
|
||||
canonical_payload = await _get_canonical_city_payload(city, detail_depth="full")
|
||||
if canonical_payload:
|
||||
_request_city_full_refresh(city)
|
||||
return canonical_payload
|
||||
return _queue_and_build_initializing_city_payload(city, kind="full")
|
||||
return await _overlay_cached_wunderground(city, payload)
|
||||
return await _refresh_city_full_data(city, False)
|
||||
canonical_payload = await _get_canonical_city_payload(city, detail_depth="full")
|
||||
if canonical_payload:
|
||||
_request_city_full_refresh(city)
|
||||
return canonical_payload
|
||||
return _queue_and_build_initializing_city_payload(city, kind="full")
|
||||
|
||||
|
||||
async def _get_city_chart_data(city: str, *, force_refresh: bool) -> Dict[str, Any]:
|
||||
@@ -714,9 +870,13 @@ async def get_city_detail_payload(
|
||||
if payload:
|
||||
_start_city_cache_stale_refresh(city, "panel", legacy_routes._refresh_city_panel_cache)
|
||||
return await _overlay_cached_wunderground(city, payload)
|
||||
return await run_in_threadpool(legacy_routes._refresh_city_panel_cache, city, False)
|
||||
return _queue_and_build_initializing_city_payload(city, kind="panel")
|
||||
return await _overlay_cached_wunderground(city, cached_entry.get("payload") or {})
|
||||
return await run_in_threadpool(legacy_routes._refresh_city_panel_cache, city, False)
|
||||
canonical_payload = await _get_canonical_city_payload(city, detail_depth="panel")
|
||||
if canonical_payload:
|
||||
_request_city_cache_refresh(city, "panel", legacy_routes._refresh_city_panel_cache)
|
||||
return canonical_payload
|
||||
return _queue_and_build_initializing_city_payload(city, kind="panel")
|
||||
if detail_mode == "nearby":
|
||||
if force_refresh:
|
||||
return await _refresh_city_cache_with_stale_timeout(
|
||||
@@ -731,9 +891,17 @@ async def get_city_detail_payload(
|
||||
if payload:
|
||||
_start_city_cache_stale_refresh(city, "nearby", legacy_routes._refresh_city_nearby_cache)
|
||||
return await _overlay_cached_wunderground(city, payload)
|
||||
return await run_in_threadpool(legacy_routes._refresh_city_nearby_cache, city, False)
|
||||
canonical_payload = await _get_canonical_city_payload(city, detail_depth="nearby")
|
||||
if canonical_payload:
|
||||
_request_city_cache_refresh(city, "nearby", legacy_routes._refresh_city_nearby_cache)
|
||||
return canonical_payload
|
||||
return _queue_and_build_initializing_city_payload(city, kind="nearby")
|
||||
return await _overlay_cached_wunderground(city, cached_entry.get("payload") or {})
|
||||
return await run_in_threadpool(legacy_routes._refresh_city_nearby_cache, city, False)
|
||||
canonical_payload = await _get_canonical_city_payload(city, detail_depth="nearby")
|
||||
if canonical_payload:
|
||||
_request_city_cache_refresh(city, "nearby", legacy_routes._refresh_city_nearby_cache)
|
||||
return canonical_payload
|
||||
return _queue_and_build_initializing_city_payload(city, kind="nearby")
|
||||
if detail_mode == "market":
|
||||
if force_refresh:
|
||||
return await _refresh_city_cache_with_stale_timeout(
|
||||
@@ -748,9 +916,17 @@ async def get_city_detail_payload(
|
||||
if payload:
|
||||
_start_city_cache_stale_refresh(city, "market", legacy_routes._refresh_city_market_cache)
|
||||
return await _overlay_cached_wunderground(city, payload)
|
||||
return await run_in_threadpool(legacy_routes._refresh_city_market_cache, city, False)
|
||||
canonical_payload = await _get_canonical_city_payload(city, detail_depth="market")
|
||||
if canonical_payload:
|
||||
_request_city_cache_refresh(city, "market", legacy_routes._refresh_city_market_cache)
|
||||
return canonical_payload
|
||||
return _queue_and_build_initializing_city_payload(city, kind="market")
|
||||
return await _overlay_cached_wunderground(city, cached_entry.get("payload") or {})
|
||||
return await run_in_threadpool(legacy_routes._refresh_city_market_cache, city, False)
|
||||
canonical_payload = await _get_canonical_city_payload(city, detail_depth="market")
|
||||
if canonical_payload:
|
||||
_request_city_cache_refresh(city, "market", legacy_routes._refresh_city_market_cache)
|
||||
return canonical_payload
|
||||
return _queue_and_build_initializing_city_payload(city, kind="market")
|
||||
return await run_in_threadpool(legacy_routes._analyze, city, force_refresh, False, detail_mode)
|
||||
|
||||
|
||||
@@ -774,9 +950,13 @@ async def get_city_summary_payload(
|
||||
if payload:
|
||||
_start_city_cache_stale_refresh(city, "summary", legacy_routes._refresh_city_summary_cache)
|
||||
return await _overlay_cached_wunderground(city, payload)
|
||||
return await run_in_threadpool(legacy_routes._refresh_city_summary_cache, city, False)
|
||||
return _queue_and_build_initializing_city_payload(city, kind="summary")
|
||||
return await _overlay_cached_wunderground(city, cached_entry.get("payload") or {})
|
||||
return await run_in_threadpool(legacy_routes._refresh_city_summary_cache, city, False)
|
||||
canonical_payload = await _get_canonical_city_payload(city, detail_depth="summary")
|
||||
if canonical_payload:
|
||||
_request_city_cache_refresh(city, "summary", legacy_routes._refresh_city_summary_cache)
|
||||
return canonical_payload
|
||||
return _queue_and_build_initializing_city_payload(city, kind="summary")
|
||||
|
||||
|
||||
async def get_city_detail_aggregate_payload(
|
||||
@@ -803,6 +983,8 @@ async def get_city_detail_aggregate_payload(
|
||||
"full_data",
|
||||
lambda: _get_city_full_data(city, force_refresh=force_refresh),
|
||||
)
|
||||
if isinstance(data, dict) and data.get("status") == "initializing":
|
||||
return data
|
||||
|
||||
return await timer.measure_async(
|
||||
"detail_payload",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Lightweight realtime temperature stream for scrolling chart.
|
||||
|
||||
Maintains per-city deque buffers (max 1440 points) fed by _analyze()
|
||||
refreshes. The /api/city/{name}/realtime-stream endpoint reads from
|
||||
these buffers and returns a simple {points, thresholds} payload that
|
||||
the frontend RealtimeScrollChart polls every 30 seconds.
|
||||
Maintains per-city deque buffers (max 1440 points) fed by cached latest
|
||||
observations. The polling endpoint must not trigger external weather
|
||||
fetches; collectors and cache refreshers feed DB state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,12 +12,14 @@ import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from web.analysis_service import _analyze
|
||||
from src.database.db_manager import DBManager
|
||||
|
||||
# Per-city ring buffers: city_name → deque of {timestamp, temp, source}
|
||||
_STREAM_BUFFERS: Dict[str, collections.deque] = {}
|
||||
_BUFFER_LOCK = threading.Lock()
|
||||
_MAXLEN = 1440
|
||||
_CACHE_DB = DBManager()
|
||||
_analyze = None # compatibility placeholder for older tests/monkeypatches
|
||||
|
||||
|
||||
def _best_temp(data: Dict[str, Any]) -> Optional[float]:
|
||||
@@ -76,19 +77,63 @@ def _extract_thresholds(data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
return thresholds
|
||||
|
||||
|
||||
def _cached_city_payload(city: str) -> Dict[str, Any]:
|
||||
normalized_city = str(city or "").strip().lower()
|
||||
if not normalized_city:
|
||||
return {}
|
||||
for kind in ("panel", "full"):
|
||||
try:
|
||||
entry = _CACHE_DB.get_city_cache(kind, normalized_city)
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
payload = entry.get("payload") or {}
|
||||
if isinstance(payload, dict) and payload:
|
||||
return payload
|
||||
return {}
|
||||
|
||||
|
||||
def _latest_canonical_point(city: str) -> Optional[Dict[str, Any]]:
|
||||
normalized_city = str(city or "").strip().lower()
|
||||
if not normalized_city:
|
||||
return None
|
||||
try:
|
||||
row = _CACHE_DB.get_canonical_temperature(normalized_city)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(row, dict):
|
||||
return None
|
||||
canonical = row.get("payload") or row
|
||||
if not isinstance(canonical, dict):
|
||||
return None
|
||||
try:
|
||||
temp = float(canonical.get("value"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
timestamp = str(
|
||||
canonical.get("observed_at")
|
||||
or canonical.get("observed_at_local")
|
||||
or canonical.get("fetched_at")
|
||||
or time.strftime("%H:%M:%S")
|
||||
)
|
||||
source = str(canonical.get("source") or "canonical")
|
||||
return {"timestamp": timestamp, "temp": round(temp, 1), "source": source}
|
||||
|
||||
|
||||
def capture_sample(city: str) -> None:
|
||||
"""Record one sample for *city* into its ring buffer."""
|
||||
try:
|
||||
data = _analyze(city, force_refresh=False, detail_mode="panel")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
temp = _best_temp(data)
|
||||
if temp is None:
|
||||
return
|
||||
|
||||
ts = time.strftime("%H:%M:%S")
|
||||
point = {"timestamp": ts, "temp": round(temp, 1), "source": "metar"}
|
||||
point = _latest_canonical_point(city)
|
||||
if point is None:
|
||||
data = _cached_city_payload(city)
|
||||
temp = _best_temp(data)
|
||||
if temp is None:
|
||||
return
|
||||
current = data.get("current") if isinstance(data, dict) else {}
|
||||
source = "cache"
|
||||
if isinstance(current, dict):
|
||||
source = str(current.get("source_code") or current.get("settlement_source") or source)
|
||||
point = {"timestamp": time.strftime("%H:%M:%S"), "temp": round(temp, 1), "source": source}
|
||||
|
||||
with _BUFFER_LOCK:
|
||||
buf = _STREAM_BUFFERS.get(city)
|
||||
@@ -107,11 +152,6 @@ def get_realtime_stream_payload(city: str) -> Dict[str, Any]:
|
||||
buf = _STREAM_BUFFERS.get(city)
|
||||
points = list(buf) if buf else []
|
||||
|
||||
# Build thresholds from cached analysis
|
||||
try:
|
||||
data = _analyze(city, force_refresh=False, detail_mode="panel")
|
||||
thresholds = _extract_thresholds(data)
|
||||
except Exception:
|
||||
thresholds = []
|
||||
thresholds = _extract_thresholds(_cached_city_payload(city))
|
||||
|
||||
return {"points": points, "thresholds": thresholds}
|
||||
|
||||
@@ -30,6 +30,10 @@ from web.analysis_service import (
|
||||
_build_city_market_scan_payload,
|
||||
_build_city_summary_payload,
|
||||
)
|
||||
from web.services.canonical_temperature import (
|
||||
attach_canonical_temperature,
|
||||
store_canonical_temperature_from_payload,
|
||||
)
|
||||
from web.scan_terminal_service import build_scan_terminal_payload # noqa: F401 - compatibility export for tests and transitional routers
|
||||
from web.core import (
|
||||
CITIES,
|
||||
@@ -275,9 +279,23 @@ def _refresh_market_scan_payload_from_cached_analysis(
|
||||
return payload.get("market_scan_payload") or {}
|
||||
|
||||
|
||||
def _attach_and_store_canonical_temperature(city: str, payload: dict) -> dict:
|
||||
if not isinstance(payload, dict):
|
||||
return payload
|
||||
attach_canonical_temperature(payload, city=city)
|
||||
try:
|
||||
store_canonical_temperature_from_payload(_CACHE_DB, city, payload)
|
||||
except Exception as exc:
|
||||
logger.debug("canonical temperature store skipped city={}: {}", city, exc)
|
||||
return payload
|
||||
|
||||
|
||||
def _refresh_city_summary_cache(city: str, force_refresh: bool = False) -> dict:
|
||||
data = _analyze_summary(city, force_refresh=force_refresh)
|
||||
_attach_and_store_canonical_temperature(city, data)
|
||||
payload = _build_city_summary_payload(data)
|
||||
if data.get("canonical_temperature"):
|
||||
payload["canonical_temperature"] = data["canonical_temperature"]
|
||||
_CACHE_DB.set_city_cache(
|
||||
"summary",
|
||||
city,
|
||||
@@ -290,6 +308,7 @@ def _refresh_city_summary_cache(city: str, force_refresh: bool = False) -> dict:
|
||||
|
||||
def _refresh_city_panel_cache(city: str, force_refresh: bool = False) -> dict:
|
||||
payload = _analyze(city, force_refresh=force_refresh, detail_mode="panel")
|
||||
_attach_and_store_canonical_temperature(city, payload)
|
||||
_CACHE_DB.set_city_cache(
|
||||
"panel",
|
||||
city,
|
||||
@@ -302,6 +321,7 @@ def _refresh_city_panel_cache(city: str, force_refresh: bool = False) -> dict:
|
||||
|
||||
def _refresh_city_nearby_cache(city: str, force_refresh: bool = False) -> dict:
|
||||
payload = _analyze(city, force_refresh=force_refresh, detail_mode="nearby")
|
||||
_attach_and_store_canonical_temperature(city, payload)
|
||||
_CACHE_DB.set_city_cache(
|
||||
"nearby",
|
||||
city,
|
||||
@@ -314,6 +334,7 @@ def _refresh_city_nearby_cache(city: str, force_refresh: bool = False) -> dict:
|
||||
|
||||
def _refresh_city_market_cache(city: str, force_refresh: bool = False) -> dict:
|
||||
payload = _analyze(city, force_refresh=force_refresh, detail_mode="market")
|
||||
_attach_and_store_canonical_temperature(city, payload)
|
||||
now_ts = time.time()
|
||||
payload["market_analysis_cached_at"] = datetime.now().isoformat()
|
||||
payload["market_analysis_cached_at_ts"] = now_ts
|
||||
@@ -330,6 +351,7 @@ def _refresh_city_market_cache(city: str, force_refresh: bool = False) -> dict:
|
||||
|
||||
def _refresh_city_full_cache(city: str, force_refresh: bool = False) -> dict:
|
||||
payload = _analyze(city, force_refresh=force_refresh, detail_mode="full")
|
||||
_attach_and_store_canonical_temperature(city, payload)
|
||||
_CACHE_DB.set_city_cache(
|
||||
"full",
|
||||
city,
|
||||
|
||||
@@ -10,7 +10,7 @@ from fastapi.concurrency import run_in_threadpool
|
||||
from loguru import logger
|
||||
|
||||
import web.routes as legacy_routes
|
||||
from web.services.city_api import _build_cities_payload
|
||||
from web.services.city_api import _build_cities_payload, get_city_detail_payload
|
||||
|
||||
|
||||
def _resolve_default_city(request: Request) -> Optional[str]:
|
||||
@@ -37,15 +37,12 @@ async def build_dashboard_init_payload(request: Request) -> Dict[str, Any]:
|
||||
detail_payload: Optional[Dict[str, Any]] = None
|
||||
if default_city:
|
||||
try:
|
||||
cached_entry = await run_in_threadpool(
|
||||
legacy_routes._CACHE_DB.get_city_cache, "panel", default_city,
|
||||
detail_payload = await get_city_detail_payload(
|
||||
request,
|
||||
default_city,
|
||||
force_refresh=False,
|
||||
depth="panel",
|
||||
)
|
||||
if cached_entry:
|
||||
detail_payload = cached_entry.get("payload") or {}
|
||||
else:
|
||||
detail_payload = await run_in_threadpool(
|
||||
legacy_routes._refresh_city_panel_cache, default_city, False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"dashboard_init default_city={} panel failed: {}", default_city, exc
|
||||
|
||||
@@ -92,20 +92,27 @@ def run_system_priority_warm(
|
||||
primary = list(batches.get("primary") or [])
|
||||
secondary = list(batches.get("secondary") or [])
|
||||
|
||||
def _queue_city_refresh(city: str, *, priority: str) -> None:
|
||||
enqueue = getattr(legacy_routes._CACHE_DB, "enqueue_observation_refresh_request", None)
|
||||
if not callable(enqueue):
|
||||
logger.warning("priority warm queue unavailable city={} timezone={}", city, timezone)
|
||||
return
|
||||
enqueue(
|
||||
city=city,
|
||||
kind="panel",
|
||||
priority=priority,
|
||||
reason="system_priority_warm",
|
||||
)
|
||||
|
||||
def _runner() -> None:
|
||||
for city in primary:
|
||||
try:
|
||||
legacy_routes._refresh_city_summary_cache(city, force_refresh=False)
|
||||
legacy_routes._refresh_city_panel_cache(city, force_refresh=False)
|
||||
legacy_routes._refresh_city_nearby_cache(city, force_refresh=False)
|
||||
legacy_routes._refresh_city_market_cache(city, force_refresh=False)
|
||||
legacy_routes._refresh_city_full_cache(city, force_refresh=False)
|
||||
_queue_city_refresh(city, priority="high")
|
||||
except Exception as exc:
|
||||
logger.warning("priority warm primary failed city={} timezone={}: {}", city, timezone, exc)
|
||||
for city in secondary:
|
||||
try:
|
||||
legacy_routes._refresh_city_summary_cache(city, force_refresh=False)
|
||||
legacy_routes._refresh_city_panel_cache(city, force_refresh=False)
|
||||
_queue_city_refresh(city, priority="normal")
|
||||
except Exception as exc:
|
||||
logger.warning("priority warm secondary failed city={} timezone={}: {}", city, timezone, exc)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user