From f6fbff9a28b40a2a6de9ad53ef79e39db49250c8 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Tue, 16 Jun 2026 05:11:49 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=9C=E4=BA=AC=E5=9B=BE?= =?UTF-8?q?=E8=A1=A8=E6=97=A7=20JMA=20=E8=A7=82=E6=B5=8B=E5=8F=A0=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_latest_observation_overlay.py | 118 ++++++++++ tests/test_web_observability.py | 73 ++++++ web/services/city_api.py | 15 ++ web/services/latest_observation_overlay.py | 260 +++++++++++++++++++++ 4 files changed, 466 insertions(+) diff --git a/tests/test_latest_observation_overlay.py b/tests/test_latest_observation_overlay.py index 76b68543..38e2fd28 100644 --- a/tests/test_latest_observation_overlay.py +++ b/tests/test_latest_observation_overlay.py @@ -1,3 +1,4 @@ +import web.services.latest_observation_overlay as observation_overlay from web.services.latest_observation_overlay import overlay_latest_amsc_observation @@ -267,3 +268,120 @@ def test_overlay_uses_latest_success_when_newer_status_row_has_no_observation(): assert result["amos"]["temp_c"] == 25.8 assert result["current"]["temp"] == 25.8 assert result["airport_current"]["source_code"] == "amsc_awos" + + +def test_overlay_latest_jma_resets_stale_tokyo_detail_to_latest_local_day(): + class FakeWeather: + def fetch_jma_amedas_official_nearby(self, city, use_fahrenheit=False): + assert (city, use_fahrenheit) == ("tokyo", False) + return [ + { + "station_label": "羽田 10分实况 (JMA)", + "temp": 24.0, + "icao": "44166", + "source": "jma", + "source_label": "JMA", + "obs_time": "2026-06-16T06:00:00+09:00", + } + ] + + stale_payload = { + "name": "tokyo", + "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", + }, + "airport_primary": { + "temp": 23.0, + "source_code": "metar", + "obs_time": "2026-06-14T10:00:00+00:00", + }, + "overview": { + "local_date": "2026-06-14", + "local_time": "19:00", + "current_temp": 23.0, + }, + "metar_today_obs": [ + {"time": "00:00", "temp": 23.0}, + {"time": "17:00", "temp": 25.0}, + ], + "timeseries": { + "metar_today_obs": [ + {"time": "00:00", "temp": 23.0}, + {"time": "17:00", "temp": 25.0}, + ], + }, + } + + assert hasattr(observation_overlay, "overlay_latest_jma_amedas_observation") + + result = observation_overlay.overlay_latest_jma_amedas_observation( + FakeWeather(), + "tokyo", + stale_payload, + ) + + assert result["local_date"] == "2026-06-16" + assert result["local_time"] == "06:00" + assert result["current"]["temp"] == 24.0 + assert result["current"]["source_code"] == "jma_amedas" + assert result["airport_current"]["obs_time"] == "2026-06-16T06:00:00+09:00" + assert result["airport_primary"]["station_code"] == "44166" + assert result["overview"]["local_date"] == "2026-06-16" + assert result["overview"]["current_temp"] == 24.0 + assert result["metar_today_obs"] == [ + { + "time": "06:00", + "temp": 24.0, + "obs_time": "2026-06-16T06:00:00+09:00", + "source_code": "jma_amedas", + "source_label": "JMA", + } + ] + assert result["timeseries"]["metar_today_obs"] == result["metar_today_obs"] + + +def test_overlay_latest_jma_does_not_downgrade_newer_payload_current(): + class FakeWeather: + def fetch_jma_amedas_official_nearby(self, city, use_fahrenheit=False): + return [ + { + "station_label": "羽田 10分实况 (JMA)", + "temp": 24.0, + "icao": "44166", + "source": "jma", + "source_label": "JMA", + "obs_time": "2026-06-16T06:00:00+09:00", + } + ] + + payload = { + "name": "tokyo", + "local_date": "2026-06-16", + "local_time": "07:00", + "current": { + "temp": 25.0, + "source_code": "jma_amedas", + "obs_time": "2026-06-16T07:00:00+09:00", + }, + } + + result = observation_overlay.overlay_latest_jma_amedas_observation( + FakeWeather(), + "tokyo", + payload, + ) + + assert result is payload + assert result["current"]["temp"] == 25.0 + assert result["local_time"] == "07:00" diff --git a/tests/test_web_observability.py b/tests/test_web_observability.py index c92ae451..3d210153 100644 --- a/tests/test_web_observability.py +++ b/tests/test_web_observability.py @@ -1310,6 +1310,79 @@ def test_chart_data_cache_hit_overlays_latest_amsc_raw(monkeypatch): assert payload["airport_current"]["source_code"] == "amsc_awos" +def test_chart_data_cache_hit_overlays_latest_jma_amedas(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 + + class FakeWeather: + def fetch_jma_amedas_official_nearby(self, city, use_fahrenheit=False): + assert (city, use_fahrenheit) == ("tokyo", False) + return [ + { + "station_label": "羽田 10分实况 (JMA)", + "temp": 24.0, + "icao": "44166", + "source": "jma", + "source_label": "JMA", + "obs_time": "2026-06-16T06:00:00+09:00", + } + ] + + 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["current"]["temp"] == 24.0 + assert payload["airport_current"]["source_code"] == "jma_amedas" + assert payload["metar_today_obs"] == [ + { + "time": "06:00", + "temp": 24.0, + "obs_time": "2026-06-16T06:00:00+09:00", + "source_code": "jma_amedas", + "source_label": "JMA", + } + ] + + 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 9afb4684..ea0650f9 100644 --- a/web/services/city_api.py +++ b/web/services/city_api.py @@ -19,6 +19,7 @@ from web.analysis_service import _runway_history_temp_for_city from web.services.canonical_temperature import build_city_weather_from_canonical from web.services.latest_observation_overlay import ( overlay_latest_amsc_observation, + overlay_latest_jma_amedas_observation, parse_observation_epoch, ) from web.services.request_timing import ServerTimingRecorder @@ -663,6 +664,13 @@ async def _get_city_chart_data(city: str, *, force_refresh: bool) -> Dict[str, A fn=overlay_latest_amsc_observation, args=(legacy_routes._CACHE_DB, city, payload), ) + payload = await _run_optional_city_chart_overlay( + city=city, + overlay_name="jma_amedas_latest", + payload=payload, + fn=overlay_latest_jma_amedas_observation, + args=(legacy_routes._weather, city, payload), + ) return await _run_optional_city_chart_overlay( city=city, overlay_name="wunderground_current", @@ -692,6 +700,13 @@ async def _get_city_chart_data(city: str, *, force_refresh: bool) -> Dict[str, A fn=overlay_latest_amsc_observation, args=(legacy_routes._CACHE_DB, city, payload), ) + payload = await _run_optional_city_chart_overlay( + city=city, + overlay_name="jma_amedas_latest", + payload=payload, + fn=overlay_latest_jma_amedas_observation, + args=(legacy_routes._weather, city, payload), + ) return await _run_optional_city_chart_overlay( city=city, overlay_name="wunderground_current", diff --git a/web/services/latest_observation_overlay.py b/web/services/latest_observation_overlay.py index f7137fdd..d9b295bf 100644 --- a/web/services/latest_observation_overlay.py +++ b/web/services/latest_observation_overlay.py @@ -43,6 +43,33 @@ def parse_observation_epoch(value: Any) -> Optional[int]: return int(dt.timestamp()) +def _parse_observation_datetime(value: Any) -> Optional[datetime]: + if value is None or value == "": + return None + if isinstance(value, datetime): + dt = value + else: + text = str(value).strip() + if not text: + return None + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + dt = datetime.fromisoformat(text) + except ValueError: + for fmt in ("%Y-%m-%d %H:%M:%S%z", "%Y-%m-%d %H:%M:%S"): + try: + dt = datetime.strptime(text, fmt) + break + except ValueError: + continue + else: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + def _payload_latest_epoch(payload: dict[str, Any], keys: tuple[str, ...]) -> Optional[int]: values = [payload.get(key) for key in keys] parsed = [epoch for epoch in (parse_observation_epoch(value) for value in values) if epoch is not None] @@ -104,6 +131,239 @@ def _to_int(value: Any) -> Optional[int]: return None +def _latest_jma_row(weather: Any, city: str, use_fahrenheit: bool) -> Optional[dict[str, Any]]: + fetcher = getattr(weather, "fetch_jma_amedas_official_nearby", None) + if callable(fetcher): + try: + rows = fetcher(city, use_fahrenheit=use_fahrenheit) + except Exception as exc: + logger.debug("latest JMA overlay read failed city={}: {}", city, exc) + rows = [] + for row in rows if isinstance(rows, list) else []: + if not isinstance(row, dict): + continue + if _to_float(row.get("temp")) is not None and row.get("obs_time"): + return row + + current_fetcher = getattr(weather, "fetch_jma_amedas_current", None) + if callable(current_fetcher): + try: + current = current_fetcher(city, use_fahrenheit=use_fahrenheit) + except Exception as exc: + logger.debug("latest JMA current overlay read failed city={}: {}", city, exc) + return None + if isinstance(current, dict): + temp = _to_float((current.get("current") or {}).get("temp")) + obs_time = current.get("obs_time") + if temp is not None and obs_time: + return { + "station_label": current.get("station_name"), + "temp": temp, + "icao": current.get("station_code"), + "source": "jma", + "source_label": "JMA", + "obs_time": obs_time, + } + return None + + +def _jma_observation_update( + city: str, + row: dict[str, Any], + obs_time: str, + temp: float, +) -> dict[str, Any]: + source_label = str(row.get("source_label") or "JMA").strip() or "JMA" + station_code = str(row.get("icao") or row.get("istNo") or "").strip() or None + station_name = str(row.get("station_label") or row.get("name") or source_label).strip() + freshness = { + "freshness_status": "fresh", + "observed_at": obs_time, + "source_code": "jma_amedas", + "source_label": source_label, + } + return { + "temp": round(float(temp), 1), + "source_code": "jma_amedas", + "source_label": source_label, + "station_code": station_code, + "station_name": station_name, + "station_label": station_name, + "observed_at": obs_time, + "observation_time": obs_time, + "obs_time": obs_time, + "freshness": freshness, + "observation_status": "live", + "city": city, + } + + +def _jma_today_point(local_time: str, obs_time: str, temp: float) -> dict[str, Any]: + return { + "time": local_time, + "temp": round(float(temp), 1), + "obs_time": obs_time, + "source_code": "jma_amedas", + "source_label": "JMA", + } + + +def _replace_or_append_today_point( + rows: Any, + point: dict[str, Any], + *, + replace_all: bool, +) -> list[dict[str, Any]]: + if replace_all: + return [point] + next_rows: list[dict[str, Any]] = [] + replaced = False + for row in rows if isinstance(rows, list) else []: + if not isinstance(row, dict): + continue + current_time = str(row.get("time") or "").strip() + current_obs_time = str(row.get("obs_time") or row.get("observed_at") or "").strip() + if current_time == point["time"] or current_obs_time == point["obs_time"]: + next_rows.append(point) + replaced = True + else: + next_rows.append(dict(row)) + if not replaced: + next_rows.append(point) + return next_rows + + +def _sync_jma_today_series( + payload: dict[str, Any], + point: dict[str, Any], + *, + replace_all: bool, +) -> None: + rows = _replace_or_append_today_point( + payload.get("metar_today_obs"), + point, + replace_all=replace_all, + ) + payload["metar_today_obs"] = rows + payload["airport_primary_today_obs"] = rows + official = payload.get("official") + if not isinstance(official, dict): + official = {} + official["airport_primary_today_obs"] = rows + payload["official"] = official + + timeseries = payload.get("timeseries") + if not isinstance(timeseries, dict): + timeseries = {} + timeseries["metar_today_obs"] = rows + for key in ("metar_recent_obs", "settlement_today_obs"): + if replace_all and key in timeseries: + timeseries[key] = [] + payload["timeseries"] = timeseries + if replace_all: + for key in ("metar_recent_obs", "settlement_today_obs"): + if key in payload: + payload[key] = [] + + +def overlay_latest_jma_amedas_observation( + weather: Any, + city: str, + payload: dict[str, Any], +) -> 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: + return payload + if normalized_city != "tokyo": + return payload + + use_fahrenheit = "F" in str(payload.get("temp_symbol") or "").upper() + row = _latest_jma_row(weather, normalized_city, use_fahrenheit) + if not isinstance(row, dict): + return payload + + temp = _to_float(row.get("temp")) + obs_time = str(row.get("obs_time") or "").strip() + if temp is None or not obs_time: + return payload + + raw_epoch = parse_observation_epoch(obs_time) + local_dt = _parse_observation_datetime(obs_time) + if raw_epoch is None or local_dt is None: + return payload + existing_epochs = [ + epoch + for epoch in ( + _block_epoch(payload.get("current")), + _block_epoch(payload.get("airport_primary")), + _block_epoch(payload.get("airport_current")), + _block_epoch(payload.get("canonical_temperature")), + ) + if epoch is not None + ] + if existing_epochs and max(existing_epochs) >= raw_epoch: + return payload + + update = _jma_observation_update(normalized_city, row, obs_time, temp) + next_payload = deepcopy(payload) + changed = False + for key in ("current", "airport_primary", "airport_current"): + changed = _merge_observation_block(next_payload, key, update, raw_epoch) or changed + + canonical = next_payload.get("canonical_temperature") + canonical_epoch = _block_epoch(canonical) + if canonical_epoch is None or raw_epoch > canonical_epoch: + canonical_payload = build_canonical_temperature( + normalized_city, + { + "name": normalized_city, + "temp_symbol": next_payload.get("temp_symbol") or "\u00b0C", + "updated_at": obs_time, + "current": update, + }, + fetched_at=obs_time, + ) + if canonical_payload: + next_payload["canonical_temperature"] = canonical_payload + changed = True + + local_date = local_dt.date().isoformat() + local_time = local_dt.strftime("%H:%M") + previous_local_date = str(next_payload.get("local_date") or "") + if next_payload.get("local_date") != local_date: + next_payload["local_date"] = local_date + changed = True + if next_payload.get("local_time") != local_time: + next_payload["local_time"] = local_time + changed = True + + overview = next_payload.get("overview") + if not isinstance(overview, dict): + overview = {} + next_overview = dict(overview) + overview_updates = { + "local_date": local_date, + "local_time": local_time, + "current_temp": round(float(temp), 1), + "airport_primary": update, + } + for key, value in overview_updates.items(): + if next_overview.get(key) != value: + next_overview[key] = value + changed = True + next_payload["overview"] = next_overview + + replace_today_series = bool(previous_local_date and previous_local_date != local_date) + _sync_jma_today_series( + next_payload, + _jma_today_point(local_time, obs_time, temp), + replace_all=replace_today_series, + ) + changed = True + + return next_payload if changed else payload + + def _amsc_payload_has_observation(raw_payload: dict[str, Any]) -> bool: temp = raw_payload.get("temp_c") if raw_payload.get("temp_c") is not None else raw_payload.get("temp") return _to_float(temp) is not None