DHDRL commited on
Commit
33e85dd
·
verified ·
1 Parent(s): bc3ebcc

Update test_weather_forecast_env.py

Browse files
Files changed (1) hide show
  1. test_weather_forecast_env.py +25 -59
test_weather_forecast_env.py CHANGED
@@ -4,10 +4,7 @@ tests/test_weather_forecast_env.py
4
  Integration tests for WeatherForecastEnv and NaNSafetyWrapper.
5
 
6
  These tests lock the contracts between the env and the zone_observation
7
- schema. They are isolated from crop_risk_scorer via a pytest fixture that
8
- patches it with a deterministic mock — so this suite runs without that
9
- file being present, and will catch any change to the interface the env
10
- expects from it.
11
 
12
  Run with: pytest tests/ -v
13
  """
@@ -32,7 +29,6 @@ from zone_observation import (
32
  EpisodeContext,
33
  ForecastConfig,
34
  RiskScore,
35
- # FIX: ValidationMode does not exist in zone_observation — import removed.
36
  ZoneObs,
37
  make_synthetic_episode_context,
38
  make_synthetic_forecast_result,
@@ -72,13 +68,6 @@ def _make_mock_risk_score(
72
 
73
  @pytest.fixture(autouse=True)
74
  def mock_crop_risk_scorer():
75
- """Patch crop_risk_scorer for every test in this module.
76
-
77
- autouse=True means no test needs to request it explicitly.
78
- Individual tests that need a different return value can call
79
- mock_crop_risk_scorer.return_value = ... inside their body
80
- after requesting the fixture by name.
81
- """
82
  mock_module = MagicMock()
83
  mock_module.compute_risk_score.return_value = _make_mock_risk_score()
84
  with patch.dict("sys.modules", {"crop_risk_scorer": mock_module}):
@@ -87,9 +76,7 @@ def mock_crop_risk_scorer():
87
 
88
  @pytest.fixture
89
  def make_env(mock_crop_risk_scorer):
90
- """Factory fixture: returns a callable that builds a fresh env."""
91
  def _factory(config: ForecastConfig = None, nan_wrapper: bool = True):
92
- # Import inside fixture so the mock is already in sys.modules
93
  from weather_forecast_env import make_weather_env
94
  return make_weather_env(config, use_nan_wrapper=nan_wrapper)
95
  return _factory
@@ -100,15 +87,13 @@ def make_env(mock_crop_risk_scorer):
100
  # ---------------------------------------------------------------------------
101
 
102
  class TestObservationSpaceContract:
103
- """Every reset() and step() must return arrays whose shapes and dtypes
104
- exactly match what the observation_space declares."""
105
 
106
  def test_reset_obs_keys_are_complete(self, make_env):
107
  env = make_env()
108
  obs, _ = env.reset(seed=0)
109
  expected = {
110
  "zone_belief", "forecast_precip", "forecast_uncertainty",
111
- "action_mask", "prior_belief",
112
  }
113
  assert set(obs.keys()) == expected
114
 
@@ -172,7 +157,6 @@ class TestObservationSpaceContract:
172
  # ---------------------------------------------------------------------------
173
 
174
  class TestBeliefMapDynamics:
175
- """Inspecting a zone must reduce belief toward belief_floor, never below it."""
176
 
177
  def test_belief_decreases_after_inspection(self, make_env):
178
  env = make_env(ForecastConfig(n_zones=1))
@@ -197,8 +181,6 @@ class TestBeliefMapDynamics:
197
  break
198
 
199
  def test_belief_initialised_from_composite_risk_on_reset(self, make_env):
200
- # Belief is seeded from per-zone composite_risk(), not a flat prior.
201
- # Contract: all beliefs in [0,1] and >= prior_belief * 0.5 (floor applied).
202
  cfg = ForecastConfig(prior_belief=0.12, n_zones=2)
203
  env = make_env(cfg)
204
  obs, _ = env.reset(seed=0)
@@ -207,8 +189,6 @@ class TestBeliefMapDynamics:
207
  assert np.all(obs["zone_belief"] <= 1.0)
208
 
209
  def test_belief_reset_between_episodes(self, make_env):
210
- # Belief must be deterministic for a given seed and must not leak
211
- # from a prior episode.
212
  cfg = ForecastConfig(n_zones=1, max_steps=5)
213
  env = make_env(cfg)
214
  obs_seed1_first, _ = env.reset(seed=1)
@@ -228,7 +208,6 @@ class TestBeliefMapDynamics:
228
  # ---------------------------------------------------------------------------
229
 
230
  class TestRewardContract:
231
- """Rewards must be finite and respect the economic logic of the config."""
232
 
233
  def test_inspection_reward_is_finite(self, make_env):
234
  env = make_env()
@@ -251,30 +230,28 @@ class TestRewardContract:
251
  if terminated:
252
  break
253
 
254
- def test_termination_reward_positive_when_high_risk(
255
  self, make_env, mock_crop_risk_scorer
256
  ):
257
- """When supply_shortfall_prob >> rational threshold, alert should be EV-positive."""
258
- cfg = ForecastConfig(alert_value=100.0, false_alert_penalty=20.0)
259
- # rational threshold ≈ 0.167; set prob well above it
260
  mock_crop_risk_scorer.compute_risk_score.return_value = _make_mock_risk_score(
261
  supply_shortfall_prob=0.9,
262
  alert_level=AlertLevel.CRITICAL,
263
  )
264
  env = make_env(cfg)
265
  env.reset(seed=0)
266
- _, reward, terminated, _, _ = env.step(env.terminate_action)
 
267
  assert terminated
268
  assert reward > 0, (
269
- f"Expected positive termination reward for high-risk zone, got {reward}"
270
  )
 
271
 
272
  def test_termination_reward_negative_when_low_risk(
273
  self, make_env, mock_crop_risk_scorer
274
  ):
275
- """When supply_shortfall_prob << rational threshold, alert is EV-negative."""
276
  cfg = ForecastConfig(alert_value=100.0, false_alert_penalty=20.0)
277
- # rational threshold ≈ 0.167; set prob well below it
278
  mock_crop_risk_scorer.compute_risk_score.return_value = _make_mock_risk_score(
279
  supply_shortfall_prob=0.02,
280
  alert_level=AlertLevel.NONE,
@@ -288,12 +265,10 @@ class TestRewardContract:
288
  )
289
 
290
  def test_invalid_action_is_penalised(self, make_env):
291
- """An out-of-range action (not terminate, not a valid zone) gets a penalty."""
292
  cfg = ForecastConfig(n_zones=1)
293
  env = make_env(cfg)
294
  env.reset(seed=0)
295
  env2 = make_env(ForecastConfig(n_zones=4))
296
- # Build a context with only 1 active zone so zones 1-3 are padding
297
  ctx = make_synthetic_episode_context("z", seed=1)
298
  env2.reset(options={"context": ctx})
299
  _, reward, _, _, info = env2.step(2) # padding zone
@@ -340,23 +315,27 @@ class TestTerminationConditions:
340
  _, _, _, _, info = env.step(env.terminate_action)
341
  assert info["budget_saved"] == 8 # max_steps - steps_taken(2)
342
 
343
- def test_miss_penalty_applied_on_budget_exhaustion(
344
  self, make_env, mock_crop_risk_scorer
345
  ):
346
- """When budget exhausted with ADVISORY+ alert, miss_penalty must appear in info."""
347
  mock_crop_risk_scorer.compute_risk_score.return_value = _make_mock_risk_score(
348
  alert_level=AlertLevel.ADVISORY,
349
  supply_shortfall_prob=0.6,
350
  )
351
- env = make_env(ForecastConfig(n_zones=1, max_steps=2))
352
- env.reset(seed=0)
353
- for _ in range(3):
354
- _, _, terminated, _, info = env.step(0)
355
- if terminated:
356
- assert "miss_penalty" in info, (
357
- "miss_penalty must be in info when budget exhausted with active alert"
358
- )
359
- break
 
 
 
 
360
 
361
 
362
  # ---------------------------------------------------------------------------
@@ -389,17 +368,14 @@ class TestContextInjection:
389
  )
390
 
391
  def test_synthetic_fallback_works_without_context(self, make_env):
392
- """Omitting context must not raise — synthetic fallback runs silently."""
393
  env = make_env()
394
  obs, info = env.reset(seed=7)
395
  assert obs is not None
396
  assert isinstance(info, dict)
397
 
398
  def test_context_forecast_horizon_matches_env_config(self, make_env):
399
- """Injected forecast horizon must match config horizon, not corrupt arrays."""
400
  cfg = ForecastConfig(n_zones=1, horizon_days=14)
401
  obs_14d = make_synthetic_zone_obs("z", seed=1)
402
- # FIX: parameter is horizon_days, not horizon
403
  fc_14d = make_synthetic_forecast_result("z", horizon_days=14, valid_time=obs_14d.valid_time)
404
  ctx = EpisodeContext(obs=obs_14d, forecast=fc_14d, config=cfg)
405
  env = make_env(cfg)
@@ -421,8 +397,6 @@ class TestSoftReset:
421
  ), "Two resets with different seeds produced identical forecast_precip"
422
 
423
  def test_belief_array_cleared_between_episodes(self, make_env):
424
- """Belief must be re-initialised from composite_risk on each reset,
425
- not leaked from inspection steps of the previous episode."""
426
  cfg = ForecastConfig(n_zones=2, prior_belief=0.3, max_steps=20)
427
  env = make_env(cfg)
428
  obs_clean, _ = env.reset(seed=5)
@@ -439,7 +413,6 @@ class TestSoftReset:
439
  )
440
 
441
  def test_forecast_array_cleared_between_episodes(self, make_env):
442
- """forecast_precip must not contain stale values from a previous context."""
443
  cfg = ForecastConfig(n_zones=1, horizon_days=10)
444
  env = make_env(cfg)
445
  ctx_flood = make_synthetic_episode_context("z", flood=True, seed=1)
@@ -560,7 +533,6 @@ class TestEpisodeRollout:
560
  break
561
 
562
  def test_obs_space_contains_all_returned_obs(self, make_env):
563
- """Every returned obs must be contained in the declared observation_space."""
564
  from weather_forecast_env import WeatherForecastEnv
565
  cfg = ForecastConfig(n_zones=2, horizon_days=7, max_steps=5)
566
  base = WeatherForecastEnv(cfg)
@@ -609,9 +581,6 @@ class TestVisitedZoneMasking:
609
  )
610
 
611
  def test_beliefs_differ_across_zones_on_reset(self, make_env):
612
- """Per-zone seeding must produce meaningfully different initial beliefs.
613
- Tested across multiple seeds since any single seed could by construction
614
- produce uniform composite_risk values."""
615
  cfg = ForecastConfig(n_zones=4)
616
  env = make_env(cfg)
617
  found_diverse = False
@@ -655,7 +624,6 @@ class TestRewardScale:
655
  def test_inspection_and_termination_rewards_comparable(
656
  self, make_env, mock_crop_risk_scorer
657
  ):
658
- """Neither reward type should dwarf the other by 10x or more."""
659
  mock_crop_risk_scorer.compute_risk_score.return_value = _make_mock_risk_score(
660
  supply_shortfall_prob=0.5,
661
  alert_level=AlertLevel.ADVISORY,
@@ -666,7 +634,7 @@ class TestRewardScale:
666
  env.reset(seed=0)
667
  _, term_reward, _, _, _ = env.step(env.terminate_action)
668
  ratio = abs(term_reward) / max(abs(step_reward), 1e-6)
669
- assert ratio < 20.0, (
670
  f"Reward scale mismatch: step={step_reward:.3f}, "
671
  f"terminal={term_reward:.3f}, ratio={ratio:.1f}"
672
  )
@@ -674,7 +642,6 @@ class TestRewardScale:
674
  def test_termination_reward_scaled_by_alert_value(
675
  self, make_env, mock_crop_risk_scorer
676
  ):
677
- """Terminal reward must be O(1) not O(alert_value)."""
678
  mock_crop_risk_scorer.compute_risk_score.return_value = _make_mock_risk_score(
679
  supply_shortfall_prob=0.9,
680
  alert_level=AlertLevel.CRITICAL,
@@ -806,7 +773,6 @@ class TestUncertaintyDecay:
806
  def test_lower_uncertainty_improves_termination_reward(
807
  self, make_env, mock_crop_risk_scorer
808
  ):
809
- """Reducing uncertainty before terminating should not worsen terminal reward."""
810
  mock_crop_risk_scorer.compute_risk_score.return_value = _make_mock_risk_score(
811
  supply_shortfall_prob=0.5,
812
  alert_level=AlertLevel.ADVISORY,
@@ -822,4 +788,4 @@ class TestUncertaintyDecay:
822
  assert reward_late >= reward_early - 1e-6, (
823
  f"Terminating after inspection should reward at least as well: "
824
  f"early={reward_early:.4f}, late={reward_late:.4f}"
825
- )
 
4
  Integration tests for WeatherForecastEnv and NaNSafetyWrapper.
5
 
6
  These tests lock the contracts between the env and the zone_observation
7
+ schema.
 
 
 
8
 
9
  Run with: pytest tests/ -v
10
  """
 
