Files
PolyWeather/tests/test_trend_engine.py
T

306 lines
12 KiB
Python
Raw Normal View History

"""
Unit tests for trend_engine core logic.
Tests: μ/σ calculation, dead market detection, forecast bust grading.
"""
import sys
import os
# Ensure project root is in path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from unittest.mock import patch
from src.analysis.trend_engine import analyze_weather_trend, _sf
# ─── Helpers ───
def _make_weather_data(
cur_temp=25.0,
max_so_far=28.0,
om_today_high=30.0,
ens_median=29.0,
ens_p10=27.0,
ens_p90=31.0,
local_time="2026-03-04 14:30",
recent_temps=None,
multi_model=None,
recent_obs=None,
):
"""Build a minimal weather_data dict for testing."""
data = {
"metar": {
"current": {
"temp": cur_temp,
"max_temp_so_far": max_so_far,
"max_temp_time": "14:00",
"wind_speed_kt": 5,
"wind_dir": 180,
"humidity": 50,
"clouds": [{"cover": "SCT", "base": 5000}],
},
"recent_temps": recent_temps or [("14:00", 27.0), ("13:00", 26.0), ("12:00", 25.0)],
"recent_obs": recent_obs or [],
},
"open-meteo": {
"current": {"local_time": local_time},
"daily": {
"temperature_2m_max": [om_today_high],
"sunrise": ["06:30"],
"sunset": ["18:30"],
},
"hourly": {
"time": [f"2026-03-04T{h:02d}:00" for h in range(24)],
"temperature_2m": [15 + (h - 6) * 1.5 if 6 <= h <= 14 else 20 - (h - 14) * 0.5 for h in range(24)],
"shortwave_radiation": [0 if h < 6 or h > 18 else 200 + h * 20 for h in range(24)],
},
},
"ensemble": {
"median": ens_median,
"p10": ens_p10,
"p90": ens_p90,
},
"multi_model": {"forecasts": multi_model or {}},
"nws": {},
}
return data
# ─── Tests: _sf ───
class TestSafeFloat:
def test_none(self):
assert _sf(None) is None
def test_int(self):
assert _sf(5) == 5.0
def test_str_number(self):
assert _sf("3.14") == 3.14
def test_invalid_str(self):
assert _sf("abc") is None
# ─── Tests: μ Calculation ───
class TestMuCalculation:
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_normal_mu_blends_forecast_and_ensemble(self, _udr, _deb_acc, _dw):
"""Normal case: μ = forecast_median * 0.7 + ens_median * 0.3"""
data = _make_weather_data(
cur_temp=25.0, max_so_far=26.0,
om_today_high=30.0, ens_median=29.0,
local_time="2026-03-04 10:00" # Before peak window to prevent early bust override
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
mu = sd["mu"]
# forecast_median = 30.0, ens_median = 29.0 → 30*0.7 + 29*0.3 = 29.7
assert mu is not None
assert 29.0 <= mu <= 31.0 # Reasonable range
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_reality_anchored_mu_on_forecast_bust(self, _udr, _deb_acc, _dw):
"""When past peak and actual << forecasts, μ anchors on actual max."""
data = _make_weather_data(
cur_temp=22.0, max_so_far=23.0,
om_today_high=30.0, ens_median=29.0,
local_time="2026-03-04 17:00", # Past peak
recent_temps=[("17:00", 22.0), ("16:00", 23.0), ("15:00", 23.0)],
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
mu = sd["mu"]
# max_so_far=23 vs forecast_median=30 → bust → μ ≈ 23
assert mu is not None
assert mu <= 24.0, f"μ should anchor on actual max (23°C), got {mu}"
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_mu_rises_when_actual_exceeds_forecast(self, _udr, _deb_acc, _dw):
"""When actual max exceeds μ, μ adjusts upward."""
data = _make_weather_data(
cur_temp=32.0, max_so_far=33.0,
om_today_high=30.0, ens_median=29.0,
local_time="2026-03-04 14:00",
recent_temps=[("14:00", 32.0), ("13:00", 31.0), ("12:00", 30.0)],
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
mu = sd["mu"]
assert mu is not None
assert mu >= 33.0, f"μ should be >= actual max (33°C), got {mu}"
# ─── Tests: Dead Market ───
class TestDeadMarket:
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_dead_market_after_peak_with_cooling(self, _udr, _deb_acc, _dw):
"""Past peak + 1.5°C drop → dead market."""
data = _make_weather_data(
cur_temp=26.0, max_so_far=28.0,
local_time="2026-03-04 17:00",
recent_temps=[("17:00", 26.0), ("16:00", 27.0), ("15:00", 28.0)],
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
ti = sd["trend_info"]
assert ti["is_dead_market"] is True
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_not_dead_market_during_peak_warming(self, _udr, _deb_acc, _dw):
"""During peak window while still warming → NOT dead market."""
data = _make_weather_data(
cur_temp=28.0, max_so_far=28.0,
local_time="2026-03-04 14:00",
recent_temps=[("14:00", 28.0), ("13:00", 27.0), ("12:00", 26.0)],
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
ti = sd["trend_info"]
assert ti["is_dead_market"] is False
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_dead_market_probability_is_100_percent(self, _udr, _deb_acc, _dw):
"""When dead market, probabilities collapse to 100% at settled value."""
data = _make_weather_data(
cur_temp=25.0, max_so_far=28.0,
local_time="2026-03-04 22:00", # Late night
recent_temps=[("22:00", 25.0), ("21:00", 26.0), ("20:00", 27.0)],
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
assert sd["trend_info"]["is_dead_market"] is True
probs = sd["probabilities"]
assert len(probs) == 1
assert probs[0]["value"] == 28 # round(28.0)
assert probs[0]["probability"] == 1.0
# ─── Tests: Forecast Bust Detection ───
class TestForecastBust:
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_heavy_forecast_bust_detected(self, _udr, _deb_acc, _dw):
"""Heavy bust: forecast_median - max_so_far > 5.0"""
data = _make_weather_data(
cur_temp=22.0, max_so_far=23.0,
om_today_high=30.0, ens_median=29.0,
local_time="2026-03-04 16:00",
recent_temps=[("16:00", 22.0), ("15:00", 23.0), ("14:00", 23.0)],
)
_, ai_context, sd = analyze_weather_trend(data, "°C", "test_city")
# forecast_median=30, max_so_far=23 → miss = 7°C → heavy
assert "预报崩盘" in ai_context
assert "重" in ai_context or "级失准" in ai_context
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_no_bust_when_on_track(self, _udr, _deb_acc, _dw):
"""No bust when actual is close to forecast."""
data = _make_weather_data(
cur_temp=29.0, max_so_far=29.5,
om_today_high=30.0, ens_median=29.5,
local_time="2026-03-04 14:00",
recent_temps=[("14:00", 29.0), ("13:00", 28.5), ("12:00", 28.0)],
)
_, ai_context, _ = analyze_weather_trend(data, "°C", "test_city")
assert "预报崩盘" not in ai_context
# ─── Tests: Trend Direction ───
class TestTrendDirection:
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_rising_trend(self, _udr, _deb_acc, _dw):
data = _make_weather_data(
recent_temps=[("14:00", 28.0), ("13:00", 27.0), ("12:00", 26.0)],
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
assert sd["trend_info"]["direction"] == "rising"
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_falling_trend(self, _udr, _deb_acc, _dw):
data = _make_weather_data(
recent_temps=[("16:00", 25.0), ("15:00", 26.0), ("14:00", 27.0)],
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
assert sd["trend_info"]["direction"] == "falling"
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_stagnant_trend(self, _udr, _deb_acc, _dw):
data = _make_weather_data(
recent_temps=[("14:00", 27.0), ("13:00", 27.0), ("12:00", 27.0)],
)
_, _, sd = analyze_weather_trend(data, "°C", "test_city")
assert sd["trend_info"]["direction"] == "stagnant"
class TestDynamicCommentary:
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_dynamic_commentary_detects_cloud_build_without_cooling(
self, _udr, _deb_acc, _dw
):
data = _make_weather_data(
cur_temp=28.0,
max_so_far=28.2,
local_time="2026-03-04 14:00",
recent_temps=[("14:00", 28.0), ("13:00", 27.6), ("12:00", 27.0)],
recent_obs=[
{"temp": 28.0, "wdir": 185, "wspd": 8, "cloud_rank": 3, "altim": 1009.2},
{"temp": 27.6, "wdir": 170, "wspd": 7, "cloud_rank": 2, "altim": 1010.0},
{"temp": 27.2, "wdir": 155, "wspd": 6, "cloud_rank": 1, "altim": 1010.8},
],
)
display_str, ai_context, sd = analyze_weather_trend(data, "°C", "test_city")
summary = sd["dynamic_commentary"]["summary"]
notes = sd["dynamic_commentary"]["notes"]
assert summary
assert "云层明显增厚" in summary
assert "结构解读" in display_str
assert any("云层明显增厚" in note for note in notes)
assert "结构解读" in ai_context
@patch("src.analysis.trend_engine.calculate_dynamic_weights", return_value=(None, ""))
@patch("src.analysis.trend_engine.get_deb_accuracy", return_value=None)
@patch("src.analysis.trend_engine.update_daily_record")
def test_dynamic_commentary_falls_back_when_recent_obs_missing(
self, _udr, _deb_acc, _dw
):
data = _make_weather_data(recent_obs=[])
display_str, ai_context, sd = analyze_weather_trend(data, "°C", "test_city")
assert sd["dynamic_commentary"]["summary"] == ""
assert sd["dynamic_commentary"]["notes"] == []
assert "结构解读" not in display_str
assert "结构解读" not in ai_context