720 lines
26 KiB
Python
720 lines
26 KiB
Python
import threading
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from src.data_collection.country_networks import build_country_network_snapshot
|
|
from src.data_collection.city_registry import ALIASES, CITY_REGISTRY
|
|
from src.data_collection.city_time import CITY_TIME_ZONES, get_city_utc_offset_seconds
|
|
from src.data_collection import metar_sources
|
|
from src.data_collection.metar_sources import MetarSourceMixin
|
|
from web.analysis_service import (
|
|
_build_city_detail_payload,
|
|
_build_intraday_meteorology,
|
|
_should_build_country_network_snapshot,
|
|
)
|
|
from web.core import CITIES
|
|
|
|
|
|
class _DummyMetarSource(MetarSourceMixin):
|
|
CITY_REGISTRY = CITY_REGISTRY
|
|
CITY_TO_ICAO = {key: value["icao"] for key, value in CITY_REGISTRY.items() if value.get("icao")}
|
|
metar_cache_ttl_sec = 600
|
|
metar_fast_cache_ttl_sec = 60
|
|
|
|
def __init__(self):
|
|
self._metar_cache = {}
|
|
self._metar_cache_lock = threading.Lock()
|
|
self.metar_timeout_sec = 0.0
|
|
self.metar_latest_timeout_sec = 0.0
|
|
self.timeout = 0.0
|
|
self._http_get: Any = None
|
|
|
|
|
|
def test_new_city_registry_entries_are_wired():
|
|
assert CITY_REGISTRY["manila"]["settlement_source"] == "wunderground"
|
|
assert CITY_REGISTRY["manila"]["settlement_station_code"] == "RPLL"
|
|
assert CITY_REGISTRY["karachi"]["settlement_source"] == "wunderground"
|
|
assert CITY_REGISTRY["karachi"]["settlement_station_code"] == "OPKC"
|
|
assert CITY_REGISTRY["qingdao"]["settlement_source"] == "wunderground"
|
|
assert CITY_REGISTRY["qingdao"]["settlement_station_code"] == "ZSQD"
|
|
assert ALIASES["rpll"] == "manila"
|
|
assert ALIASES["opkc"] == "karachi"
|
|
assert ALIASES["zsqd"] == "qingdao"
|
|
assert CITIES["manila"]["lat"] == CITY_REGISTRY["manila"]["lat"]
|
|
assert CITIES["karachi"]["lon"] == CITY_REGISTRY["karachi"]["lon"]
|
|
assert CITIES["qingdao"]["lat"] == CITY_REGISTRY["qingdao"]["lat"]
|
|
|
|
|
|
def test_city_time_zone_mapping_covers_every_city_and_dst_offsets():
|
|
missing = sorted(set(CITY_REGISTRY) - set(CITY_TIME_ZONES))
|
|
assert missing == []
|
|
|
|
april = datetime(2026, 4, 19, 6, 30, tzinfo=timezone.utc)
|
|
|
|
assert get_city_utc_offset_seconds("guangzhou", april) == 28800
|
|
assert get_city_utc_offset_seconds("qingdao", april) == 28800
|
|
assert get_city_utc_offset_seconds("ankara", april) == 10800
|
|
assert get_city_utc_offset_seconds("london", april) == 3600
|
|
assert get_city_utc_offset_seconds("paris", april) == 7200
|
|
assert get_city_utc_offset_seconds("new york", april) == -14400
|
|
assert get_city_utc_offset_seconds("chicago", april) == -18000
|
|
assert get_city_utc_offset_seconds("los angeles", april) == -25200
|
|
assert get_city_utc_offset_seconds("wellington", april) == 43200
|
|
|
|
|
|
def test_paris_registry_uses_le_bourget_anchor():
|
|
paris = CITY_REGISTRY["paris"]
|
|
|
|
assert paris["icao"] == "LFPB"
|
|
assert paris["settlement_source"] == "aeroweb"
|
|
assert paris["settlement_station_code"] == "LFPB"
|
|
settlement_url = paris.get("settlement_url")
|
|
assert isinstance(settlement_url, str)
|
|
assert "bonneuil-en-france/LFPB" in settlement_url
|
|
assert CITIES["paris"]["lat"] == paris["lat"]
|
|
assert CITIES["paris"]["settlement_source"] == "aeroweb"
|
|
assert _DummyMetarSource.CITY_TO_ICAO["paris"] == "LFPB"
|
|
|
|
|
|
def test_new_york_config_uses_registry_airport_anchor():
|
|
config_path = Path(__file__).resolve().parents[1] / "config" / "config.yaml"
|
|
config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
|
|
city_config = next(city for city in config["cities"] if city["id"] == "new_york")
|
|
registry = CITY_REGISTRY["new york"]
|
|
|
|
assert city_config["latitude"] == pytest.approx(registry["lat"])
|
|
assert city_config["longitude"] == pytest.approx(registry["lon"])
|
|
|
|
|
|
def test_turkey_metar_uses_fast_cache_ttl():
|
|
source = _DummyMetarSource()
|
|
|
|
assert source._metar_cache_ttl_for_city("ankara", "LTAC") == 60
|
|
assert source._metar_cache_ttl_for_city("istanbul", "LTFM") == 60
|
|
assert source._metar_cache_ttl_for_city("karachi", "OPKC") == 600
|
|
|
|
|
|
def test_metar_marks_previous_local_day_report_stale(monkeypatch):
|
|
class FixedDateTime(datetime):
|
|
@classmethod
|
|
def now(cls, tz=None): # type: ignore
|
|
value = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc)
|
|
return value if tz is None else value.astimezone(tz)
|
|
|
|
@classmethod
|
|
def utcnow(cls): # type: ignore
|
|
return datetime(2026, 4, 21, 12, 0)
|
|
|
|
class FakeResponse:
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def json(self):
|
|
return [
|
|
{
|
|
"rawOb": "METAR OPKC 200600Z 00000KT 9999 SCT015 26/25 Q1012",
|
|
"reportTime": "2026-04-20T06:00:00Z",
|
|
"receiptTime": "2026-04-20T06:12:07Z",
|
|
"temp": 26,
|
|
"dewp": 25,
|
|
"name": "Jinnah International Airport",
|
|
}
|
|
]
|
|
|
|
source = _DummyMetarSource()
|
|
source._metar_cache = {}
|
|
source._metar_cache_lock = threading.Lock()
|
|
source.metar_timeout_sec = 1
|
|
source.metar_latest_timeout_sec = 1
|
|
source.timeout = 1
|
|
source._http_get = lambda *args, **kwargs: FakeResponse()
|
|
|
|
monkeypatch.setattr(metar_sources, "datetime", FixedDateTime)
|
|
|
|
result = source.fetch_metar("karachi", utc_offset=18000)
|
|
|
|
assert result is not None
|
|
assert result["stale_for_today"] is True
|
|
assert result["observation_local_date"] == "2026-04-20"
|
|
assert result["current_local_date"] == "2026-04-21"
|
|
assert result["today_obs"] == []
|
|
|
|
|
|
def test_turkey_mgm_provider_returns_official_nearby_rows():
|
|
anchor_time = datetime.now(timezone.utc).replace(microsecond=0)
|
|
station_time = anchor_time + timedelta(minutes=8)
|
|
raw = {
|
|
"metar": {
|
|
"observation_time": anchor_time.isoformat().replace("+00:00", "Z"),
|
|
"current": {"temp": 16.0},
|
|
},
|
|
"mgm_nearby": [
|
|
{
|
|
"name": "Airport (MGM/17128)",
|
|
"istNo": "17128",
|
|
"lat": 40.1,
|
|
"lon": 32.9,
|
|
"temp": 17.1,
|
|
"obs_time": station_time.isoformat().replace("+00:00", "Z"),
|
|
}
|
|
],
|
|
}
|
|
|
|
snapshot = build_country_network_snapshot("ankara", raw)
|
|
|
|
assert snapshot["provider_code"] == "turkey_mgm"
|
|
assert snapshot["official_network_status"]["available"] is True
|
|
assert snapshot["official_nearby"][0]["source_code"] == "mgm"
|
|
assert snapshot["official_nearby"][0]["is_official"] is True
|
|
assert snapshot["official_nearby"][0]["time_delta_vs_anchor_minutes"] == 8
|
|
assert snapshot["official_nearby"][0]["sync_status"] == "synced"
|
|
assert snapshot["official_nearby"][0]["usable_for_intraday"] is True
|
|
assert snapshot["official_network_status"]["usable_row_count"] == 1
|
|
|
|
|
|
def test_turkey_mgm_primary_today_obs_uses_mgm_airport_history_not_metar():
|
|
raw = {
|
|
"metar": {
|
|
"observation_time": "2026-05-29T11:50:00Z",
|
|
"today_obs": [{"time": "14:50", "temp": 17.0}],
|
|
"current": {"temp": 17.0},
|
|
},
|
|
"mgm": {
|
|
"obs_time": "2026-05-29T11:56:00Z",
|
|
"current": {"temp": 17.3},
|
|
},
|
|
"mgm_today_obs": [{"time": "14:56", "temp": 17.3}],
|
|
}
|
|
|
|
snapshot = build_country_network_snapshot("ankara", raw)
|
|
|
|
assert snapshot["airport_primary_current"]["source_code"] == "mgm"
|
|
assert snapshot["airport_primary_today_obs"] == [{"time": "14:56", "temp": 17.3}]
|
|
|
|
|
|
def test_panel_mode_still_builds_turkey_mgm_airport_snapshot():
|
|
raw = {
|
|
"mgm": {
|
|
"obs_time": "2026-05-29T11:56:00Z",
|
|
"current": {"temp": 17.3},
|
|
}
|
|
}
|
|
|
|
assert (
|
|
_should_build_country_network_snapshot(
|
|
"ankara", raw, is_panel_mode=True, is_market_mode=False
|
|
)
|
|
is True
|
|
)
|
|
assert (
|
|
_should_build_country_network_snapshot(
|
|
"istanbul", raw, is_panel_mode=True, is_market_mode=False
|
|
)
|
|
is True
|
|
)
|
|
assert (
|
|
_should_build_country_network_snapshot(
|
|
"guangzhou", raw, is_panel_mode=True, is_market_mode=False
|
|
)
|
|
is False
|
|
)
|
|
assert (
|
|
_should_build_country_network_snapshot(
|
|
"ankara", raw, is_panel_mode=False, is_market_mode=False
|
|
)
|
|
is True
|
|
)
|
|
assert (
|
|
_should_build_country_network_snapshot(
|
|
"ankara", raw, is_panel_mode=True, is_market_mode=True
|
|
)
|
|
is False
|
|
)
|
|
|
|
|
|
def test_nearby_station_timing_marks_stale_rows_unusable_for_network_signal():
|
|
anchor_time = datetime.now(timezone.utc).replace(microsecond=0)
|
|
fresh_time = anchor_time + timedelta(minutes=20)
|
|
stale_time = anchor_time - timedelta(minutes=90)
|
|
raw = {
|
|
"metar": {
|
|
"observation_time": anchor_time.isoformat().replace("+00:00", "Z"),
|
|
"current": {"temp": 20.0},
|
|
},
|
|
"mgm_nearby": [
|
|
{
|
|
"name": "Fresh",
|
|
"istNo": "100",
|
|
"lat": 40.1,
|
|
"lon": 32.9,
|
|
"temp": 21.0,
|
|
"obs_time": fresh_time.isoformat().replace("+00:00", "Z"),
|
|
},
|
|
{
|
|
"name": "Stale Hot",
|
|
"istNo": "101",
|
|
"lat": 40.2,
|
|
"lon": 33.0,
|
|
"temp": 26.0,
|
|
"obs_time": stale_time.isoformat().replace("+00:00", "Z"),
|
|
},
|
|
],
|
|
}
|
|
|
|
snapshot = build_country_network_snapshot("ankara", raw)
|
|
|
|
stale = next(row for row in snapshot["official_nearby"] if row["station_label"] == "Stale Hot")
|
|
assert stale["sync_status"] == "stale"
|
|
assert stale["usable_for_intraday"] is False
|
|
assert snapshot["official_network_status"]["stale_row_count"] == 1
|
|
assert snapshot["network_lead_signal"]["leader_station_label"] == "Fresh"
|
|
assert snapshot["airport_vs_network_delta"] == 1.0
|
|
|
|
|
|
def test_china_provider_falls_back_to_metar_cluster_without_replacing_airport_anchor():
|
|
raw = {
|
|
"metar": {
|
|
"observation_time": "2026-04-06T10:00:00.000Z",
|
|
"current": {"temp": 22.5},
|
|
},
|
|
"mgm_nearby": [
|
|
{
|
|
"name": "Hongqiao",
|
|
"icao": "ZSSS",
|
|
"lat": 31.2,
|
|
"lon": 121.3,
|
|
"temp": 23.1,
|
|
}
|
|
],
|
|
}
|
|
|
|
snapshot = build_country_network_snapshot("shanghai", raw)
|
|
|
|
assert snapshot["provider_code"] == "china_cma"
|
|
assert snapshot["airport_primary_current"]["source_code"] == "metar"
|
|
assert snapshot["airport_primary_current"]["is_airport_station"] is True
|
|
assert snapshot["official_nearby"][0]["source_code"] == "metar_cluster"
|
|
assert snapshot["official_nearby"][0]["is_official"] is False
|
|
|
|
|
|
def test_metar_cluster_obs_time_label_uses_city_local_time():
|
|
raw = {
|
|
"metar": {
|
|
"observation_time": "2026-04-19T06:30:00.000Z",
|
|
"current": {"temp": 32.0},
|
|
},
|
|
"mgm_nearby": [
|
|
{
|
|
"name": "Guangzhou/Baiyun",
|
|
"icao": "ZGGG",
|
|
"lat": 23.39,
|
|
"lon": 113.30,
|
|
"temp": 32.0,
|
|
"obs_time": "2026-04-19T06:30:00.000Z",
|
|
}
|
|
],
|
|
}
|
|
|
|
snapshot = build_country_network_snapshot("guangzhou", raw)
|
|
row = snapshot["official_nearby"][0]
|
|
|
|
assert row["source_code"] == "metar_cluster"
|
|
assert row["obs_time_label"] == "14:30"
|
|
assert row["obs_time_display_tz"] == "city_local"
|
|
|
|
|
|
def test_metar_cluster_obs_time_label_uses_dst_aware_city_time():
|
|
raw = {
|
|
"metar": {
|
|
"observation_time": "2026-04-19T06:30:00.000Z",
|
|
"current": {"temp": 12.0},
|
|
},
|
|
"mgm_nearby": [
|
|
{
|
|
"name": "London test",
|
|
"icao": "EGLC",
|
|
"lat": 51.50,
|
|
"lon": -0.05,
|
|
"temp": 12.0,
|
|
"obs_time": "2026-04-19T06:30:00.000Z",
|
|
}
|
|
],
|
|
}
|
|
|
|
snapshot = build_country_network_snapshot("london", raw)
|
|
row = snapshot["official_nearby"][0]
|
|
|
|
assert row["source_code"] == "metar_cluster"
|
|
assert row["obs_time_label"] == "07:30"
|
|
|
|
|
|
def test_metar_cluster_naive_obs_time_is_interpreted_as_utc_before_city_display():
|
|
raw = {
|
|
"metar": {
|
|
"observation_time": "2026-04-19 08:00",
|
|
"current": {"temp": 4.0},
|
|
},
|
|
"mgm_nearby": [
|
|
{
|
|
"name": "Moscow/Vnukovo",
|
|
"icao": "UUWW",
|
|
"lat": 55.59,
|
|
"lon": 37.26,
|
|
"temp": 4.0,
|
|
"obs_time": "2026-04-19 08:00",
|
|
}
|
|
],
|
|
}
|
|
|
|
snapshot = build_country_network_snapshot("moscow", raw)
|
|
row = snapshot["official_nearby"][0]
|
|
|
|
assert row["source_code"] == "metar_cluster"
|
|
assert row["obs_time_label"] == "11:00"
|
|
assert row["obs_time_display_tz"] == "city_local"
|
|
|
|
|
|
def test_hko_provider_marks_explicit_official_station_as_anchor():
|
|
raw = {
|
|
"settlement_current": {
|
|
"station_code": "LFS",
|
|
"station_name": "shenzhen",
|
|
"observation_time": "2026-04-06T10:00:00+08:00",
|
|
"current": {"temp": 25.0},
|
|
}
|
|
}
|
|
|
|
snapshot = build_country_network_snapshot("shenzhen", raw)
|
|
|
|
assert snapshot["provider_code"] == "hongkong_hko"
|
|
assert snapshot["settlement_station"]["is_official_station_anchor"] is True
|
|
assert snapshot["official_nearby"][0]["is_settlement_anchor"] is True
|
|
assert snapshot["official_nearby"][0]["station_code"] == "LFS"
|
|
|
|
|
|
def test_hong_kong_cowin_primary_uses_station_6087_history(monkeypatch):
|
|
from src.database import runtime_state
|
|
|
|
requested = {}
|
|
|
|
class FakeOfficialIntradayObservationRepository:
|
|
def load_points(self, *, source_code, station_code, target_date):
|
|
requested["source_code"] = source_code
|
|
requested["station_code"] = station_code
|
|
requested["target_date"] = target_date
|
|
if source_code == "cowin_obs" and station_code == "6087":
|
|
return [
|
|
{"time": "10:40", "temp": 31.1},
|
|
{"time": "10:41", "temp": 31.3},
|
|
]
|
|
return []
|
|
|
|
monkeypatch.setattr(
|
|
runtime_state,
|
|
"OfficialIntradayObservationRepository",
|
|
FakeOfficialIntradayObservationRepository,
|
|
)
|
|
|
|
snapshot = build_country_network_snapshot(
|
|
"hong kong",
|
|
{
|
|
"cowin_current": {
|
|
"temp": 31.3,
|
|
"obs_time": "2026-05-27T02:41:00Z",
|
|
"istNo": "6087",
|
|
"icao": "COWIN6087",
|
|
"station_label": "保良局陳守仁小學 1min (CoWIN)",
|
|
},
|
|
"settlement_current": {
|
|
"station_code": "HKO",
|
|
"station_name": "HK Observatory",
|
|
"observation_time": "2026-05-27T10:40:00+08:00",
|
|
"current": {"temp": 31.2},
|
|
},
|
|
},
|
|
)
|
|
|
|
assert snapshot["airport_primary_current"]["source_code"] == "cowin_obs"
|
|
assert snapshot["airport_primary_current"]["station_code"] == "6087"
|
|
assert "陳守仁" in snapshot["airport_primary_current"]["station_label"]
|
|
assert requested["station_code"] == "6087"
|
|
assert snapshot["airport_primary_today_obs"] == [
|
|
{"time": "10:40", "temp": 31.1},
|
|
{"time": "10:41", "temp": 31.3},
|
|
]
|
|
|
|
|
|
def test_hong_kong_cowin_primary_prefers_live_intraday_series(monkeypatch):
|
|
from src.database import runtime_state
|
|
|
|
class FakeOfficialIntradayObservationRepository:
|
|
def load_points(self, *, source_code, station_code, target_date):
|
|
return [{"time": "09:00", "temp": 30.0}]
|
|
|
|
monkeypatch.setattr(
|
|
runtime_state,
|
|
"OfficialIntradayObservationRepository",
|
|
FakeOfficialIntradayObservationRepository,
|
|
)
|
|
|
|
snapshot = build_country_network_snapshot(
|
|
"hong kong",
|
|
{
|
|
"cowin_current": {
|
|
"temp": 31.3,
|
|
"obs_time": "2026-05-27T02:41:00Z",
|
|
"istNo": "6087",
|
|
"icao": "COWIN6087",
|
|
"station_label": "保良局陳守仁小學 1min (CoWIN)",
|
|
},
|
|
"cowin_today_obs": [
|
|
{"time": "10:40", "temp": 31.1},
|
|
{"time": "10:41", "temp": 31.3},
|
|
],
|
|
"settlement_current": {
|
|
"station_code": "HKO",
|
|
"station_name": "HK Observatory",
|
|
"observation_time": "2026-05-27T10:40:00+08:00",
|
|
"current": {"temp": 31.2},
|
|
},
|
|
},
|
|
)
|
|
|
|
assert snapshot["airport_primary_current"]["source_code"] == "cowin_obs"
|
|
assert snapshot["airport_primary_today_obs"] == [
|
|
{"time": "10:40", "temp": 31.1},
|
|
{"time": "10:41", "temp": 31.3},
|
|
]
|
|
|
|
|
|
def test_moscow_provider_uses_realtime_metar_cluster_not_station_archive_rows():
|
|
raw = {
|
|
"metar": {
|
|
"observation_time": "2026-04-06T10:00:00.000Z",
|
|
"current": {"temp": 11.0},
|
|
},
|
|
"ru_official_nearby": [
|
|
{
|
|
"station_code": "27524",
|
|
"station_label": "Vnukovo",
|
|
"lat": 55.5870,
|
|
"lon": 37.2500,
|
|
"temp": 12.3,
|
|
"obs_time": "2026-04-06T09:00:00+00:00",
|
|
"is_airport_station": True,
|
|
"page_url": "https://www.pogodaiklimat.ru/weather.php?id=27524",
|
|
}
|
|
],
|
|
"mgm_nearby": [
|
|
{
|
|
"name": "Sheremetyevo",
|
|
"icao": "UUEE",
|
|
"lat": 55.97,
|
|
"lon": 37.41,
|
|
"temp": 12.0,
|
|
}
|
|
],
|
|
}
|
|
|
|
snapshot = build_country_network_snapshot("moscow", raw)
|
|
|
|
assert snapshot["provider_code"] == "global_metar"
|
|
assert snapshot["official_network_status"]["available"] is True
|
|
assert snapshot["official_network_status"]["mode"] == "fallback_metar_cluster"
|
|
assert snapshot["official_nearby"][0]["source_code"] == "metar_cluster"
|
|
assert snapshot["official_nearby"][0]["is_airport_station"] is True
|
|
|
|
|
|
def test_us_madis_airport_primary_uses_city_display_unit():
|
|
raw = {
|
|
"madis_hfmetar_current": {
|
|
"icao": "KHOU",
|
|
"temp_c": 19.4,
|
|
"temp": 66.9,
|
|
"obs_time": "2026-05-27T17:00:00+00:00",
|
|
"wind_kt": 8.0,
|
|
},
|
|
"metar": {
|
|
"observation_time": "2026-05-27T17:00:00+00:00",
|
|
"current": {
|
|
"temp": 66.9,
|
|
"max_temp_so_far": 84.9,
|
|
},
|
|
},
|
|
}
|
|
|
|
snapshot = build_country_network_snapshot("houston", raw)
|
|
airport_primary = snapshot["airport_primary_current"]
|
|
|
|
assert airport_primary["source_code"] == "madis_hfmetar"
|
|
assert airport_primary["source_label"] == "NOAA MADIS"
|
|
assert airport_primary["temp"] == 66.9
|
|
assert airport_primary["temp_c"] == 19.4
|
|
|
|
|
|
def test_city_detail_payload_exposes_airport_and_official_network_layers():
|
|
payload = _build_city_detail_payload(
|
|
{
|
|
"name": "ankara",
|
|
"display_name": "Ankara",
|
|
"local_time": "12:00",
|
|
"local_date": "2026-04-06",
|
|
"temp_symbol": "°C",
|
|
"updated_at": "2026-04-06T04:00:00Z",
|
|
"current": {
|
|
"temp": 16.0,
|
|
"settlement_source": "metar",
|
|
"settlement_source_label": "METAR",
|
|
},
|
|
"risk": {"icao": "LTAC", "airport": "Esenboga", "level": "medium", "warning": ""},
|
|
"airport_primary": {"temp": 16.0, "source_code": "metar"},
|
|
"airport_primary_today_obs": [{"time": "10:00", "temp": 16.0}],
|
|
"official_nearby": [{"station_code": "17128", "temp": 17.2, "source_code": "mgm"}],
|
|
"official_network_source": "turkey_mgm",
|
|
"official_network_status": {"provider_code": "turkey_mgm", "available": True},
|
|
"network_lead_signal": {"available": True, "delta": 1.2},
|
|
"network_spread_signal": {"available": True, "spread": 2.1},
|
|
"center_station_candidate": {"station_code": "17128", "temp": 17.2},
|
|
"airport_vs_network_delta": 1.2,
|
|
"settlement_station": {
|
|
"settlement_station_code": "LTAC",
|
|
"settlement_station_label": "Ankara Esenboga Airport",
|
|
"is_airport_anchor": True,
|
|
},
|
|
"probabilities": {"distribution": []},
|
|
"multi_model": {},
|
|
"multi_model_daily": {},
|
|
"dynamic_commentary": {"summary": "", "notes": []},
|
|
"taf": {},
|
|
}
|
|
)
|
|
|
|
assert payload["official"]["airport_primary"]["source_code"] == "metar"
|
|
assert payload["official"]["official_nearby"][0]["source_code"] == "mgm"
|
|
assert payload["settlement_station"]["settlement_station_code"] == "LTAC"
|
|
|
|
|
|
def test_intraday_meteorology_supportive_heating_case():
|
|
payload = _build_intraday_meteorology(
|
|
{
|
|
"local_time": "12:04",
|
|
"temp_symbol": "°C",
|
|
"current": {"temp": 38.2, "max_so_far": 38.2},
|
|
"deb": {"prediction": 40.4},
|
|
"probabilities": {"distribution": [{"value": 40, "probability": 0.42}]},
|
|
"peak": {"first_h": 14, "last_h": 15, "status": "before"},
|
|
"deviation_monitor": {"direction": "hot", "severity": "strong", "current_delta": 1.9},
|
|
"vertical_profile_signal": {
|
|
"heating_setup": "supportive",
|
|
"summary_zh": "混合层偏深,仍支持午后继续升温。",
|
|
},
|
|
"taf": {"signal": {"available": True, "suppression_level": "low", "summary_zh": "TAF 暂未提示强云雨压温。"}},
|
|
}
|
|
)
|
|
|
|
assert "上修空间" in payload["headline"]
|
|
assert "upside" in payload["headline_en"].lower()
|
|
assert payload["confidence"] == "high"
|
|
assert payload["base_case_bucket"] == "40°C"
|
|
assert payload["next_observation_time"] == "12:30"
|
|
assert any(item["direction"] == "support" for item in payload["signal_contributions"])
|
|
assert all(item.get("summary_en") for item in payload["signal_contributions"])
|
|
|
|
|
|
def test_intraday_meteorology_suppressed_cloud_rain_case():
|
|
payload = _build_intraday_meteorology(
|
|
{
|
|
"local_time": "13:10",
|
|
"temp_symbol": "°C",
|
|
"current": {"temp": 36.0, "max_so_far": 36.5},
|
|
"deb": {"prediction": 40.2},
|
|
"probabilities": {"distribution": [{"value": 40, "probability": 0.35}]},
|
|
"peak": {"first_h": 14, "last_h": 15, "status": "before"},
|
|
"deviation_monitor": {"direction": "cold", "severity": "strong", "current_delta": -2.0},
|
|
"vertical_profile_signal": {"heating_setup": "suppressed", "suppression_risk": "high"},
|
|
"taf": {"signal": {"available": True, "suppression_level": "high", "disruption_level": "high"}},
|
|
}
|
|
)
|
|
|
|
assert "压制" in payload["headline"]
|
|
assert "capping" in payload["headline_en"].lower()
|
|
assert payload["confidence"] == "high"
|
|
assert any("云雨" in rule for rule in payload["invalidation_rules"])
|
|
assert any("cloud" in rule.lower() for rule in payload["invalidation_rules_en"])
|
|
assert any(item["direction"] == "suppress" for item in payload["signal_contributions"])
|
|
|
|
|
|
def test_intraday_meteorology_structural_cap_does_not_claim_taf_cloud_rain():
|
|
payload = _build_intraday_meteorology(
|
|
{
|
|
"local_time": "11:30",
|
|
"temp_symbol": "°C",
|
|
"current": {"temp": 39.0, "max_so_far": 39.0},
|
|
"deb": {"prediction": 40.4},
|
|
"probabilities": {"distribution": [{"value": 42, "probability": 0.35}]},
|
|
"peak": {"first_h": 12, "last_h": 16, "status": "before"},
|
|
"deviation_monitor": {"direction": "cold", "severity": "strong", "current_delta": -1.4},
|
|
"vertical_profile_signal": {
|
|
"heating_setup": "suppressed",
|
|
"suppression_risk": "medium",
|
|
"summary_zh": "边界层混合偏弱,午后上修需要后续观测确认。",
|
|
},
|
|
"taf": {
|
|
"signal": {
|
|
"available": True,
|
|
"suppression_level": "low",
|
|
"disruption_level": "low",
|
|
"summary_zh": "TAF 在峰值窗口暂未提示明显云雨压温。",
|
|
}
|
|
},
|
|
}
|
|
)
|
|
|
|
assert "结构信号压制" in payload["headline"]
|
|
assert "TAF 云雨层暂未构成主压温理由" in payload["headline"]
|
|
assert "存在云雨或结构压制" not in payload["headline"]
|
|
assert any(item["label"] == "TAF 云雨扰动" and item["direction"] == "support" for item in payload["signal_contributions"])
|
|
|
|
|
|
def test_intraday_meteorology_handles_sparse_observations():
|
|
payload = _build_intraday_meteorology(
|
|
{
|
|
"local_time": "08:00",
|
|
"temp_symbol": "°C",
|
|
"current": {},
|
|
"probabilities": {"distribution": []},
|
|
"peak": {},
|
|
"taf": {},
|
|
"vertical_profile_signal": {},
|
|
}
|
|
)
|
|
|
|
assert payload["confidence"] == "low"
|
|
assert payload["next_observation_time"] == "08:30"
|
|
assert payload["signal_contributions"][0]["label"] == "数据完整性"
|
|
assert payload["signal_contributions"][0]["label_en"] == "Data completeness"
|
|
|
|
|
|
def test_intraday_meteorology_past_peak_case():
|
|
payload = _build_intraday_meteorology(
|
|
{
|
|
"local_time": "17:20",
|
|
"temp_symbol": "°C",
|
|
"current": {"temp": 33.0, "max_so_far": 39.0},
|
|
"probabilities": {"distribution": [{"value": 39, "probability": 0.5}]},
|
|
"peak": {"first_h": 13, "last_h": 15, "status": "past"},
|
|
"deviation_monitor": {"direction": "normal", "severity": "normal", "current_delta": 0.1},
|
|
}
|
|
)
|
|
|
|
assert "峰值窗口已过" in payload["headline"]
|
|
assert "passed" in payload["headline_en"].lower()
|
|
assert payload["base_case_bucket"] == "39°C"
|
|
assert any("最终高点" in rule for rule in payload["invalidation_rules"])
|
|
assert any("final" in rule.lower() for rule in payload["invalidation_rules_en"])
|