DHDRL commited on
Commit
c8d23e4
·
verified ·
1 Parent(s): 910f8ae

Update physics_dynamics.py

Browse files
Files changed (1) hide show
  1. physics_dynamics.py +1 -245
physics_dynamics.py CHANGED
@@ -14,28 +14,6 @@ The model predicts how the zone-level forecast state evolves over time,
14
  constrained by an advection-diffusion PDE residual that prevents physically
15
  impossible predictions (e.g. precipitation materialising from nothing,
16
  uncertainty decreasing without new observations).
17
-
18
-
19
- Classes
20
- -------
21
- ZoneStateTensor — named container for the three observation arrays
22
- TemporalDynamicsModel — core learned model (GRU encoder + MLP transition)
23
- PhysicsResidualLoss — advection-diffusion PDE residual along time axis
24
- DynamicsTrainer — offline pre-training on ERA5 sequences
25
- EnsembleDynamics — N models for epistemic uncertainty quantification
26
- DynaRolloutBuffer — generates synthetic transitions for PPO augmentation
27
-
28
- Usage
29
- -----
30
- # 1. Pre-train on ERA5 sequences
31
- trainer = DynamicsTrainer(n_zones=4, horizon_days=14)
32
- trainer.train(era5_sequences) # list of ZoneStateTensor
33
- trainer.save("dynamics_model.pt")
34
-
35
- # 2. Load in training loop and generate synthetic rollouts
36
- dynamics = TemporalDynamicsModel.load("dynamics_model.pt")
37
- buffer = DynaRolloutBuffer(dynamics, n_synthetic_per_real=4)
38
- # Pass buffer to custom PPO callback (see train_curriculum.py notes)
39
  """
40
 
41
  from __future__ import annotations
@@ -60,19 +38,6 @@ logger = logging.getLogger(__name__)
60
 
61
  @dataclass
62
  class ZoneStateTensor:
63
- """
64
- A single time-step of zone-level state, as tensors.
65
-
66
- Mirrors the WeatherForecastEnv observation space:
67
- precip: [batch, n_zones, horizon_days]
68
- uncertainty: [batch, n_zones]
69
- belief: [batch, n_zones]
70
-
71
- All values float32 in their natural ranges:
72
- precip [0, 500] mm
73
- uncertainty [0, 1]
74
- belief [0, 1]
75
- """
76
  precip: torch.Tensor # [batch, n_zones, horizon_days]
77
  uncertainty: torch.Tensor # [batch, n_zones]
78
  belief: torch.Tensor # [batch, n_zones]
@@ -97,7 +62,6 @@ class ZoneStateTensor:
97
  )
98
 
99
  def flat(self) -> torch.Tensor:
100
- """Flatten to [batch, n_zones * (horizon_days + 2)] for MLP input."""
101
  B, Z, H = self.precip.shape
102
  precip_flat = self.precip.reshape(B, Z * H)
103
  return torch.cat([precip_flat, self.uncertainty, self.belief], dim=-1)
@@ -113,7 +77,6 @@ class ZoneStateTensor:
113
  uncertainty: np.ndarray,
114
  belief: np.ndarray,
115
  ) -> "ZoneStateTensor":
116
- """Construct from numpy arrays (adds batch dim if missing)."""
117
  if precip.ndim == 2:
118
  precip = precip[None]
119
  if uncertainty.ndim == 1:
@@ -132,36 +95,10 @@ class ZoneStateTensor:
132
  # ---------------------------------------------------------------------------
133
 
134
  class PhysicsResidualLoss(nn.Module):
135
- """
136
- Advection-diffusion PDE residual along the TEMPORAL dimension.
137
-
138
- The physical intuition: as time advances by dt days, the precipitation
139
- forecast at day t in the horizon should approximately equal the forecast
140
- at day (t - dt) from the previous time step, shifted by advection and
141
- smoothed by diffusion. This is the atmospheric forecast evolution equation.
142
-
143
- Residual: ∂u/∂t + v ∂u/∂τ - D ∂²u/∂τ² = 0
144
-
145
- where:
146
- u = precipitation forecast value
147
- t = real time (day-to-day model evolution)
148
- τ = forecast lead time (the horizon axis, days 0..H-1)
149
- v = advection speed (learnable)
150
- D = diffusion coefficient (learnable)
151
-
152
- This is applied per zone independently (zones are not a spatial grid).
153
-
154
- Args:
155
- weight: Scalar multiplier for the physics loss term.
156
- Start with 0.01-0.05; increase if predictions are unphysical.
157
- """
158
 
159
  def __init__(self, weight: float = 0.01):
160
  super().__init__()
161
  self.weight = weight
162
- # Learnable physics parameters — initialised to physically plausible values
163
- # v: forecast advection ~1 day/day (forecast evolves with real time)
164
- # D: diffusion smoothing ~0.1 (moderate smoothing of forecast errors)
165
  self.log_v = nn.Parameter(torch.tensor(0.0)) # exp(0) = 1.0
166
  self.log_D = nn.Parameter(torch.tensor(-2.3)) # exp(-2.3) ≈ 0.1
167
 
@@ -179,13 +116,6 @@ class PhysicsResidualLoss(nn.Module):
179
  u_next: torch.Tensor, # [batch, n_zones, horizon_days]
180
  dt: float = 1.0,
181
  ) -> torch.Tensor:
182
- """
183
- Compute mean squared PDE residual.
184
-
185
- u_current: forecast at time t
186
- u_next: predicted forecast at time t + dt
187
- dt: real-time step in days
188
- """
189
  B, Z, H = u_current.shape
190
 
191
  # ∂u/∂t ≈ (u_next - u_current) / dt
@@ -219,27 +149,6 @@ class PhysicsResidualLoss(nn.Module):
219
  # ---------------------------------------------------------------------------
220
 
221
  class TemporalDynamicsModel(nn.Module):
222
- """
223
- Learned dynamics model: predicts next zone state from current state.
224
-
225
- Architecture:
226
- 1. Per-zone GRU encoder compresses the forecast horizon sequence
227
- into a latent zone embedding.
228
- 2. MLP transition model maps current latent → next latent.
229
- 3. MLP decoder reconstructs full next-state from latent.
230
-
231
- Why GRU encoder (not FNO): Your forecast data is [n_zones, horizon_days]
232
- where n_zones is small (2-4) and horizon_days is short (7-14). FNO is
233
- designed for large spatial fields (64x64+). A GRU over the horizon axis
234
- per zone is exact for this scale and directly compatible with your
235
- existing GRUWeatherFeaturesExtractor architecture.
236
-
237
- Args:
238
- n_zones: Number of geographic zones (matches env config)
239
- horizon_days: Forecast horizon length (matches env config)
240
- latent_dim: Dimension of per-zone latent embedding
241
- hidden_dim: MLP hidden size for transition and decoder
242
- """
243
 
244
  def __init__(
245
  self,
@@ -253,8 +162,6 @@ class TemporalDynamicsModel(nn.Module):
253
  self.horizon_days = horizon_days
254
  self.latent_dim = latent_dim
255
 
256
- # --- Encoder: horizon sequence → zone latent ---
257
- # Applied identically to each zone (weight sharing)
258
  self.precip_encoder = nn.GRU(
259
  input_size=1,
260
  hidden_size=latent_dim,
@@ -262,7 +169,6 @@ class TemporalDynamicsModel(nn.Module):
262
  batch_first=True,
263
  )
264
 
265
- # Zone metadata (uncertainty + belief) → extra latent dims
266
  self.meta_encoder = nn.Sequential(
267
  nn.Linear(2, latent_dim),
268
  nn.Tanh(),
@@ -270,7 +176,6 @@ class TemporalDynamicsModel(nn.Module):
270
 
271
  zone_latent_dim = latent_dim * 2 # precip latent + meta latent
272
 
273
- # --- Transition: current latent → next latent (all zones jointly) ---
274
  full_latent_dim = n_zones * zone_latent_dim
275
  self.transition = nn.Sequential(
276
  nn.Linear(full_latent_dim, hidden_dim),
@@ -280,7 +185,6 @@ class TemporalDynamicsModel(nn.Module):
280
  nn.Linear(hidden_dim, full_latent_dim),
281
  )
282
 
283
- # --- Decoder: latent → next state components ---
284
  self.precip_decoder = nn.Sequential(
285
  nn.Linear(zone_latent_dim, hidden_dim),
286
  nn.SiLU(),
@@ -300,7 +204,6 @@ class TemporalDynamicsModel(nn.Module):
300
  nn.Sigmoid(), # belief in [0, 1]
301
  )
302
 
303
- # Physics loss module (parameters learned jointly with model)
304
  self.physics_loss = PhysicsResidualLoss(weight=0.01)
305
 
306
  logger.info(
@@ -309,16 +212,12 @@ class TemporalDynamicsModel(nn.Module):
309
  )
310
 
311
  def _encode(self, state: ZoneStateTensor) -> torch.Tensor:
312
- """Encode zone state → latent. Returns [batch, n_zones, zone_latent_dim]."""
313
  B, Z, H = state.precip.shape
314
 
315
- # Encode each zone's precipitation forecast sequence with the GRU
316
- # Reshape to [batch * n_zones, horizon_days, 1] for GRU
317
  precip_seq = state.precip.reshape(B * Z, H, 1)
318
  _, h_n = self.precip_encoder(precip_seq) # h_n: [1, B*Z, latent_dim]
319
  precip_latent = h_n.squeeze(0).reshape(B, Z, self.latent_dim)
320
 
321
- # Encode per-zone metadata [uncertainty, belief]
322
  meta = torch.stack([state.uncertainty, state.belief], dim=-1) # [B, Z, 2]
323
  meta_flat = meta.reshape(B * Z, 2)
324
  meta_latent = self.meta_encoder(meta_flat).reshape(B, Z, self.latent_dim)
@@ -331,30 +230,14 @@ class TemporalDynamicsModel(nn.Module):
331
  return_physics_loss: bool = True,
332
  dt: float = 1.0,
333
  ) -> Tuple[ZoneStateTensor, Optional[torch.Tensor]]:
334
- """
335
- Predict next state from current state.
336
-
337
- Args:
338
- current: Current zone state
339
- return_physics_loss: Whether to compute and return the physics residual
340
- dt: Real-time gap in days between current and next snapshot.
341
- Must match pairing cadence (e.g. 5.0 for cache step_days=5).
342
-
343
- Returns:
344
- next_state: Predicted next zone state
345
- physics_loss: PDE residual loss (None if return_physics_loss=False)
346
- """
347
  B, Z, H = current.precip.shape
348
 
349
- # Encode current state
350
  latent = self._encode(current) # [B, Z, zone_latent_dim]
351
  latent_flat = latent.reshape(B, -1) # [B, Z * zone_latent_dim]
352
 
353
- # Transition in latent space (all zones jointly — captures inter-zone correlations)
354
  next_latent_flat = self.transition(latent_flat)
355
  next_latent = next_latent_flat.reshape(B, Z, -1) # [B, Z, zone_latent_dim]
356
 
357
- # Decode next state per zone
358
  next_latent_per_zone = next_latent.reshape(B * Z, -1)
359
 
360
  next_precip = self.precip_decoder(next_latent_per_zone).reshape(B, Z, H)
@@ -367,7 +250,6 @@ class TemporalDynamicsModel(nn.Module):
367
  belief=next_belief,
368
  )
369
 
370
- # Physics residual loss on the precipitation forecast evolution
371
  phys_loss = None
372
  if return_physics_loss:
373
  phys_loss = self.physics_loss(
@@ -381,20 +263,11 @@ class TemporalDynamicsModel(nn.Module):
381
  initial: ZoneStateTensor,
382
  steps: int = 5,
383
  ) -> List[ZoneStateTensor]:
384
- """
385
- Generate a multi-step synthetic rollout.
386
-
387
- Used by DynaRolloutBuffer to produce model-imagined transitions
388
- for PPO augmentation. Gradients are not tracked here (inference only).
389
-
390
- Returns list of states [s_0, s_1, ..., s_steps] where s_0 = initial.
391
- """
392
  states = [initial]
393
  current = initial
394
  with torch.no_grad():
395
  for _ in range(steps):
396
  next_state, _ = self.forward(current, return_physics_loss=False)
397
- # Clamp to valid ranges
398
  next_state = ZoneStateTensor(
399
  precip=torch.clamp(next_state.precip, 0.0, 500.0),
400
  uncertainty=torch.clamp(next_state.uncertainty, 0.0, 1.0),
@@ -438,23 +311,6 @@ class TemporalDynamicsModel(nn.Module):
438
  # ---------------------------------------------------------------------------
439
 
440
  class DynamicsTrainer:
441
- """
442
- Pre-trains TemporalDynamicsModel on sequences of zone states.
443
-
444
- Training data format: list of consecutive (current, next) ZoneStateTensor
445
- pairs extracted from ERA5 reanalysis or from environment rollouts.
446
-
447
- Loss: data_loss + physics_loss
448
- data_loss = MSE(predicted_next, actual_next) for all three components
449
- physics_loss = advection-diffusion PDE residual (see PhysicsResidualLoss)
450
-
451
- Args:
452
- n_zones: Must match your env config
453
- horizon_days: Must match your env config
454
- physics_weight: Weight for physics residual in total loss.
455
- Start at 0.01, increase to 0.1 if predictions violate physics.
456
- device: 'cuda' if available, else 'cpu'
457
- """
458
 
459
  def __init__(
460
  self,
@@ -487,24 +343,6 @@ class DynamicsTrainer:
487
  dts: Optional[List[float]] = None,
488
  default_dt: float = 1.0,
489
  ) -> dict:
490
- """
491
- Train the dynamics model on (current_state, next_state) pairs.
492
-
493
- Args:
494
- sequence_pairs: List of (current, next) ZoneStateTensor pairs.
495
- epochs: Training epochs
496
- batch_size: Batch size
497
- lr: Learning rate
498
- val_split: Fraction of data held out for validation
499
- dts: Optional per-pair real-time gaps in days (same
500
- length as sequence_pairs). When None, uses
501
- default_dt for every pair.
502
- default_dt: Fallback dt (days). Use 5.0 for historical
503
- cache pairs built at step_days=5.
504
-
505
- Returns:
506
- Training history dict with 'train_loss' and 'val_loss' lists.
507
- """
508
  if not sequence_pairs:
509
  raise ValueError("sequence_pairs is empty — provide ERA5 data")
510
 
@@ -514,7 +352,6 @@ class DynamicsTrainer:
514
  f"{len(sequence_pairs)}"
515
  )
516
 
517
- # Build tensor dataset from pairs
518
  current_precips, current_uncerts, current_beliefs = [], [], []
519
  next_precips, next_uncerts, next_beliefs = [], [], []
520
  dt_list: List[float] = []
@@ -528,7 +365,6 @@ class DynamicsTrainer:
528
  next_beliefs.append(nxt.belief)
529
  dt_list.append(float(dts[i]) if dts is not None else float(default_dt))
530
 
531
- # Stack along batch dimension
532
  cp = torch.cat(current_precips, dim=0)
533
  cu = torch.cat(current_uncerts, dim=0)
534
  cb = torch.cat(current_beliefs, dim=0)
@@ -538,8 +374,6 @@ class DynamicsTrainer:
538
  dt_t = torch.tensor(dt_list, dtype=torch.float32)
539
 
540
  N = cp.shape[0]
541
- # Guard: we need at least 1 sample in the training split.
542
- # When val_split=0 or N is too small, skip validation entirely.
543
  n_val = int(N * val_split) if val_split > 0 else 0
544
  if n_val >= N:
545
  n_val = max(0, N - 1) # leave at least 1 sample for training
@@ -571,7 +405,6 @@ class DynamicsTrainer:
571
  history = {"train_loss": [], "val_loss": [], "physics_loss": [], "dt_mean": float(dt_t.mean())}
572
 
573
  for epoch in range(epochs):
574
- # --- Train ---
575
  self.model.train()
576
  epoch_data_loss = 0.0
577
  epoch_phys_loss = 0.0
@@ -584,15 +417,11 @@ class DynamicsTrainer:
584
  current = ZoneStateTensor(precip=cp_b, uncertainty=cu_b, belief=cb_b)
585
  target = ZoneStateTensor(precip=np_b, uncertainty=nu_b, belief=nb_b)
586
 
587
- # Batch may mix dts; use batch mean (pairs are homogeneous
588
- # when built with exact step_days only).
589
  batch_dt = float(dt_b.mean().item())
590
  pred, phys_loss = self.model(
591
  current, return_physics_loss=True, dt=batch_dt,
592
  )
593
 
594
- # Data fidelity: MSE on all three components
595
- # Normalise precip by max scale (500mm) to balance loss magnitudes
596
  data_loss = (
597
  F.mse_loss(pred.precip / 500.0, target.precip / 500.0)
598
  + F.mse_loss(pred.uncertainty, target.uncertainty)
@@ -614,7 +443,6 @@ class DynamicsTrainer:
614
  avg_data = epoch_data_loss / len(train_loader)
615
  avg_phys = epoch_phys_loss / len(train_loader)
616
 
617
- # --- Validate ---
618
  self.model.eval()
619
  val_loss = 0.0
620
  with torch.no_grad():
@@ -658,19 +486,6 @@ class DynamicsTrainer:
658
  # ---------------------------------------------------------------------------
659
 
660
  class EnsembleDynamics:
661
- """
662
- Ensemble of N dynamics models for epistemic uncertainty quantification.
663
-
664
- Each model is trained with different random seed initialization.
665
- Disagreement between models = epistemic uncertainty = regions where
666
- the policy should be conservative (important for plasma/fusion applications
667
- where high uncertainty = potentially dangerous operating regime).
668
-
669
- Usage in PPO training:
670
- ensemble = EnsembleDynamics(n_models=5, ...)
671
- mean_next, uncertainty = ensemble.predict(current_state)
672
- # Add uncertainty penalty to reward: reward -= uncertainty_weight * uncertainty
673
- """
674
 
675
  def __init__(self, n_models: int = 5, **model_kwargs):
676
  self.models = [TemporalDynamicsModel(**model_kwargs) for _ in range(n_models)]
@@ -680,13 +495,6 @@ class EnsembleDynamics:
680
  self,
681
  current: ZoneStateTensor,
682
  ) -> Tuple[ZoneStateTensor, torch.Tensor]:
683
- """
684
- Return mean prediction and epistemic uncertainty.
685
-
686
- uncertainty is a scalar tensor: mean std across all zones and features.
687
- Use this to penalise the RL policy for actions that lead to high
688
- uncertainty states (encourages conservative, well-characterised behaviour).
689
- """
690
  all_precips, all_uncerts, all_beliefs = [], [], []
691
 
692
  for model in self.models:
@@ -707,10 +515,7 @@ class EnsembleDynamics:
707
  belief=belief_stack.mean(0),
708
  )
709
 
710
- # Epistemic uncertainty: normalised std across ensemble members
711
  epistemic = (
712
- # correction=0 avoids NaN when n_models=1 (Bessel correction
713
- # would divide by zero with a single sample).
714
  (precip_stack.std(0, correction=0) / 500.0).mean()
715
  + uncert_stack.std(0, correction=0).mean()
716
  + belief_stack.std(0, correction=0).mean()
@@ -729,40 +534,6 @@ class EnsembleDynamics:
729
  # ---------------------------------------------------------------------------
730
 
731
  class DynaRolloutBuffer:
732
- """
733
- Generates synthetic (s, a, r, s') transitions for PPO augmentation.
734
-
735
- Dyna-style model-based RL: use the learned dynamics model to generate
736
- additional training transitions from states already in the replay buffer.
737
- This improves sample efficiency without changing the PPO algorithm.
738
-
739
- Integration with train_curriculum.py:
740
- -------------------------------------
741
- Add a DynaCallback to the PPO training loop:
742
-
743
- class DynaCallback(BaseCallback):
744
- def __init__(self, dynamics: TemporalDynamicsModel, n_synthetic: int = 4):
745
- super().__init__()
746
- self.dynamics = dynamics
747
- self.n_synthetic = n_synthetic
748
-
749
- def _on_rollout_end(self) -> None:
750
- # After each real rollout, generate synthetic transitions
751
- # and inject them into the rollout buffer before the update.
752
- # (Implementation depends on SB3 internals — see notes below.)
753
- pass
754
-
755
- Note: Direct rollout buffer injection is not officially supported in SB3.
756
- The practical approach is to use the dynamics model for reward shaping:
757
- predict next state, measure surprise (|| actual - predicted ||), and add
758
- a small exploration bonus for high-surprise transitions. This requires
759
- no SB3 modifications and still improves sample efficiency.
760
-
761
- Args:
762
- dynamics: Trained TemporalDynamicsModel
763
- n_synthetic_steps: Steps to roll out from each seed state
764
- uncertainty_weight: Weight for epistemic uncertainty penalty in reward
765
- """
766
 
767
  def __init__(
768
  self,
@@ -779,20 +550,10 @@ class DynaRolloutBuffer:
779
  obs_current: ZoneStateTensor,
780
  obs_actual_next: ZoneStateTensor,
781
  ) -> torch.Tensor:
782
- """
783
- Reward bonus for transitions that surprise the dynamics model.
784
-
785
- High surprise = model uncertainty = exploration bonus.
786
- This is the simplest Dyna integration: no SB3 modifications needed,
787
- just add this to the reward in a step callback.
788
-
789
- Returns scalar bonus in [0, ~1].
790
- """
791
  self.dynamics.eval()
792
  with torch.no_grad():
793
  pred_next, _ = self.dynamics(obs_current, return_physics_loss=False)
794
 
795
- # Normalised prediction error
796
  precip_err = F.mse_loss(
797
  pred_next.precip / 500.0,
798
  obs_actual_next.precip / 500.0,
@@ -807,9 +568,4 @@ class DynaRolloutBuffer:
807
  self,
808
  seed_state: ZoneStateTensor,
809
  ) -> List[ZoneStateTensor]:
810
- """
811
- Generate synthetic state sequence from a seed state.
812
-
813
- Returns list of n_synthetic_steps + 1 states starting from seed_state.
814
- """
815
- return self.dynamics.rollout(seed_state, steps=self.n_synthetic_steps)
 
14
  constrained by an advection-diffusion PDE residual that prevents physically
15
  impossible predictions (e.g. precipitation materialising from nothing,
16
  uncertainty decreasing without new observations).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  """
18
 
19
  from __future__ import annotations
 
38
 
39
  @dataclass
40
  class ZoneStateTensor:
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  precip: torch.Tensor # [batch, n_zones, horizon_days]
42
  uncertainty: torch.Tensor # [batch, n_zones]
43
  belief: torch.Tensor # [batch, n_zones]
 
62
  )
