306 lines
12 KiB
Python
306 lines
12 KiB
Python
"""
|
||
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
|