Files
PolyWeather/web/services/canonical_temperature.py
2026-06-15 22:47:13 +08:00

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