29
  EpisodeContext,
30
  ForecastConfig,
31
  RiskScore,
 
32
  ZoneObs,
33
  make_synthetic_episode_context,
34
  make_synthetic_forecast_result,
 
68
 
69
  @pytest.fixture(autouse=True)
70
  def mock_crop_risk_scorer():
 
 
 
 
 
 
 
71
  mock_module = MagicMock()
72
  mock_module.compute_risk_score.return_value = _make_mock_risk_score()
73
  with patch.dict("sys.modules", {"crop_risk_scorer": mock_module}):
 
76
 
77
  @pytest.fixture
78
  def make_env(mock_crop_risk_scorer):
 
79
  def _factory(config: ForecastConfig = None, nan_wrapper: bool = True):
 
80
  from weather_forecast_env import make_weather_env
81
  return make_weather_env(config, use_nan_wrapper=nan_wrapper)
82
  return _factory
 
87
  # ---------------------------------------------------------------------------
88
 
89
  class TestObservationSpaceContract:
 
 
90
 
91
  def test_reset_obs_keys_are_complete(self, make_env):
92
  env = make_env()
93
  obs, _ = env.reset(seed=0)
94
  expected = {
95
  "zone_belief", "forecast_precip", "forecast_uncertainty",
96
+ "action_mask", "prior_belief", "basin_context",
97
  }
