# 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. """Defines AbstractModel API, standard implementations and helper functions.""" from __future__ import annotations import collections import dataclasses import functools from typing import Any, Callable, Dict, Optional, Sequence, Union from dinosaur import coordinate_systems from dinosaur import layer_coordinates from dinosaur import scales from dinosaur import sigma_coordinates from dinosaur import spherical_harmonic from dinosaur import time_integration from dinosaur import typing from dinosaur import xarray_utils import gin import haiku as hk import jax.numpy as jnp from model.legacy import correctors # pylint: disable=unused-import from model.legacy import decoders # pylint: disable=unused-import from model.legacy import embeddings # pylint: disable=unused-import from model.legacy import encoders # pylint: disable=unused-import from model.legacy import equations # pylint: disable=unused-import from model.legacy import features # pylint: disable=unused-import from model.legacy import filters # pylint: disable=unused-import from model.legacy import forcings # pylint: disable=unused-import from model.legacy import gin_utils from model.legacy import layers # pylint: disable=unused-import from model.legacy import mappings # pylint: disable=unused-import from model.legacy import model_utils from model.legacy import physics_specifications from model.legacy import steps # pylint: disable=unused-import from model.legacy import stochastic # pylint: disable=unused-import from model.legacy import towers # pylint: disable=unused-import from model.legacy import transforms # pylint: disable=unused-import import numpy as np import xarray # Note: many unused imports are needed to load configurable components; DEFAULT_REFERENCE_TEMPERATURE = 288 DEFAULT_REFERENCE_DATETIME_STR = '1979-01-01T00' Array = typing.Array AuxFeatures = typing.AuxFeatures DataState = typing.DataState PyTreeState = typing.PyTreeState ModelState = typing.ModelState ForcingData = typing.ForcingData Forcing = typing.Forcing Numeric = typing.Numeric QuantityOrStr = Union[str, scales.Quantity] # Specifying the full signatures of Callable would get somewhat onerous # pylint: disable=g-bare-generic # Overzealous linter is getting confused by ABC typing. # pylint: disable=function-missing-types # pylint: disable=missing-arg-types # Register data to xarray conversion methods. data_to_xarray = gin.external_configurable( xarray_utils.data_to_xarray, 'data_to_xarray' ) # TODO(dkochkov) Remove this legacy name when no best checkpoints rely on it. primitive_eq_to_xarray = gin.external_configurable( xarray_utils.data_to_xarray, 'primitive_eq_to_xarray' ) data_to_xarray_with_renaming = gin.external_configurable( xarray_utils.data_to_xarray_with_renaming, 'data_to_xarray_with_renaming' ) dynamic_covariate_data_to_xarray = gin.external_configurable( xarray_utils.dynamic_covariate_data_to_xarray, 'dynamic_covariate_data_to_xarray', ) # Register xarray to data conversion methods. xarray_to_shallow_water = gin.external_configurable( xarray_utils.xarray_to_shallow_water_eq_data, 'xarray_to_shallow_water' ) xarray_to_primitive_eq = gin.external_configurable( xarray_utils.xarray_to_primitive_eq_data, 'xarray_to_primitive_eq' ) xarray_to_primitive_eq_with_time = gin.external_configurable( xarray_utils.xarray_to_primitive_equations_with_time_data, 'xarray_to_primitive_eq_with_time', ) xarray_to_weatherbench_data = gin.external_configurable( xarray_utils.xarray_to_weatherbench_data, 'xarray_to_weatherbench_data' ) xarray_to_data_with_renaming = gin.external_configurable( xarray_utils.xarray_to_data_with_renaming, 'xarray_to_data_with_renaming' ) xarray_to_dynamic_covariate_data = gin.external_configurable( xarray_utils.xarray_to_dynamic_covariate_data, 'xarray_to_dynamic_covariate_data', ) xarray_to_state_and_dynamic_covariate_data = gin.external_configurable( xarray_utils.xarray_to_state_and_dynamic_covariate_data, 'xarray_to_state_and_dynamic_covariate_data', ) coordinate_system_from_dataset = gin.external_configurable( xarray_utils.coordinate_system_from_dataset, 'coordinate_system_from_dataset', allowlist=['truncation', 'spherical_harmonics_impl'], ) # Register grids and coordinates for instantiation of coordinate systems. Grid = gin.external_configurable( spherical_harmonic.Grid, denylist=['spmd_mesh'] ) GridWithWavenumbers = gin.external_configurable( spherical_harmonic.Grid.with_wavenumbers, 'GridWithWavenumbers' ) GridT21 = gin.external_configurable(spherical_harmonic.Grid.T21, 'GridT21') GridT31 = gin.external_configurable(spherical_harmonic.Grid.T31, 'GridT31') GridT42 = gin.external_configurable(spherical_harmonic.Grid.T42, 'GridT42') GridT85 = gin.external_configurable(spherical_harmonic.Grid.T85, 'GridT85') GridT106 = gin.external_configurable(spherical_harmonic.Grid.T106, 'GridT106') GridT119 = gin.external_configurable(spherical_harmonic.Grid.T119, 'GridT119') GridT170 = gin.external_configurable(spherical_harmonic.Grid.T170, 'GridT170') GridT213 = gin.external_configurable(spherical_harmonic.Grid.T213, 'GridT213') GridTL31 = gin.external_configurable(spherical_harmonic.Grid.TL31, 'GridTL31') GridTL63 = gin.external_configurable(spherical_harmonic.Grid.TL63, 'GridTL63') GridTL95 = gin.external_configurable(spherical_harmonic.Grid.TL95, 'GridTL95') GridTL127 = gin.external_configurable( spherical_harmonic.Grid.TL127, 'GridTL127' ) GridTL159 = gin.external_configurable( spherical_harmonic.Grid.TL159, 'GridTL159' ) GridTL179 = gin.external_configurable( spherical_harmonic.Grid.TL179, 'GridTL179' ) GridTL255 = gin.external_configurable( spherical_harmonic.Grid.TL255, 'GridTL255' ) RealSphericalHarmonics = gin.external_configurable( spherical_harmonic.RealSphericalHarmonics, ) RealSphericalHarmonicsWithZeroImag = gin.external_configurable( spherical_harmonic.RealSphericalHarmonicsWithZeroImag, denylist=['spmd_mesh'], ) LayerCoordinates = gin.external_configurable(layer_coordinates.LayerCoordinates) SigmaCoordinates = gin.external_configurable(sigma_coordinates.SigmaCoordinates) SigmaCoordinatesEquidistant = gin.external_configurable( sigma_coordinates.SigmaCoordinates.equidistant, 'SigmaCoordinatesEquidistant', ) CoordinateSystem = gin.external_configurable( coordinate_systems.CoordinateSystem, denylist=['spmd_mesh'] ) # Register vertical interpolation methods centered_vertical_advection = gin.external_configurable( sigma_coordinates.centered_vertical_advection ) upwind_vertical_advection = gin.external_configurable( sigma_coordinates.upwind_vertical_advection ) @dataclasses.dataclass(frozen=True) class ModelSpecs(collections.abc.Mapping): """Specification of model configuration. Attributes: coords: horizontal and vertical grid data. dt: nondimensionalized model time step. physics_specs: physical constants and definition of custom units. aux_features: additional static data. """ coords: coordinate_systems.CoordinateSystem dt: float physics_specs: Any aux_features: typing.AuxFeatures def __len__(self): return len(dataclasses.fields(self)) def __iter__(self): return iter(f.name for f in dataclasses.fields(self)) def __getitem__(self, key): return getattr(self, key) @gin.configurable( allowlist=( 'model_time_step', 'custom_coords', 'reference_temperature', 'reference_datetime_str', ) ) def get_model_specs( data_coords: coordinate_systems.CoordinateSystem, physics_specs: Any, aux_features: typing.AuxFeatures, model_time_step: Optional[Union[float, QuantityOrStr]] = None, custom_coords: Optional[coordinate_systems.CoordinateSystem] = None, reference_temperature: Optional[float | Sequence] = None, reference_datetime_str: Optional[str] = None, ) -> ModelSpecs: """Returns specifications for a WhirlModel configuration. Provides gin hooks, and in some cases defaults, for model specification formerly encoded in aux_features. Args: data_coords: coordinate system in which states are represented in the data. physics_specs: physical constants and definition of custom units. aux_features: auxiliary features that come with the dataset. model_time_step: duration of the outer time-step in our model, i.e., the time by which the state is advanced in a single model.advance call. custom_coords: optional coordinate system to be used by the model instead of data_coords. reference_temperature: reference temperature to use for sigma coordinates. Must be None if already defined in aux_features. Default value of 288 used if None and also not in aux_features. reference_datetime_str: reference datetime for which nondimensionalized time is set to 0. Must be None if already defined in aux_features. Default value of '1979-01-01T00' used if None and also not in aux_features. Returns: Configured specification of coordinate system, time-step, physical constants and units, and aux_features and for our hybrid ML/physics model. """ if model_time_step is None: raise ValueError('must provide model_time_step or outer_time_step') if custom_coords is None: coords = data_coords else: coords = dataclasses.replace(custom_coords, spmd_mesh=data_coords.spmd_mesh) if aux_features.get(xarray_utils.REF_TEMP_KEY) is None: if reference_temperature is None: ones = np.ones(coords.vertical.layers, np.float32) ref_temps = DEFAULT_REFERENCE_TEMPERATURE * ones aux_features[xarray_utils.REF_TEMP_KEY] = ref_temps else: ones = np.ones(coords.vertical.layers, np.float32) ref_temps = np.asarray(reference_temperature) if ref_temps.ndim == 1 and ref_temps.shape[0] != coords.vertical.layers: raise ValueError( '`ref_temps` must be a scalar or a sequence with ' f'{coords.vertical.layers=} elements, got {ref_temps.shape=}' ) ref_temps = ref_temps * ones aux_features[xarray_utils.REF_TEMP_KEY] = ref_temps else: # cannot set ref temp if already specified in aux_data if reference_temperature is not None: raise ValueError( 'reference temperature already specified in aux_features' ) if aux_features.get(xarray_utils.REFERENCE_DATETIME_KEY) is None: if reference_datetime_str is None: reference_datetime = np.datetime64(DEFAULT_REFERENCE_DATETIME_STR) aux_features[xarray_utils.REFERENCE_DATETIME_KEY] = reference_datetime else: reference_datetime = np.datetime64(reference_datetime_str) aux_features[xarray_utils.REFERENCE_DATETIME_KEY] = reference_datetime else: # cannot set ref datetime if already specified in aux_data if reference_datetime_str is not None: raise ValueError('reference datetime already specified in aux_data') if isinstance(model_time_step, (str, scales.Quantity)): # pyrefly: ignore[invalid-argument] dt = physics_specs.nondimensionalize(scales.Quantity(model_time_step)) else: dt = model_time_step return ModelSpecs( coords=coords, dt=dt, physics_specs=physics_specs, aux_features=aux_features, ) def _identity(x): return x class DynamicalSystem(hk.Module): """Abstract class for modeling dynamical systems.""" def __init__( self, coords: coordinate_systems.CoordinateSystem, dt: float, physics_specs: Any, aux_features: Dict[str, Any], input_coords: coordinate_systems.CoordinateSystem, output_coords: coordinate_systems.CoordinateSystem, name: Optional[str] = None, ): super().__init__(name=name) self.coords = coords self.dt = dt self.physics_specs = physics_specs self.aux_features = aux_features self.input_coords = input_coords self.output_coords = output_coords def encode(self, x: PyTreeState, forcing: Forcing) -> PyTreeState: """Encodes input trajectory `x` with `forcing` to the model state.""" raise NotImplementedError('Model subclass did not define encode') def decode(self, x: PyTreeState, forcing: Forcing) -> PyTreeState: """Decodes a model state `x` with `forcing` to a data representation.""" raise NotImplementedError('Model subclass did not define decode') def advance(self, x: PyTreeState, forcing: Forcing) -> PyTreeState: """Returns a model state `x` with `forcing` advanced by `self.dt`.""" raise NotImplementedError('Model subclass did not define advance') def forcing_fn(self, forcing_data: ForcingData, sim_time: Numeric) -> Forcing: """Returns forcing at sim_time, possibly using `forcing_data`.""" raise NotImplementedError('Model subclass did not define forcing_fn') def trajectory( self, x, outer_steps: int, inner_steps: int = 1, *, forcing_data: ForcingData, start_with_input: bool = False, post_process_fn: Callable = _identity, ): """Returns a final model state and trajectory.""" def step_fn(x: PyTreeState) -> PyTreeState: # if x does not have `sim_time`, expect forcing_fn to handle sim_time=None if isinstance(x, typing.ModelState): sim_time = getattr(x.state, 'sim_time', None) else: sim_time = getattr(x, 'sim_time', None) forcing = self.forcing_fn(forcing_data, sim_time) # pyrefly: ignore[bad-argument-type] x, forcing = self.coords.with_dycore_sharding((x, forcing)) y = self.advance(x, forcing) y = self.coords.with_dycore_sharding(y) return y return trajectory_from_step( step_fn, outer_steps, inner_steps, start_with_input=start_with_input, post_process_fn=post_process_fn, )(x) @gin.configurable class ModularStepModel(DynamicalSystem): """Dynamical model based on independent encoder/decoder/step components.""" def __init__( self, coords: coordinate_systems.CoordinateSystem, dt: float, physics_specs: Any, aux_features: Dict[str, Any], input_coords: coordinate_systems.CoordinateSystem, output_coords: coordinate_systems.CoordinateSystem, advance_module=gin.REQUIRED, encoder_module=gin.REQUIRED, decoder_module=gin.REQUIRED, forcing_module=forcings.NoForcing, name: Optional[str] = None, ): super().__init__( coords, dt, physics_specs, aux_features, input_coords, output_coords, name=name, ) self.advance_fn = advance_module(coords, dt, physics_specs, aux_features) # pyrefly: ignore[not-callable] self.encoder_fn = encoder_module( # pyrefly: ignore[not-callable] coords, dt, physics_specs, aux_features, input_coords ) self.decoder_fn = decoder_module( # pyrefly: ignore[not-callable] coords, dt, physics_specs, aux_features, output_coords ) self.forcing_fn = forcing_module(coords, dt, physics_specs, aux_features) def encode(self, x: PyTreeState, forcing: Forcing) -> PyTreeState: return self.encoder_fn(x, forcing) def decode(self, x: PyTreeState, forcing: Forcing) -> PyTreeState: return self.decoder_fn(x, forcing) def advance(self, x: PyTreeState, forcing: Forcing) -> PyTreeState: return self.advance_fn(x, forcing) @gin.configurable class StochasticModularStepModel(DynamicalSystem): """Dynamical model with modular components and stochasticity. This instance of DynamicalSystem works with ModelState representation of the model state. The `advance_module` initializes a RandomnessModule. This must be compatible with ModelState. Since randomness initialization might depend on the timestep at which it is evolved, RandomnessModule module is initialized with `num_substeps`. """ def __init__( self, coords: coordinate_systems.CoordinateSystem, dt: float, physics_specs: Any, aux_features: Dict[str, Any], input_coords: coordinate_systems.CoordinateSystem, output_coords: coordinate_systems.CoordinateSystem, advance_module=gin.REQUIRED, encoder_module=gin.REQUIRED, decoder_module=gin.REQUIRED, forcing_module=forcings.NoForcing, name: Optional[str] = None, ): super().__init__( coords, dt, physics_specs, aux_features, input_coords, output_coords, name=name, ) self.advance_fn = advance_module(coords, dt, physics_specs, aux_features) # pyrefly: ignore[not-callable] self.encoder_fn = encoder_module( # pyrefly: ignore[not-callable] coords, dt, physics_specs, aux_features, input_coords ) self.decoder_fn = decoder_module( # pyrefly: ignore[not-callable] coords, dt, physics_specs, aux_features, output_coords ) self.forcing_fn = forcing_module(coords, dt, physics_specs, aux_features) def encode( self, x: DataState, forcing: Forcing, ) -> ModelState: """Encodes model state and creates a new perturbation.""" model_state = self.encoder_fn(x, forcing=forcing) # encoder_fn returns `ModelState` that contains prognostic state # and initial values for memory, diagnostics and randomness. return self.advance_fn.finalize_state(model_state, forcing) def decode(self, x: ModelState, forcing: Forcing) -> typing.Pytree: """Returns model state with perturbation component removed.""" # TODO(langmore) Consider propagating decoding fields so decoder noise at # different lead times is correlated. return self.decoder_fn(x, forcing=forcing) def advance( self, x: ModelState, forcing: Forcing, ) -> ModelState: """Advances model state.""" return self.advance_fn(x, forcing) @gin.configurable( allowlist=( 'checkpoint_step', 'checkpoint_multistep', 'checkpoint_post_process', ) ) def trajectory_from_step( step_fn: Callable, outer_steps: int, inner_steps: int, *, start_with_input: bool, post_process_fn: Callable, checkpoint_step: bool = True, checkpoint_multistep: bool = False, checkpoint_post_process: bool = True, ) -> Callable: """Returns a function that accumulates repeated applications of `step_fn`. Compute a trajectory by repeatedly calling `step_fn()` `outer_steps * inner_steps` times. Args: step_fn: function that takes a state and returns state after one time step. outer_steps: number of steps to save in the generated trajectory. inner_steps: number of repeated calls to step_fn() between saved steps. start_with_input: if True, output the trajectory at steps [0, ..., steps-1] instead of steps [1, ..., steps]. post_process_fn: function to apply to trajectory outputs. checkpoint_step: whether to use `jax.checkpoint` on `step_fn`. checkpoint_multistep: weather to use `jax.checkpoint` on `step_fn` repeated steps between outputting observations used in the loss. Multi-step checkpointing is off by default; turn it on to trade off ~25% increased computed for ~25% less memory usage. checkpoint_post_process: whether to use `jax.checkpoint` on `post_process_fn`. `checkpoint_post_process` is a no-op if multi-step checkpointing is enabled. Returns: A function that takes an initial state and returns a tuple consisting of: (1) the final frame of the trajectory. (2) trajectory of length `outer_steps` representing time evolution. """ if checkpoint_step: step_fn = hk.remat(step_fn) if checkpoint_post_process: post_process_fn = hk.remat(post_process_fn) if checkpoint_multistep: def outer_scan_fn(f, init, xs, length=None): return hk.scan(hk.remat(f), init, xs, length=length) else: outer_scan_fn = hk.scan return time_integration.trajectory_from_step( step_fn, outer_steps, inner_steps, start_with_input=start_with_input, post_process_fn=post_process_fn, inner_scan_fn=hk.scan, outer_scan_fn=outer_scan_fn, ) @gin.configurable(allowlist=('model_cls', 'to_xarray_fn', 'from_xarray_fn')) class WhirlModel: """Class that holds a Haiku model class and xarray conversion methods.""" def __init__( self, coords: coordinate_systems.CoordinateSystem, dt: float, physics_specs: Any, aux_features: Optional[AuxFeatures] = None, input_coords: Optional[coordinate_systems.CoordinateSystem] = None, output_coords: Optional[coordinate_systems.CoordinateSystem] = None, model_cls: Callable[[], DynamicalSystem] = gin.REQUIRED, # pyrefly: ignore[bad-function-definition] to_xarray_fn: Optional[Callable[..., xarray.Dataset]] = None, from_xarray_fn: Optional[Callable[..., DataState]] = None, ): """Constructs pre-defined model functions and holds conversion functions. Args: coords: horizontal and vertical descritization. dt: time step of the model. physics_specs: object describing the scales and physical constants. aux_features: dictionary holding static features that the model may use. input_coords: horizontal and vertical descritization of the input data. if `None`, uses `coords`. Default `None. output_coords: horizontal and vertical descritization for the output data. if `None`, uses `coords`. Default `None. model_cls: model Haiku class that implements encode/advance/decode fns. to_xarray_fn: function that converts decoded data slices to xarray. from_xarray_fn: function that extracts data slices from xarray. """ if aux_features is None: aux_features = {} if input_coords is None: input_coords = coords if output_coords is None: output_coords = coords self._coords = coords self._data_coords = input_coords # by data coords we refer to model inputs. specs = ModelSpecs(coords, dt, physics_specs, aux_features) model_cls = functools.partial( model_cls, coords=coords, dt=dt, physics_specs=physics_specs, aux_features=aux_features, input_coords=input_coords, output_coords=output_coords, ) def forcing_fwd(forcing_data, sim_time): return model_cls().forcing_fn(forcing_data, sim_time) # pytype: disable=wrong-keyword-args forcing_fn = hk.transform(forcing_fwd).apply encode_fwd = lambda x, forcing: model_cls().encode(x, forcing) # pytype: disable=wrong-keyword-args encode_fn = hk.transform(encode_fwd).apply decode_fwd = lambda x, forcing: model_cls().decode(x, forcing) # pytype: disable=wrong-keyword-args decode_fn = hk.transform(decode_fwd).apply advance_fwd = lambda x, forcing: model_cls().advance(x, forcing) # pytype: disable=wrong-keyword-args advance_fn = hk.transform(advance_fwd).apply if to_xarray_fn is not None: to_xarray_fn = functools.partial(to_xarray_fn, coords=output_coords) self.forcing_fn = forcing_fn self.encode_fn = encode_fn self.decode_fn = decode_fn self.advance_fn = advance_fn self.specs = specs self.model_cls = model_cls self.to_xarray_fn = to_xarray_fn self.from_xarray_fn = from_xarray_fn @property def coords(self) -> coordinate_systems.CoordinateSystem: return self._coords @property def data_coords(self) -> coordinate_systems.CoordinateSystem: return self._data_coords def init_params( self, rng: Array, input_trajectory: typing.DataState, forcing_data: ForcingData, ) -> typing.Params: """Returns model parameters by initializing encode/advance/decode fn.""" def fwd(x): model = self.model_cls() # pytype: disable=wrong-keyword-args decode = model_utils.with_forcing( model.decode, model.forcing_fn, forcing_data ) advance = model_utils.with_forcing( model.advance, model.forcing_fn, forcing_data ) encode = model_utils.with_forcing( model.encode, model.forcing_fn, forcing_data ) return decode(advance(encode(x))) hk_model = hk.transform(fwd) return hk_model.init(rng, input_trajectory) def get_whirl_model( data_ds: xarray.Dataset, model_config_str: str, additional_gin_bindings: Optional[list[str]] = None, ) -> WhirlModel: """Returns a configured WhirlModel.""" if additional_gin_bindings is None: additional_gin_bindings = [] try: data_aux_features = xarray_utils.aux_features_from_xarray(data_ds) except KeyError: data_aux_features = {} if 'physics_config_str' in data_ds.attrs: physics_config_str = data_ds.attrs['physics_config_str'] else: physics_config_str = '' # empty string is equivalent to skipping. gin.enter_interactive_mode() gin.clear_config() gin_utils.parse_gin_config( physics_config_str, model_config_str, override_physics_configs_from_data=True, gin_bindings=additional_gin_bindings, ) data_coords = coordinate_system_from_dataset(data_ds) physics_specs = physics_specifications.get_physics_specs() model_specs = get_model_specs(data_coords, physics_specs, data_aux_features) return WhirlModel( coords=model_specs.coords, dt=model_specs.dt, physics_specs=model_specs.physics_specs, aux_features=model_specs.aux_features, input_coords=data_coords, output_coords=data_coords, ) _ECMWF_CUTOFFS = { # On Palmer 2009 (http://shortn/_56HCcQwmSS) page 4, the cutoffs for # perturbations are given. Here we translate them to sigma levels. # low_cutoffs: (100hPa, 50hPa) 'low_cutoffs': (0.05, 0.1), # Will not be accurate over topography. # high_cutoffs: (1300m, 300m) 'high_cutoffs': (0.86, 0.965), } def _piecewise_squasher( sigma: Array, low_cutoffs: Sequence[float], high_cutoffs: Sequence[float], ) -> Array: """Piecewise linear values used to "squash" values by sigma level. See function χ definition at: http://screen/5V3jzU7ZFA4vVJP Args: sigma: 1-D array of values for sigma levels. Should be in [0, 1]. low_cutoffs: σ=low_cutoffs[0] is when χ starts linearly increasing from 0. σ=low_cutoffs[1] is when χ levels out at 1 high_cutoffs: σ=high_cutoffs[0] is when χ starts linearly decreasing from 1. σ=high_cutoffs[1] is when χ reaches 0. Returns: Values χ of shape `sigma.shape + (1, 1)` that should be multiplied by arrays of shape (n_levels, K, L) to "squash" high/low σ values. """ if sigma.ndim != 1: raise ValueError(f'{sigma.shape=} but should have been a 1-D array') if len(low_cutoffs) != 2: raise ValueError(f'{len(low_cutoffs)=} but should have been 2.') if len(high_cutoffs) != 2: raise ValueError(f'{len(high_cutoffs)=} but should have been 2.') low_func = (sigma - low_cutoffs[0]) / (low_cutoffs[1] - low_cutoffs[0]) high_func = (high_cutoffs[1] - sigma) / (high_cutoffs[1] - high_cutoffs[0]) # lower_bound is a function equal to the squasher between # low_cutoffs[0] and high_cutoffs[1]. # It becomes negative outside that range. lower_bound = jnp.minimum(1.0, jnp.minimum(low_func, high_func)) return jnp.maximum(0.0, lower_bound)[:, jnp.newaxis, jnp.newaxis]