63
 
64
  def flat(self) -> torch.Tensor:
 
65
  B, Z, H = self.precip.shape
66
  precip_flat = self.precip.reshape(B, Z * H)
67
  return torch.cat([precip_flat, self.uncertainty, self.belief], dim=-1)
 
77
  uncertainty: np.ndarray,
78
  belief: np.ndarray,
79
  ) -> "ZoneStateTensor":
 
80
  if precip.ndim == 2:
81
  precip = precip[None]
82
  if uncertainty.ndim == 1:
 
95
  # ---------------------------------------------------------------------------
96
 
97
  class PhysicsResidualLoss(nn.Module):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
  def __init__(self, weight: float = 0.01):
100
  super().__init__()
101
  self.weight = weight
 
 
 
102
  self.log_v = nn.Parameter(torch.tensor(0.0)) # exp(0) = 1.0
103
  self.log_D = nn.Parameter(torch.tensor(-2.3)) # exp(-2.3) ≈ 0.1
104
 
 
116
  u_next: torch.Tensor, # [batch, n_zones, horizon_days]
117
  dt: float = 1.0,
118
  ) -> torch.Tensor:
 
 
 
 
 
 
 
119
  B, Z, H = u_current.shape
120
 
121
  # ∂u/∂t ≈ (u_next - u_current) / dt
 
