Source code for eradiate_disort._measurements

# SPDX-FileCopyrightText: 2026 Rayference
#
# SPDX-License-Identifier: GPL-3.0-or-later

"""
DISORT-specific measure type for Eradiate experiments.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import attrs
import numpy as np
import pint
from eradiate import unit_registry as ureg
from eradiate.scenes.measure import (
    AzimuthRingLayout,
    GridLayout,
    HemispherePlaneLayout,
    Measure,
    measure_factory,
)
from numpy.typing import ArrayLike

if TYPE_CHECKING:
    from eradiate.radprops import ZGrid


def _extract_kwargs(kwargs: dict, keys: list[str]) -> dict:
    """Pop and return named keys from a kwargs dict (mutates in-place)."""
    return {key: kwargs.pop(key) for key in keys if key in kwargs}


def _utau_from_spec(
    z_levels: pint.Quantity | None,
    utau: np.ndarray | None,
    tau_btt: np.ndarray,
    zgrid: ZGrid,
) -> tuple[np.ndarray, pint.Quantity]:
    """
    Resolve a user altitude or optical-depth specification to DISORT utau values.

    Cumulative optical depth (utau) is measured from the top of the atmosphere
    (TOA), so utau = 0 at TOA and utau = total_optical_depth at BOA.

    Parameters
    ----------
    z_levels : pint.Quantity or None
        User-specified output altitudes. Each value is snapped to the nearest
        zgrid level boundary. Mutually exclusive with ``utau``.

    utau : array-like or None
        Direct optical-depth specification (from TOA). Mutually exclusive with
        ``z_levels``. If both are ``None``, return the default ``[0.0, total_tau]``
        pair (TOA and BOA).

    tau_btt : ndarray
        Per-layer optical depths in bottom-to-top order, shape ``(n_layers,)``.

    zgrid : ZGrid
        Atmospheric altitude grid.

    Returns
    -------
    utau_values : ndarray
        Sorted ascending utau values for DISORT, shape ``(n,)``.

    level_altitudes : pint.Quantity
        Altitudes corresponding to each utau value, shape ``(n,)``.

    Notes
    -----
    This measurement adheres to DISORT conventions. This is a deliberate
    choice made to simplify debugging and to make the effort of
    transitioning from another DISORT-powered RTM easier.
    """
    # Cumulative tau from TOA at each level boundary, indexed top-to-bottom
    # tau_cumsum_tob[0] = 0 (TOA), tau_cumsum_tob[-1] = total tau (BOA)
    tau_cumsum_tob = np.concatenate([[0.0], np.cumsum(tau_btt[::-1])])
    # Reindex to bottom-to-top to match zgrid.levels ordering
    # tau_at_levels[0] = total tau (ground), tau_at_levels[-1] = 0 (TOA)
    tau_at_levels = tau_cumsum_tob[::-1]
    z_levels_grid = zgrid.levels  # bottom-to-top Quantity, shape (n_levels,)
    n_levels = len(z_levels_grid)

    if z_levels is not None:
        z_m = z_levels.m_as("m")
        z_grid_m = z_levels_grid.m_as("m")
        # Snap each altitude to the nearest level boundary
        idxs = np.clip(
            np.round(np.interp(z_m, z_grid_m, np.arange(n_levels))).astype(int),
            0,
            n_levels - 1,
        )
        utau_values = tau_at_levels[idxs]
        alt_values = z_levels_grid[idxs]

    elif utau is not None:
        utau_values = np.asarray(utau, dtype=float)
        # Interpolate altitudes from tau values for coordinate labelling.
        # tau_at_levels is decreasing → reverse to get ascending xp for np.interp.
        alt_m = np.interp(
            utau_values,
            tau_at_levels[::-1],  # ascending: 0 (TOA) … total (BOA)
            z_levels_grid.m_as("m")[::-1],  # descending: z_TOA … z_ground
        )
        alt_values = alt_m * z_levels_grid.u

    else:
        # Default: TOA and BOA
        utau_values = np.array([0.0, tau_cumsum_tob[-1]])
        alt_values = z_levels_grid[[-1, 0]]  # [z_TOA, z_ground]

    # Sort ascending (DISORT requirement)
    order = np.argsort(utau_values)

    return utau_values[order], alt_values[order]


def _validate_direction_layout(instance, attribute, value):
    if value is not None and not isinstance(
        value, (HemispherePlaneLayout, AzimuthRingLayout, GridLayout)
    ):
        raise TypeError(
            f"DisortMeasure.direction_layout must be a HemispherePlaneLayout, "
            f"AzimuthRingLayout, or GridLayout (or None for flux-only mode); "
            f"got {type(value).__name__}"
        )


[docs] @attrs.define(eq=False, slots=False) class DisortMeasure(Measure): """ DISORT measurement [``disort``]. Records irradiance (flux) and intensity quantities at user-specified altitudes or optical depths. When a direction layout is specified, also records the full spectral radiance field ``uu(umu, utau, phi)``. When no ``direction_layout`` is given (the default), DISORT runs with ``onlyfl = True``, skipping angular radiance computation for speed. The following irradiance and intensity quantities are always recorded: - ``rfldir``: direct-beam downward irradiance - ``rfldn``: diffuse downward irradiance - ``flup``: diffuse upward irradiance - ``dfdt``: flux divergence d(net flux)/d(tau) - ``uavg``: intensity (direct + diffuse) - ``uavgdn``: diffuse downward intensity - ``uavgup``: diffuse upward intensity - ``uavgso``: direct-beam intensity Parameters ---------- direction_layout : HemispherePlaneLayout or AzimuthRingLayout or GridLayout, \ optional Viewing direction specification. Only structured layouts are accepted: - :class:`~eradiate.scenes.measure.HemispherePlaneLayout` (hemisphere plane cut) - :class:`~eradiate.scenes.measure.AzimuthRingLayout` (azimuth ring) - :class:`~eradiate.scenes.measure.GridLayout` (zenith × azimuth grid) Defaults to ``None`` (flux-only mode). Use the :meth:`hplane`, :meth:`aring`, or :meth:`grid` class-method constructors for convenience. z_levels : quantity, optional Output altitudes. Each value is snapped to the nearest zgrid level boundary. Mutually exclusive with ``utau``. If neither is set, defaults to TOA and BOA. utau : array-like, optional Output optical depths from TOA. Mutually exclusive with ``z_levels``. If neither is set, defaults to TOA and BOA. Notes ----- The ``kernel_type`` and ``template`` properties are not implemented; this measure type is only usable with the DISORT backend. """ direction_layout: HemispherePlaneLayout | AzimuthRingLayout | GridLayout | None = ( attrs.field( kw_only=True, default=None, validator=_validate_direction_layout, ) ) z_levels: pint.Quantity | None = attrs.field(kw_only=True, default=None) utau: np.ndarray | None = attrs.field(kw_only=True, default=None) def __attrs_post_init__(self): if self.z_levels is not None and self.utau is not None: raise ValueError("DisortMeasure: z_levels and utau are mutually exclusive") @property def origin(self) -> pint.Quantity: # Dummy origin required by the Measure interface (measure_inside_atmosphere). # Not used by the DISORT backend. return ureg.Quantity([0.0, 0.0, 0.0], "m") @property def film_resolution(self) -> tuple[int, int]: if self.direction_layout is None: raise NotImplementedError( "DisortMeasure in flux-only mode does not map to a Mitsuba film" ) return (self.direction_layout.n_directions, 1) @property def template(self) -> dict: return {} # -------------------------------------------------------------------------- # Convenience constructors # --------------------------------------------------------------------------
[docs] @classmethod def hplane( cls, zeniths: ArrayLike, azimuth: float | pint.Quantity, **kwargs ) -> DisortMeasure: """ Construct using a hemisphere-plane viewing direction layout. Parameters ---------- zeniths : array-like Zenith angle values. Negative values map to the ``azimuth + 180°`` half-plane. Unitless values are converted to ``ucc['angle']``. azimuth : float or quantity Azimuth of the hemisphere plane cut. azimuth_convention : AzimuthConvention or str, optional Azimuth convention for the layout. **kwargs Forwarded to :class:`DisortMeasure`. """ layout = HemispherePlaneLayout( zeniths=zeniths, azimuth=azimuth, **_extract_kwargs(kwargs, ["azimuth_convention"]), ) return cls(direction_layout=layout, **kwargs)
[docs] @classmethod def aring( cls, zenith: float | pint.Quantity, azimuths: ArrayLike, **kwargs ) -> DisortMeasure: """ Construct using an azimuth-ring viewing direction layout. Parameters ---------- zenith : float or quantity Ring zenith angle. Unitless values are converted to ``ucc['angle']``. azimuths : array-like Azimuth values. Unitless values are converted to ``ucc['angle']``. azimuth_convention : AzimuthConvention or str, optional Azimuth convention for the layout. **kwargs Forwarded to :class:`DisortMeasure`. """ layout = AzimuthRingLayout( zenith=zenith, azimuths=azimuths, **_extract_kwargs(kwargs, ["azimuth_convention"]), ) return cls(direction_layout=layout, **kwargs)
[docs] @classmethod def grid(cls, zeniths: ArrayLike, azimuths: ArrayLike, **kwargs) -> DisortMeasure: """ Construct using a gridded (Cartesian product) viewing direction layout. Parameters ---------- zeniths : array-like Zenith values. azimuths : array-like Azimuth values. azimuth_convention : AzimuthConvention or str, optional Azimuth convention for the layout. **kwargs Forwarded to :class:`DisortMeasure`. """ layout = GridLayout( zeniths=zeniths, azimuths=azimuths, **_extract_kwargs(kwargs, ["azimuth_convention"]), ) return cls(direction_layout=layout, **kwargs)
measure_factory.register(DisortMeasure, type_id="disort", aliases=["disort_measure"])