180 lines
5.1 KiB
Python
180 lines
5.1 KiB
Python
"""Observation source adapters with a normalized collector-facing output."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any, Callable, Iterable
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ObservationRecord:
|
|
source: str
|
|
city: str
|
|
value: float
|
|
observed_at: str
|
|
observed_at_local: str
|
|
station_code: str
|
|
station_name: str
|
|
runway: str
|
|
value_unit: str
|
|
source_label: str
|
|
payload: dict[str, Any]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ObservationSourceResult:
|
|
source: str
|
|
city: str
|
|
status: str
|
|
error: str
|
|
records: tuple[ObservationRecord, ...]
|
|
|
|
|
|
_ATTACH_METHODS: dict[str, str] = {
|
|
"amsc_awos": "_attach_china_amsc_awos_data",
|
|
"amos": "_attach_korean_amos_data",
|
|
"madis_hfmetar": "_attach_madis_hfmetar_data",
|
|
"hko_obs": "_attach_hko_obs_official_nearby",
|
|
"cowin_obs": "_attach_cowin_official_nearby",
|
|
}
|
|
|
|
|
|
def _normalize_source(source: Any) -> str:
|
|
return str(source or "").strip().lower()
|
|
|
|
|
|
def _normalize_city(city: Any) -> str:
|
|
return str(city or "").strip().lower()
|
|
|
|
|
|
def _float_or_none(value: Any) -> float | None:
|
|
try:
|
|
if value is None or value == "":
|
|
return None
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _text(row: dict[str, Any], keys: Iterable[str]) -> str:
|
|
for key in keys:
|
|
value = str(row.get(key) or "").strip()
|
|
if value:
|
|
return value
|
|
return ""
|
|
|
|
|
|
def _observation_value(row: dict[str, Any]) -> float | None:
|
|
for key in ("temp_c", "temperature_c", "temp", "value"):
|
|
value = _float_or_none(row.get(key))
|
|
if value is not None:
|
|
return value
|
|
current = row.get("current")
|
|
if isinstance(current, dict):
|
|
return _float_or_none(current.get("temp"))
|
|
return None
|
|
|
|
|
|
def _iter_result_rows(results: dict[str, Any]) -> Iterable[dict[str, Any]]:
|
|
for value in (results or {}).values():
|
|
if isinstance(value, dict):
|
|
yield value
|
|
continue
|
|
if isinstance(value, list):
|
|
for item in value:
|
|
if isinstance(item, dict):
|
|
yield item
|
|
|
|
|
|
def _record_from_row(
|
|
*,
|
|
source: str,
|
|
city: str,
|
|
row: dict[str, Any],
|
|
) -> ObservationRecord | None:
|
|
value = _observation_value(row)
|
|
if value is None:
|
|
return None
|
|
record_source = _normalize_source(row.get("source") or row.get("source_code") or source)
|
|
return ObservationRecord(
|
|
source=record_source or source,
|
|
city=city,
|
|
value=value,
|
|
observed_at=_text(row, ("observation_time", "observed_at", "obs_time", "time_utc", "time")),
|
|
observed_at_local=_text(
|
|
row,
|
|
("observation_time_local", "observed_at_local", "obs_time_local", "local_time"),
|
|
),
|
|
station_code=_text(row, ("station_code", "icao", "istNo", "station_id", "code")).upper(),
|
|
station_name=_text(row, ("station_name", "station_label", "name")),
|
|
runway=_text(row, ("runway",)).upper(),
|
|
value_unit=str(row.get("unit") or row.get("temp_unit") or "c").strip().lower(),
|
|
source_label=_text(row, ("source_label", "label", "source_name"))
|
|
or (record_source or source).replace("_", " ").upper(),
|
|
payload=dict(row),
|
|
)
|
|
|
|
|
|
def collect_observation_source(
|
|
weather: Any,
|
|
source: Any,
|
|
city: Any,
|
|
*,
|
|
use_fahrenheit: bool,
|
|
) -> ObservationSourceResult:
|
|
normalized_source = _normalize_source(source)
|
|
normalized_city = _normalize_city(city)
|
|
method_name = _ATTACH_METHODS.get(normalized_source)
|
|
if not method_name:
|
|
return ObservationSourceResult(
|
|
source=normalized_source,
|
|
city=normalized_city,
|
|
status="unsupported",
|
|
error="unsupported observation source",
|
|
records=(),
|
|
)
|
|
attach: Callable[[dict[str, Any], str, bool], Any] | None = getattr(weather, method_name, None)
|
|
if not callable(attach):
|
|
return ObservationSourceResult(
|
|
source=normalized_source,
|
|
city=normalized_city,
|
|
status="unsupported",
|
|
error=f"weather collector missing {method_name}",
|
|
records=(),
|
|
)
|
|
|
|
results: dict[str, Any] = {}
|
|
attach(results, normalized_city, bool(use_fahrenheit))
|
|
if not results:
|
|
return ObservationSourceResult(
|
|
source=normalized_source,
|
|
city=normalized_city,
|
|
status="no_results",
|
|
error="source returned no observation rows",
|
|
records=(),
|
|
)
|
|
|
|
records = tuple(
|
|
record
|
|
for record in (
|
|
_record_from_row(source=normalized_source, city=normalized_city, row=row)
|
|
for row in _iter_result_rows(results)
|
|
)
|
|
if record is not None
|
|
)
|
|
if not records:
|
|
return ObservationSourceResult(
|
|
source=normalized_source,
|
|
city=normalized_city,
|
|
status="parse_error",
|
|
error="source response had no usable temperature",
|
|
records=(),
|
|
)
|
|
return ObservationSourceResult(
|
|
source=normalized_source,
|
|
city=normalized_city,
|
|
status="ok",
|
|
error="",
|
|
records=records,
|
|
)
|