Reinforcement Learning
stable-baselines3
deep-reinforcement-learning
agricultural-ai
weather-modelling
curriculum-learning
edge-ai
Instructions to use DHDRL/monsoon-rl with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- stable-baselines3
How to use DHDRL/monsoon-rl with stable-baselines3:
from huggingface_sb3 import load_from_hub checkpoint = load_from_hub( repo_id="DHDRL/monsoon-rl", filename="{MODEL FILENAME}.zip", ) - Notebooks
- Google Colab
- Kaggle
| """ | |
| test_physics_dynamics.py | |
| ======================== | |
| Comprehensive test suite for physics_dynamics.py. | |
| Coverage | |
| -------- | |
| ZoneStateTensor | |
| - Shape contracts for all three tensors | |
| - from_numpy: batch dim injection (2D and 3D inputs) | |
| - from_numpy: dtype coercion to float32 | |
| - Properties: batch_size, n_zones, horizon_days | |
| - flat() output shape and content | |
| - flat_dim consistency with flat() output | |
| - to() device round-trip (CPU only) | |
| PhysicsResidualLoss | |
| - Output is a non-negative scalar tensor | |
| - Output has gradient attached (learnable parameters) | |
| - Residual is zero for exact linear advection (analytical check) | |
| - Learnable parameters v, D are strictly positive (log-space parameterisation) | |
| - weight=0 produces zero loss | |
| - H <= 2 edge case does not raise | |
| TemporalDynamicsModel | |
| - Output shapes match input for all supported (n_zones, horizon_days) | |
| - precip output is non-negative (Softplus) | |
| - uncertainty output is in [0, 1] (Sigmoid) | |
| - belief output is in [0, 1] (Sigmoid) | |
| - return_physics_loss=False returns None physics loss | |
| - return_physics_loss=True returns scalar tensor | |
| - physics loss is non-negative | |
| - physics loss has gradient | |
| - Batch size > 1 produces correct output shapes | |
| - n_zones=1 edge case | |
| - horizon_days=1 edge case | |
| - horizon_days=2 edge case (boundary of finite-difference guard) | |
| - save() / load() round-trip: weights identical after reload | |
| - save() creates parent directories | |
| - load() restores n_zones and horizon_days correctly | |
| - forward() is deterministic (eval mode, same input → same output) | |
| TemporalDynamicsModel.rollout() | |
| - Returns steps+1 states (including initial) | |
| - All returned states have correct shapes | |
| - All precip values >= 0 throughout rollout | |
| - All uncertainty values in [0, 1] throughout rollout | |
| - All belief values in [0, 1] throughout rollout | |
| - No gradient tracking during rollout (inference mode) | |
| - states[0] IS the initial state (identity, not a copy through forward) | |
| DynamicsTrainer | |
| - train() returns history dict with correct keys | |
| - train_loss list has length == epochs | |
| - val_loss list has length == epochs | |
| - physics_loss list has length == epochs | |
| - Loss decreases over training (not just random noise) | |
| - train() raises on empty sequence_pairs | |
| - save() delegates to TemporalDynamicsModel.save() | |
| - Single-pair dataset (n=1) does not crash | |
| - val_split=0.0 edge case (all training, 1 val sample minimum) | |
| EnsembleDynamics | |
| - predict() returns ZoneStateTensor with correct shapes | |
| - predict() returns scalar epistemic uncertainty tensor | |
| - epistemic uncertainty is non-negative | |
| - With n_models=1, uncertainty is zero (single model, no variance) | |
| - With n_models > 1 and random weights, uncertainty > 0 | |
| - to() moves all models to device without error | |
| DynaRolloutBuffer | |
| - compute_surprise_bonus() returns scalar in [0, uncertainty_weight] | |
| - Identical current and next → near-zero bonus | |
| - Very different current and next → higher bonus than identical inputs | |
| - generate_rollout() returns n_synthetic_steps+1 states | |
| - No gradient tracking in compute_surprise_bonus (inference) | |
| Run with: pytest test_physics_dynamics.py -v | |
| """ | |
| from __future__ import annotations | |
| import sys | |
| import os | |
| import tempfile | |
| from pathlib import Path | |
| from typing import Tuple | |
| import numpy as np | |
| import pytest | |
| import torch | |
| import torch.nn as nn | |
| sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) | |
| from physics_dynamics import ( | |
| DynamicsTrainer, | |
| DynaRolloutBuffer, | |
| EnsembleDynamics, | |
| PhysicsResidualLoss, | |
| TemporalDynamicsModel, | |
| ZoneStateTensor, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Shared fixtures | |
| # --------------------------------------------------------------------------- | |
| def n_zones() -> int: | |
| return 4 | |
| def horizon_days() -> int: | |
| return 14 | |
| def batch_size() -> int: | |
| return 3 | |
| def state(n_zones, horizon_days, batch_size) -> ZoneStateTensor: | |
| """Standard random state tensor for use across tests.""" | |
| torch.manual_seed(0) | |
| return ZoneStateTensor( | |
| precip=torch.rand(batch_size, n_zones, horizon_days) * 20.0, | |
| uncertainty=torch.rand(batch_size, n_zones), | |
| belief=torch.rand(batch_size, n_zones), | |
| ) | |
| def model(n_zones, horizon_days) -> TemporalDynamicsModel: | |
| torch.manual_seed(42) | |
| m = TemporalDynamicsModel(n_zones=n_zones, horizon_days=horizon_days) | |
| m.eval() | |
| return m | |
| def tiny_pairs(n_zones, horizon_days) -> list: | |
| """Small set of (current, next) pairs for trainer tests.""" | |
| torch.manual_seed(7) | |
| pairs = [] | |
| for _ in range(20): | |
| curr = ZoneStateTensor( | |
| precip=torch.rand(1, n_zones, horizon_days) * 15.0, | |
| uncertainty=torch.rand(1, n_zones), | |
| belief=torch.rand(1, n_zones), | |
| ) | |
| nxt = ZoneStateTensor( | |
| precip=torch.rand(1, n_zones, horizon_days) * 15.0, | |
| uncertainty=torch.rand(1, n_zones), | |
| belief=torch.rand(1, n_zones), | |
| ) | |
| pairs.append((curr, nxt)) | |
| return pairs | |
| # --------------------------------------------------------------------------- | |
| # ZoneStateTensor | |
| # --------------------------------------------------------------------------- | |
| class TestZoneStateTensor: | |
| def test_properties_match_tensor_shapes(self, state, n_zones, horizon_days, batch_size): | |
| assert state.batch_size == batch_size | |
| assert state.n_zones == n_zones | |
| assert state.horizon_days == horizon_days | |
| def test_precip_shape(self, state, batch_size, n_zones, horizon_days): | |
| assert state.precip.shape == (batch_size, n_zones, horizon_days) | |
| def test_uncertainty_shape(self, state, batch_size, n_zones): | |
| assert state.uncertainty.shape == (batch_size, n_zones) | |
| def test_belief_shape(self, state, batch_size, n_zones): | |
| assert state.belief.shape == (batch_size, n_zones) | |
| def test_all_tensors_are_float32(self, state): | |
| assert state.precip.dtype == torch.float32 | |
| assert state.uncertainty.dtype == torch.float32 | |
| assert state.belief.dtype == torch.float32 | |
| def test_flat_shape(self, state, batch_size, n_zones, horizon_days): | |
| flat = state.flat() | |
| expected_dim = n_zones * (horizon_days + 2) | |
| assert flat.shape == (batch_size, expected_dim) | |
| def test_flat_dim_property_matches_flat_output(self, state): | |
| assert state.flat_dim == state.flat().shape[1] | |
| def test_flat_content_concatenation(self, state): | |
| flat = state.flat() | |
| B, Z, H = state.precip.shape | |
| # First Z*H elements should be flattened precip | |
| precip_flat = state.precip.reshape(B, Z * H) | |
| assert torch.allclose(flat[:, :Z * H], precip_flat) | |
| # Next Z elements: uncertainty | |
| assert torch.allclose(flat[:, Z * H : Z * H + Z], state.uncertainty) | |
| # Last Z elements: belief | |
| assert torch.allclose(flat[:, Z * H + Z :], state.belief) | |
| def test_from_numpy_2d_precip_adds_batch_dim(self, n_zones, horizon_days): | |
| precip = np.random.rand(n_zones, horizon_days).astype(np.float32) | |
| uncert = np.random.rand(n_zones).astype(np.float32) | |
| belief = np.random.rand(n_zones).astype(np.float32) | |
| s = ZoneStateTensor.from_numpy(precip, uncert, belief) | |
| assert s.precip.shape == (1, n_zones, horizon_days) | |
| assert s.uncertainty.shape == (1, n_zones) | |
| assert s.belief.shape == (1, n_zones) | |
| def test_from_numpy_3d_precip_preserved(self, batch_size, n_zones, horizon_days): | |
| precip = np.random.rand(batch_size, n_zones, horizon_days).astype(np.float32) | |
| uncert = np.random.rand(batch_size, n_zones).astype(np.float32) | |
| belief = np.random.rand(batch_size, n_zones).astype(np.float32) | |
| s = ZoneStateTensor.from_numpy(precip, uncert, belief) | |
| assert s.precip.shape == (batch_size, n_zones, horizon_days) | |
| def test_from_numpy_dtype_coercion_float64(self, n_zones, horizon_days): | |
| precip = np.random.rand(n_zones, horizon_days) # float64 | |
| uncert = np.random.rand(n_zones) | |
| belief = np.random.rand(n_zones) | |
| s = ZoneStateTensor.from_numpy(precip, uncert, belief) | |
| assert s.precip.dtype == torch.float32 | |
| assert s.uncertainty.dtype == torch.float32 | |
| assert s.belief.dtype == torch.float32 | |
| def test_from_numpy_values_preserved(self, n_zones, horizon_days): | |
| precip = np.ones((n_zones, horizon_days), dtype=np.float32) * 7.5 | |
| uncert = np.full(n_zones, 0.3, dtype=np.float32) | |
| belief = np.full(n_zones, 0.6, dtype=np.float32) | |
| s = ZoneStateTensor.from_numpy(precip, uncert, belief) | |
| assert torch.allclose(s.precip, torch.full((1, n_zones, horizon_days), 7.5)) | |
| assert torch.allclose(s.uncertainty, torch.full((1, n_zones), 0.3)) | |
| assert torch.allclose(s.belief, torch.full((1, n_zones), 0.6)) | |
| def test_to_cpu_returns_new_instance(self, state): | |
| moved = state.to(torch.device("cpu")) | |
| assert moved is not state | |
| assert moved.precip.device.type == "cpu" | |
| def test_to_cpu_values_unchanged(self, state): | |
| moved = state.to(torch.device("cpu")) | |
| assert torch.allclose(state.precip, moved.precip) | |
| assert torch.allclose(state.uncertainty, moved.uncertainty) | |
| assert torch.allclose(state.belief, moved.belief) | |
| # --------------------------------------------------------------------------- | |
| # PhysicsResidualLoss | |
| # --------------------------------------------------------------------------- | |
| class TestPhysicsResidualLoss: | |
| def test_output_is_scalar_tensor(self): | |
| loss_fn = PhysicsResidualLoss(weight=0.01) | |
| u = torch.rand(2, 3, 10) | |
| v = torch.rand(2, 3, 10) | |
| out = loss_fn(u, v) | |
| assert out.shape == torch.Size([]) | |
| def test_output_is_non_negative(self): | |
| torch.manual_seed(0) | |
| loss_fn = PhysicsResidualLoss(weight=0.01) | |
| for _ in range(10): | |
| u = torch.rand(2, 4, 14) * 20 | |
| v = torch.rand(2, 4, 14) * 20 | |
| assert loss_fn(u, v).item() >= 0.0 | |
| def test_output_has_gradient(self): | |
| loss_fn = PhysicsResidualLoss(weight=0.01) | |
| u = torch.rand(2, 3, 10, requires_grad=True) | |
| v = torch.rand(2, 3, 10, requires_grad=True) | |
| out = loss_fn(u, v) | |
| out.backward() | |
| assert u.grad is not None | |
| assert v.grad is not None | |
| def test_learnable_v_is_positive(self): | |
| loss_fn = PhysicsResidualLoss() | |
| assert loss_fn.v.item() > 0.0 | |
| def test_learnable_D_is_positive(self): | |
| loss_fn = PhysicsResidualLoss() | |
| assert loss_fn.D.item() > 0.0 | |
| def test_log_v_is_parameter(self): | |
| loss_fn = PhysicsResidualLoss() | |
| param_names = [n for n, _ in loss_fn.named_parameters()] | |
| assert "log_v" in param_names | |
| assert "log_D" in param_names | |
| def test_weight_zero_produces_zero_loss(self): | |
| loss_fn = PhysicsResidualLoss(weight=0.0) | |
| u = torch.rand(2, 3, 10) | |
| v = torch.rand(2, 3, 10) | |
| assert loss_fn(u, v).item() == pytest.approx(0.0) | |
| def test_exact_linear_advection_has_low_residual(self): | |
| """ | |
| If u_next is u shifted by exactly v steps along the horizon axis, | |
| the advection term v*∂u/∂τ should largely cancel ∂u/∂t, | |
| producing a small residual (not exactly zero due to diffusion term | |
| and boundary approximations, but significantly lower than random). | |
| """ | |
| loss_fn = PhysicsResidualLoss(weight=1.0) | |
| # Fix v to a known value for this test | |
| with torch.no_grad(): | |
| loss_fn.log_v.fill_(0.0) # v = 1.0 | |
| loss_fn.log_D.fill_(-10.0) # D ≈ 0 (nearly pure advection) | |
| B, Z, H = 1, 1, 20 | |
| tau = torch.arange(H, dtype=torch.float32) | |
| # Linear ramp: u = a * tau + b (∂u/∂τ = a, ∂²u/∂τ² = 0) | |
| # Exact solution after dt=1: u_next = a*(tau+1) + b = u + a | |
| a = 2.0 | |
| u = a * tau.unsqueeze(0).unsqueeze(0).expand(B, Z, H) | |
| u_next = u + a # shift by a (= v * ∂u/∂τ = 1.0 * a) | |
| residual_advection = loss_fn(u, u_next) | |
| # For comparison: random u_next should have a larger residual than | |
| # the analytically correct solution. | |
| torch.manual_seed(0) | |
| u_random = torch.rand_like(u) * 40.0 # unrelated to u | |
| residual_random = loss_fn(u, u_random) | |
| # The analytically correct solution should produce a strictly lower | |
| # residual than a completely unrelated random prediction. | |
| assert residual_advection.item() < residual_random.item(), ( | |
| f"Expected advection residual ({residual_advection.item():.4f}) < " | |
| f"random residual ({residual_random.item():.4f})" | |
| ) | |
| def test_h_equals_1_does_not_raise(self): | |
| loss_fn = PhysicsResidualLoss(weight=0.01) | |
| u = torch.rand(2, 3, 1) | |
| v = torch.rand(2, 3, 1) | |
| out = loss_fn(u, v) # H=1: finite differences are skipped | |
| assert out.shape == torch.Size([]) | |
| def test_h_equals_2_does_not_raise(self): | |
| loss_fn = PhysicsResidualLoss(weight=0.01) | |
| u = torch.rand(2, 3, 2) | |
| v = torch.rand(2, 3, 2) | |
| out = loss_fn(u, v) # H=2: boundary branch, no interior differences | |
| assert out.shape == torch.Size([]) | |
| # --------------------------------------------------------------------------- | |
| # TemporalDynamicsModel — output shapes and value ranges | |
| # --------------------------------------------------------------------------- | |
| class TestTemporalDynamicsModelShapes: | |
| def test_precip_output_shape(self, model, state, batch_size, n_zones, horizon_days): | |
| next_s, _ = model(state) | |
| assert next_s.precip.shape == (batch_size, n_zones, horizon_days) | |
| def test_uncertainty_output_shape(self, model, state, batch_size, n_zones): | |
| next_s, _ = model(state) | |
| assert next_s.uncertainty.shape == (batch_size, n_zones) | |
| def test_belief_output_shape(self, model, state, batch_size, n_zones): | |
| next_s, _ = model(state) | |
| assert next_s.belief.shape == (batch_size, n_zones) | |
| def test_precip_non_negative(self, model, state): | |
| next_s, _ = model(state) | |
| assert (next_s.precip >= 0).all(), "Softplus decoder produced negative precip" | |
| def test_uncertainty_in_unit_interval(self, model, state): | |
| next_s, _ = model(state) | |
| assert (next_s.uncertainty >= 0).all() and (next_s.uncertainty <= 1).all() | |
| def test_belief_in_unit_interval(self, model, state): | |
| next_s, _ = model(state) | |
| assert (next_s.belief >= 0).all() and (next_s.belief <= 1).all() | |
| def test_return_physics_loss_false_gives_none(self, model, state): | |
| _, phys = model(state, return_physics_loss=False) | |
| assert phys is None | |
| def test_return_physics_loss_true_gives_tensor(self, model, state): | |
| _, phys = model(state, return_physics_loss=True) | |
| assert phys is not None | |
| assert phys.shape == torch.Size([]) | |
| def test_physics_loss_non_negative(self, model, state): | |
| _, phys = model(state) | |
| assert phys.item() >= 0.0 | |
| def test_physics_loss_has_gradient(self, n_zones, horizon_days): | |
| m = TemporalDynamicsModel(n_zones=n_zones, horizon_days=horizon_days) | |
| m.train() | |
| s = ZoneStateTensor( | |
| precip=torch.rand(2, n_zones, horizon_days) * 10, | |
| uncertainty=torch.rand(2, n_zones), | |
| belief=torch.rand(2, n_zones), | |
| ) | |
| _, phys = m(s, return_physics_loss=True) | |
| phys.backward() | |
| grads = [p.grad for p in m.parameters() if p.grad is not None] | |
| assert len(grads) > 0, "No gradients flowed through physics loss" | |
| def test_batch_size_variants(self, n_zones, horizon_days, batch): | |
| torch.manual_seed(0) | |
| m = TemporalDynamicsModel(n_zones=n_zones, horizon_days=horizon_days) | |
| m.eval() | |
| s = ZoneStateTensor( | |
| precip=torch.rand(batch, n_zones, horizon_days), | |
| uncertainty=torch.rand(batch, n_zones), | |
| belief=torch.rand(batch, n_zones), | |
| ) | |
| next_s, _ = m(s) | |
| assert next_s.precip.shape == (batch, n_zones, horizon_days) | |
| def test_shape_combinations(self, nz, hd): | |
| torch.manual_seed(0) | |
| m = TemporalDynamicsModel(n_zones=nz, horizon_days=hd) | |
| m.eval() | |
| s = ZoneStateTensor( | |
| precip=torch.rand(2, nz, hd) * 10, | |
| uncertainty=torch.rand(2, nz), | |
| belief=torch.rand(2, nz), | |
| ) | |
| next_s, phys = m(s) | |
| assert next_s.precip.shape == (2, nz, hd) | |
| assert next_s.uncertainty.shape == (2, nz) | |
| assert next_s.belief.shape == (2, nz) | |
| assert phys is not None and phys.shape == torch.Size([]) | |
| def test_deterministic_in_eval_mode(self, model, state): | |
| with torch.no_grad(): | |
| out1, _ = model(state) | |
| out2, _ = model(state) | |
| assert torch.allclose(out1.precip, out2.precip) | |
| assert torch.allclose(out1.uncertainty, out2.uncertainty) | |
| assert torch.allclose(out1.belief, out2.belief) | |
| def test_output_is_finite(self, model, state): | |
| next_s, phys = model(state) | |
| assert torch.isfinite(next_s.precip).all() | |
| assert torch.isfinite(next_s.uncertainty).all() | |
| assert torch.isfinite(next_s.belief).all() | |
| assert torch.isfinite(phys) | |
| # --------------------------------------------------------------------------- | |
| # TemporalDynamicsModel — save / load | |
| # --------------------------------------------------------------------------- | |
| class TestTemporalDynamicsModelSaveLoad: | |
| def test_save_creates_file(self, model): | |
| with tempfile.TemporaryDirectory() as tmp: | |
| path = Path(tmp) / "model.pt" | |
| model.save(str(path)) | |
| assert path.exists() | |
| def test_save_creates_parent_directories(self, model): | |
| with tempfile.TemporaryDirectory() as tmp: | |
| path = Path(tmp) / "nested" / "dir" / "model.pt" | |
| model.save(str(path)) | |
| assert path.exists() | |
| def test_load_restores_n_zones(self, model, n_zones): | |
| with tempfile.TemporaryDirectory() as tmp: | |
| path = Path(tmp) / "m.pt" | |
| model.save(str(path)) | |
| loaded = TemporalDynamicsModel.load(str(path)) | |
| assert loaded.n_zones == n_zones | |
| def test_load_restores_horizon_days(self, model, horizon_days): | |
| with tempfile.TemporaryDirectory() as tmp: | |
| path = Path(tmp) / "m.pt" | |
| model.save(str(path)) | |
| loaded = TemporalDynamicsModel.load(str(path)) | |
| assert loaded.horizon_days == horizon_days | |
| def test_load_restores_weights_exactly(self, model, state): | |
| with tempfile.TemporaryDirectory() as tmp: | |
| path = Path(tmp) / "m.pt" | |
| model.save(str(path)) | |
| loaded = TemporalDynamicsModel.load(str(path)) | |
| loaded.eval() | |
| with torch.no_grad(): | |
| out_orig, _ = model(state) | |
| out_load, _ = loaded(state) | |
| assert torch.allclose(out_orig.precip, out_load.precip, atol=1e-6) | |
| assert torch.allclose(out_orig.uncertainty, out_load.uncertainty, atol=1e-6) | |
| assert torch.allclose(out_orig.belief, out_load.belief, atol=1e-6) | |
| def test_load_restores_physics_parameters(self, model): | |
| with tempfile.TemporaryDirectory() as tmp: | |
| path = Path(tmp) / "m.pt" | |
| # Modify physics params to non-default values | |
| with torch.no_grad(): | |
| model.physics_loss.log_v.fill_(0.5) | |
| model.physics_loss.log_D.fill_(-1.0) | |
| model.save(str(path)) | |
| loaded = TemporalDynamicsModel.load(str(path)) | |
| assert loaded.physics_loss.log_v.item() == pytest.approx(0.5, abs=1e-5) | |
| assert loaded.physics_loss.log_D.item() == pytest.approx(-1.0, abs=1e-5) | |
| def test_path_object_accepted(self, model): | |
| with tempfile.TemporaryDirectory() as tmp: | |
| path = Path(tmp) / "m.pt" | |
| model.save(path) # Path object, not str | |
| loaded = TemporalDynamicsModel.load(path) | |
| assert loaded.n_zones == model.n_zones | |
| # --------------------------------------------------------------------------- | |
| # TemporalDynamicsModel.rollout() | |
| # --------------------------------------------------------------------------- | |
| class TestTemporalDynamicsModelRollout: | |
| def test_returns_steps_plus_one_states(self, model, state): | |
| steps = 5 | |
| result = model.rollout(state, steps=steps) | |
| assert len(result) == steps + 1 | |
| def test_first_state_is_initial(self, model, state): | |
| result = model.rollout(state, steps=3) | |
| assert result[0] is state # identity, not a copy | |
| def test_all_states_have_correct_shapes(self, model, state, batch_size, n_zones, horizon_days): | |
| result = model.rollout(state, steps=4) | |
| for s in result: | |
| assert s.precip.shape == (batch_size, n_zones, horizon_days) | |
| assert s.uncertainty.shape == (batch_size, n_zones) | |
| assert s.belief.shape == (batch_size, n_zones) | |
| def test_all_precip_non_negative(self, model, state): | |
| result = model.rollout(state, steps=5) | |
| for i, s in enumerate(result[1:], 1): # skip initial | |
| assert (s.precip >= 0).all(), f"Negative precip at step {i}" | |
| def test_all_uncertainty_in_unit_interval(self, model, state): | |
| result = model.rollout(state, steps=5) | |
| for i, s in enumerate(result[1:], 1): | |
| assert (s.uncertainty >= 0).all() and (s.uncertainty <= 1).all(), \ | |
| f"Uncertainty out of [0,1] at step {i}" | |
| def test_all_belief_in_unit_interval(self, model, state): | |
| result = model.rollout(state, steps=5) | |
| for i, s in enumerate(result[1:], 1): | |
| assert (s.belief >= 0).all() and (s.belief <= 1).all(), \ | |
| f"Belief out of [0,1] at step {i}" | |
| def test_no_gradient_tracking_during_rollout(self, model, state): | |
| result = model.rollout(state, steps=3) | |
| for s in result[1:]: | |
| assert not s.precip.requires_grad | |
| assert not s.uncertainty.requires_grad | |
| assert not s.belief.requires_grad | |
| def test_steps_zero_returns_only_initial(self, model, state): | |
| result = model.rollout(state, steps=0) | |
| assert len(result) == 1 | |
| assert result[0] is state | |
| def test_rollout_values_are_finite(self, model, state): | |
| result = model.rollout(state, steps=10) | |
| for s in result: | |
| assert torch.isfinite(s.precip).all() | |
| assert torch.isfinite(s.uncertainty).all() | |
| assert torch.isfinite(s.belief).all() | |
| # --------------------------------------------------------------------------- | |
| # DynamicsTrainer | |
| # --------------------------------------------------------------------------- | |
| class TestDynamicsTrainer: | |
| def test_train_returns_dict_with_correct_keys(self, tiny_pairs, n_zones, horizon_days): | |
| trainer = DynamicsTrainer(n_zones=n_zones, horizon_days=horizon_days) | |
| history = trainer.train(tiny_pairs, epochs=2, batch_size=4) | |
| assert "train_loss" in history | |
| assert "val_loss" in history | |
| assert "physics_loss" in history | |
| def test_history_lengths_equal_epochs(self, tiny_pairs, n_zones, horizon_days): | |
| epochs = 3 | |
| trainer = DynamicsTrainer(n_zones=n_zones, horizon_days=horizon_days) | |
| history = trainer.train(tiny_pairs, epochs=epochs, batch_size=4) | |
| assert len(history["train_loss"]) == epochs | |
| assert len(history["val_loss"]) == epochs | |
| assert len(history["physics_loss"]) == epochs | |
| def test_all_losses_are_finite(self, tiny_pairs, n_zones, horizon_days): | |
| trainer = DynamicsTrainer(n_zones=n_zones, horizon_days=horizon_days) | |
| history = trainer.train(tiny_pairs, epochs=3, batch_size=4) | |
| for loss in history["train_loss"] + history["val_loss"] + history["physics_loss"]: | |
| assert np.isfinite(loss), f"Non-finite loss value: {loss}" | |
| def test_all_losses_are_non_negative(self, tiny_pairs, n_zones, horizon_days): | |
| trainer = DynamicsTrainer(n_zones=n_zones, horizon_days=horizon_days) | |
| history = trainer.train(tiny_pairs, epochs=3, batch_size=4) | |
| for loss in history["train_loss"] + history["val_loss"] + history["physics_loss"]: | |
| assert loss >= 0.0, f"Negative loss: {loss}" | |
| def test_loss_decreases_over_training(self, n_zones, horizon_days): | |
| """ | |
| Loss should trend downward over sufficient epochs on a small fixed dataset. | |
| We test that final loss < initial loss (not strictly monotonic — that is | |
| not guaranteed with SGD). Seed is fixed for reproducibility. | |
| """ | |
| torch.manual_seed(0) | |
| np.random.seed(0) | |
| # Build a more learnable target: next = current + small noise | |
| pairs = [] | |
| for _ in range(30): | |
| curr = ZoneStateTensor( | |
| precip=torch.rand(1, n_zones, horizon_days) * 10, | |
| uncertainty=torch.rand(1, n_zones) * 0.5, | |
| belief=torch.rand(1, n_zones) * 0.5, | |
| ) | |
| nxt = ZoneStateTensor( | |
| precip=curr.precip + torch.randn_like(curr.precip) * 0.1, | |
| uncertainty=torch.clamp(curr.uncertainty + torch.randn_like(curr.uncertainty) * 0.01, 0, 1), | |
| belief=torch.clamp(curr.belief + torch.randn_like(curr.belief) * 0.01, 0, 1), | |
| ) | |
| pairs.append((curr, nxt)) | |
| trainer = DynamicsTrainer( | |
| n_zones=n_zones, horizon_days=horizon_days, | |
| physics_weight=0.001, # low physics weight for this test | |
| ) | |
| history = trainer.train(pairs, epochs=20, batch_size=8, lr=1e-2) | |
| first_loss = history["train_loss"][0] | |
| last_loss = history["train_loss"][-1] | |
| assert last_loss < first_loss, ( | |
| f"Loss did not decrease: first={first_loss:.4f} last={last_loss:.4f}" | |
| ) | |
| def test_raises_on_empty_pairs(self, n_zones, horizon_days): | |
| trainer = DynamicsTrainer(n_zones=n_zones, horizon_days=horizon_days) | |
| with pytest.raises(ValueError, match="empty"): | |
| trainer.train([], epochs=1) | |
| def test_single_pair_does_not_crash(self, n_zones, horizon_days): | |
| torch.manual_seed(0) | |
| pair = ( | |
| ZoneStateTensor( | |
| precip=torch.rand(1, n_zones, horizon_days), | |
| uncertainty=torch.rand(1, n_zones), | |
| belief=torch.rand(1, n_zones), | |
| ), | |
| ZoneStateTensor( | |
| precip=torch.rand(1, n_zones, horizon_days), | |
| uncertainty=torch.rand(1, n_zones), | |
| belief=torch.rand(1, n_zones), | |
| ), | |
| ) | |
| trainer = DynamicsTrainer(n_zones=n_zones, horizon_days=horizon_days) | |
| # val_split=0.0 → n_val=0, n_train=1 (after the guard fix). | |
| # With val_split=0.1 and N=1 → n_val would be 1, n_train=0 → should raise. | |
| # val_split=0.0 is the correct way to train on a tiny dataset. | |
| history = trainer.train([pair], epochs=1, batch_size=1, val_split=0.0) | |
| assert "train_loss" in history | |
| assert len(history["train_loss"]) == 1 | |
| # Verify that passing exactly 2 pairs with val_split=1.0 raises clearly | |
| # (n_val = max(0, 2-1) = 1, n_train = 1 is fine — but val_split=1.0 | |
| # with the guard: n_val = int(2*1.0) = 2 >= N=2 → n_val = max(0,2-1)=1 | |
| # So we need val_split such that int(N*val_split) >= N with our guard. | |
| # The guard sets n_val = max(0, N-1), so n_train = 1 always survives. | |
| # The real failure case is N=1 with val_split=0.5+: int(1*0.5)=0 → fine. | |
| # Actually to trigger the error we need a 0-sample training set which | |
| # cannot happen with the guard. Verify graceful handling instead. | |
| history2 = trainer.train([pair, pair], epochs=1, batch_size=1, val_split=0.5) | |
| assert "train_loss" in history2 | |
| def test_save_delegates_to_model(self, tiny_pairs, n_zones, horizon_days): | |
| trainer = DynamicsTrainer(n_zones=n_zones, horizon_days=horizon_days) | |
| trainer.train(tiny_pairs, epochs=1, batch_size=4) | |
| with tempfile.TemporaryDirectory() as tmp: | |
| path = Path(tmp) / "trainer_model.pt" | |
| trainer.save(str(path)) | |
| assert path.exists() | |
| loaded = TemporalDynamicsModel.load(str(path)) | |
| assert loaded.n_zones == n_zones | |
| assert loaded.horizon_days == horizon_days | |
| def test_physics_weight_zero_trains_without_physics(self, tiny_pairs, n_zones, horizon_days): | |
| trainer = DynamicsTrainer( | |
| n_zones=n_zones, horizon_days=horizon_days, physics_weight=0.0 | |
| ) | |
| history = trainer.train(tiny_pairs, epochs=2, batch_size=4) | |
| # history["physics_loss"] records the RAW unweighted PDE residual, | |
| # not the weighted contribution to the total loss. When physics_weight=0 | |
| # the residual is still computed and logged — it just doesn't affect | |
| # the gradient. We verify it is finite and non-negative. | |
| for pl in history["physics_loss"]: | |
| assert np.isfinite(pl), f"Physics loss is not finite: {pl}" | |
| assert pl >= 0.0, f"Physics loss is negative: {pl}" | |
| # --------------------------------------------------------------------------- | |
| # EnsembleDynamics | |
| # --------------------------------------------------------------------------- | |
| class TestEnsembleDynamics: | |
| def test_predict_returns_state_and_scalar(self, state, n_zones, horizon_days): | |
| torch.manual_seed(0) | |
| ens = EnsembleDynamics(n_models=3, n_zones=n_zones, horizon_days=horizon_days) | |
| mean_state, epistemic = ens.predict(state) | |
| assert isinstance(mean_state, ZoneStateTensor) | |
| assert epistemic.shape == torch.Size([]) | |
| def test_mean_state_shapes(self, state, batch_size, n_zones, horizon_days): | |
| torch.manual_seed(0) | |
| ens = EnsembleDynamics(n_models=3, n_zones=n_zones, horizon_days=horizon_days) | |
| mean_state, _ = ens.predict(state) | |
| assert mean_state.precip.shape == (batch_size, n_zones, horizon_days) | |
| assert mean_state.uncertainty.shape == (batch_size, n_zones) | |
| assert mean_state.belief.shape == (batch_size, n_zones) | |
| def test_epistemic_uncertainty_non_negative(self, state, n_zones, horizon_days): | |
| torch.manual_seed(0) | |
| ens = EnsembleDynamics(n_models=3, n_zones=n_zones, horizon_days=horizon_days) | |
| _, epistemic = ens.predict(state) | |
| assert epistemic.item() >= 0.0 | |
| def test_single_model_ensemble_has_zero_uncertainty(self, state, n_zones, horizon_days): | |
| """ | |
| With n_models=1 there is no variance — std of a single value is 0. | |
| """ | |
| torch.manual_seed(0) | |
| ens = EnsembleDynamics(n_models=1, n_zones=n_zones, horizon_days=horizon_days) | |
| _, epistemic = ens.predict(state) | |
| assert epistemic.item() == pytest.approx(0.0, abs=1e-6) | |
| def test_multi_model_ensemble_has_positive_uncertainty(self, state, n_zones, horizon_days): | |
| """ | |
| With n_models=5 and randomly initialised weights, the ensemble members | |
| will disagree, producing non-zero epistemic uncertainty. | |
| """ | |
| torch.manual_seed(99) | |
| ens = EnsembleDynamics(n_models=5, n_zones=n_zones, horizon_days=horizon_days) | |
| _, epistemic = ens.predict(state) | |
| assert epistemic.item() > 0.0, "Expected positive uncertainty from diverse ensemble" | |
| def test_mean_state_values_finite(self, state, n_zones, horizon_days): | |
| torch.manual_seed(0) | |
| ens = EnsembleDynamics(n_models=3, n_zones=n_zones, horizon_days=horizon_days) | |
| mean_state, _ = ens.predict(state) | |
| assert torch.isfinite(mean_state.precip).all() | |
| assert torch.isfinite(mean_state.uncertainty).all() | |
| assert torch.isfinite(mean_state.belief).all() | |
| def test_to_cpu_does_not_raise(self, n_zones, horizon_days): | |
| ens = EnsembleDynamics(n_models=2, n_zones=n_zones, horizon_days=horizon_days) | |
| result = ens.to(torch.device("cpu")) | |
| assert result is ens # returns self | |
| def test_no_gradient_in_predict(self, state, n_zones, horizon_days): | |
| torch.manual_seed(0) | |
| ens = EnsembleDynamics(n_models=2, n_zones=n_zones, horizon_days=horizon_days) | |
| mean_state, epistemic = ens.predict(state) | |
| assert not mean_state.precip.requires_grad | |
| assert not epistemic.requires_grad | |
| # --------------------------------------------------------------------------- | |
| # DynaRolloutBuffer | |
| # --------------------------------------------------------------------------- | |
| class TestDynaRolloutBuffer: | |
| def buffer(self, model) -> DynaRolloutBuffer: | |
| return DynaRolloutBuffer( | |
| dynamics=model, | |
| n_synthetic_steps=3, | |
| uncertainty_weight=0.1, | |
| ) | |
| def test_compute_surprise_bonus_is_scalar(self, buffer, state): | |
| bonus = buffer.compute_surprise_bonus(state, state) | |
| assert bonus.shape == torch.Size([]) | |
| def test_compute_surprise_bonus_non_negative(self, buffer, state): | |
| bonus = buffer.compute_surprise_bonus(state, state) | |
| assert bonus.item() >= 0.0 | |
| def test_compute_surprise_bonus_bounded_by_uncertainty_weight(self, buffer, state): | |
| """Bonus is clipped to [0, uncertainty_weight].""" | |
| n_zones, horizon_days = state.n_zones, state.horizon_days | |
| # Make next_state very different to maximise surprise | |
| very_different = ZoneStateTensor( | |
| precip=torch.full_like(state.precip, 499.0), | |
| uncertainty=torch.ones_like(state.uncertainty), | |
| belief=torch.zeros_like(state.belief), | |
| ) | |
| bonus = buffer.compute_surprise_bonus(state, very_different) | |
| assert bonus.item() <= buffer.uncertainty_weight + 1e-6 | |
| def test_identical_states_give_near_zero_bonus(self, buffer, state): | |
| """ | |
| When current and next are identical, the model's prediction error | |
| should be low, producing a near-zero surprise bonus. | |
| Note: not exactly zero because the model doesn't predict the identity. | |
| We just check it is lower than the maximum possible bonus. | |
| """ | |
| bonus_same = buffer.compute_surprise_bonus(state, state) | |
| very_different = ZoneStateTensor( | |
| precip=torch.full_like(state.precip, 499.0), | |
| uncertainty=torch.ones_like(state.uncertainty), | |
| belief=torch.zeros_like(state.belief), | |
| ) | |
| bonus_diff = buffer.compute_surprise_bonus(state, very_different) | |
| # Identical input should produce smaller or equal bonus than maximally different | |
| assert bonus_same.item() <= bonus_diff.item() + 1e-6 | |
| def test_no_gradient_in_compute_surprise_bonus(self, buffer, state): | |
| bonus = buffer.compute_surprise_bonus(state, state) | |
| assert not bonus.requires_grad | |
| def test_generate_rollout_length(self, buffer, state): | |
| steps = buffer.n_synthetic_steps | |
| result = buffer.generate_rollout(state) | |
| assert len(result) == steps + 1 | |
| def test_generate_rollout_shapes(self, buffer, state, batch_size, n_zones, horizon_days): | |
| result = buffer.generate_rollout(state) | |
| for s in result: | |
| assert s.precip.shape == (batch_size, n_zones, horizon_days) | |
| def test_generate_rollout_no_gradient(self, buffer, state): | |
| result = buffer.generate_rollout(state) | |
| for s in result[1:]: | |
| assert not s.precip.requires_grad | |
| def test_dynamics_attribute_accessible(self, buffer, model): | |
| assert buffer.dynamics is model | |
| def test_bonus_respects_uncertainty_weight_parameter(self, model, state, weight): | |
| buf = DynaRolloutBuffer(dynamics=model, uncertainty_weight=weight) | |
| very_different = ZoneStateTensor( | |
| precip=torch.full_like(state.precip, 499.0), | |
| uncertainty=torch.ones_like(state.uncertainty), | |
| belief=torch.zeros_like(state.belief), | |
| ) | |
| bonus = buf.compute_surprise_bonus(state, very_different) | |
| assert bonus.item() <= weight + 1e-6 | |
| assert bonus.item() >= 0.0 | |