Source code for pyradtran.era5

"""
ERA5 integration: normalisation, atmosphere files, and cloud profiles.

ERA5 data arrives under several naming conventions — the CDS NetCDF short
names (``t``, ``q``, ``o3``, ``clwc``, coordinate ``pressure_level``) and
the ARCO-ERA5 Zarr long names (``temperature``, ``specific_humidity``,
``geopotential``, coordinate ``level``).  :func:`normalize_era5` maps any
of them onto one canonical form so the rest of pyRadtran never has to
care which flavour the user opened.

From a normalised dataset this module can produce, per (time, lat, lon)
point:

* a libRadtran ``radiosonde`` atmosphere file — pressure, temperature,
  humidity and (when available) an ozone profile
  (:func:`write_atmosphere_file`), and
* dict-valued ``wc_file`` / ``ic_file`` cloud profiles from the ERA5
  ``clwc`` / ``ciwc`` fields (:func:`cloud_profiles`).

:func:`recommend_atmosphere` suggests the AFGL background profile
(``atmosphere_file``) that matches a latitude and date.  The background
profile still matters when a radiosonde is used: libRadtran takes every
level above the radiosonde top — and every gas the radiosonde does not
provide — from the ``atmosphere_file``.

See Also
--------
pyradtran.io.ERA5AtmosphereGenerator : Backwards-compatible wrapper.
"""

import logging
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional, Tuple, Union

import numpy as np
import pandas as pd
import xarray as xr

from .exceptions import InputGenerationError

logger = logging.getLogger(__name__)

# Physical constants
G0 = 9.80665  # standard gravity (m s-2)
R_DRY = 287.058  # specific gas constant, dry air (J kg-1 K-1)

#: canonical name -> accepted aliases (first match wins)
VAR_ALIASES: Dict[str, Tuple[str, ...]] = {
    "t": ("t", "temperature"),
    "q": ("q", "specific_humidity"),
    "z": ("z", "geopotential"),
    "o3": ("o3", "ozone_mass_mixing_ratio"),
    "clwc": ("clwc", "specific_cloud_liquid_water_content"),
    "ciwc": ("ciwc", "specific_cloud_ice_water_content"),
    "cc": ("cc", "fraction_of_cloud_cover"),
    "skt": ("skt", "skin_temperature"),
    "sst": ("sst", "sea_surface_temperature"),
}

COORD_ALIASES: Dict[str, Tuple[str, ...]] = {
    "pressure_level": ("pressure_level", "level", "plev", "isobaricInhPa"),
    "valid_time": ("valid_time", "time"),
}


