修复东京图表旧 JMA 观测叠加
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user