Source code for physicsnemo.models.dlwp_healpix.HEALPixRecUNet

# SPDX-FileCopyrightText: Copyright (c) 2023 - 2026 NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
Implementation of the Deep Learning Weather Prediction (DLWP) recurrent UNet on the HEALPix mesh.

This class provides the core functionality for the DLWP recurrent UNet on the HEALPix mesh.
It handles the forward pass of the model, the backward pass, and the initialization of the hidden states.
It also supports coupling the model with external inputs from various earth system components.

"""

import logging
from dataclasses import dataclass
from typing import Any, Dict, Sequence

import pandas as pd
import torch
from hydra.utils import instantiate
from omegaconf import DictConfig

from physicsnemo.core.meta import ModelMetaData
from physicsnemo.core.module import Module
from physicsnemo.nn.module.hpx import HEALPixFoldFaces, HEALPixUnfoldFaces

from .layers import (
    _backward_compat_dlesym_v1_args,
    _dlesym_v02_version_mismatch_warning,
    _legacy_hydra_targets_warning,
    _remap_obj,
)

logger = logging.getLogger(__name__)


@dataclass
class MetaData(ModelMetaData):
    r"""Metadata for the DLWP HEALPix recurrent model."""

    # Optimization
    jit: bool = False
    cuda_graphs: bool = True
    amp_cpu: bool = True
    amp_gpu: bool = True
    # Inference
    onnx: bool = False
    # Physics informed
    var_dim: int = 1
    func_torch: bool = False
    auto_grad: bool = False


[docs] class HEALPixRecUNet(Module): r""" Deep Learning Weather Prediction (DLWP) recurrent UNet on the HEALPix mesh. Parameters ---------- encoder : DictConfig Instantiable configuration for the U-Net encoder block. decoder : DictConfig Instantiable configuration for the U-Net decoder block. input_channels : int Number of prognostic input channels per time step. output_channels : int Number of prognostic output channels per time step. n_constants : int Number of constant channels provided for all faces. decoder_input_channels : int Number of prescribed decoder input channels per time step. input_time_dim : int Number of input time steps :math:`T_{in}`. output_time_dim : int Number of output time steps :math:`T_{out}`. delta_time : str, optional Time difference between samples, e.g., ``\"6h\"``. Defaults to ``\"6h\"``. reset_cycle : str, optional Period for recurrent state reset, e.g., ``\"24h\"``. Defaults to ``\"24h\"``. presteps : int, optional Number of warm-up steps used to initialize recurrent states. enable_nhwc : bool, optional If ``True``, use channels-last tensors. enable_healpixpad : bool, optional Enable CUDA HEALPix padding when available. couplings : list, optional Optional coupling specifications appended to the input feature channels. residual_prediction : bool, optional If ``True``, the model will predict the residual of the input and the output and add it to the output. If ``False``, the model will predict the output directly. couplings_time_first : bool, optional If ``True``, the couplings will be passed to the model in the time dimension first. If ``False``, the couplings will be passed to the model in the channel dimension first. constraints : list[DictConfig], optional Optional constraints to be applied to the model outputs. is_diagnostic : bool, optional If ``True``, the model runs in diagnostic mode: it performs a single forward step and produces exactly one output time (``output_time_dim`` must be ``1``). Defaults to ``False``. Forward ------- inputs : Sequence[torch.Tensor] Inputs shaped :math:`(B, F, T_{in}, C_{in}, H, W)` plus decoder inputs, constants, and optional coupling tensors. output_only_last : bool, optional If ``True``, return only the final forecast step. Outputs ------- torch.Tensor Predictions shaped :math:`(B, F, T_{out}, C_{out}, H, W)`. """ __model_checkpoint_version__ = "0.3.0" __supported_model_checkpoint_version__ = { "0.1.0": _legacy_hydra_targets_warning, "0.2.0": _dlesym_v02_version_mismatch_warning, } @classmethod def _backward_compat_arg_mapper( cls, version: str, args: Dict[str, Any] ) -> Dict[str, Any]: r""" Map arguments from older checkpoints to the current format. Parameters ---------- version : str Version of the checkpoint being loaded. args : Dict[str, Any] Arguments dictionary from the checkpoint. Returns ------- Dict[str, Any] Updated arguments dictionary compatible with the current version. """ args = super()._backward_compat_arg_mapper(version, args) if version == "0.1.0": args = _remap_obj(args) if version in ("0.1.0", "0.2.0"): args = _backward_compat_dlesym_v1_args(args) return args def __init__( self, encoder: DictConfig, decoder: DictConfig, input_channels: int, output_channels: int, n_constants: int, decoder_input_channels: int, input_time_dim: int, output_time_dim: int, delta_time: str = "6h", reset_cycle: str = "24h", presteps: int = 1, enable_nhwc: bool = False, enable_healpixpad: bool = False, couplings: list = [], residual_prediction: bool = True, couplings_time_first: bool = True, constraints: list[DictConfig] = None, is_diagnostic: bool = False, ): r"""Initialize the recurrent DLWP HEALPix UNet.""" super().__init__(meta=MetaData()) self.channel_dim = 2 # Now 2 with [B, F, T*C, H, W]. Was 1 in old data format with [B, T*C, F, H, W] self.input_channels = input_channels if n_constants == 0 and decoder_input_channels == 0: raise NotImplementedError( "support for models with no constant fields and no decoder inputs (TOA insolation) is not available at this time." ) if len(couplings) > 0: if n_constants == 0: raise NotImplementedError( "support for coupled models with no constant fields is not available at this time." ) if decoder_input_channels == 0: raise NotImplementedError( "support for coupled models with no decoder inputs (TOA insolation) is not available at this time." ) # add coupled fields to input channels for model initialization self.coupled_channels = self._compute_coupled_channels(couplings) self.couplings = couplings self.train_couplers = None self.output_channels = output_channels self.n_constants = n_constants self.decoder_input_channels = decoder_input_channels self.input_time_dim = input_time_dim self.output_time_dim = output_time_dim self.delta_t = int(pd.Timedelta(delta_time).total_seconds() // 3600) self.reset_cycle = int(pd.Timedelta(reset_cycle).total_seconds() // 3600) self.presteps = presteps self.enable_nhwc = enable_nhwc self.enable_healpixpad = enable_healpixpad self.residual_prediction = residual_prediction self.couplings_time_first = couplings_time_first # A diagnostic model performs a single forward step and produces # exactly one output time. self.is_diagnostic = is_diagnostic if self.is_diagnostic and self.output_time_dim != 1: raise ValueError( "A diagnostic model (is_diagnostic=True) must have " f"output_time_dim == 1 (got {self.output_time_dim})." ) # We can't have a diagnostic model that tries to predict a residual if self.residual_prediction and self.is_diagnostic: raise ValueError( "A diagnostic model cannot predict a residual. Please set " "residual_prediction to False when is_diagnostic is True." ) if not self.is_diagnostic and (self.output_time_dim % self.input_time_dim != 0): raise ValueError( f"'output_time_dim' must be a multiple of 'input_time_dim' (got " f"{self.output_time_dim} and {self.input_time_dim})" ) # Build the model layers self.fold = HEALPixFoldFaces() self.unfold = HEALPixUnfoldFaces(num_faces=12) self.encoder = instantiate( config=encoder, input_channels=self._compute_input_channels(), enable_nhwc=self.enable_nhwc, enable_healpixpad=self.enable_healpixpad, ) self.encoder_depth = len(self.encoder.n_channels) self.decoder = instantiate( config=decoder, output_channels=self._compute_output_channels(), enable_nhwc=self.enable_nhwc, enable_healpixpad=self.enable_healpixpad, ) self.constraints = None self.set_constraints(constraints) @property def integration_steps(self): r""" Number of implicit forward integration steps. Returns ------- int Integration horizon :math:`T_{out} / T_{in}` (minimum 1). """ return max(self.output_time_dim // self.input_time_dim, 1) def _compute_input_channels(self) -> int: r""" Calculate total number of input channels. Returns ------- int Total channel count including couplings and constants. """ return ( self.input_time_dim * (self.input_channels + self.decoder_input_channels) + self.n_constants + self.coupled_channels ) def _compute_coupled_channels(self, couplings): r""" Get the number of coupled channels. Parameters ---------- couplings : list Coupling configuration dictionaries. Returns ------- int The number of coupled channels. """ return sum( len(c["params"]["variables"]) * len(c["params"]["input_times"]) for c in couplings ) def _compute_output_channels(self) -> int: r""" Compute the total number of output channels in the model. Returns ------- int Output channel count for each integration step. """ return (1 if self.is_diagnostic else self.input_time_dim) * self.output_channels def _reshape_inputs(self, inputs: Sequence, step: int = 0) -> torch.Tensor: r""" Concatenate prognostic, decoder, constant, and coupling inputs for the encoder. Parameters ---------- inputs : Sequence Tensors arranged as ``[prognostics, decoder_inputs, constants]`` with optional couplings. step : int, optional Integration step index. Returns ------- torch.Tensor Folded encoder input shaped :math:`(B \cdot F, C, H, W)`. """ if len(self.couplings) > 0: result = [ inputs[0].flatten( start_dim=self.channel_dim, end_dim=self.channel_dim + 1 ), inputs[1][ :, :, slice(step * self.input_time_dim, (step + 1) * self.input_time_dim), ..., ].flatten( start_dim=self.channel_dim, end_dim=self.channel_dim + 1 ), # DI inputs[2].expand( *tuple([inputs[0].shape[0]] + len(inputs[2].shape) * [-1]) ), # constants inputs[3].permute(0, 2, 1, 3, 4) if self.couplings_time_first else inputs[3], # coupled inputs ] res = torch.cat(result, dim=self.channel_dim) else: if self.n_constants == 0: result = [ inputs[0].flatten( start_dim=self.channel_dim, end_dim=self.channel_dim + 1 ), inputs[1][ :, :, slice( step * self.input_time_dim, (step + 1) * self.input_time_dim ), ..., ].flatten( start_dim=self.channel_dim, end_dim=self.channel_dim + 1 ), # DI ] res = torch.cat(result, dim=self.channel_dim) # fold faces into batch dim res = self.fold(res) return res if self.decoder_input_channels == 0: result = [ inputs[0].flatten( start_dim=self.channel_dim, end_dim=self.channel_dim + 1 ), inputs[1].expand( *tuple([inputs[0].shape[0]] + len(inputs[1].shape) * [-1]) ), # constants ] res = torch.cat(result, dim=self.channel_dim) # fold faces into batch dim res = self.fold(res) return res result = [ inputs[0].flatten( start_dim=self.channel_dim, end_dim=self.channel_dim + 1 ), inputs[1][ :, :, slice(step * self.input_time_dim, (step + 1) * self.input_time_dim), ..., ].flatten( start_dim=self.channel_dim, end_dim=self.channel_dim + 1 ), # DI inputs[2].expand( *tuple([inputs[0].shape[0]] + len(inputs[2].shape) * [-1]) ), # constants ] res = torch.cat(result, dim=self.channel_dim) # fold faces into batch dim res = self.fold(res) if self.enable_nhwc: res = res.to(memory_format=torch.channels_last) return res
[docs] def set_constraints(self, constraints: list[DictConfig] = None): r""" Set constraints (e.g., non-negative) to be applied to model outputs. Parameters ---------- constraints : list[DictConfig], optional Hydra instantiable constraint configurations. """ if constraints is not None: # Use a ModuleList (rather than a plain list) so the constraint # modules are part of the module tree: their buffers (e.g. the # non-persistent ``thresholds`` and ``var_indices`` in # ``NonnegativeConstraint``) are then correctly moved by # ``.to(device)`` along with the rest of the model. self.constraints = torch.nn.ModuleList( [instantiate(constraints[constraint]) for constraint in constraints] )
def _reshape_outputs(self, outputs: torch.Tensor) -> torch.Tensor: r""" Reshape decoder output back to explicit time and channel dimensions. Parameters ---------- outputs : torch.Tensor Decoder output shaped :math:`(B \cdot F, C, H, W)`. Returns ------- torch.Tensor Unfolded tensor shaped :math:`(B, F, T_{out}, C_{out}, H, W)`. """ # unfold: outputs = self.unfold(outputs) # extract shape and reshape shape = tuple(outputs.shape) res = torch.reshape( outputs, shape=( shape[0], shape[1], 1 if self.is_diagnostic else self.input_time_dim, -1, *shape[3:], ), ) return res def _initialize_hidden( self, inputs: Sequence, outputs: Sequence, step: int, conditions_cln: Sequence = None, ) -> None: r""" Initialize the recurrent hidden states. Parameters ---------- inputs : Sequence Input tensors used for warm-up. outputs : Sequence Outputs accumulated so far. step : int Current integration step index. Returns ------- None """ self.reset() for prestep in range(self.presteps): if step < self.presteps: s = step + prestep if len(self.couplings) > 0: input_tensor = self._reshape_inputs( inputs=[ inputs[0][ :, :, s * self.input_time_dim : (s + 1) * self.input_time_dim, ] ] + list(inputs[1:3]) + [inputs[3][prestep]], step=step + prestep, ) else: input_tensor = self._reshape_inputs( inputs=[ inputs[0][ :, :, s * self.input_time_dim : (s + 1) * self.input_time_dim, ] ] + list(inputs[1:]), step=step + prestep, ) else: s = step - self.presteps + prestep # Only the prognostic channels of the previous output are fed # back in; any extra (diagnostic) output channels are dropped. prognostics = outputs[s - 1][:, :, :, : self.input_channels] if len(self.couplings) > 0: input_tensor = self._reshape_inputs( inputs=[prognostics] + list(inputs[1:3]) + [inputs[3][step - (prestep - self.presteps)]], step=s + 1, ) else: input_tensor = self._reshape_inputs( inputs=[prognostics] + list(inputs[1:]), step=s + 1, ) if conditions_cln is not None: self.decoder( self.encoder(input_tensor, conditions_cln=conditions_cln), conditions_cln=conditions_cln, ) else: self.decoder(self.encoder(input_tensor))
[docs] def forward( self, inputs: Sequence, output_only_last: bool = False, conditions_cln=None, ) -> torch.Tensor: r""" Forward pass of the recurrent HEALPix UNet. Parameters ---------- inputs : Sequence List ``[prognostics, decoder_inputs, constants]`` or ``[prognostics, decoder_inputs, constants, couplings]`` with shapes consistent with :math:`(B, F, T, C, H, W)`. output_only_last : bool, optional If ``True``, return only the final forecast step. Returns ------- torch.Tensor Model outputs shaped :math:`(B, F, T_{out}, C_{out}, H, W)`. """ if not torch.compiler.is_compiling(): if inputs[0].ndim != 6: raise ValueError( "HEALPixRecUNet.forward expects prognostics shaped " "(B, F, T, C, H, W)" ) self.reset() outputs = [] for step in range(self.integration_steps): # (Re-)initialize recurrent hidden states if (step * (self.delta_t * self.input_time_dim)) % self.reset_cycle == 0: if conditions_cln is not None: self._initialize_hidden( inputs=inputs, outputs=outputs, step=step, conditions_cln=conditions_cln[step], ) else: self._initialize_hidden(inputs=inputs, outputs=outputs, step=step) # Construct concatenated input: [prognostics|TISR|constants] if step == 0: s = self.presteps if len(self.couplings) > 0: input_tensor = self._reshape_inputs( inputs=[ inputs[0][ :, :, s * self.input_time_dim : (s + 1) * self.input_time_dim, ] ] + list(inputs[1:3]) + [inputs[3][s]], step=s, ) else: input_tensor = self._reshape_inputs( inputs=[ inputs[0][ :, :, s * self.input_time_dim : (s + 1) * self.input_time_dim, ] ] + list(inputs[1:]), step=s, ) else: # Only the prognostic channels of the previous output are fed # back in; any extra (diagnostic) output channels are dropped. prognostics = outputs[-1][:, :, :, : self.input_channels] if len(self.couplings) > 0: input_tensor = self._reshape_inputs( inputs=[prognostics] + list(inputs[1:3]) + [inputs[3][self.presteps + step]], step=step + self.presteps, ) else: input_tensor = self._reshape_inputs( inputs=[prognostics] + list(inputs[1:]), step=step + self.presteps, ) if conditions_cln is not None: kwargs = {"conditions_cln": conditions_cln[step]} else: kwargs = {} encodings = self.encoder(input_tensor, **kwargs) decodings = self.decoder(encodings, **kwargs) combined = self._reshape_outputs(decodings) prognostics = combined[:, :, :, : self.input_channels] if self.residual_prediction: prognostics = prognostics + self._reshape_outputs( input_tensor[:, : self.input_channels * self.input_time_dim] ) diagnostics = combined[:, :, :, self.input_channels :] out = torch.cat([prognostics, diagnostics], dim=3) if self.constraints is not None: for constraint in self.constraints: out = constraint(out) outputs.append(out) if output_only_last: return outputs[-1] return torch.cat(outputs, dim=self.channel_dim)
[docs] def reset(self): r"""Reset the state of the encoder and decoder recurrent blocks.""" self.encoder.reset() self.decoder.reset()