Source code for eradiate.scenes.measure._multi_distant

from __future__ import annotations

import typing as t
from abc import ABC, abstractmethod

import attrs
import numpy as np
import pint
import pinttr

from ._distant import DistantMeasure
from ... import converters, frame
from ..._config import config
from ...attrs import documented, parse_docs
from ...units import symbol
from ...units import unit_context_config as ucc
from ...units import unit_context_kernel as uck
from ...units import unit_registry as ureg
from ...util.misc import flatten

# ------------------------------------------------------------------------------
#                               Layout framework
# ------------------------------------------------------------------------------


[docs] @parse_docs @attrs.define class Layout(ABC): """ Abstract base class for all viewing direction layouts. """ azimuth_convention: frame.AzimuthConvention = documented( attrs.field( default=None, converter=lambda x: config.azimuth_convention if x is None else (frame.AzimuthConvention[x.upper()] if isinstance(x, str) else x), validator=attrs.validators.instance_of(frame.AzimuthConvention), kw_only=True, ), doc="Azimuth convention used by this layout. If ``None``, the global " "default configuration is used (see :class:`.EradiateConfig`).", type=".AzimuthConvention", init_type=".AzimuthConvention or str, optional", default="None", )
[docs] @staticmethod def convert(value: t.Any) -> t.Any: """ Attempt to instantiate a :class:`Layout` concrete class from an object. This conversion protocol accepts: * a dictionary of the form ``{"type": type_name, **kwargs}``; * a (N, 2)-array or a (2,)-array; * a (N, 3)-array or a (3,)-array. Other values pass through the converter. Dictionaries have their parameters forwarded to the type selected by the ``type`` parameter. A (N, 2) or (2,)-array is passed to an :class:`.AngleLayout`. A (N, 3) or (3,)-array is passed to a :class:`.DirectionLayout`. .. list-table:: :header-rows: 1 * - Type key - Class * - angles - :class:`.AngleLayout` * - aring - :class:`.AzimuthRingLayout` * - directions - :class:`.DirectionLayout` * - grid - :class:`.GridLayout` * - hplane - :class:`.HemispherePlaneLayout` """ if isinstance(value, Layout): return value if isinstance(value, dict): d = pinttr.interpret_units(value, ureg=ureg) type_key = d.pop("type") cls = { "angles": AngleLayout, "aring": AzimuthRingLayout, "directions": DirectionLayout, "grid": GridLayout, "hplane": HemispherePlaneLayout, }[type_key] return cls(**d) if np.ndim(value) == 2: if np.shape(value)[1] == 2: return AngleLayout(angles=value) if np.shape(value)[1] == 3: return DirectionLayout(directions=value) if np.ndim(value) == 1: if np.shape(value) == (2,): return AngleLayout(angles=value) if np.shape(value) == (3,): return DirectionLayout(directions=value) return value
@property def n_directions(self) -> int: """ int: Number of viewing directions defined by this layout. """ return len(self.angles) @property @abstractmethod def angles(self) -> pint.Quantity: """ quantity: A sequence of viewing angles, corresponding to the direction sequence produced by :attr:`directions`, as a (N, 2) array. The last dimension is ordered as (zenith, azimuth). """ pass @property def directions(self) -> np.narray: """ ndarray: A sequence of viewing directions, pointing *outwards* the observed target, as a (N, 3) array. """ # Default implementation computes directions from angles return frame.angles_to_direction( self.angles, azimuth_convention=self.azimuth_convention )
def _angles_converter(value): value = pinttr.util.ensure_units(value, ucc.deferred("angle")) angle_units = value.u magnitude = np.reshape(value.m_as(ureg.deg), (-1, 2)) zeniths = magnitude[:, 0] azimuths = magnitude[:, 1] return (np.stack((zeniths, azimuths % 360), axis=1) * ureg.deg).to(angle_units)
[docs] @parse_docs @attrs.define class AngleLayout(Layout): """ A viewing direction layout directly defined by explicit (zenith, azimuth) pairs. """ _angles: pint.Quantity = documented( pinttr.ib( converter=_angles_converter, units=ucc.deferred("angle"), ), doc="A sequence of viewing angles, corresponding to the direction " "sequence produced by :attr:`directions`, as a (N, 2) array. " "The last dimension is ordered as (zenith, azimuth). Zenith values " "must be between 0 and 180°. **Required, no default**.\n" "\n" "Unit-enabled field (default: ucc['angle']).", type="quantity", init_type="array-like", ) @_angles.validator def _angles_validator(self, attribute, value): zeniths = value[:, 0].m_as(ureg.deg) if np.any((zeniths < 0) | (zeniths > 180)): raise ValueError( f"while validating {attribute.name}: zenith values must be in " "[0°, 180°]" ) @property def angles(self) -> pint.Quantity: # Inherit docstring return self._angles
[docs] @parse_docs @attrs.define class AzimuthRingLayout(Layout): """ A viewing direction layout defined by a single zenith and a vector of explicit azimuth values. """ zenith: pint.Quantity = documented( pinttr.field( converter=attrs.converters.pipe( pinttr.converters.to_units(ucc.deferred("angle")), lambda x: converters.on_quantity(float)(x), ), units=ucc.deferred("angle"), ), doc="A single zenith value. Must be in [0°, 180°]. **Required, no default**.\n" "\n" "Unit-enabled field (default: ucc['angle']).", type="quantity", init_type="float or quantity", ) @zenith.validator def _zenith_validator(self, attribute, value): zenith = value.m_as(ureg.deg) if zenith < 0 or zenith > 180: raise ValueError( f"while validating {attribute.name}: zenith value must be in [0°, 180°]" ) azimuths: pint.Quantity = documented( pinttr.field( converter=attrs.converters.pipe( pinttr.converters.to_units(ucc.deferred("angle")), lambda x: np.reshape(x, (-1,)), lambda x: x % (360.0 * ureg.deg), ), units=ucc.deferred("angle"), ), doc="A vector of azimuth values. **Required, no default**.\n" "\n" "Unit-enabled field (default: ucc['angle']).", type="quantity", init_type="array-like", ) @property def angles(self) -> pint.Quantity: # Inherit docstring # Basic unit conversion and broadcasting angle_units = ucc.get("angle") azimuths = np.reshape(self.azimuths.m_as(angle_units), (-1, 1)) zeniths = np.full_like(azimuths, self.zenith.m_as(angle_units)) # Assemble angles return np.hstack((zeniths, azimuths)) * angle_units
[docs] @parse_docs @attrs.define class DirectionLayout(Layout): """ A viewing direction layout directly defined by explicit (zenith, azimuth) pairs. """ _directions: np.ndarray = documented( attrs.field(converter=lambda x: np.reshape(x, (-1, 3))), doc="A sequence of 3-vectors specifying distant sensing directions. " "Note that directions point outward the target. **Required, no default**.", type="ndarray", init_type="array-like", ) @property def angles(self) -> pint.Quantity: # Inherit docstring return frame.direction_to_angles( self.directions, azimuth_convention=self.azimuth_convention, normalize=True, ).to( ucc.get("angle") ) # Convert to default angle units @property def n_directions(self) -> int: # Inherit docstring return len(self._directions) @property def directions(self) -> np.narray: # Inherit docstring return self._directions
[docs] @parse_docs @attrs.define class HemispherePlaneLayout(Layout): """ A viewing direction layout defined by a single azimuth and a vector of zenith values. Negative zenith values are mapped to (azimuth + 180°). """ zeniths: pint.Quantity = documented( pinttr.field( converter=attrs.converters.pipe( pinttr.converters.to_units(ucc.deferred("angle")), lambda x: np.reshape(x, (-1,)), ), units=ucc.deferred("angle"), ), doc="A vector of zenith values. **Required, no default**.\n" "\n" "Unit-enabled field (default: ucc['angle']).", type="quantity", init_type="array-like", ) azimuth: pint.Quantity = documented( pinttr.field( converter=attrs.converters.pipe( pinttr.converters.to_units(ucc.deferred("angle")), lambda x: x % (360 * ureg.deg), ), units=ucc.deferred("angle"), ), doc="A single zenith value. **Required, no default**.", type="quantity", init_type="float or quantity", ) @property def angles(self) -> pint.Quantity: # Inherit docstring # Basic unit conversion and broadcasting angle_units = ucc.get("angle") zeniths = np.reshape(self.zeniths.m_as(angle_units), (-1, 1)) azimuths = np.full_like(zeniths, self.azimuth.m_as(angle_units)) # Assemble angles return np.hstack((zeniths, azimuths)) * angle_units
[docs] @parse_docs @attrs.define class GridLayout(Layout): """ A viewing direction layout defined as the Cartesian product of an azimuth and zenith vectors. """ zeniths: pint.Quantity = documented( pinttr.field( converter=attrs.converters.pipe( pinttr.converters.to_units(ucc.deferred("angle")), lambda x: np.reshape(x, (-1,)), ), units=ucc.deferred("angle"), ), doc="A vector of zenith values. **Required, no default**.\n" "\n" "Unit-enabled field (default: ucc['angle']).", type="quantity", init_type="array-like", ) @zeniths.validator def _zeniths_validator(self, attribute, value): zeniths = value.m_as(ureg.deg) if np.any((zeniths < 0) | (zeniths > 180)): raise ValueError( f"while validating {attribute.name}: zenith values must be in " "[0°, 180°]" ) azimuths: pint.Quantity = documented( pinttr.field( converter=attrs.converters.pipe( pinttr.converters.to_units(ucc.deferred("angle")), lambda x: np.reshape(x, (-1,)), ), units=ucc.deferred("angle"), ), doc="A vector of azimuth values. **Required, no default**.\n" "\n" "Unit-enabled field (default: ucc['angle']).", type="quantity", init_type="array-like", ) @property def angles(self) -> pint.Quantity: # Inherit docstring # Basic unit conversion and broadcasting angle_units = ucc.get("angle") zeniths = self.zeniths.m_as(angle_units) azimuths = self.azimuths.m_as(angle_units) # Assemble angles # This effectively produces the Cartesian product of the zeniths and # azimuths arrays (see https://stackoverflow.com/a/11146645/3645374) return np.dstack(np.meshgrid(zeniths, azimuths)).reshape(-1, 2) * angle_units
# ------------------------------------------------------------------------------ # MultiDistantMeasure implementation # ------------------------------------------------------------------------------ def _extract_kwargs(kwargs: dict, keys: list[str]) -> dict: # Helper function to collect common layout keyword arguments # (mutates the param dictionary) # Used in MultiDistantMeasure constructors return {key: kwargs.pop(key) for key in keys if key in kwargs}
[docs] @parse_docs @attrs.define(eq=False, slots=False) class MultiDistantMeasure(DistantMeasure): """ Multi-distant radiance measure scene element [``distant``, ``mdistant``, \ ``multi_distant``]. This scene element creates a measure consisting of an array of radiancemeters positioned at an infinite distance from the scene. In practice, it can be used to compute the radiance leaving a scene at the top of the atmosphere (or canopy if there is no atmosphere). Coupled to appropriate post-processing operations, scene reflectance can be derived from the radiance values it produces. .. admonition:: Class method constructors .. autosummary:: aring grid hplane from_angles from_directions Notes ----- * Setting the ``target`` parameter is required to get meaningful results. Experiment classes should take care of setting it appropriately. """ # -------------------------------------------------------------------------- # Fields and properties # -------------------------------------------------------------------------- direction_layout: Layout = documented( attrs.field( kw_only=True, factory=lambda: DirectionLayout(directions=[0, 0, 1]), converter=Layout.convert, validator=attrs.validators.instance_of(Layout), ), doc="A viewing direction layout. Specification through a dictionary or " "arrays, as documented by :meth:`Layout.convert`, is also possible. " "The constructor methods provide a convenient interface to configure " "this parameter automatically.", type=".Layout", init_type="dict or array-like or .Layout, optional", default="DirectionLayout(directions=[0, 0, 1])", ) @property def viewing_angles(self) -> pint.Quantity: """ quantity: Viewing angles computed from stored `directions` as a (N, 1, 2) array, where N is the number of directions. The last dimension is ordered as (zenith, azimuth). """ # Note: The middle dimension in (N, 1, 2) is the film height return self.direction_layout.angles.reshape(-1, 1, 2) @property def film_resolution(self) -> tuple[int, int]: # Inherit docstring return (self.direction_layout.n_directions, 1) # -------------------------------------------------------------------------- # Additional constructors # --------------------------------------------------------------------------
[docs] @classmethod def hplane( cls, zeniths: np.typing.ArrayLike, azimuth: float | pint.Quantity, **kwargs, ) -> MultiDistantMeasure: """ Construct using a hemisphere plane cut viewing direction layout. Parameters ---------- zeniths : array-like List of zenith values. Negative values are mapped to the `azimuth + 180°` half-plane. Unitless values are converted to ``ucc['angle']``. azimuth : float or quantity Hemisphere plane cut azimuth value. Unitless values are converted to ``ucc['angle']``. azimuth_convention : .AzimuthConvention or str, optional The azimuth convention applying to the viewing direction layout. If unset, the global default convention is used. **kwargs Remaining keyword arguments are forwarded to the :class:`.MultiDistantMeasure` constructor. Returns ------- MultiDistantMeasure """ 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: np.typing.ArrayLike, **kwargs, ) -> MultiDistantMeasure: """ Construct using an azimuth ring viewing direction layout. Parameters ---------- zenith : float or quantity Azimuth ring zenith value. Unitless values are converted to ``ucc['angle']``. azimuths : array-like List of azimuth values. Unitless values are converted to ``ucc['angle']``. azimuth_convention : .AzimuthConvention or str, optional The azimuth convention applying to the viewing direction layout. If unset, the global default convention is used. **kwargs Remaining keyword arguments are forwarded to the :class:`.MultiDistantMeasure` constructor. Returns ------- MultiDistantMeasure """ layout = AzimuthRingLayout( zenith=zenith, azimuths=azimuths, **_extract_kwargs(kwargs, ["azimuth_convention"]), ) return cls(direction_layout=layout, **kwargs)
[docs] @classmethod def grid( cls, zeniths: np.typing.ArrayLike, azimuths: np.typing.ArrayLike, **kwargs ) -> MultiDistantMeasure: """ Construct using a gridded viewing direction layout, defined as the Cartesian product of zenith and azimuth arrays. Parameters ---------- azimuths : array-like List of azimuth values. zeniths : array-like List of zenith values. azimuth_convention : .AzimuthConvention or str, optional The azimuth convention applying to the viewing direction layout. If unset, the global default convention is used. **kwargs Remaining keyword arguments are forwarded to the :class:`.MultiDistantMeasure` constructor. Returns ------- MultiDistantMeasure """ layout = GridLayout( zeniths=zeniths, azimuths=azimuths, **_extract_kwargs(kwargs, ["azimuth_convention"]), ) return cls(direction_layout=layout, **kwargs)
[docs] @classmethod def from_angles(cls, angles: np.typing.ArrayLike, **kwargs) -> MultiDistantMeasure: """ Construct using a direction layout defined by explicit (zenith, azimuth) pairs. Parameters ---------- angles : array-like A sequence of (zenith, azimuth), interpreted as (N, 2)-shaped array. azimuth_convention : .AzimuthConvention or str, optional The azimuth convention applying to the viewing direction layout. If unset, the global default convention is used. **kwargs Remaining keyword arguments are forwarded to the :class:`.MultiDistantMeasure` constructor. Returns ------- MultiDistantMeasure """ layout = AngleLayout( angles=angles, **_extract_kwargs(kwargs, ["azimuth_convention"]), ) return cls(direction_layout=layout, **kwargs)
[docs] @classmethod def from_directions( cls, directions: np.typing.ArrayLike, **kwargs ) -> MultiDistantMeasure: """ Construct using a direction layout defined by explicit direction vectors. Parameters ---------- directions : array-like A sequence of direction vectors, interpreted as (N, 3)-shaped array. azimuth_convention : .AzimuthConvention or str, optional The azimuth convention applying to the viewing direction layout. If unset, the global default convention is used. **kwargs Remaining keyword arguments are forwarded to the :class:`.MultiDistantMeasure` constructor. Returns ------- MultiDistantMeasure Warnings -------- Viewing directions are defined pointing *outwards* the target location. """ layout = DirectionLayout( directions=directions, **_extract_kwargs(kwargs, ["azimuth_convention"]), ) return cls(direction_layout=layout, **kwargs)
# -------------------------------------------------------------------------- # Kernel dictionary generation # -------------------------------------------------------------------------- @property def kernel_type(self) -> str: return "mdistant" @property def template(self) -> dict: result = super().template result["directions"] = ",".join( map(str, -self.direction_layout.directions.ravel(order="C")) ) if self.target is not None: result.update(flatten({"target": self.target.kernel_item()})) if self.ray_offset is not None: result["ray_offset"] = self.ray_offset.m_as(uck.get("length")) return result # -------------------------------------------------------------------------- # Post-processing information # -------------------------------------------------------------------------- @property def var(self) -> tuple[str, dict]: # Inherit docstring return "radiance", { "standard_name": "radiance", "long_name": "radiance", "units": symbol(uck.get("radiance")), }