# pyradtran/clouds.py
"""
Cloud utilities for pyRadtran.
This module provides tools to create libRadtran-compatible cloud input
files from various data sources. The typical workflow is:
1. Build a list of :class:`CloudLayer` objects — either from simple
parameters (:meth:`CloudGenerator.from_simple_parameters`) or from an
ERA5 dataset (:meth:`CloudGenerator.from_era5_dataset`).
2. Write the layers to a ``.dat`` file with :class:`CloudFileWriter`.
3. Pass the file path to ``ds.pyradtran.run()`` via
``parameter_overrides={'wc_file 1D': 'my_cloud.dat'}``.
Examples
--------
Create a simple water cloud and write it to a file:
>>> from pyradtran.clouds import CloudGenerator, CloudFileWriter
>>> from pathlib import Path
>>> layers = CloudGenerator.from_simple_parameters(
... z_base_km=1.0, z_top_km=2.0, lwc_g_m3=0.3, r_eff_um=10.0, n_layers=5
... )
>>> CloudFileWriter.write_water_cloud_file(layers, Path("work/wc.dat"))
See Also
--------
pyradtran.config.CloudParameters : Declarative cloud settings in YAML configs.
"""
import logging
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
import numpy as np
import xarray as xr
from .era5 import _z_to_km
logger = logging.getLogger(__name__)
[docs]
@dataclass
class CloudLayer:
"""A single cloud layer with vertical extent and microphysical properties.
``CloudLayer`` is a validated data container. It is not written to disk
directly — pass a list of layers to :class:`CloudFileWriter` instead.
Parameters
----------
z_bottom_km : float
Bottom altitude of the layer in km above sea level.
z_top_km : float
Top altitude of the layer in km above sea level. Must be greater
than *z_bottom_km*.
lwc_g_m3 : float, default ``0.0``
Liquid water content in g m⁻³.
iwc_g_m3 : float, default ``0.0``
Ice water content in g m⁻³.
r_eff_um : float, default ``10.0``
Effective droplet / crystal radius in µm.
cloud_fraction : float, default ``1.0``
Cloud fraction (0–1).
Raises
------
ValueError
If *z_bottom_km* >= *z_top_km*, water content is negative,
effective radius is non-positive, or cloud fraction is outside [0, 1].
Examples
--------
>>> layer = CloudLayer(z_bottom_km=1.0, z_top_km=2.0, lwc_g_m3=0.3)
>>> layer.r_eff_um
10.0
See Also
--------
CloudGenerator.from_simple_parameters : Build layers from basic numbers.
CloudGenerator.from_era5_dataset : Build layers from ERA5 reanalysis.
"""
z_bottom_km: float
z_top_km: float
lwc_g_m3: float = 0.0 # Liquid water content
iwc_g_m3: float = 0.0 # Ice water content
r_eff_um: float = 10.0 # Effective radius in micrometers
cloud_fraction: float = 1.0 # Cloud fraction (0-1)
def __post_init__(self):
"""Validate cloud layer parameters."""
if self.z_bottom_km >= self.z_top_km:
raise ValueError(
f"Bottom altitude ({self.z_bottom_km}) must be less than top altitude ({self.z_top_km})"
)
if self.lwc_g_m3 < 0 or self.iwc_g_m3 < 0:
raise ValueError("Water content cannot be negative")
if self.r_eff_um <= 0:
raise ValueError("Effective radius must be positive")
if not 0 <= self.cloud_fraction <= 1:
raise ValueError("Cloud fraction must be between 0 and 1")
[docs]
class CloudGenerator:
"""Factory for :class:`CloudLayer` sequences.
All methods are static — no instantiation needed. Choose a source
method according to your input data:
* :meth:`from_simple_parameters` — uniform slab from a few numbers.
* :meth:`from_era5_dataset` — vertically resolved layers from ERA5.
See Also
--------
CloudFileWriter : Persist layers to libRadtran ``.dat`` files.
"""
[docs]
@staticmethod
def from_era5_dataset(
ds: xr.Dataset,
time: Optional[datetime] = None,
lat: Optional[float] = None,
lon: Optional[float] = None,
cloud_variables: Optional[Dict[str, str]] = None,
altitude_levels_km: Optional[np.ndarray] = None,
lwc_threshold: float = 1e-6,
iwc_threshold: float = 1e-6,
default_r_eff_water: float = 10.0,
default_r_eff_ice: float = 30.0,
pressure_levels: Optional[str] = None,
geopotential_var: Optional[str] = None,
) -> List[CloudLayer]:
"""Extract cloud layers from an ERA5 :class:`xarray.Dataset`.
The method selects a single profile (time, lat, lon), converts
LWC / IWC from kg kg⁻¹ to g m⁻³, and returns
one :class:`CloudLayer` per model level that exceeds the water-
content thresholds.
Parameters
----------
ds : xarray.Dataset
ERA5 dataset containing, at minimum, cloud water content on
pressure levels.
time : datetime, optional
Time step to select. Defaults to the first available.
lat, lon : float, optional
Coordinates for nearest-neighbour selection. If either is
*None* the spatial mean is used.
cloud_variables : dict of str, optional
Mapping ``{'lwc': 'clwc', 'iwc': 'ciwc', 'cc': 'cc',
'temp': 't', 'z': 'z'}`` that connects internal keys to
dataset variable names.
altitude_levels_km : numpy.ndarray, optional
Pre-computed altitude grid. When *None*, altitudes are
derived from geopotential height or the hypsometric equation.
lwc_threshold : float, default ``1e-6``
Minimum liquid water content (g m⁻³) to retain.
iwc_threshold : float, default ``1e-6``
Minimum ice water content (g m⁻³) to retain.
default_r_eff_water : float, default ``10.0``
Effective radius for liquid droplets (µm).
default_r_eff_ice : float, default ``30.0``
Effective radius for ice crystals (µm).
pressure_levels : str, optional
Name of the pressure coordinate. Auto-detected when *None*.
geopotential_var : str, optional
Name of the geopotential variable. Auto-detected when *None*.
Returns
-------
list of CloudLayer
One layer per model level with significant cloud content,
sorted from lowest to highest altitude.
Raises
------
ValueError
If no pressure coordinate can be found.
KeyError
If a required cloud variable is missing from *ds*.
Examples
--------
>>> import xarray as xr
>>> ds = xr.open_dataset("era5_cloud.nc")
>>> layers = CloudGenerator.from_era5_dataset(
... ds, lat=78.0, lon=15.0, default_r_eff_water=8.0
... )
See Also
--------
generate_cloud_file_from_era5 : One-step convenience wrapper.
"""
logger.info("Generating cloud layers from ERA5 dataset")
# Default variable mappings for ERA5
if cloud_variables is None:
cloud_variables = {
"lwc": "clwc", # Cloud liquid water content
"iwc": "ciwc", # Cloud ice water content
"cc": "cc", # Cloud cover
"temp": "t", # Temperature (for altitude calculation)
"z": "z", # Geopotential height
}
# Select time
if time is not None:
try:
ds_time = ds.sel(time=time, method="nearest")
except (KeyError, ValueError):
logger.warning(f"Time {time} not found, using first available time")
ds_time = ds.isel(time=0)
else:
ds_time = ds.isel(time=0)
# Select location or compute spatial mean
if lat is not None and lon is not None:
try:
ds_point = ds_time.sel(latitude=lat, longitude=lon, method="nearest")
logger.debug(f"Selected point: lat={lat}, lon={lon}")
except (KeyError, ValueError):
logger.warning("Lat/lon not found, using spatial mean")
ds_point = ds_time.mean(dim=["latitude", "longitude"], skipna=True)
else:
ds_point = ds_time.mean(dim=["latitude", "longitude"], skipna=True)
logger.debug("Using spatial mean for cloud properties")
# Auto-detect pressure coordinate
if pressure_levels is None:
pressure_coords = [
coord
for coord in ds_point.coords
if any(name in coord.lower() for name in ["pressure", "level", "plev"])
]
if pressure_coords:
pressure_levels = pressure_coords[0]
logger.debug(f"Auto-detected pressure coordinate: {pressure_levels}")
else:
raise ValueError("Could not find pressure coordinate in dataset")
# Get pressure levels (in Pa, convert to hPa if needed)
pressure = ds_point[pressure_levels].values
if np.max(pressure) > 10000: # Likely in Pa, convert to hPa
pressure = pressure / 100
# Calculate altitude levels - prioritize geopotential height if available
if altitude_levels_km is None:
# Try to use geopotential height first
geopotential_var = geopotential_var or cloud_variables.get("z", "z")
if geopotential_var in ds_point:
# Use geopotential height (convert from geopotential to geometric height)
geopotential = ds_point[geopotential_var].values
# Check if it's 1D (constant across time/space) or needs to be extracted
if geopotential.ndim > 1:
# Take mean over any non-level dimensions
non_level_dims = [
dim
for dim in ds_point[geopotential_var].dims
if dim != pressure_levels
]
if non_level_dims:
geopotential = (
ds_point[geopotential_var]
.mean(dim=non_level_dims, skipna=True)
.values
)
# Convert geopotential to geometric height (m -> km); the
# units attribute decides, magnitude only as fallback
altitude_levels_km = _z_to_km(
geopotential, ds_point[geopotential_var].attrs.get("units")
)
logger.debug(
f"Using geopotential height data with {len(altitude_levels_km)} levels"
)
logger.debug(
f"Altitude range: {altitude_levels_km.min():.2f} - {altitude_levels_km.max():.2f} km"
)
else:
# Fall back to hypsometric equation approximation
logger.warning(
f"Geopotential variable '{geopotential_var}' not found, using pressure-based approximation"
)
# h ≈ -7 * ln(p/p0) where p0 = 1013.25 hPa, h in km
altitude_levels_km = -7.0 * np.log(pressure / 1013.25)
logger.debug(
f"Calculated {len(altitude_levels_km)} altitude levels from pressure"
)
# Extract every per-level array in the dataset's native level order,
# so the ascending-altitude reordering below moves them all together.
lwc_var = cloud_variables.get("lwc")
iwc_var = cloud_variables.get("iwc")
cc_var = cloud_variables.get("cc")
temp_var = cloud_variables.get("temp", "t")
lwc = (
ds_point[lwc_var].values
if lwc_var and lwc_var in ds_point
else np.zeros_like(pressure)
)
iwc = (
ds_point[iwc_var].values
if iwc_var and iwc_var in ds_point
else np.zeros_like(pressure)
)
cc = (
ds_point[cc_var].values
if cc_var and cc_var in ds_point
else np.ones_like(pressure)
)
temp = ds_point[temp_var].values if temp_var in ds_point else None
# Ensure altitudes are in ascending order (surface to top)
if (
len(altitude_levels_km) > 1
and altitude_levels_km[0] > altitude_levels_km[1]
):
altitude_levels_km = altitude_levels_km[::-1]
pressure = pressure[::-1]
lwc = lwc[::-1]
iwc = iwc[::-1]
cc = cc[::-1]
if temp is not None:
temp = temp[::-1]
logger.debug("Reversed level arrays to ensure ascending altitude order")
# Convert from kg/kg to g/m³ (typical ERA5 units) using air density
# ρ_air ≈ p/(R*T) with R ≈ 287 J/(kg·K); rough standard-atmosphere
# fallback without a temperature profile
if temp is not None:
air_density = (pressure * 100) / (287 * temp) # kg/m³
else:
air_density = pressure / 10 # kg/m³
if lwc_var and lwc_var in ds_point:
lwc = lwc * air_density * 1000
if iwc_var and iwc_var in ds_point:
iwc = iwc * air_density * 1000
cloud_layers = []
try:
# Create cloud layers for each level with significant cloud content
for i, (alt, p, lwc_val, iwc_val, cc_val) in enumerate(
zip(altitude_levels_km, pressure, lwc, iwc, cc)
):
# Check if there's significant cloud content
has_liquid = lwc_val > lwc_threshold
has_ice = iwc_val > iwc_threshold
if has_liquid or has_ice:
# Determine layer boundaries (midpoint between levels)
# Use a minimum layer thickness to avoid zero-thickness layers
min_layer_thickness = 0.05 # 50 meters minimum
if i == 0:
# Top layer
if i < len(altitude_levels_km) - 1:
z_bottom = (alt + altitude_levels_km[i + 1]) / 2
else:
z_bottom = alt - min_layer_thickness
z_top = alt + min_layer_thickness
elif i == len(altitude_levels_km) - 1:
# Bottom layer
z_top = (altitude_levels_km[i - 1] + alt) / 2
z_bottom = alt - min_layer_thickness
else:
# Middle layers
z_top = (altitude_levels_km[i - 1] + alt) / 2
z_bottom = (alt + altitude_levels_km[i + 1]) / 2
# Ensure proper ordering and minimum thickness
if z_bottom >= z_top:
# Fallback: create a thin layer around the level
z_bottom = alt - min_layer_thickness / 2
z_top = alt + min_layer_thickness / 2
# Ensure minimum layer thickness
if (z_top - z_bottom) < min_layer_thickness:
layer_center = (z_top + z_bottom) / 2
z_bottom = layer_center - min_layer_thickness / 2
z_top = layer_center + min_layer_thickness / 2
# Ensure non-negative altitudes
if z_bottom < 0:
z_bottom = 0
z_top = max(z_top, min_layer_thickness)
# Choose appropriate effective radius
if has_liquid and has_ice:
# Mixed phase - use temperature to decide dominant phase
r_eff = default_r_eff_ice if alt > 8.0 else default_r_eff_water
elif has_liquid:
r_eff = default_r_eff_water
else:
r_eff = default_r_eff_ice
try:
cloud_layer = CloudLayer(
z_bottom_km=z_bottom,
z_top_km=z_top,
lwc_g_m3=lwc_val,
iwc_g_m3=iwc_val,
r_eff_um=r_eff,
cloud_fraction=cc_val,
)
cloud_layers.append(cloud_layer)
except ValueError as e:
logger.warning(
f"Skipping invalid cloud layer at {alt:.3f} km: {e}"
)
continue
except KeyError as e:
logger.error(f"Required cloud variable not found in dataset: {e}")
raise
except Exception as e:
logger.error(f"Error processing cloud data: {e}")
raise
logger.info(f"Generated {len(cloud_layers)} cloud layers from ERA5 data")
return cloud_layers
[docs]
@staticmethod
def from_simple_parameters(
z_base_km: float,
z_top_km: float,
lwc_g_m3: float = 0.1,
iwc_g_m3: float = 0.0,
r_eff_um: float = 10.0,
cloud_fraction: float = 1.0,
n_layers: int = 1,
) -> List[CloudLayer]:
"""Create uniform cloud layers from basic parameters.
The altitude range *z_base_km* … *z_top_km* is split into
*n_layers* sub-layers, each receiving the same microphysical
values.
Parameters
----------
z_base_km : float
Cloud base altitude (km above sea level).
z_top_km : float
Cloud top altitude (km above sea level).
lwc_g_m3 : float, default ``0.1``
Liquid water content (g m⁻³).
iwc_g_m3 : float, default ``0.0``
Ice water content (g m⁻³).
r_eff_um : float, default ``10.0``
Effective droplet / crystal radius (µm).
cloud_fraction : float, default ``1.0``
Cloud fraction (0–1).
n_layers : int, default ``1``
Number of sub-layers to create.
Returns
-------
list of CloudLayer
Layers ordered from bottom to top.
Raises
------
ValueError
If *n_layers* < 1 or *z_base_km* >= *z_top_km*.
Examples
--------
>>> layers = CloudGenerator.from_simple_parameters(
... z_base_km=1.0, z_top_km=2.0, lwc_g_m3=0.3, n_layers=5
... )
>>> len(layers)
5
See Also
--------
CloudFileWriter.write_water_cloud_file : Write layers to disk.
"""
if n_layers <= 0:
raise ValueError("Number of layers must be positive")
layer_thickness = (z_top_km - z_base_km) / n_layers
layers = []
for i in range(n_layers):
z_bottom = z_base_km + i * layer_thickness
z_top = z_bottom + layer_thickness
layer = CloudLayer(
z_bottom_km=z_bottom,
z_top_km=z_top,
lwc_g_m3=lwc_g_m3,
iwc_g_m3=iwc_g_m3,
r_eff_um=r_eff_um,
cloud_fraction=cloud_fraction,
)
layers.append(layer)
return layers
[docs]
class CloudFileWriter:
"""Persist :class:`CloudLayer` sequences as libRadtran ``.dat`` files.
Two static methods are provided — one per cloud phase:
* :meth:`write_water_cloud_file` → ``wc_file 1D``
* :meth:`write_ice_cloud_file` → ``ic_file 1D``
The output format is a three-column ASCII table
(altitude, water-content, effective-radius) understood by
libRadtran's ``wc_file`` / ``ic_file`` input options.
See Also
--------
CloudGenerator : Build :class:`CloudLayer` lists from data or parameters.
"""
[docs]
@staticmethod
def write_water_cloud_file(
cloud_layers: List[CloudLayer],
output_path: Path,
include_zero_layers: bool = True,
altitude_resolution_km: float = 0.1,
) -> Path:
"""Write a water-cloud ``.dat`` file for libRadtran.
The file contains three columns — altitude (km), liquid water
content (g m⁻³), and effective radius (µm) — on a
regular altitude grid.
Parameters
----------
cloud_layers : list of CloudLayer
Layers to rasterise. Only entries with ``lwc_g_m3 > 0``
contribute unless *include_zero_layers* is set.
output_path : pathlib.Path
Destination file. Parent directories are created
automatically.
include_zero_layers : bool, default ``True``
If *True*, grid points outside any layer are written as
zero LWC. Set to *False* to skip them.
altitude_resolution_km : float, default ``0.1``
Vertical spacing of the output grid (km).
Returns
-------
pathlib.Path
*output_path* (echoed back for convenience).
Examples
--------
>>> from pathlib import Path
>>> layers = CloudGenerator.from_simple_parameters(
... z_base_km=1.0, z_top_km=2.0, lwc_g_m3=0.3
... )
>>> CloudFileWriter.write_water_cloud_file(layers, Path("wc.dat"))
PosixPath('wc.dat')
"""
logger.info(f"Writing water cloud file to {output_path}")
# Filter for water clouds only
water_layers = [layer for layer in cloud_layers if layer.lwc_g_m3 > 0]
if not water_layers and not include_zero_layers:
logger.warning("No water cloud layers found")
water_layers = cloud_layers # Include all layers anyway
# Determine altitude range
if water_layers:
z_min = min(layer.z_bottom_km for layer in water_layers)
z_max = max(layer.z_top_km for layer in water_layers)
else:
z_min, z_max = 0.0, 20.0
# Create altitude grid
z_grid = np.arange(
z_max, z_min - altitude_resolution_km, -altitude_resolution_km
)
# Interpolate cloud properties to grid
lwc_profile = np.zeros_like(z_grid)
r_eff_profile = np.full_like(z_grid, 10.0) # Default effective radius
for layer in water_layers:
# Find grid points within this layer
in_layer = (z_grid >= layer.z_bottom_km) & (z_grid <= layer.z_top_km)
lwc_profile[in_layer] = layer.lwc_g_m3
r_eff_profile[in_layer] = layer.r_eff_um
# Write file
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w") as f:
f.write("# z LWC R_eff\n")
f.write("# (km) (g/m^3) (um)\n")
for z, lwc, r_eff in zip(z_grid, lwc_profile, r_eff_profile):
f.write(f"{z:10.3f} {lwc:8.3f} {r_eff:8.1f}\n")
logger.info(f"Wrote {len(z_grid)} altitude levels to {output_path}")
return output_path
[docs]
@staticmethod
def write_ice_cloud_file(
cloud_layers: List[CloudLayer],
output_path: Path,
include_zero_layers: bool = True,
altitude_resolution_km: float = 0.1,
) -> Path:
"""Write an ice-cloud ``.dat`` file for libRadtran.
Analogous to :meth:`write_water_cloud_file` but uses
``iwc_g_m3`` and defaults to a 30 µm effective radius.
Parameters
----------
cloud_layers : list of CloudLayer
Layers to rasterise. Only entries with ``iwc_g_m3 > 0``
contribute unless *include_zero_layers* is set.
output_path : pathlib.Path
Destination file.
include_zero_layers : bool, default ``True``
Write zero-IWC grid points when *True*.
altitude_resolution_km : float, default ``0.1``
Vertical spacing of the output grid (km).
Returns
-------
pathlib.Path
*output_path*.
Examples
--------
>>> CloudFileWriter.write_ice_cloud_file(layers, Path("ic.dat"))
PosixPath('ic.dat')
"""
logger.info(f"Writing ice cloud file to {output_path}")
# Filter for ice clouds only
ice_layers = [layer for layer in cloud_layers if layer.iwc_g_m3 > 0]
if not ice_layers and not include_zero_layers:
logger.warning("No ice cloud layers found")
ice_layers = cloud_layers
# Determine altitude range
if ice_layers:
z_min = min(layer.z_bottom_km for layer in ice_layers)
z_max = max(layer.z_top_km for layer in ice_layers)
else:
z_min, z_max = 5.0, 15.0 # Typical ice cloud range
# Create altitude grid
z_grid = np.arange(
z_max, z_min - altitude_resolution_km, -altitude_resolution_km
)
# Interpolate cloud properties to grid
iwc_profile = np.zeros_like(z_grid)
r_eff_profile = np.full_like(z_grid, 30.0) # Default ice effective radius
for layer in ice_layers:
in_layer = (z_grid >= layer.z_bottom_km) & (z_grid <= layer.z_top_km)
iwc_profile[in_layer] = layer.iwc_g_m3
r_eff_profile[in_layer] = layer.r_eff_um
# Write file
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w") as f:
f.write("# z IWC R_eff\n")
f.write("# (km) (g/m^3) (um)\n")
for z, iwc, r_eff in zip(z_grid, iwc_profile, r_eff_profile):
f.write(f"{z:10.3f} {iwc:8.3f} {r_eff:8.1f}\n")
logger.info(f"Wrote {len(z_grid)} altitude levels to {output_path}")
return output_path
[docs]
def generate_cloud_file_from_era5(
era5_dataset: xr.Dataset,
output_path: Path,
cloud_type: str = "wc",
time: Optional[datetime] = None,
lat: Optional[float] = None,
lon: Optional[float] = None,
**kwargs,
) -> Path:
"""One-step cloud-file generation from an ERA5 dataset.
Combines :meth:`CloudGenerator.from_era5_dataset` and the
appropriate :class:`CloudFileWriter` method into a single call.
Parameters
----------
era5_dataset : xarray.Dataset
ERA5 dataset with cloud variables on pressure levels.
output_path : pathlib.Path
Destination ``.dat`` file.
cloud_type : {"wc", "ic"}, default ``"wc"``
``"wc"`` for liquid water cloud, ``"ic"`` for ice cloud.
time : datetime, optional
Time step to select from the dataset.
lat, lon : float, optional
Coordinates for nearest-neighbour selection.
**kwargs
Forwarded to :meth:`CloudGenerator.from_era5_dataset`.
Returns
-------
pathlib.Path
*output_path*.
Raises
------
ValueError
If *cloud_type* is not ``"wc"`` or ``"ic"``.
Examples
--------
>>> path = generate_cloud_file_from_era5(
... ds, Path("work/wc.dat"), cloud_type="wc", lat=78.0, lon=15.0
... )
See Also
--------
CloudGenerator.from_era5_dataset : Extract layers only (no I/O).
CloudFileWriter : Write layers to disk separately.
"""
# Generate cloud layers from ERA5
cloud_layers = CloudGenerator.from_era5_dataset(
era5_dataset, time=time, lat=lat, lon=lon, **kwargs
)
# Write appropriate cloud file
if cloud_type.lower() == "wc":
return CloudFileWriter.write_water_cloud_file(cloud_layers, output_path)
elif cloud_type.lower() == "ic":
return CloudFileWriter.write_ice_cloud_file(cloud_layers, output_path)
else:
raise ValueError(f"Unknown cloud type: {cloud_type}. Use 'wc' or 'ic'")