Files
PolyWeather/tests/test_trend_engine.py
T
2026-05-19 13:04:28 +08:00

306 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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