DHDRL commited on
Commit
2822ea9
·
verified ·
1 Parent(s): 0c6f6d6

Update test_physics_dynamics.py

Browse files
Files changed (1) hide show
  1. test_physics_dynamics.py +1 -53
test_physics_dynamics.py CHANGED
@@ -124,7 +124,6 @@ def batch_size() -> int:
124
 
125
  @pytest.fixture
126
  def state(n_zones, horizon_days, batch_size) -> ZoneStateTensor:
127
- """Standard random state tensor for use across tests."""
128
  torch.manual_seed(0)
129
  return ZoneStateTensor(
130
  precip=torch.rand(batch_size, n_zones, horizon_days) * 20.0,
@@ -143,7 +142,6 @@ def model(n_zones, horizon_days) -> TemporalDynamicsModel:
143
 
144
  @pytest.fixture
145
  def tiny_pairs(n_zones, horizon_days) -> list:
146
- """Small set of (current, next) pairs for trainer tests."""
147
  torch.manual_seed(7)
148
  pairs = []
149
  for _ in range(20):
@@ -302,36 +300,23 @@ class TestPhysicsResidualLoss:
302
  assert loss_fn(u, v).item() == pytest.approx(0.0)
303
 
304
  def test_exact_linear_advection_has_low_residual(self):
305
- """
306
- If u_next is u shifted by exactly v steps along the horizon axis,
307
- the advection term v*∂u/∂τ should largely cancel ∂u/∂t,
308
- producing a small residual (not exactly zero due to diffusion term
309
- and boundary approximations, but significantly lower than random).
310
- """
311
  loss_fn = PhysicsResidualLoss(weight=1.0)
312
- # Fix v to a known value for this test
313
  with torch.no_grad():
314
  loss_fn.log_v.fill_(0.0) # v = 1.0
315
  loss_fn.log_D.fill_(-10.0) # D ≈ 0 (nearly pure advection)
316
 
317
  B, Z, H = 1, 1, 20
318
  tau = torch.arange(H, dtype=torch.float32)
319
- # Linear ramp: u = a * tau + b (∂u/∂τ = a, ∂²u/∂τ² = 0)
320
- # Exact solution after dt=1: u_next = a*(tau+1) + b = u + a
321
  a = 2.0
322
  u = a * tau.unsqueeze(0).unsqueeze(0).expand(B, Z, H)
323
  u_next = u + a # shift by a (= v * ∂u/∂τ = 1.0 * a)
324
 
325
  residual_advection = loss_fn(u, u_next)
326
 
327
- # For comparison: random u_next should have a larger residual than
328
- # the analytically correct solution.
329
  torch.manual_seed(0)
330
  u_random = torch.rand_like(u) * 40.0 # unrelated to u
331
  residual_random = loss_fn(u, u_random)
332
 
333
- # The analytically correct solution should produce a strictly lower
334
- # residual than a completely unrelated random prediction.
335
  assert residual_advection.item() < residual_random.item(), (
336
  f"Expected advection residual ({residual_advection.item():.4f}) < "
337
  f"random residual ({residual_random.item():.4f})"
@@ -611,14 +596,8 @@ class TestDynamicsTrainer:
611
  assert loss >= 0.0, f"Negative loss: {loss}"
612
 
613
  def test_loss_decreases_over_training(self, n_zones, horizon_days):
614
- """
615
- Loss should trend downward over sufficient epochs on a small fixed dataset.
616
- We test that final loss < initial loss (not strictly monotonic — that is
617
- not guaranteed with SGD). Seed is fixed for reproducibility.
618
- """
619
  torch.manual_seed(0)
620
  np.random.seed(0)
621
- # Build a more learnable target: next = current + small noise
622
  pairs = []
623
  for _ in range(30):
624
  curr = ZoneStateTensor(
@@ -664,21 +643,10 @@ class TestDynamicsTrainer:
664
  ),
665
  )
666
  trainer = DynamicsTrainer(n_zones=n_zones, horizon_days=horizon_days)
667
- # val_split=0.0 → n_val=0, n_train=1 (after the guard fix).
668
- # With val_split=0.1 and N=1 → n_val would be 1, n_train=0 → should raise.
669
- # val_split=0.0 is the correct way to train on a tiny dataset.
670
  history = trainer.train([pair], epochs=1, batch_size=1, val_split=0.0)
671
  assert "train_loss" in history
672
  assert len(history["train_loss"]) == 1
673
 
674
- # Verify that passing exactly 2 pairs with val_split=1.0 raises clearly
675
- # (n_val = max(0, 2-1) = 1, n_train = 1 is fine — but val_split=1.0
676
- # with the guard: n_val = int(2*1.0) = 2 >= N=2 → n_val = max(0,2-1)=1
677
- # So we need val_split such that int(N*val_split) >= N with our guard.
678
- # The guard sets n_val = max(0, N-1), so n_train = 1 always survives.
679
- # The real failure case is N=1 with val_split=0.5+: int(1*0.5)=0 → fine.
680
- # Actually to trigger the error we need a 0-sample training set which
681
- # cannot happen with the guard. Verify graceful handling instead.
682
  history2 = trainer.train([pair, pair], epochs=1, batch_size=1, val_split=0.5)
683
  assert "train_loss" in history2
684
 
@@ -698,10 +666,6 @@ class TestDynamicsTrainer:
698
  n_zones=n_zones, horizon_days=horizon_days, physics_weight=0.0
699
  )