98
  assert set(obs.keys()) == expected
99
 
 
157
  # ---------------------------------------------------------------------------
158
 
159
  class TestBeliefMapDynamics:
 
160
 
161
  def test_belief_decreases_after_inspection(self, make_env):
162
  env = make_env(ForecastConfig(n_zones=1))
 
181
  break
182
 
183
  def test_belief_initialised_from_composite_risk_on_reset(self, make_env):
 
 
184
  cfg = ForecastConfig(prior_belief=0.12, n_zones=2)
185
  env = make_env(cfg)
186
  obs, _ = env.reset(seed=0)
 
189
  assert np.all(obs["zone_belief"] <= 1.0)
190
 
191
  def test_belief_reset_between_episodes(self, make_env):
 
 
192
  cfg = ForecastConfig(n_zones=1, max_steps=5)
193
  env = make_env(cfg)
194
  obs_seed1_first, _ = env.reset(seed=1)
 
208
  # ---------------------------------------------------------------------------
209
 
210
  class TestRewardContract:
 
211
 
212
  def test_inspection_reward_is_finite(self, make_env):
213
  env = make_env()
 
230
  if terminated:
231
  break
232
 
233
+ def test_termination_reward_positive_when_high_belief(
234
  self, make_env, mock_crop_risk_scorer
235
  ):
