Introduce observation source adapters
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user