File size: 8,381 Bytes
976eb45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c059d87
976eb45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c059d87
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
"""
tests/test_crop_risk_scorer.py
================================
Validation tests for crop_risk_scorer.compute_risk_score.

These tests ensure:
- numerical stability
- monotonic risk behavior
- correct alert threshold transitions
- proper use of forecast signals
"""

import numpy as np
import pytest

from crop_risk_scorer import compute_risk_score, RiskWeights
from zone_observation import (
    AlertLevel,
    ForecastConfig,
    make_synthetic_zone_obs,
    make_synthetic_forecast_result,
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture
def base_inputs():
    obs = make_synthetic_zone_obs("z", seed=0)
    fc  = make_synthetic_forecast_result("z", horizon_days=14, valid_time=obs.valid_time)
    return obs, fc


# ---------------------------------------------------------------------------
# Numerical sanity
# ---------------------------------------------------------------------------

class TestNumericalSanity:

    def test_outputs_are_finite(self, base_inputs):
        obs, fc = base_inputs
        rs = compute_risk_score(obs, fc)

        values = [
            rs.supply_shortfall_prob,
            rs.drought_risk,
            rs.flood_risk,
            rs.fungi_contamination_prob,
            rs.quality_risk_composite,
            rs.confidence,
        ]

        for v in values:
            assert np.isfinite(v), f"Non-finite value detected: {v}"

    def test_outputs_in_unit_interval(self, base_inputs):
        obs, fc = base_inputs
        rs = compute_risk_score(obs, fc)

        for field in [
            rs.supply_shortfall_prob,
            rs.drought_risk,
            rs.flood_risk,
            rs.fungi_contamination_prob,
            rs.quality_risk_composite,
            rs.confidence,
        ]:
            assert 0.0 <= field <= 1.0


# ---------------------------------------------------------------------------
# Monotonicity (critical for RL learning)
# ---------------------------------------------------------------------------

class TestMonotonicity:

    def test_drought_increase_raises_risk(self, base_inputs):
        obs, fc = base_inputs

        obs_low = obs
        obs_high = make_synthetic_zone_obs("z", drought=True, seed=1)

        rs_low  = compute_risk_score(obs_low, fc)
        rs_high = compute_risk_score(obs_high, fc)

        assert rs_high.drought_risk >= rs_low.drought_risk

    def test_flood_increase_raises_risk(self, base_inputs):
        obs, fc = base_inputs

        obs_low = obs
        obs_high = make_synthetic_zone_obs("z", flood=True, seed=2)

        rs_low  = compute_risk_score(obs_low, fc)
        rs_high = compute_risk_score(obs_high, fc)

        assert rs_high.flood_risk >= rs_low.flood_risk

    def test_combined_risk_raises_supply(self, base_inputs):
        obs, fc = base_inputs

        obs_low = obs
        obs_high = make_synthetic_zone_obs("z", flood=True, drought=True, seed=3)

        rs_low  = compute_risk_score(obs_low, fc)
        rs_high = compute_risk_score(obs_high, fc)

        assert rs_high.supply_shortfall_prob >= rs_low.supply_shortfall_prob


# ---------------------------------------------------------------------------
# Forecast influence
# ---------------------------------------------------------------------------

class TestForecastInfluence:

    def test_heavy_rain_forecast_increases_flood_risk(self, base_inputs):
        obs, fc = base_inputs

        fc_heavy = make_synthetic_forecast_result(
            "z", horizon_days=14, valid_time=obs.valid_time, flood=True
        )

        rs_base  = compute_risk_score(obs, fc)
        rs_heavy = compute_risk_score(obs, fc_heavy)

        assert rs_heavy.flood_risk >= rs_base.flood_risk

    def test_drought_forecast_increases_drought_risk(self, base_inputs):
        obs, fc = base_inputs

        fc_dry = make_synthetic_forecast_result(
            "z", horizon_days=14, valid_time=obs.valid_time, drought=True
        )

        rs_base = compute_risk_score(obs, fc)
        rs_dry  = compute_risk_score(obs, fc_dry)

        assert rs_dry.drought_risk >= rs_base.drought_risk


# ---------------------------------------------------------------------------
# Alert thresholds (VERY important)
# ---------------------------------------------------------------------------

class TestAlertThresholds:

    def test_critical_threshold(self, base_inputs):
        obs, fc = base_inputs

        from zone_observation import ForecastResult

        obs_extreme = make_synthetic_zone_obs("z", drought=True, flood=True, seed=10)
        obs_extreme.precip_anomaly_idx = -4.0   # maxes drought_signal()'s precip term
        obs_extreme.soil_moisture_anom = -4.0   # maxes drought_signal()'s soil term
        obs_extreme.flood_extent_pct = 100.0    # maxes flood_signal()'s extent term
        obs_extreme.drainage_risk_idx = 1.0     # maxes flood_signal()'s drainage term

        fc_extreme = ForecastResult(
            zone_id=fc.zone_id,
            forecast_time=fc.forecast_time,
            horizon_days=fc.horizon_days,
            precip_mm=fc.precip_mm,
            precip_p10=fc.precip_p10,
            precip_p90=fc.precip_p90,
            temp_mean_c=fc.temp_mean_c,
            temp_p10=fc.temp_p10,
            temp_p90=fc.temp_p90,
            rh_mean_pct=fc.rh_mean_pct,
            prob_heavy_rain=tuple([1.0] * fc.horizon_days),
            prob_drought_day=tuple([1.0] * fc.horizon_days),
            prob_high_humidity=fc.prob_high_humidity,
            source=fc.source,
        )

        rs = compute_risk_score(obs_extreme, fc_extreme)

        assert rs.alert_level in [
            AlertLevel.WARNING,
            AlertLevel.CRITICAL,
        ]

    def test_low_risk_produces_none_or_watch(self, base_inputs):
        obs, fc = base_inputs

        rs = compute_risk_score(obs, fc)

        assert rs.alert_level in [
            AlertLevel.NONE,
            AlertLevel.WATCH,
            AlertLevel.ADVISORY,
        ]


# ---------------------------------------------------------------------------
# Confidence model
# ---------------------------------------------------------------------------

class TestConfidence:

    def test_confidence_in_unit_interval(self, base_inputs):
        obs, fc = base_inputs
        rs = compute_risk_score(obs, fc)
        assert 0 <= rs.confidence <= 1

    def test_observational_data_has_higher_confidence(self):
        obs_obs = make_synthetic_zone_obs("z", seed=0)
        fc_obs  = make_synthetic_forecast_result("z", horizon_days=14, valid_time=obs_obs.valid_time)

        obs_lowq = make_synthetic_zone_obs("z", seed=1)
        obs_lowq.quality_flag = 3

        rs_high = compute_risk_score(obs_obs, fc_obs)
        rs_low  = compute_risk_score(obs_lowq, fc_obs)

        assert rs_high.confidence >= rs_low.confidence


# ---------------------------------------------------------------------------
# Stability / repeatability
# ---------------------------------------------------------------------------

class TestStability:

    def test_same_inputs_same_output(self, base_inputs):
        obs, fc = base_inputs

        rs1 = compute_risk_score(obs, fc)
        rs2 = compute_risk_score(obs, fc)

        assert rs1.supply_shortfall_prob == pytest.approx(rs2.supply_shortfall_prob)

    def test_no_nan_under_extreme_inputs(self):
        obs = make_synthetic_zone_obs("z", seed=99)
        fc_base = make_synthetic_forecast_result("z", horizon_days=30, valid_time=obs.valid_time)
        from zone_observation import ForecastResult
        fc = ForecastResult(
            zone_id=fc_base.zone_id,
            forecast_time=fc_base.forecast_time,
            horizon_days=30,
            precip_mm=tuple([500.0] * 30),
            precip_p10=tuple([400.0] * 30),
            precip_p90=tuple([500.0] * 30),
            temp_mean_c=fc_base.temp_mean_c,
            temp_p10=fc_base.temp_p10,
            temp_p90=fc_base.temp_p90,
            rh_mean_pct=fc_base.rh_mean_pct,
            prob_heavy_rain=tuple([1.0] * 30),
            prob_drought_day=tuple([0.0] * 30),
            prob_high_humidity=fc_base.prob_high_humidity,
            source=fc_base.source,
        )

        rs = compute_risk_score(obs, fc)

        assert np.isfinite(rs.supply_shortfall_prob)