237 lines
7.9 KiB
Python
237 lines
7.9 KiB
Python
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 _first_float(*values: Any) -> Optional[float]:
|
|
for value in values:
|
|
number = _to_float(value)
|
|
if number is not None:
|
|
return number
|
|
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
|
|
or "hko" in normalized
|
|
or "cowin" in normalized
|
|
or "cwa" in normalized
|
|
):
|
|
return "settlement_official"
|
|
if normalized in _AIRPORT_OFFICIAL_SOURCES or "metar" in normalized or "madis" in normalized:
|
|
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),
|
|
"max_so_far": _first_float(current.get("max_so_far"), current.get("max_temp_so_far")),
|
|
"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}."
|
|
deb = payload.get("deb") if isinstance(payload.get("deb"), dict) else {}
|
|
deb_prediction = _to_float(deb.get("prediction"))
|
|
if deb_prediction is not None:
|
|
canonical["deb_prediction"] = deb_prediction
|
|
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,
|
|
"max_so_far": _first_float(canonical.get("max_so_far"), 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,
|
|
"max_so_far": _first_float(canonical.get("max_so_far"), value),
|
|
"source_code": source,
|
|
"source_label": source_label,
|
|
"station_code": canonical.get("station_code"),
|
|
"station_name": canonical.get("station_name"),
|
|
"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
|