[docs] def normalize_era5(ds: xr.Dataset) -> xr.Dataset: """Return *ds* with ERA5 variables/coordinates on canonical names. Accepts CDS-style short names, ARCO-ERA5 long names, or an already canonical dataset (the call is idempotent). The pressure coordinate is converted to hPa when it arrives in Pa. Unit attributes that ERA5 pipelines commonly strip are restored with their known values. Parameters ---------- ds : xarray.Dataset Any ERA5(-like) dataset on pressure levels, e.g. straight from ``xr.open_zarr(gcsfs...)`` or a CDS NetCDF download. Returns ------- xarray.Dataset Same data, canonical names: variables ``t``, ``q``, ``z``, ``o3``, ``clwc``, ``ciwc``, ``cc``, ``skt``, ``sst`` (those present), coordinates ``pressure_level`` (hPa) and ``valid_time``. """ rename = {} for canonical, aliases in VAR_ALIASES.items(): if canonical in ds.variables: continue for alias in aliases[1:]: if alias in ds.variables: rename[alias] = canonical break for canonical, aliases in COORD_ALIASES.items(): if canonical in ds.coords or canonical in ds.dims: continue for alias in aliases[1:]: if alias in ds.coords or alias in ds.dims: rename[alias] = canonical break if rename: ds = ds.rename(rename) logger.debug(f"normalize_era5 renamed: {rename}") if "pressure_level" in ds.coords: plev = ds["pressure_level"] unit = str(plev.attrs.get("units", "")).strip() values = np.asarray(plev.values, dtype=float) if unit == "Pa" or (unit == "" and values.max() > 2000.0): ds = ds.assign_coords(pressure_level=values / 100.0) elif unit.lower() in ("", "hpa", "millibars", "millibar", "mb", "mbar"): ds = ds.assign_coords(pressure_level=values) else: raise InputGenerationError( f"Unsupported pressure unit '{unit}' in ERA5 dataset; " f"expected 'Pa' or 'hPa'" ) ds["pressure_level"].attrs["units"] = "hPa" known_units = { "t": "K", "q": "kg kg-1", "o3": "kg kg-1", "clwc": "kg kg-1", "ciwc": "kg kg-1", "z": "m2 s-2", "skt": "K", "sst": "K", } for var, unit in known_units.items(): if var in ds.variables and "units" not in ds[var].attrs: ds[var].attrs["units"] = unit return ds
[docs] def select_profile( ds: xr.Dataset, latitude: Optional[float] = None, longitude: Optional[float] = None, time: Union[str, datetime, np.datetime64, None] = None, ) -> xr.Dataset: """Select a single vertical profile from a (normalised) ERA5 dataset. Handles datasets that still carry spatial/time dimensions as well as ones already reduced to a single column. Selection is nearest-neighbour. Returns ------- xarray.Dataset Profile with ``pressure_level`` as the only remaining dimension, loaded into memory. """ if "latitude" in ds.dims or "longitude" in ds.dims: if latitude is None or longitude is None: raise ValueError( "Dataset has spatial dimensions; latitude and longitude required" ) ds = ds.sel(latitude=latitude, longitude=longitude, method="nearest") if "valid_time" in ds.dims: if time is None: raise ValueError("Dataset has a time dimension; time required") ds = ds.sel(valid_time=pd.to_datetime(time), method="nearest") extra = [d for d in ds.dims if d != "pressure_level"] if extra: ds = ds.isel({d: 0 for d in extra}) return ds.load()
[docs] def write_atmosphere_file( profile: xr.Dataset, output_filepath: Union[str, Path], include_ozone: bool = True, ) -> Path: """Write a profile as a libRadtran ``radiosonde`` atmosphere file. Columns are pressure (hPa), temperature (K), specific humidity (MMR, kg/kg) and — when ``o3`` is present and *include_ozone* is true — ozone MMR. The column layout is recorded in a machine-readable header line (``# columns: H2O MMR O3 MMR``) that the input builder reads back to construct the matching ``radiosonde`` option line. Levels containing NaN pressure/temperature/humidity (e.g. ERA5 levels below the surface) are dropped; NaN ozone values become ``-1``, which tells libRadtran to fall back to the ``atmosphere_file`` value at that level. Parameters ---------- profile : xarray.Dataset Single column, canonical names (see :func:`normalize_era5`), with ``pressure_level`` in hPa and variables ``t`` and ``q``. output_filepath : str or pathlib.Path Destination file. include_ozone : bool, default ``True`` Write the ozone column when available. Returns ------- pathlib.Path """ for var in ("t", "q"): if var not in profile.variables: raise InputGenerationError( f"Profile is missing required variable '{var}' " f"(have: {sorted(profile.data_vars)})" ) if "pressure_level" not in profile.coords: raise InputGenerationError("Profile has no 'pressure_level' coordinate") p = np.asarray(profile["pressure_level"].values, dtype=float).ravel() t = np.asarray(profile["t"].values, dtype=float).ravel() q = np.asarray(profile["q"].values, dtype=float).ravel() has_o3 = include_ozone and "o3" in profile.variables o3 = np.asarray(profile["o3"].values, dtype=float).ravel() if has_o3 else None valid = ~(np.isnan(p) | np.isnan(t) | np.isnan(q)) if not valid.any(): raise InputGenerationError("No valid (non-NaN) levels in ERA5 profile") p, t, q = p[valid], t[valid], q[valid] if o3 is not None: o3 = o3[valid] o3 = np.where(np.isnan(o3), -1.0, o3) # TOA first: ascending pressure; drop duplicate levels (libRadtran # requires a strictly monotonic grid). order = np.argsort(p) p, t, q = p[order], t[order], q[order] if o3 is not None: o3 = o3[order] keep = np.concatenate(([True], np.diff(p) > 0)) p, t, q = p[keep], t[keep], q[keep] if o3 is not None: o3 = o3[keep] columns = "H2O MMR O3 MMR" if o3 is not None else "H2O MMR" output_path = Path(output_filepath) output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, "w") as f: f.write("# ERA5 atmosphere profile (libRadtran radiosonde format)\n") f.write(f"# columns: {columns}\n") f.write("# p(hPa) T(K) " + columns.replace(" ", "_") + "\n") for i in range(len(p)): row = f"{p[i]:.2f} {t[i]:.2f} {q[i]:.4e}" if o3 is not None: row += f" {o3[i]:.4e}" f.write(row + "\n") logger.info(f"Wrote ERA5 atmosphere file ({columns}): {output_path}") return output_path
[docs] def era5_atmosphere_file( era5_ds: xr.Dataset, latitude: float, longitude: float, time: Union[str, datetime, np.datetime64], output_filepath: Union[str, Path], include_ozone: bool = True, ) -> Path: """One-step: normalise, select a point, write the atmosphere file.""" try: ds = normalize_era5(era5_ds) profile = select_profile(ds, latitude, longitude, time) return write_atmosphere_file(profile, output_filepath, include_ozone) except InputGenerationError: raise except Exception as e: raise InputGenerationError(f"Failed to create ERA5 atmosphere file: {e}") from e
def _z_to_km(z: np.ndarray, units) -> np.ndarray: """Convert geopotential / height values to altitude in km. The units attribute decides the conversion; the magnitude heuristic is only a fallback for data without (or with unrecognised) units, where it can misread profiles whose top lies below ~10 km. """ z = np.asarray(z, dtype=float) unit = str(units or "").strip().lower().replace("**", "").replace("^", "") if unit in ("m2 s-2", "m2/s2", "m2 s2", "m2s-2"): return z / G0 / 1000.0 if unit in ("m", "meter", "metre", "meters", "metres", "gpm"): return z / 1000.0 if unit == "km": return z if unit: logger.warning( f"Unrecognised geopotential unit '{units}'; guessing from magnitude" ) if np.nanmax(np.abs(z)) > 100_000: # geopotential (m2 s-2) return z / G0 / 1000.0 return z / 1000.0 # geopotential height (m) def _altitude_km(profile: xr.Dataset, p_hpa: np.ndarray) -> np.ndarray: """Geometric altitude per level: geopotential when present, else hypsometric.""" if "z" in profile.variables: z = np.asarray(profile["z"].values, dtype=float).ravel() return _z_to_km(z, profile["z"].attrs.get("units")) logger.debug("No geopotential in profile; hypsometric altitude estimate") return -7.0 * np.log(p_hpa / 1013.25)
[docs] def cloud_profiles( profile: xr.Dataset, reff_water_um: float = 10.0, reff_ice_um: float = 20.0, threshold_g_m3: float = 1e-4, ) -> Tuple[Optional[dict], Optional[dict]]: """Build ``wc_file`` / ``ic_file`` profile dicts from an ERA5 column. Converts ``clwc`` / ``ciwc`` (kg/kg) to g m⁻³ using the ideal-gas air density, places layer boundaries midway between model levels, and writes the values with libRadtran's 1D cloud-file semantics: the value at altitude *z* fills the layer **above** *z*, and the top row closes the grid with zero content. Parameters ---------- profile : xarray.Dataset Single column, canonical names, ``pressure_level`` in hPa. Needs ``t`` plus at least one of ``clwc`` / ``ciwc``. reff_water_um, reff_ice_um : float Effective radii to assign (ERA5 has none). threshold_g_m3 : float, default ``1e-4`` Layers below this water content count as cloud-free. Returns ------- (wc, ic) : tuple of dict or None Each a ``{"z": [...], "lwc"/"iwc": [...], "reff": [...]}`` mapping ready for a dict-valued ``wc_file`` / ``ic_file`` parameter, or *None* when that phase has no cloud. """ p = np.asarray(profile["pressure_level"].values, dtype=float).ravel() t = np.asarray(profile["t"].values, dtype=float).ravel() valid = ~(np.isnan(p) | np.isnan(t)) results: Dict[str, Optional[dict]] = {"clwc": None, "ciwc": None} alt_all = _altitude_km(profile, p) for var, key, reff in ( ("clwc", "lwc", reff_water_um), ("ciwc", "iwc", reff_ice_um), ): if var not in profile.variables: continue mmr = np.asarray(profile[var].values, dtype=float).ravel() ok = valid & ~np.isnan(mmr) & ~np.isnan(alt_all) if not ok.any(): continue p_v, t_v, mmr_v, alt_v = p[ok], t[ok], mmr[ok], alt_all[ok] # ascending altitude order = np.argsort(alt_v) p_v, t_v, mmr_v, alt_v = p_v[order], t_v[order], mmr_v[order], alt_v[order] air_density = (p_v * 100.0) / (R_DRY * t_v) # kg m-3 content = mmr_v * air_density * 1000.0 # g m-3 cloudy = content > threshold_g_m3 if not cloudy.any(): continue # contiguous index range spanning all cloudy levels idx = np.where(cloudy)[0] lo, hi = idx.min(), idx.max() levels = np.arange(lo, hi + 1) n = len(alt_v) # boundaries midway between levels; half-spacing at the ends def boundary_below(i): if i == 0: return max( alt_v[0] - (alt_v[1] - alt_v[0]) / 2 if n > 1 else alt_v[0] - 0.05, 0.0, ) return (alt_v[i - 1] + alt_v[i]) / 2 def boundary_above(i): if i == n - 1: return ( alt_v[-1] + (alt_v[-1] - alt_v[-2]) / 2 if n > 1 else alt_v[-1] + 0.05 ) return (alt_v[i] + alt_v[i + 1]) / 2 # rows from cloud top down: top boundary closes the grid (zero), # each lower boundary carries the content of the layer above it. z_rows = [boundary_above(int(levels[-1]))] c_rows = [0.0] r_rows = [float(reff)] for i in levels[::-1]: z_rows.append(boundary_below(int(i))) c_rows.append(float(max(content[i], 0.0))) r_rows.append(float(reff)) results[var] = {"z": z_rows, key: c_rows, "reff": r_rows} return results["clwc"], results["ciwc"]
[docs] def recommend_atmosphere( latitude: float, time: Union[str, datetime, np.datetime64], ) -> str: """Suggest the AFGL ``atmosphere_file`` shortname for a location/date. The background atmosphere still supplies everything a radiosonde profile does not: levels above the profile top and all trace gases without a dedicated column. Matching it to the season and latitude band keeps those fill-ins consistent with the profile. Parameters ---------- latitude : float Degrees north. time : str, datetime, or numpy.datetime64 Date (month decides the season). Returns ------- str One of ``"afglt"``, ``"afglms"``, ``"afglmw"``, ``"afglss"``, ``"afglsw"`` — accepted directly by ``paths.atmosphere_profile`` and the ``atmosphere_file`` option. """ month = pd.to_datetime(time).month northern_summer = month in (4, 5, 6, 7, 8, 9) summer = northern_summer if latitude >= 0 else not northern_summer abs_lat = abs(latitude) if abs_lat < 30.0: return "afglt" if abs_lat < 60.0: return "afglms" if summer else "afglmw" return "afglss" if summer else "afglsw"
__all__ = [ "normalize_era5", "select_profile", "write_atmosphere_file", "era5_atmosphere_file", "cloud_profiles", "recommend_atmosphere", ]