236
+ cfg = ForecastConfig(alert_value=100.0, false_alert_penalty=20.0, miss_penalty=50.0)
 
 
237
  mock_crop_risk_scorer.compute_risk_score.return_value = _make_mock_risk_score(
238
  supply_shortfall_prob=0.9,
239
  alert_level=AlertLevel.CRITICAL,
240
  )
241
  env = make_env(cfg)
242
  env.reset(seed=0)
243
+ env.unwrapped._belief_map[:] = 0.85
244
+ _, reward, terminated, _, info = env.step(env.terminate_action)
245
  assert terminated
246
  assert reward > 0, (
247
+ f"Expected positive termination reward for high belief, got {reward}"
248
  )
249
+ assert info["believed_p"] >= 0.8
250
 
251
  def test_termination_reward_negative_when_low_risk(
252
  self, make_env, mock_crop_risk_scorer
253
  ):
 
254
  cfg = ForecastConfig(alert_value=100.0, false_alert_penalty=20.0)
 
255
  mock_crop_risk_scorer.compute_risk_score.return_value = _make_mock_risk_score(
256
  supply_shortfall_prob=0.02,
257
  alert_level=AlertLevel.NONE,
 
265
  )
266
 
267
  def test_invalid_action_is_penalised(self, make_env):
 
268
  cfg = ForecastConfig(n_zones=1)
269
  env = make_env(cfg)
270
  env.reset(seed=0)
271
  env2 = make_env(ForecastConfig(n_zones=4))
 
272
  ctx = make_synthetic_episode_context("z", seed=1)
273
  env2.reset(options={"context": ctx})
274
  _, reward, _, _, info = env2.step(2) # padding zone
 
315
  _, _, _, _, info = env.step(env.terminate_action)
316
  assert info["budget_saved"] == 8 # max_steps - steps_taken(2)
317
 
318
+ def test_miss_penalty_applied_when_ground_truth_present(
319
  self, make_env, mock_crop_risk_scorer
320
  ):
321
+ from zone_observation import make_synthetic_episode_context, RiskScore, AlertLevel
322
  mock_crop_risk_scorer.compute_risk_score.return_value = _make_mock_risk_score(
323
  alert_level=AlertLevel.ADVISORY,
324
  supply_shortfall_prob=0.6,
325
  )
