Source code for sasktran2.constituent.numdenscatterer2d

from __future__ import annotations

from typing import Any

import numpy as np

import sasktran2 as sk
from sasktran2._core_rust import PyNumberDensityScatterer2D
from sasktran2.atmosphere import Atmosphere
from sasktran2.optical.base import OpticalProperty

from .base import Constituent


[docs] class NumberDensityScatterer2D(Constituent): """A scatterer specified by number density on a Geometry2D grid. The number density and all spatially varying optical-property arguments are stored in ``(horizontal, altitude)`` order. They are used directly on the native :class:`~sasktran2.Geometry2D` grid without interpolation or an expanded identity mapping. Parameters ---------- optical_property : OpticalProperty Property supplying extinction, scattering, and phase-function data. number_density : numpy.ndarray Particle number density in ``m^-3`` with shape ``(horizontal, altitude)``. **kwargs Additional spatial inputs for ``optical_property``. Each input must be either a scalar or an array with the same shape as ``number_density``. Scalars are applied uniformly to all locations. """ _constituent: PyNumberDensityScatterer2D
[docs] def __init__( self, optical_property: OpticalProperty, number_density: np.ndarray, **kwargs, ) -> None: super().__init__() number_density = self._validate_native_profile(number_density, "number_density") self._volume_shape = number_density.shape self._optical_property = optical_property self._kwargs = { name: self._validate_aux_input(value, name) for name, value in kwargs.items() } self._vertical_deriv_factor = np.ones(self._volume_shape, dtype=np.float64) self._d_vertical_deriv_factor: dict[str, np.ndarray] = {} self._wf_name = "number_density" self._constituent = PyNumberDensityScatterer2D( optical_property, np.ascontiguousarray(number_density).reshape(-1), **self._flat_kwargs(), ) self._sync_constituent_state()
@staticmethod def _validate_native_profile(value: np.ndarray, name: str) -> np.ndarray: value = np.asarray(value, dtype=np.float64) if value.ndim != 2: msg = f"{name} must have shape (horizontal, altitude); got {value.shape}" raise ValueError(msg) if 0 in value.shape: msg = f"{name} horizontal and altitude dimensions must both be non-empty" raise ValueError(msg) return np.ascontiguousarray(value) def _validate_aux_input(self, value: np.ndarray, name: str) -> np.ndarray: value = np.asarray(value, dtype=np.float64) if value.ndim == 0: return np.full(self._volume_shape, value.item(), dtype=np.float64) if value.shape != self._volume_shape: msg = ( f"{name} must be scalar or have shape {self._volume_shape}; " f"got {value.shape}" ) raise ValueError(msg) return np.ascontiguousarray(value) def _flat_kwargs(self) -> dict[str, np.ndarray]: return { name: np.ascontiguousarray(value).reshape(-1) for name, value in self._kwargs.items() } def _sync_constituent_state(self) -> None: self._constituent.aux_inputs = self._flat_kwargs() self._constituent.vertical_deriv_factor = np.ascontiguousarray( self._vertical_deriv_factor ).reshape(-1) self._constituent.d_vertical_deriv_factor = { name: np.ascontiguousarray(value).reshape(-1) for name, value in self._d_vertical_deriv_factor.items() } self._constituent.wf_name = self._wf_name def __getattr__(self, name: str) -> Any: if name in self.__dict__.get("_kwargs", {}): return self._kwargs[name] msg = f"{type(self).__name__!s} has no attribute {name!r}" raise AttributeError(msg) def __setattr__(self, name: str, value: Any) -> None: if name in self.__dict__.get("_kwargs", {}): self._kwargs[name] = self._validate_aux_input(value, name) self._sync_constituent_state() else: super().__setattr__(name, value) @property def volume_spatial_mode(self) -> str: return "native_2d" @property def number_density(self) -> np.ndarray: """Particle number density in ``(horizontal, altitude)`` order.""" return self._constituent.number_density.reshape(self._volume_shape) @number_density.setter def number_density(self, number_density: np.ndarray) -> None: number_density = np.asarray(number_density, dtype=np.float64) if number_density.shape != self._volume_shape: msg = ( f"number_density must retain shape {self._volume_shape}; " f"got {number_density.shape}" ) raise ValueError(msg) self._constituent.number_density = np.ascontiguousarray(number_density).reshape( -1 ) def _validate_atmosphere(self, atmo: Atmosphere) -> None: if not isinstance(atmo.model_geometry, sk.Geometry2D): msg = f"{type(self).__name__} requires an atmosphere using Geometry2D" raise TypeError(msg) if tuple(atmo.volume_shape) != self._volume_shape: msg = ( f"{type(self).__name__} shape does not match the atmosphere: " f"{self._volume_shape} != {atmo.volume_shape}" ) raise ValueError(msg) def add_to_atmosphere(self, atmo: Atmosphere) -> None: self._validate_atmosphere(atmo) self._sync_constituent_state() self._constituent.add_to_atmosphere(atmo) def register_derivative(self, atmo: Atmosphere, name: str) -> None: self._validate_atmosphere(atmo) self._sync_constituent_state() self._constituent.register_derivative(atmo, name)
[docs] class ExtinctionScatterer2D(NumberDensityScatterer2D): """A Geometry2D scatterer normalized to an extinction profile. This is the native-grid counterpart to :class:`~sasktran2.constituent.ExtinctionScatterer`. The supplied extinction is converted to number density using the optical property's cross section at ``extinction_wavelength_nm``. The conversion is evaluated independently at every Geometry2D location, including the supplied optical property arguments. Parameters ---------- optical_property : OpticalProperty Property supplying extinction, scattering, and phase-function data. extinction_per_m : numpy.ndarray Extinction in ``m^-1`` with shape ``(horizontal, altitude)``. extinction_wavelength_nm : float Wavelength in nanometres at which ``extinction_per_m`` is specified. **kwargs Additional optical-property inputs. Shape rules are identical to :class:`NumberDensityScatterer2D`. """
[docs] def __init__( self, optical_property: OpticalProperty, extinction_per_m: np.ndarray, extinction_wavelength_nm: float, **kwargs, ) -> None: extinction_per_m = self._validate_native_profile( extinction_per_m, "extinction_per_m" ) extinction_wavelength_nm = float(extinction_wavelength_nm) if not np.isfinite(extinction_wavelength_nm) or extinction_wavelength_nm <= 0: msg = "extinction_wavelength_nm must be finite and positive" raise ValueError(msg) self._extinction_per_m = extinction_per_m.copy() self._extinction_wavelength_nm = extinction_wavelength_nm self._extinction_to_numden_factors: np.ndarray | None = None super().__init__( optical_property, np.zeros_like(extinction_per_m, dtype=np.float64), **kwargs, ) self._wf_name = "extinction" self._sync_constituent_state()
def _update_number_density(self, atmo: Atmosphere) -> None: native_altitudes = np.asarray(atmo._native_altitudes(), dtype=np.float64) factors = np.asarray( self._optical_property.cross_sections( np.array([self._extinction_wavelength_nm]), altitudes_m=native_altitudes, **self._flat_kwargs(), ).extinction, dtype=np.float64, ).reshape(-1) expected_size = int(np.prod(self._volume_shape)) if factors.size != expected_size: msg = ( "Optical-property reference cross section has spatial size " f"{factors.size}; expected {expected_size}" ) raise ValueError(msg) if np.any(~np.isfinite(factors)) or np.any(factors <= 0): msg = "Reference extinction cross sections must be finite and positive" raise ValueError(msg) self._extinction_to_numden_factors = factors.reshape(self._volume_shape) self._vertical_deriv_factor = 1.0 / self._extinction_to_numden_factors self.number_density = ( self._extinction_per_m / self._extinction_to_numden_factors ) derivatives = self._optical_property.cross_section_derivatives( np.array([self._extinction_wavelength_nm]), altitudes_m=native_altitudes, **self._flat_kwargs(), ) self._d_vertical_deriv_factor = {} for name, derivative in derivatives.items(): derivative_array = np.asarray(derivative, dtype=np.float64) if derivative_array.size != expected_size: msg = ( f"Cross-section derivative {name!r} has spatial size " f"{derivative_array.size}; expected {expected_size}" ) raise ValueError(msg) derivative_array = derivative_array.reshape(self._volume_shape) self._d_vertical_deriv_factor[name] = ( -derivative_array * self._vertical_deriv_factor**2 ) @property def extinction_per_m(self) -> np.ndarray: """Reference extinction in ``(horizontal, altitude)`` order.""" return self._extinction_per_m @extinction_per_m.setter def extinction_per_m(self, extinction_per_m: np.ndarray) -> None: extinction_per_m = np.asarray(extinction_per_m, dtype=np.float64) if extinction_per_m.shape != self._volume_shape: msg = ( f"extinction_per_m must retain shape {self._volume_shape}; " f"got {extinction_per_m.shape}" ) raise ValueError(msg) self._extinction_per_m = np.ascontiguousarray(extinction_per_m) @property def extinction_wavelength_nm(self) -> float: """Wavelength in nanometres used to normalize the extinction.""" return self._extinction_wavelength_nm def add_to_atmosphere(self, atmo: Atmosphere) -> None: self._validate_atmosphere(atmo) self._update_number_density(atmo) super().add_to_atmosphere(atmo)