Source code for sasktran2.optical.baum

from __future__ import annotations

import warnings
from pathlib import Path
from typing import Literal

import numpy as np
import xarray as xr

from sasktran2.atmosphere import Atmosphere
from sasktran2.database.web import StandardDatabase
from sasktran2.optical.base import OpticalQuantities
from sasktran2.optical.database import OpticalDatabaseGenericScattererRust

BaumParticleModel = Literal[
    "general_habit_mixture",
    "aggregate_solid_columns",
    "solid_columns",
]

_PARTICLE_MODELS = (
    "general_habit_mixture",
    "aggregate_solid_columns",
    "solid_columns",
)
_DEFAULT_DATABASE_MOMENTS = 256
_STANDARD_DATABASE_KEY = "cross_sections/ice/baum_ice_crystals_v3_6.nc"
_FULL_STANDARD_DATABASE_KEY = "cross_sections/ice/baum_ice_crystals_v3_6_full.nc"
_RUNTIME_VARIABLES = (
    "xs_total",
    "xs_scattering",
    "lm_a1",
    "lm_a2",
    "lm_a3",
    "lm_a4",
    "lm_b1",
    "lm_b2",
)


[docs] class BaumIceCrystal(OpticalDatabaseGenericScattererRust): """Baum V3.6 severely rough ice-crystal optical properties. This database provides extinction, single-scattering albedo, and the stored polarized phase-matrix expansion for three ice-crystal habit models. The tabulation contains 445 wavelengths from 199 nm to 99,000 nm (0.199 to 99 microns) and 23 effective diameters from 10 to 120 microns in 5-micron increments. Wavelengths supplied to SASKTRAN2 remain in nanometres. The available ``particle_model`` values are: - ``"general_habit_mixture"``: the Baum general habit mixture. - ``"aggregate_solid_columns"``: aggregates of solid columns. - ``"solid_columns"``: solid columns. All three models use the severely rough particle treatment in the Baum V3.6 tables. The large database is capped at 16,384 moments. Some sharply peaked short-wavelength phase matrices do not meet the database-generation reconstruction tolerances at that cap; the NetCDF generation diagnostics mark those cells explicitly. This optical property is intended for use with :class:`sasktran2.constituent.ExtinctionScatterer`. The effective diameter is selected by passing ``effective_diameter_um`` to the constituent; supply one value per point on the constituent altitude grid. Values between tabulated diameters are interpolated by the Rust database backend. The stored extinction cross section is extinction per ice-water content in ``m^2 g^-1``, so an extinction-profile constituent is preferred over treating the table as a per-particle number-density cross section. Parameters ---------- particle_model : {"general_habit_mixture", "aggregate_solid_columns", "solid_columns"}, optional Ice-crystal habit model to select. By default ``"general_habit_mixture"``. max_moments : int or None, optional Maximum number of Greek-coefficient moments to load. Limits up to 256 use the smaller standard-database artifact. Values above 256 select the full database, which is several gigabytes. ``None`` selects the full database and loads all 16,384 stored moments, which can also require multiple gigabytes of memory. This value must be at least :attr:`sasktran2.Config.num_singlescatter_moments` for any atmosphere using the property. By default 256. db_filepath : pathlib.Path or None, optional Optional local database override. By default the file is obtained from the SASKTRAN2 standard database and cached locally. Raises ------ TypeError If ``max_moments`` is not an integer or ``None``. ValueError If the particle model or moment limit is invalid, or when a requested effective diameter is outside 10--120 microns. OSError If the database cannot be resolved or opened. Examples -------- Construct an extinction-profile ice cloud with a 40-micron effective diameter: >>> import numpy as np >>> import sasktran2 as sk >>> altitudes_m = np.arange(0.0, 20001.0, 1000.0) >>> extinction_per_m = np.full_like(altitudes_m, 1.0e-5) >>> effective_diameter_um = np.full_like(altitudes_m, 40.0) >>> ice_optical_property = sk.optical.BaumIceCrystal( ... particle_model="general_habit_mixture", ... max_moments=256, ... ) >>> ice_cloud = sk.constituent.ExtinctionScatterer( ... ice_optical_property, ... altitudes_m, ... extinction_per_m, ... extinction_wavelength_nm=550.0, ... effective_diameter_um=effective_diameter_um, ... ) """
[docs] def __init__( self, particle_model: BaumParticleModel = "general_habit_mixture", max_moments: int | None = 256, db_filepath: Path | None = None, ) -> None: if particle_model not in _PARTICLE_MODELS: valid = ", ".join(_PARTICLE_MODELS) msg = f"Unknown Baum particle model {particle_model!r}; expected one of {valid}" raise ValueError(msg) if max_moments is not None: if isinstance(max_moments, bool | np.bool_) or not isinstance( max_moments, int | np.integer ): msg = "max_moments must be an integer or None" raise TypeError(msg) if max_moments <= 0: msg = "max_moments must be positive or None" raise ValueError(msg) max_moments = int(max_moments) if db_filepath is None: use_full_database = ( max_moments is None or max_moments > _DEFAULT_DATABASE_MOMENTS ) database_key = ( _FULL_STANDARD_DATABASE_KEY if use_full_database else _STANDARD_DATABASE_KEY ) if use_full_database: warnings.warn( "The requested max_moments selects the full Baum ice-crystal " "database, whose download is several gigabytes. Runtime memory " "use scales with max_moments and can also reach several gigabytes.", UserWarning, stacklevel=2, ) db_filepath = StandardDatabase().path(database_key) if db_filepath is None: msg = "Could not resolve the Baum ice-crystal database" raise OSError(msg) db_filepath = Path(db_filepath) if not db_filepath.exists(): msg = f"Baum ice-crystal database does not exist: {db_filepath}" raise OSError(msg) with xr.open_dataset(db_filepath) as source: missing = [name for name in _RUNTIME_VARIABLES if name not in source] if missing: msg = f"Baum database is missing required variables: {missing}" raise ValueError(msg) if "particle_model" not in source.coords: msg = "Baum database is missing the particle_model coordinate" raise ValueError(msg) available_models = tuple( str(value) for value in source.particle_model.values ) if particle_model not in available_models: msg = ( f"Particle model {particle_model!r} is not present in {db_filepath}; " f"available models are {available_models}" ) raise ValueError(msg) available_moments = source.sizes.get("legendre", 0) if available_moments == 0: msg = "Baum database contains no Legendre moments" raise ValueError(msg) if max_moments is None: loaded_moments = available_moments else: if max_moments > available_moments: msg = ( f"Requested {max_moments} moments, but the Baum database only " f"contains {available_moments}" ) raise ValueError(msg) loaded_moments = int(max_moments) selected = ( source[list(_RUNTIME_VARIABLES)] .sel(particle_model=particle_model, drop=True) .isel(legendre=slice(0, loaded_moments)) .load() ) diameter = np.asarray(selected["effective_diameter_um"], dtype=np.float64) self._particle_model = particle_model self._available_moments = available_moments self._max_moments = loaded_moments self._diameter_range_um = (float(diameter.min()), float(diameter.max())) super().__init__(db=selected) # The Rust interpolator owns its packed coefficient array after construction; # retaining the selected xarray data would duplicate the largest allocation. self._database = None
@property def particle_model(self) -> BaumParticleModel: """Selected ice-crystal habit model.""" return self._particle_model @property def max_moments(self) -> int: """Number of Greek-coefficient moments loaded into the Rust backend.""" return self._max_moments @property def available_moments(self) -> int: """Moments stored in the selected standard or local database file.""" return self._available_moments def _validate_effective_diameter(self, kwargs: dict) -> None: if "effective_diameter_um" not in kwargs: msg = "effective_diameter_um must be supplied for BaumIceCrystal" raise ValueError(msg) diameter = np.asarray(kwargs["effective_diameter_um"], dtype=np.float64) lower, upper = self._diameter_range_um if not np.all(np.isfinite(diameter)): msg = "effective_diameter_um must contain only finite values" raise ValueError(msg) if np.any((diameter < lower) | (diameter > upper)): msg = f"effective_diameter_um must be within [{lower}, {upper}] microns" raise ValueError(msg) def _validate_atmosphere_moments(self, atmo: Atmosphere) -> None: requested = atmo.leg_coeff.a1.shape[0] if requested > self._max_moments: msg = ( f"Atmosphere requests {requested} single-scatter moments, but this " f"BaumIceCrystal loaded only {self._max_moments}; increase max_moments" ) raise ValueError(msg) def cross_sections( self, wavelengths_nm: np.ndarray, altitudes_m: np.ndarray, **kwargs ) -> OpticalQuantities: self._validate_effective_diameter(kwargs) return super().cross_sections(wavelengths_nm, altitudes_m, **kwargs) def atmosphere_quantities(self, atmo: Atmosphere, **kwargs) -> OpticalQuantities: self._validate_effective_diameter(kwargs) self._validate_atmosphere_moments(atmo) return super().atmosphere_quantities(atmo, **kwargs) def optical_derivatives(self, atmo: Atmosphere, **kwargs) -> dict: self._validate_effective_diameter(kwargs) self._validate_atmosphere_moments(atmo) return super().optical_derivatives(atmo, **kwargs) def cross_section_derivatives( self, wavelengths_nm: np.ndarray, altitudes_m: np.ndarray, **kwargs ) -> dict: self._validate_effective_diameter(kwargs) return super().cross_section_derivatives(wavelengths_nm, altitudes_m, **kwargs) def _into_rust_object(self): # Keep the thin Python validation layer so a moment mismatch is reported # before the Rust interpolator fills an atmosphere-sized output buffer. return None