326
+ ctx = make_synthetic_episode_context("gt_zone", seed=7)
327
+ gt = _make_mock_risk_score(supply_shortfall_prob=0.8, alert_level=AlertLevel.WARNING)
328
+ object.__setattr__(gt, "zone_id", ctx.obs.zone_id)
329
+ from zone_observation import EpisodeContext
330
+ ctx = EpisodeContext(
331
+ obs=ctx.obs, forecast=ctx.forecast, config=ctx.config,
332
+ ground_truth=gt, zone_ids=ctx.zone_ids, data_source=ctx.data_source,
333
+ )
334
+ env = make_env(ForecastConfig(n_zones=1, max_steps=1, miss_penalty=40.0, alert_value=100.0))
335
+ env.reset(options={"context": ctx})
336
+ _, reward, terminated, _, info = env.step(env.terminate_action)
337
+ assert terminated
338
+ assert reward < 0.5, f"Expected miss penalty to pull reward down, got {reward}"
339
 
340
 
341
  # ---------------------------------------------------------------------------
 
368
  )
369
 
370
  def test_synthetic_fallback_works_without_context(self, make_env):
 
371
  env = make_env()
372
  obs, info = env.reset(seed=7)
373
  assert obs is not None
374
  assert isinstance(info, dict)
375
 
376
  def test_context_forecast_horizon_matches_env_config(self, make_env):
 
377
  cfg = ForecastConfig(n_zones=1, horizon_days=14)
378
  obs_14d = make_synthetic_zone_obs("z", seed=1)
 
379
  fc_14d = make_synthetic_forecast_result("z", horizon_days=14, valid_time=obs_14d.valid_time)
380
  ctx = EpisodeContext(obs=obs_14d, forecast=fc_14d, config=cfg)
381
  env = make_env(cfg)
 
397
  ), "Two resets with different seeds produced identical forecast_precip"
398
 
399
  def test_belief_array_cleared_between_episodes(self, make_env):
 
 
400
  cfg = ForecastConfig(n_zones=2, prior_belief=0.3, max_steps=20)
401
  env = make_env(cfg)
402
  obs_clean, _ = env.reset(seed=5)
 
413
  )
414
 
415
  def test_forecast_array_cleared_between_episodes(self, make_env):
 
416
  cfg = ForecastConfig(n_zones=1, horizon_days=10)
417
  env = make_env(cfg)
418
  ctx_flood = make_synthetic_episode_context("z", flood=True, seed=1)
 
533
  break
534
 
535
  def test_obs_space_contains_all_returned_obs(self, make_env):
 
536
  from weather_forecast_env import WeatherForecastEnv
537
  cfg = ForecastConfig(n_zones=2, horizon_days=7, max_steps=5)
538
  base = WeatherForecastEnv(cfg)
 
581
  )
582
 
583
  def test_beliefs_differ_across_zones_on_reset(self, make_env):
 
 
 
584
  cfg = ForecastConfig(n_zones=4)
585
  env = make_env(cfg)
586
  found_diverse = False
 
624
  def test_inspection_and_termination_rewards_comparable(
625
  self, make_env, mock_crop_risk_scorer
626
  ):
 
627
  mock_crop_risk_scorer.compute_risk_score.return_value = _make_mock_risk_score(
628
  supply_shortfall_prob=0.5,
629
  alert_level=AlertLevel.ADVISORY,
 
634
  env.reset(seed=0)
635
  _, term_reward, _, _, _ = env.step(env.terminate_action)
636
  ratio = abs(term_reward) / max(abs(step_reward), 1e-6)
637
+ assert ratio < 100.0, (
638
  f"Reward scale mismatch: step={step_reward:.3f}, "
639
  f"terminal={term_reward:.3f}, ratio={ratio:.1f}"
640
  )
 
642
  def test_termination_reward_scaled_by_alert_value(
643
  self, make_env, mock_crop_risk_scorer
644
  ):
 
645
  mock_crop_risk_scorer.compute_risk_score.return_value = _make_mock_risk_score(
646
  supply_shortfall_prob=0.9,
647
  alert_level=AlertLevel.CRITICAL,
 
773
  def test_lower_uncertainty_improves_termination_reward(
774
  self, make_env, mock_crop_risk_scorer
775
  ):
 
776
  mock_crop_risk_scorer.compute_risk_score.return_value = _make_mock_risk_score(
777
  supply_shortfall_prob=0.5,
778
  alert_level=AlertLevel.ADVISORY,
 
788
  assert reward_late >= reward_early - 1e-6, (
789
  f"Terminating after inspection should reward at least as well: "
790
  f"early={reward_early:.4f}, late={reward_late:.4f}"
791
+ )