700
  history = trainer.train(tiny_pairs, epochs=2, batch_size=4)
701
- # history["physics_loss"] records the RAW unweighted PDE residual,
702
- # not the weighted contribution to the total loss. When physics_weight=0
703
- # the residual is still computed and logged — it just doesn't affect
704
- # the gradient. We verify it is finite and non-negative.
705
  for pl in history["physics_loss"]:
706
  assert np.isfinite(pl), f"Physics loss is not finite: {pl}"
707
  assert pl >= 0.0, f"Physics loss is negative: {pl}"
@@ -735,19 +699,12 @@ class TestEnsembleDynamics:
735
  assert epistemic.item() >= 0.0
736
 
737
  def test_single_model_ensemble_has_zero_uncertainty(self, state, n_zones, horizon_days):
738
- """
739
- With n_models=1 there is no variance — std of a single value is 0.
740
- """
741
  torch.manual_seed(0)
742
  ens = EnsembleDynamics(n_models=1, n_zones=n_zones, horizon_days=horizon_days)
743
  _, epistemic = ens.predict(state)
744
  assert epistemic.item() == pytest.approx(0.0, abs=1e-6)
745
 
746
  def test_multi_model_ensemble_has_positive_uncertainty(self, state, n_zones, horizon_days):
747
- """
748
- With n_models=5 and randomly initialised weights, the ensemble members
749
- will disagree, producing non-zero epistemic uncertainty.
750
- """
751
  torch.manual_seed(99)
752
  ens = EnsembleDynamics(n_models=5, n_zones=n_zones, horizon_days=horizon_days)
753
  _, epistemic = ens.predict(state)
@@ -797,9 +754,7 @@ class TestDynaRolloutBuffer:
797
  assert bonus.item() >= 0.0
798
 
799
  def test_compute_surprise_bonus_bounded_by_uncertainty_weight(self, buffer, state):
800
- """Bonus is clipped to [0, uncertainty_weight]."""
801
  n_zones, horizon_days = state.n_zones, state.horizon_days
802
- # Make next_state very different to maximise surprise
803
  very_different = ZoneStateTensor(
804
  precip=torch.full_like(state.precip, 499.0),
805
  uncertainty=torch.ones_like(state.uncertainty),
@@ -809,12 +764,6 @@ class TestDynaRolloutBuffer:
809
  assert bonus.item() <= buffer.uncertainty_weight + 1e-6
810
 
811
  def test_identical_states_give_near_zero_bonus(self, buffer, state):
812
- """
813
- When current and next are identical, the model's prediction error
814
- should be low, producing a near-zero surprise bonus.
815
- Note: not exactly zero because the model doesn't predict the identity.
816
- We just check it is lower than the maximum possible bonus.
817
- """
818
  bonus_same = buffer.compute_surprise_bonus(state, state)
819
  very_different = ZoneStateTensor(
820
  precip=torch.full_like(state.precip, 499.0),
@@ -822,7 +771,6 @@ class TestDynaRolloutBuffer:
822
  belief=torch.zeros_like(state.belief),
823
  )
824
  bonus_diff = buffer.compute_surprise_bonus(state, very_different)
825
- # Identical input should produce smaller or equal bonus than maximally different
826
  assert bonus_same.item() <= bonus_diff.item() + 1e-6
827
 
828
  def test_no_gradient_in_compute_surprise_bonus(self, buffer, state):
@@ -857,4 +805,4 @@ class TestDynaRolloutBuffer:
857
  )
858
  bonus = buf.compute_surprise_bonus(state, very_different)
859
  assert bonus.item() <= weight + 1e-6
860
- assert bonus.item() >= 0.0
 
124
 
125
  @pytest.fixture
126
  def state(n_zones, horizon_days, batch_size) -> ZoneStateTensor:
 
127
  torch.manual_seed(0)
