From ba59aebf87b74890934418b7e3aaaaa5dd6e3ed1 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 16 Jun 2026 16:43:15 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=85=88=E4=BD=BF=E7=94=A8=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=AE=98=E6=96=B9=E8=A7=82=E6=B5=8B=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=9B=BE=E8=A1=A8=E6=97=A7=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_web_observability.py | 135 +++++++++++++++++++++ web/services/city_api.py | 8 +- web/services/latest_observation_overlay.py | 130 ++++++++++++++++++-- 3 files changed, 260 insertions(+), 13 deletions(-) diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index faf8ada1..e5f1b4a0 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -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 diff --git a/web/services/city_api.py b/web/services/city_api.py index adb3b5c2..f9072c2d 100644 --- a/web/services/city_api.py +++ b/web/services/city_api.py @@ -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, diff --git a/web/services/latest_observation_overlay.py b/web/services/latest_observation_overlay.py index e5e4721b..4eab400a 100644 --- a/web/services/latest_observation_overlay.py +++ b/web/services/latest_observation_overlay.py @@ -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