149
  # ---------------------------------------------------------------------------
150
 
151
  class TemporalDynamicsModel(nn.Module):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
  def __init__(
154
  self,
 
162
  self.horizon_days = horizon_days
163
  self.latent_dim = latent_dim
164
 
 
 
165
  self.precip_encoder = nn.GRU(
166
  input_size=1,
167
  hidden_size=latent_dim,
 
169
  batch_first=True,
170
  )
171
 
 
172
  self.meta_encoder = nn.Sequential(
173
  nn.Linear(2, latent_dim),
174
  nn.Tanh(),
 
176
 
177
  zone_latent_dim = latent_dim * 2 # precip latent + meta latent
178
 
 
179
  full_latent_dim = n_zones * zone_latent_dim
180
  self.transition = nn.Sequential(
181
  nn.Linear(full_latent_dim, hidden_dim),
 
185
  nn.Linear(hidden_dim, full_latent_dim),
186
  )
187
 
 
188
  self.precip_decoder = nn.Sequential(
189
  nn.Linear(zone_latent_dim, hidden_dim),
190
  nn.SiLU(),
 
204
  nn.Sigmoid(), # belief in [0, 1]
205
  )
206
 
 
207
  self.physics_loss = PhysicsResidualLoss(weight=0.01)
208
 
209
  logger.info(
 
212
  )
213
 
214
  def _encode(self, state: ZoneStateTensor) -> torch.Tensor:
 
215
  B, Z, H = state.precip.shape
216
 
 
 
217
  precip_seq = state.precip.reshape(B * Z, H, 1)
218
  _, h_n = self.precip_encoder(precip_seq) # h_n: [1, B*Z, latent_dim]
219
  precip_latent = h_n.squeeze(0).reshape(B, Z, self.latent_dim)
220
 
 
221
  meta = torch.stack([state.uncertainty, state.belief], dim=-1) # [B, Z, 2]
222
  meta_flat = meta.reshape(B * Z, 2)
223
  meta_latent = self.meta_encoder(meta_flat).reshape(B, Z, self.latent_dim)
 
230
  return_physics_loss: bool = True,
231
  dt: float = 1.0,
232
  ) -> Tuple[ZoneStateTensor, Optional[torch.Tensor]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  B, Z, H = current.precip.shape
234
 
 
235
  latent = self._encode(current) # [B, Z, zone_latent_dim]
236
  latent_flat = latent.reshape(B, -1) # [B, Z * zone_latent_dim]
237
 
 
238
  next_latent_flat = self.transition(latent_flat)
239
  next_latent = next_latent_flat.reshape(B, Z, -1) # [B, Z, zone_latent_dim]
240
 
 
241
  next_latent_per_zone = next_latent.reshape(B * Z, -1)
242
 
243
  next_precip = self.precip_decoder(next_latent_per_zone).reshape(B, Z, H)
 
250
  belief=next_belief,
251
  )
252
 
 
253
  phys_loss = None
254
  if return_physics_loss:
255
  phys_loss = self.physics_loss(
 
263
  initial: ZoneStateTensor,
264
  steps: int = 5,
265
  ) -> List[ZoneStateTensor]:
 
 
 
 
 
 
 
 
266
  states = [initial]
267
  current = initial
268
  with torch.no_grad():
269
  for _ in range(steps):
270
  next_state, _ = self.forward(current, return_physics_loss=False)
 
271
  next_state = ZoneStateTensor(
272
  precip=torch.clamp(next_state.precip, 0.0, 500.0),
273
  uncertainty=torch.clamp(next_state.uncertainty, 0.0, 1.0),
 
311
  # ---------------------------------------------------------------------------
312
 
313
  class DynamicsTrainer:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
 
315
  def __init__(
316
  self,
 
343
  dts: Optional[List[float]] = None,
344
  default_dt: float = 1.0,
345
  ) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  if not sequence_pairs:
347
  raise ValueError("sequence_pairs is empty — provide ERA5 data")
348
 
 
352
  f"{len(sequence_pairs)}"
353
  )
354
 
 
355
  current_precips, current_uncerts, current_beliefs = [], [], []
356
  next_precips, next_uncerts, next_beliefs = [], [], []
357
  dt_list: List[float] = []
 
365
  next_beliefs.append(nxt.belief)
366
  dt_list.append(float(dts[i]) if dts is not None else float(default_dt))
367
 
 
368
  cp = torch.cat(current_precips, dim=0)
369
  cu = torch.cat(current_uncerts, dim=0)
370
  cb = torch.cat(current_beliefs, dim=0)
 
374
  dt_t = torch.tensor(dt_list, dtype=torch.float32)
375
 
376
  N = cp.shape[0]
 
 
377
  n_val = int(N * val_split) if val_split > 0 else 0
378
  if n_val >= N:
379
  n_val = max(0, N - 1) # leave at least 1 sample for training
 
405
  history = {"train_loss": [], "val_loss": [], "physics_loss": [], "dt_mean": float(dt_t.mean())}
406
 
407
  for epoch in range(epochs):
 
408
  self.model.train()
409
  epoch_data_loss = 0.0
410
  epoch_phys_loss = 0.0
 
417
  current = ZoneStateTensor(precip=cp_b, uncertainty=cu_b, belief=cb_b)
418
  target = ZoneStateTensor(precip=np_b, uncertainty=nu_b, belief=nb_b)
419
 
 
 
420
  batch_dt = float(dt_b.mean().item())
421
  pred, phys_loss = self.model(
422
  current, return_physics_loss=True, dt=batch_dt,
423
  )
424
 
 
 
425
  data_loss = (
426
  F.mse_loss(pred.precip / 500.0, target.precip / 500.0)
427
  + F.mse_loss(pred.uncertainty, target.uncertainty)
 
443
  avg_data = epoch_data_loss / len(train_loader)
444
  avg_phys = epoch_phys_loss / len(train_loader)
445
 
 
446
  self.model.eval()
447
  val_loss = 0.0
448
  with torch.no_grad():
 
486
  # ---------------------------------------------------------------------------
487
 
488
  class EnsembleDynamics:
 
 
 
 
 
 
 
 
 
 
 
 
 
489
 
490
  def __init__(self, n_models: int = 5, **model_kwargs):
491
  self.models = [TemporalDynamicsModel(**model_kwargs) for _ in range(n_models)]
 
495
  self,
496
  current: ZoneStateTensor,
497
  ) -> Tuple[ZoneStateTensor, torch.Tensor]:
 
 
 
 
 
 
 
498
  all_precips, all_uncerts, all_beliefs = [], [], []
499
 
500
  for model in self.models:
 
515
  belief=belief_stack.mean(0),
516
  )
517
 
 
518
  epistemic = (
 
 
519
  (precip_stack.std(0, correction=0) / 500.0).mean()
520
  + uncert_stack.std(0, correction=0).mean()
521
  + belief_stack.std(0, correction=0).mean()
 
534
  # ---------------------------------------------------------------------------
535
 
536
  class DynaRolloutBuffer:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
 
538
  def __init__(
539
  self,
 
550
  obs_current: ZoneStateTensor,
551
  obs_actual_next: ZoneStateTensor,
552
  ) -> torch.Tensor:
 
 
 
 
 
 
 
 
 
553
  self.dynamics.eval()
554
  with torch.no_grad():
555
  pred_next, _ = self.dynamics(obs_current, return_physics_loss=False)
556
 
 
557
  precip_err = F.mse_loss(
558
  pred_next.precip / 500.0,
559
  obs_actual_next.precip / 500.0,
 
568
  self,
569
  seed_state: ZoneStateTensor,
570
  ) -> List[ZoneStateTensor]:
571
+ return self.dynamics.rollout(seed_state, steps=self.n_synthetic_steps)