128
  return ZoneStateTensor(
129
  precip=torch.rand(batch_size, n_zones, horizon_days) * 20.0,
 
142
 
143
  @pytest.fixture
144
  def tiny_pairs(n_zones, horizon_days) -> list:
 
145
  torch.manual_seed(7)
146
  pairs = []
147
  for _ in range(20):
 
300
  assert loss_fn(u, v).item() == pytest.approx(0.0)
301
 
302
  def test_exact_linear_advection_has_low_residual(self):
 
 
 
 
 
 
303
  loss_fn = PhysicsResidualLoss(weight=1.0)
 
304
  with torch.no_grad():
305
  loss_fn.log_v.fill_(0.0) # v = 1.0
306
  loss_fn.log_D.fill_(-10.0) # D ≈ 0 (nearly pure advection)
307
 
308
  B, Z, H = 1, 1, 20
309
  tau = torch.arange(H, dtype=torch.float32)
 
 
310
  a = 2.0
311
  u = a * tau.unsqueeze(0).unsqueeze(0).expand(B, Z, H)
312
  u_next = u + a # shift by a (= v * ∂u/∂τ = 1.0 * a)
313
 
314
  residual_advection = loss_fn(u, u_next)
315
 
 
 
316
  torch.manual_seed(0)
317
  u_random = torch.rand_like(u) * 40.0 # unrelated to u
318
  residual_random = loss_fn(u, u_random)
319
 
 
 
320
  assert residual_advection.item() < residual_random.item(), (
321
  f"Expected advection residual ({residual_advection.item():.4f}) < "
322
  f"random residual ({residual_random.item():.4f})"
 
596
  assert loss >= 0.0, f"Negative loss: {loss}"
597
 
598
  def test_loss_decreases_over_training(self, n_zones, horizon_days):
 
 
 
 
 
599
  torch.manual_seed(0)
600
  np.random.seed(0)
 
601
  pairs = []
602
  for _ in range(30):
603
  curr = ZoneStateTensor(
 
643
  ),
644
  )
645
  trainer = DynamicsTrainer(n_zones=n_zones, horizon_days=horizon_days)
 
 
 
646
  history = trainer.train([pair], epochs=1, batch_size=1, val_split=0.0)
647
  assert "train_loss" in history
648
  assert len(history["train_loss"]) == 1
649
 
 
 
 
 
 
 
 
 
650
  history2 = trainer.train([pair, pair], epochs=1, batch_size=1, val_split=0.5)
651
  assert "train_loss" in history2
652
 
 
666
  n_zones=n_zones, horizon_days=horizon_days, physics_weight=0.0
667
  )
668
  history = trainer.train(tiny_pairs, epochs=2, batch_size=4)
 
 
 
 
669
  for pl in history["physics_loss"]:
670
  assert np.isfinite(pl), f"Physics loss is not finite: {pl}"
671
  assert pl >= 0.0, f"Physics loss is negative: {pl}"
 
699
  assert epistemic.item() >= 0.0
700
 
701
  def test_single_model_ensemble_has_zero_uncertainty(self, state, n_zones, horizon_days):
 
 
 
702
  torch.manual_seed(0)
703
  ens = EnsembleDynamics(n_models=1, n_zones=n_zones, horizon_days=horizon_days)
704
  _, epistemic = ens.predict(state)
705
  assert epistemic.item() == pytest.approx(0.0, abs=1e-6)
706
 
707
  def test_multi_model_ensemble_has_positive_uncertainty(self, state, n_zones, horizon_days):
 
 
 
 
708
  torch.manual_seed(99)
709
  ens = EnsembleDynamics(n_models=5, n_zones=n_zones, horizon_days=horizon_days)
710
  _, epistemic = ens.predict(state)
 
754
  assert bonus.item() >= 0.0
755
 
756
  def test_compute_surprise_bonus_bounded_by_uncertainty_weight(self, buffer, state):
 
757
  n_zones, horizon_days = state.n_zones, state.horizon_days
 
758
  very_different = ZoneStateTensor(
759
  precip=torch.full_like(state.precip, 499.0),
760
  uncertainty=torch.ones_like(state.uncertainty),
 
764
  assert bonus.item() <= buffer.uncertainty_weight + 1e-6
765
 
766
  def test_identical_states_give_near_zero_bonus(self, buffer, state):
 
 
 
 
 
 
767
  bonus_same = buffer.compute_surprise_bonus(state, state)
768
  very_different = ZoneStateTensor(
769
  precip=torch.full_like(state.precip, 499.0),
 
771
  belief=torch.zeros_like(state.belief),
772
  )
773
  bonus_diff = buffer.compute_surprise_bonus(state, very_different)
 
774
  assert bonus_same.item() <= bonus_diff.item() + 1e-6
775
 
776
  def test_no_gradient_in_compute_surprise_bonus(self, buffer, state):
 
805
  )
806
  bonus = buf.compute_surprise_bonus(state, very_different)
807
  assert bonus.item() <= weight + 1e-6
808
+ assert bonus.item() >= 0.0