优先使用本地官方观测修复图表旧值
This commit is contained in:
@@ -1383,6 +1383,141 @@ def test_chart_data_cache_hit_overlays_latest_jma_amedas(monkeypatch):
|
||||
]
|
||||
|
||||
|
||||
def test_chart_data_cache_hit_overlays_latest_jma_from_airport_obs_log(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
class FakeCache:
|
||||
def get_city_cache(self, kind, city):
|
||||
assert kind == "full"
|
||||
return {
|
||||
"payload": {
|
||||
"name": city,
|
||||
"display_name": "Tokyo",
|
||||
"temp_symbol": "°C",
|
||||
"local_date": "2026-06-14",
|
||||
"local_time": "19:00",
|
||||
"current": {
|
||||
"temp": 23.0,
|
||||
"source_code": "metar",
|
||||
"obs_time": "2026-06-14T10:00:00+00:00",
|
||||
},
|
||||
"airport_current": {
|
||||
"temp": 23.0,
|
||||
"source_code": "metar",
|
||||
"obs_time": "2026-06-14T10:00:00+00:00",
|
||||
},
|
||||
"metar_today_obs": [{"time": "19:00", "temp": 23.0}],
|
||||
"timeseries": {"metar_today_obs": [{"time": "19:00", "temp": 23.0}]},
|
||||
},
|
||||
}
|
||||
|
||||
def get_runway_obs_recent(self, icao, minutes=60):
|
||||
return []
|
||||
|
||||
def get_latest_raw_observation(self, source, city):
|
||||
return None
|
||||
|
||||
def get_airport_obs_recent(self, icao, minutes=30):
|
||||
assert icao == "44166"
|
||||
return [
|
||||
{
|
||||
"icao": "44166",
|
||||
"city": "tokyo",
|
||||
"temp_c": 24.0,
|
||||
"obs_time": "2026-06-16T06:00:00+09:00",
|
||||
"created_at": "2026-06-15T21:00:15+00:00",
|
||||
}
|
||||
]
|
||||
|
||||
class FakeWeather:
|
||||
def fetch_jma_amedas_official_nearby(self, city, use_fahrenheit=False):
|
||||
return []
|
||||
|
||||
def fetch_jma_amedas_current(self, city, use_fahrenheit=False):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(city_api.legacy_routes, "_CACHE_DB", FakeCache())
|
||||
monkeypatch.setattr(city_api.legacy_routes, "_weather", FakeWeather())
|
||||
monkeypatch.setattr(
|
||||
city_api.legacy_routes,
|
||||
"_overlay_latest_wunderground_current",
|
||||
lambda city, payload: payload,
|
||||
)
|
||||
|
||||
payload = asyncio.run(city_api._get_city_chart_data("tokyo", force_refresh=False))
|
||||
|
||||
assert payload["local_date"] == "2026-06-16"
|
||||
assert payload["local_time"] == "06:00"
|
||||
assert payload["airport_current"]["temp"] == 24.0
|
||||
assert payload["airport_current"]["source_code"] == "jma_amedas"
|
||||
|
||||
|
||||
def test_chart_data_cache_hit_overlays_latest_cwa_from_airport_obs_log(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
class FakeCache:
|
||||
def get_city_cache(self, kind, city):
|
||||
assert kind == "full"
|
||||
return {
|
||||
"payload": {
|
||||
"name": city,
|
||||
"display_name": "Taipei",
|
||||
"temp_symbol": "°C",
|
||||
"local_date": "2026-06-14",
|
||||
"local_time": "18:00",
|
||||
"current": {
|
||||
"temp": 26.0,
|
||||
"source_code": "cwa",
|
||||
"obs_time": "2026-06-14T10:00:00+00:00",
|
||||
},
|
||||
"airport_current": {
|
||||
"temp": 26.0,
|
||||
"source_code": "cwa",
|
||||
"obs_time": "2026-06-14T10:00:00+00:00",
|
||||
},
|
||||
"metar_today_obs": [{"time": "18:00", "temp": 26.0}],
|
||||
"timeseries": {"metar_today_obs": [{"time": "18:00", "temp": 26.0}]},
|
||||
},
|
||||
}
|
||||
|
||||
def get_runway_obs_recent(self, icao, minutes=60):
|
||||
return []
|
||||
|
||||
def get_latest_raw_observation(self, source, city):
|
||||
return None
|
||||
|
||||
def get_airport_obs_recent(self, icao, minutes=30):
|
||||
assert icao == "466920"
|
||||
return [
|
||||
{
|
||||
"icao": "466920",
|
||||
"city": "taipei",
|
||||
"temp_c": 29.4,
|
||||
"obs_time": "2026-06-16T15:30:00+08:00",
|
||||
"created_at": "2026-06-16T07:30:15+00:00",
|
||||
}
|
||||
]
|
||||
|
||||
class FakeWeather:
|
||||
def fetch_cwa_taipei_settlement_current(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(city_api.legacy_routes, "_CACHE_DB", FakeCache())
|
||||
monkeypatch.setattr(city_api.legacy_routes, "_weather", FakeWeather())
|
||||
monkeypatch.setattr(
|
||||
city_api.legacy_routes,
|
||||
"_overlay_latest_wunderground_current",
|
||||
lambda city, payload: payload,
|
||||
)
|
||||
|
||||
payload = asyncio.run(city_api._get_city_chart_data("taipei", force_refresh=False))
|
||||
|
||||
assert payload["local_date"] == "2026-06-16"
|
||||
assert payload["local_time"] == "15:30"
|
||||
assert payload["airport_current"]["temp"] == 29.4
|
||||
assert payload["airport_current"]["source_code"] == "cwa"
|
||||
|
||||
|
||||
def test_chart_data_returns_cached_payload_when_optional_overlay_times_out(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
|
||||
@@ -693,7 +693,7 @@ async def _get_city_chart_data(city: str, *, force_refresh: bool) -> Dict[str, A
|
||||
overlay_name="jma_amedas_latest",
|
||||
payload=payload,
|
||||
fn=overlay_latest_jma_amedas_observation,
|
||||
args=(legacy_routes._weather, city, payload),
|
||||
args=(legacy_routes._weather, city, payload, legacy_routes._CACHE_DB),
|
||||
)
|
||||
payload = await _run_latest_observation_city_chart_overlay(
|
||||
city=city,
|
||||
@@ -721,7 +721,7 @@ async def _get_city_chart_data(city: str, *, force_refresh: bool) -> Dict[str, A
|
||||
overlay_name="cwa_taipei",
|
||||
payload=payload,
|
||||
fn=overlay_latest_cwa_observation,
|
||||
args=(legacy_routes._weather, city, payload),
|
||||
args=(legacy_routes._weather, city, payload, legacy_routes._CACHE_DB),
|
||||
)
|
||||
return await _run_optional_city_chart_overlay(
|
||||
city=city,
|
||||
@@ -757,7 +757,7 @@ async def _get_city_chart_data(city: str, *, force_refresh: bool) -> Dict[str, A
|
||||
overlay_name="jma_amedas_latest",
|
||||
payload=payload,
|
||||
fn=overlay_latest_jma_amedas_observation,
|
||||
args=(legacy_routes._weather, city, payload),
|
||||
args=(legacy_routes._weather, city, payload, legacy_routes._CACHE_DB),
|
||||
)
|
||||
payload = await _run_latest_observation_city_chart_overlay(
|
||||
city=city,
|
||||
@@ -785,7 +785,7 @@ async def _get_city_chart_data(city: str, *, force_refresh: bool) -> Dict[str, A
|
||||
overlay_name="cwa_taipei",
|
||||
payload=payload,
|
||||
fn=overlay_latest_cwa_observation,
|
||||
args=(legacy_routes._weather, city, payload),
|
||||
args=(legacy_routes._weather, city, payload, legacy_routes._CACHE_DB),
|
||||
)
|
||||
return await _run_optional_city_chart_overlay(
|
||||
city=city,
|
||||
|
||||
@@ -131,7 +131,78 @@ def _to_int(value: Any) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _latest_jma_row(weather: Any, city: str, use_fahrenheit: bool) -> Optional[dict[str, Any]]:
|
||||
def _latest_airport_obs_log_row(
|
||||
db: Any,
|
||||
*,
|
||||
station_code: str,
|
||||
city: str,
|
||||
source_code: str,
|
||||
source_label: str,
|
||||
station_label: str,
|
||||
use_fahrenheit: bool,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
reader = getattr(db, "get_airport_obs_recent", None)
|
||||
if not callable(reader):
|
||||
return None
|
||||
try:
|
||||
rows = reader(station_code, minutes=180)
|
||||
except Exception as exc:
|
||||
logger.debug("latest airport obs log read failed city={} station={}: {}", city, station_code, exc)
|
||||
return None
|
||||
|
||||
latest: Optional[tuple[int, dict[str, Any]]] = None
|
||||
normalized_city = str(city or "").strip().lower()
|
||||
for row in rows if isinstance(rows, list) else []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
row_city = str(row.get("city") or "").strip().lower()
|
||||
if row_city and normalized_city and row_city != normalized_city:
|
||||
continue
|
||||
temp_c = _to_float(row.get("temp_c") if row.get("temp_c") is not None else row.get("temp"))
|
||||
obs_time = str(row.get("obs_time") or row.get("observed_at") or "").strip()
|
||||
epoch = parse_observation_epoch(obs_time)
|
||||
if temp_c is None or not obs_time or epoch is None:
|
||||
continue
|
||||
if latest is None or epoch > latest[0]:
|
||||
latest = (epoch, row)
|
||||
|
||||
if latest is None:
|
||||
return None
|
||||
row = latest[1]
|
||||
temp = _to_float(row.get("temp_c") if row.get("temp_c") is not None else row.get("temp"))
|
||||
obs_time = str(row.get("obs_time") or row.get("observed_at") or "").strip()
|
||||
if temp is None or not obs_time:
|
||||
return None
|
||||
if use_fahrenheit:
|
||||
temp = temp * 9 / 5 + 32
|
||||
return {
|
||||
"station_label": station_label,
|
||||
"temp": round(float(temp), 1),
|
||||
"icao": station_code,
|
||||
"source": source_code,
|
||||
"source_label": source_label,
|
||||
"obs_time": obs_time,
|
||||
}
|
||||
|
||||
|
||||
def _latest_jma_row(
|
||||
weather: Any,
|
||||
city: str,
|
||||
use_fahrenheit: bool,
|
||||
db: Any = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
airport_obs_row = _latest_airport_obs_log_row(
|
||||
db,
|
||||
station_code="44166",
|
||||
city=city,
|
||||
source_code="jma_amedas",
|
||||
source_label="JMA",
|
||||
station_label="\u7fbd\u7530 10\u5206\u5b9e\u51b5 (JMA)",
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
)
|
||||
if airport_obs_row:
|
||||
return airport_obs_row
|
||||
|
||||
fetcher = getattr(weather, "fetch_jma_amedas_official_nearby", None)
|
||||
if callable(fetcher):
|
||||
try:
|
||||
@@ -287,6 +358,7 @@ def overlay_latest_jma_amedas_observation(
|
||||
weather: Any,
|
||||
city: str,
|
||||
payload: dict[str, Any],
|
||||
db: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized_city = str(city or payload.get("name") or payload.get("city") or "").strip().lower()
|
||||
if not normalized_city or not isinstance(payload, dict) or not payload:
|
||||
@@ -295,7 +367,7 @@ def overlay_latest_jma_amedas_observation(
|
||||
return payload
|
||||
|
||||
use_fahrenheit = "F" in str(payload.get("temp_symbol") or "").upper()
|
||||
row = _latest_jma_row(weather, normalized_city, use_fahrenheit)
|
||||
row = _latest_jma_row(weather, normalized_city, use_fahrenheit, db=db)
|
||||
if not isinstance(row, dict):
|
||||
return payload
|
||||
|
||||
@@ -808,19 +880,59 @@ def overlay_latest_amsc_observation(
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def overlay_latest_cwa_observation(weather, city, payload):
|
||||
def _latest_cwa_data_from_airport_obs_log(db: Any, city: str, use_fahrenheit: bool) -> Optional[dict[str, Any]]:
|
||||
row = _latest_airport_obs_log_row(
|
||||
db,
|
||||
station_code="466920",
|
||||
city=city,
|
||||
source_code="cwa",
|
||||
source_label="CWA",
|
||||
station_label="\u81fa\u5317",
|
||||
use_fahrenheit=use_fahrenheit,
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"source": "cwa",
|
||||
"source_label": "CWA",
|
||||
"station_code": row.get("icao") or "466920",
|
||||
"station_name": row.get("station_label") or "\u81fa\u5317",
|
||||
"observation_time": row.get("obs_time"),
|
||||
"current": {
|
||||
"temp": row.get("temp"),
|
||||
},
|
||||
"unit": "fahrenheit" if use_fahrenheit else "celsius",
|
||||
}
|
||||
|
||||
|
||||
def _newer_cwa_payload(left: Optional[dict[str, Any]], right: Optional[dict[str, Any]]) -> Optional[dict[str, Any]]:
|
||||
if not isinstance(left, dict):
|
||||
return right if isinstance(right, dict) else None
|
||||
if not isinstance(right, dict):
|
||||
return left
|
||||
left_epoch = parse_observation_epoch(left.get("observation_time"))
|
||||
right_epoch = parse_observation_epoch(right.get("observation_time"))
|
||||
if right_epoch is not None and (left_epoch is None or right_epoch > left_epoch):
|
||||
return right
|
||||
return left
|
||||
|
||||
|
||||
def overlay_latest_cwa_observation(weather, city, payload, db=None):
|
||||
normalized_city = str(city or payload.get("name") or payload.get("city") or "").strip().lower()
|
||||
if normalized_city != "taipei" or not isinstance(payload, dict) or not payload:
|
||||
return payload
|
||||
|
||||
use_fahrenheit = "F" in str(payload.get("temp_symbol") or "").upper()
|
||||
cwa_data = _latest_cwa_data_from_airport_obs_log(db, normalized_city, use_fahrenheit)
|
||||
|
||||
fetcher = getattr(weather, "fetch_cwa_taipei_settlement_current", None)
|
||||
if not callable(fetcher):
|
||||
return payload
|
||||
try:
|
||||
cwa_data = fetcher()
|
||||
except Exception as exc:
|
||||
logger.debug("latest CWA overlay fetch failed: {}", exc)
|
||||
if not callable(fetcher) and not cwa_data:
|
||||
return payload
|
||||
if callable(fetcher):
|
||||
try:
|
||||
cwa_data = _newer_cwa_payload(cwa_data, fetcher())
|
||||
except Exception as exc:
|
||||
logger.debug("latest CWA overlay fetch failed: {}", exc)
|
||||
if not isinstance(cwa_data, dict):
|
||||
return payload
|
||||
|
||||
|
||||
Reference in New Issue
Block a user