# Copyright 2024 Google LLC
#
# 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
#
#     https://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.
"""Modules that computes relevant state features to be used by ML components."""

from typing import Any, Callable, Mapping, Optional, Protocol, Sequence
from dinosaur import coordinate_systems
from dinosaur import primitive_equations
from dinosaur import pytree_utils
from dinosaur import radiation
from dinosaur import scales
from dinosaur import spherical_harmonic
from dinosaur import typing
from dinosaur import xarray_utils
import gin
import haiku as hk
import jax
import jax.numpy as jnp
from neuralgcm import transforms
import numpy as np


Array = typing.Array
Pytree = typing.Pytree
TransformModule = typing.TransformModule
KeyWithCosLatFactor = typing.KeyWithCosLatFactor


class FeaturesFn(Protocol):

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Pytree] = None,
  ) -> typing.Pytree:
    ...


FeaturesModule = Callable[..., FeaturesFn]


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class PrimitiveEquationsDiagnosticState(hk.Module):
  """Features modules that returns processed DiagnosticState for PE."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      features_transform_module: TransformModule = transforms.IdentityTransform,
      name: Optional[str] = None,
  ):
    super().__init__(name=name)
    self.features_transform_fn = features_transform_module(
        coords, dt, physics_specs, aux_features
    )
    self.coords = coords

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Pytree] = None,
  ) -> primitive_equations.DiagnosticState:
    del memory, diagnostics, randomness, forcing  # unused
    if not isinstance(inputs, primitive_equations.State):
      inputs = primitive_equations.State(**inputs)
    d_state = primitive_equations.compute_diagnostic_state(inputs, self.coords)
    return self.features_transform_fn(d_state.asdict())


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class VelocityAndPrognostics(hk.Module):
  """Features module that returns prognostics + u,v and optionally gradients."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      fields_to_include: Optional[Sequence[str]] = None,
      features_transform_module: TransformModule = transforms.IdentityTransform,
      compute_gradients_module: TransformModule = transforms.EmptyTransform,
      name: Optional[str] = None,
  ):
    super().__init__(name=name)
    self.features_transform_fn = features_transform_module(
        coords, dt, physics_specs, aux_features
    )
    self.coords = coords
    self.fields_to_include = fields_to_include
    self.compute_gradients_fn = compute_gradients_module(
        coords, dt, physics_specs, aux_features
    )

  def _extract_features(
      self,
      inputs: typing.Pytree,
      prefix: str = '',
  ) -> typing.Pytree:
    """Returns a nodal velocity and prognostic features."""
    # Note: all intermediate features have an explicit cos-lat factors in key.
    # These factors are removed in the `__call__` method before returning.

    # compute `u, v` if div/curl is available and `u, v` not in prognosics.
    if set(['vorticity', 'divergence']).issubset(inputs.keys()) and not set(
        ['u', 'v']
    ).intersection(inputs.keys()):
      cos_lat_u, cos_lat_v = spherical_harmonic.get_cos_lat_vector(
          inputs['vorticity'], inputs['divergence'], self.coords.horizontal
      )
      modal_features = {
          KeyWithCosLatFactor(prefix + 'u', 1): cos_lat_u,
          KeyWithCosLatFactor(prefix + 'v', 1): cos_lat_v,
      }
    else:
      modal_features = {}
    prognostics_keys = list(inputs.keys())
    prognostics_keys.remove('tracers')
    prognostics_keys.remove('sim_time')
    for k in prognostics_keys:
      if self.fields_to_include is None or k in self.fields_to_include:
        modal_features[KeyWithCosLatFactor(prefix + k, 0)] = inputs[k]

    for k, v in inputs['tracers'].items():
      if self.fields_to_include is None or k in self.fields_to_include:
        modal_features[KeyWithCosLatFactor(prefix + k, 0)] = v
    # Computing gradient features and adjusting cos_lat factors.
    modal_features = self.coords.with_dycore_sharding(modal_features)
    diff_operator_features = self.compute_gradients_fn(modal_features)
    sec_lat = 1 / self.coords.horizontal.cos_lat
    sec2_lat = self.coords.horizontal.sec2_lat
    sec_lat_scales = {0: 1, 1: sec_lat, 2: sec2_lat}
    # Computing all features in nodal space.
    features = {}
    for k, v in (diff_operator_features | modal_features).items():
      sec_lat_scale = sec_lat_scales[k.factor_order]
      features[k.name] = self.coords.horizontal.to_nodal(v) * sec_lat_scale
    features = self.coords.with_dycore_sharding(features)
    return features

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Pytree] = None,
  ) -> typing.Pytree:
    del memory, diagnostics, randomness, forcing  # unused.
    nodal_features = self._extract_features(inputs)
    return self.features_transform_fn(nodal_features)


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class MemoryVelocityAndValues(VelocityAndPrognostics):
  """Similar to `VelocityAndPrognostics`, but operates on memory."""

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Pytree] = None,
  ) -> typing.Pytree:
    del inputs, diagnostics, randomness, forcing  # unused.
    nodal_features = self._extract_features(memory, 'memory_')
    return self.features_transform_fn(nodal_features)


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class NodalInputVelocityAndPrognostics(VelocityAndPrognostics):
  """Features modules that returns velocities, temperature, and optionally gradients."""

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Pytree] = None,
  ) -> typing.Pytree:
    to_modal_fn = self.coords.horizontal.to_modal
    inputs = to_modal_fn(inputs)
    memory = to_modal_fn(memory)
    return super().__call__(inputs, memory, randomness, forcing)


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class RadiationFeatures(hk.Module):
  """Feature module that computes incident radiation flux."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      features_transform_module: TransformModule = transforms.IdentityTransform,
      name: Optional[str] = None,
  ):
    super().__init__(name=name)
    self.features_transform_fn = features_transform_module(
        coords, dt, physics_specs, aux_features
    )
    ref_datetime_str = str(aux_features[xarray_utils.REFERENCE_DATETIME_KEY])
    self.solar_radiation = radiation.SolarRadiation.normalized(
        coords=coords,
        physics_specs=physics_specs,
        reference_datetime=np.datetime64(ref_datetime_str),
    )

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Pytree] = None,
  ) -> typing.Pytree:
    del memory, diagnostics, randomness, forcing  # unused.
    features = {}
    features['radiation'] = self.solar_radiation.radiation_flux(
        inputs['sim_time']
    )
    # TODO(janniyuval) add a flag that allow to get radiation of next time step
    # insert a feature axis.
    features = jax.tree_util.tree_map(lambda x: jnp.expand_dims(x, 0), features)
    return self.features_transform_fn(features)


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class OrbitalTimeFeatures(hk.Module):
  """Feature module that computes orbital time features."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      features_transform_module: TransformModule = transforms.IdentityTransform,
      name: Optional[str] = None,
  ):
    super().__init__(name=name)
    self.features_transform_fn = features_transform_module(
        coords, dt, physics_specs, aux_features
    )
    ref_datetime_str = str(aux_features[xarray_utils.REFERENCE_DATETIME_KEY])
    self.solar_radiation = radiation.SolarRadiation.normalized(
        coords=coords,
        physics_specs=physics_specs,
        reference_datetime=np.datetime64(ref_datetime_str),
    )

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Pytree] = None,
  ) -> typing.Pytree:
    del memory, diagnostics, randomness, forcing  # unused.
    features = {}
    # Cosine and sine of Earth's orbital phase around the Sun
    orbital_time = self.solar_radiation.time_to_orbital_time(inputs['sim_time'])
    # Convert from orbital_phase=0 on January 1st UTC to orbital_phase=0 at the
    # approximate perihelion (when earth is closest to the sun).
    orbital_phase = orbital_time.orbital_phase - radiation.PERIHELION
    # All longitude, latitude locations share the same orbital phase
    ones = jnp.ones(self.solar_radiation.coords.surface_nodal_shape)
    features['cos_orbital_phase'] = jnp.cos(orbital_phase) * ones
    features['sin_orbital_phase'] = jnp.sin(orbital_phase) * ones
    # Cosine and sine of local hour angle (angle from solar noon)
    solar_hour_angle = self.solar_radiation.solar_hour_angle(inputs['sim_time'])
    solar_hour_angle = jnp.expand_dims(solar_hour_angle, 0)
    features['cos_solar_hour'] = jnp.cos(solar_hour_angle)
    features['sin_solar_hour'] = jnp.sin(solar_hour_angle)
    # TODO(janniyuval) add a flag that allow to get radiation of next time step
    return self.features_transform_fn(features)


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class ForcingFeatures(hk.Module):
  """Feature module that provides forcing values as features."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      forcing_to_include: Sequence[str] = tuple(),
      features_transform_module: TransformModule = transforms.IdentityTransform,
      name: Optional[str] = None,
  ):
    super().__init__(name=name)
    self.forcing_to_include = forcing_to_include
    self.features_transform_fn = features_transform_module(
        coords, dt, physics_specs, aux_features
    )

  def __call__(
      self,
      inputs: Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Forcing] = None,
  ) -> Pytree:
    del inputs, memory, diagnostics, randomness
    features = {}
    for key in self.forcing_to_include:
      value = forcing[key]
      # Expect singleton "level" dimension for surface forcings
      if value.ndim > 3:
        raise ValueError(
            f'Expected forcing "{key}" to have ndim <= 3, got {value.ndim}'
        )
      if value.ndim == 2:
        value = jnp.expand_dims(value, axis=0)
      if value.shape[0] != 1:
        raise ValueError(
            f'Expected forcing "{key}" to have leading dimension 1'
            f'for level, got {value.shape}'
        )
      features[key] = value

    return self.features_transform_fn(features)


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class LatitudeFeatures(hk.Module):
  """Feature module that creates cos and sin of latitude as features."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      features_transform_module: TransformModule = transforms.IdentityTransform,
      name: Optional[str] = None,
  ):
    super().__init__(name=name)
    self.features_transform_fn = features_transform_module(
        coords, dt, physics_specs, aux_features
    )
    self.coords = coords

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Pytree] = None,
  ) -> typing.Pytree:
    del inputs, memory, diagnostics, randomness, forcing  # unused.
    _, sin_lat = self.coords.horizontal.nodal_mesh
    sin_features = sin_lat[np.newaxis, ...]
    cos_features = jnp.cos(jnp.arcsin(sin_features))
    features = {
        'cos_latitude': cos_features,
        'sin_latitude': sin_features,
    }
    return self.features_transform_fn(features)


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class RandomnessFeatures(hk.Module):
  """Feature module that returns fields from `randomness` as features."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      features_transform_module: TransformModule = transforms.IdentityTransform,
      name: Optional[str] = None,
  ):
    super().__init__(name=name)
    self.features_transform_fn = features_transform_module(
        coords, dt, physics_specs, aux_features
    )

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Pytree] = None,
  ) -> typing.Pytree:
    del inputs, memory, diagnostics, forcing  # unused.
    if randomness is None:
      random_features = {}
    elif isinstance(randomness, dict):
      random_features, _ = pytree_utils.flatten_dict(randomness)
    elif isinstance(randomness, jax.Array):
      random_features = {'randomness': randomness}
    else:
      raise ValueError(f'randomness has unsupported {type(randomness)=}.')
    # random fields are 2D by construction, adding a feature/level dimension.
    if randomness is not None:
      ndims = set(x.ndim for x in jax.tree_util.tree_leaves(random_features))
      if not ndims.issubset({2, 3}):
        raise ValueError(
            f'Random fields expected to be 2D and/or 3D. Found {ndims=}'
        )

      def make_3d(x):
        if x.ndim == 3:
          return x
        if x.ndim == 2:
          return x[np.newaxis, ...]

      random_features = jax.tree_util.tree_map(make_3d, random_features)
    return self.features_transform_fn(random_features)


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class OrographyFeatures(hk.Module):
  """Feature module that computes orographic features."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      features_transform_module: TransformModule = transforms.IdentityTransform,
      name: Optional[str] = None,
  ):
    super().__init__(name=name)
    if xarray_utils.OROGRAPHY not in aux_features:
      raise ValueError('OrographyFeatures requires orography in aux_features.')
    self.nodal_orography = aux_features[xarray_utils.OROGRAPHY]
    self.features_transform_fn = features_transform_module(
        coords, dt, physics_specs, aux_features
    )

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Pytree] = None,
  ) -> typing.Pytree:
    del inputs, memory, diagnostics, randomness, forcing  # unused.
    features = {
        xarray_utils.OROGRAPHY: jnp.expand_dims(self.nodal_orography, 0),
    }
    return self.features_transform_fn(features)


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class OneHotAuxFeatures(hk.Module):
  """Feature module that produces one-hot encodings from binary covariates."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      covariate_keys: Sequence[str] = gin.REQUIRED,
      convert_float_to_int: bool = False,
      name: Optional[str] = None,
  ):
    del coords, dt, physics_specs  # unused.
    super().__init__(name=name)
    covariates = {}
    num_classes = {}
    for key in covariate_keys:
      if key not in aux_features:
        raise ValueError(f'Covariate {key} not found in aux_features.')
      if not np.issubdtype(aux_features[key].dtype, np.integer):
        if convert_float_to_int:
          aux_features[key] = np.round(aux_features[key]).astype(int)
        else:
          raise ValueError(
              f'Covariate {key} is expected to be integer dtype, '
              f'but is: {aux_features[key].dtype}'
          )
      covariates[key] = aux_features[key]
      num_classes[key] = np.unique(aux_features[key]).size
    self.covariates = covariates
    self.num_classes = num_classes

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Pytree] = None,
  ) -> dict[str, jnp.ndarray]:
    del inputs, memory, diagnostics, randomness, forcing  # unused.
    features = {
        k: jax.nn.one_hot(v, self.num_classes[k], axis=0)
        for k, v in self.covariates.items()
    }
    return features


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class LearnedPositionalFeatures(hk.Module):
  """Feature module with learned params at surface nodal locations."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      latent_size: int,
      scale: float = 1.0,
      name: Optional[str] = None,
  ):
    super().__init__(name=name)
    self.scale = scale
    self.padding = coords.horizontal.nodal_padding
    unpadded_nodal_shape = tuple(
        x - y for x, y in zip(coords.horizontal.nodal_shape, self.padding)
    )
    self.positional_features = hk.get_parameter(
        'learned_positional_features',
        (latent_size,) + unpadded_nodal_shape,
        jnp.float32,
        init=hk.initializers.Constant(0.0),
    )

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Pytree] = None,
  ) -> dict[str, jnp.ndarray]:
    """Returns scaled parameter values at surface nodal locations."""
    del inputs, memory, diagnostics, randomness, forcing  # unused.
    pad_x, pad_y = self.padding
    positional_features = self.scale * jnp.pad(
        self.positional_features, [(0, 0), (0, pad_x), (0, pad_y)]
    )
    return {'learned_positional_features': positional_features}


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class EmbeddingSurfaceFeatures(hk.Module):
  """Feature module that specifies embedding surface outputs as features.

  Returns {feature_name: nn_output}
    where nn_output.shape = (output_size, lon, lat).
  """

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      feature_name: str,
      output_size: int,
      embedding_module: typing.EmbeddingModule,
      features_transform_module: TransformModule = transforms.IdentityTransform,
      name: Optional[str] = None,
  ):
    super().__init__(name=name)
    # output shapes are arrays to be pytree leaves for tree_map
    output_shapes = {
        feature_name: np.asarray((output_size,) + coords.horizontal.nodal_shape)
    }
    self.embedding_fn = embedding_module(
        coords, dt, physics_specs, aux_features, output_shapes=output_shapes
    )
    self.features_transform_fn = features_transform_module(
        coords, dt, physics_specs, aux_features
    )

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Pytree] = None,
  ) -> typing.Pytree:
    features = self.embedding_fn(
        inputs, memory, diagnostics, randomness, forcing
    )
    return self.features_transform_fn(features)


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class EmbeddingVolumeFeatures(hk.Module):
  """Feature module that specifies embedding volume outputs as features.

  Returns {feature_name_0: nn_output_0,
           feature_name_1: nn_output_1,
           ...
          }
    where the NN output array has shape (output_size, level, lon, lat), which is
    unpacked over output_size such that nn_output_{i}.shape = (level, lon, lat)
    for each i in range(output_size).
  """

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      feature_name: str,
      output_size: int,
      embedding_module: typing.EmbeddingModule,
      features_transform_module: TransformModule = transforms.IdentityTransform,
      name: Optional[str] = None,
  ):
    super().__init__(name=name)
    # output shapes are arrays to be pytree leaves for tree_map
    output_shapes = {
        f'{feature_name}_{i}': np.asarray(coords.nodal_shape)
        for i in range(output_size)
    }
    self.embedding_fn = embedding_module(
        coords, dt, physics_specs, aux_features, output_shapes=output_shapes
    )
    self.features_transform_fn = features_transform_module(
        coords, dt, physics_specs, aux_features
    )

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Pytree] = None,
  ) -> typing.Pytree:
    features = self.embedding_fn(
        inputs, memory, diagnostics, randomness, forcing
    )
    return self.features_transform_fn(features)


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class FloatDataFeatures(hk.Module):
  """Feature module that supplies floating point covariates from data."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      covariate_data_path: str = gin.REQUIRED,
      covariate_keys: Sequence[str] = gin.REQUIRED,
      renaming_dict: Optional[Mapping[str, str]] = None,
      compute_gradients_module: TransformModule = transforms.EmptyTransform,
      name: Optional[str] = None,
  ):
    super().__init__(name=name)
    self.covariates = {}
    self.compute_gradients_fn = compute_gradients_module(
        coords, dt, physics_specs, aux_features
    )
    self.coords = coords
    ds = xarray_utils.ds_from_path_or_aux(covariate_data_path, aux_features)
    if renaming_dict is not None:
      ds = ds.rename(renaming_dict)
    lon, lat = (ds[xarray_utils.XR_LON_NAME], ds[xarray_utils.XR_LAT_NAME])
    xarray_utils.verify_grid_consistency(lon, lat, coords.horizontal)
    lon_lat_order = (xarray_utils.XR_LON_NAME, xarray_utils.XR_LAT_NAME)
    for key in covariate_keys:
      data = ds[key].transpose(*lon_lat_order)
      data_units = scales.parse_units(data.attrs['units'])
      data = physics_specs.nondimensionalize(data.values * data_units)
      if data.ndim != 3:
        data = data[np.newaxis, ...]
      self.covariates[key] = data

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Pytree] = None,
  ) -> dict[str, jnp.ndarray]:
    del inputs, memory, diagnostics, forcing, randomness  # unused.
    features = {k: v for k, v in self.covariates.items()}
    modal_features = self.coords.horizontal.to_modal(features)
    modal_features = {  # jit should eliminate to_modal if it is not used.
        KeyWithCosLatFactor(k, 0): v for k, v in modal_features.items()
    }
    modal_gradient_features = self.compute_gradients_fn(modal_features)
    sec_lat = 1 / self.coords.horizontal.cos_lat
    sec2_lat = self.coords.horizontal.sec2_lat
    sec_lat_scales = {0: 1, 1: sec_lat, 2: sec2_lat}
    for k, v in modal_gradient_features.items():
      sec_lat_scale = sec_lat_scales[k.factor_order]
      features[k.name] = self.coords.horizontal.to_nodal(v) * sec_lat_scale
    return features


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class CombinedFeatures(hk.Module):
  """Feature module that combines multiple feature modules together."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      feature_modules: Sequence[FeaturesModule] = gin.REQUIRED,
      feature_module_names_to_exclude: Sequence[str] = tuple(),
      features_to_exclude: Sequence[str] = tuple(),
      features_transform_module: TransformModule = transforms.IdentityTransform,
      name: Optional[str] = None,
  ):
    super().__init__(name=name)
    self.feature_fns = [
        module(coords, dt, physics_specs, aux_features)
        for module in feature_modules
    ]
    self.feature_module_names_to_exclude = feature_module_names_to_exclude
    self.features_to_exclude = features_to_exclude
    self.features_transform_fn = features_transform_module(
        coords, dt, physics_specs, aux_features
    )

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Forcing] = None,
  ) -> dict[str, jnp.ndarray]:
    all_features = {}
    for feature_fn in self.feature_fns:
      if type(feature_fn).__name__ not in self.feature_module_names_to_exclude:
        features = feature_fn(inputs, memory, diagnostics, randomness, forcing)
        for k, v in features.items():
          if k in all_features:
            raise ValueError(f'Encountered duplicate feature {k}')
          all_features[k] = v
    all_features = self.features_transform_fn(all_features)
    for k in self.features_to_exclude:
      all_features.pop(k, None)
    return all_features


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class NullFeatures(hk.Module):
  """Placeholder features module that returns an empty dict."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      name: Optional[str] = None,
  ):
    del coords, dt, physics_specs, aux_features  # unused
    super().__init__(name=name)

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Pytree] = None,
  ) -> dict[str, jnp.ndarray]:
    del inputs, memory, diagnostics, randomness, forcing  # unused
    return {}


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class PressureFeatures(hk.Module):
  """Feature module that computes pressure."""

  def __init__(
      self,
      coords: coordinate_systems.CoordinateSystem,
      dt: float,
      physics_specs: Any,
      aux_features: typing.AuxFeatures,
      features_transform_module: TransformModule = transforms.IdentityTransform,
      name: Optional[str] = None,
  ):
    super().__init__(name=name)
    self.coords = coords
    self.features_transform_fn = features_transform_module(
        coords, dt, physics_specs, aux_features
    )

  def _nodal_pressure(
      self,
      inputs: typing.Pytree,
      prefix: str = '',
  ) -> Mapping[str, Array]:
    """Computes nodal pressure from model inputs."""
    # Compute nodal, dimensionalized quantities
    to_nodal_fn = self.coords.horizontal.to_nodal
    sigma = self.coords.vertical.centers
    surface_pressure = jnp.exp(to_nodal_fn(inputs['log_surface_pressure']))
    pressure = surface_pressure * sigma[:, jnp.newaxis, jnp.newaxis]
    nodal_features = {prefix + 'pressure': pressure}
    return nodal_features

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Pytree] = None,
  ) -> typing.Pytree:
    del memory, diagnostics, randomness, forcing  # unused.
    nodal_features = self._nodal_pressure(inputs)
    return self.features_transform_fn(nodal_features)


@gin.register(denylist=['coords', 'dt', 'physics_specs', 'aux_features'])
class MemoryPressureFeatures(PressureFeatures):
  """Feature module that computes pressure from memory values."""

  def __call__(
      self,
      inputs: typing.Pytree,
      memory: Optional[typing.PyTreeState] = None,
      diagnostics: Optional[typing.Pytree] = None,
      randomness: Optional[typing.PyTreeState] = None,
      forcing: Optional[typing.Pytree] = None,
  ) -> typing.Pytree:
    del inputs, diagnostics, randomness, forcing  # unused.
    nodal_features = self._nodal_pressure(memory, 'memory_')
    return self.features_transform_fn(nodal_features)
