from __future__ import annotations
import typing as t
from abc import ABC, abstractmethod
import attrs
import mitsuba as mi
import numpy as np
import pint
import pinttr
from .shapes import CuboidShape, RectangleShape, Shape, SphereShape
from ..attrs import documented, parse_docs
from ..constants import EARTH_RADIUS
from ..kernel import map_cube, map_unit_cube
from ..radprops import ZGrid
from ..units import unit_context_config as ucc
from ..units import unit_context_kernel as uck
from ..units import unit_registry as ureg
[docs]
@parse_docs
@attrs.define
class SceneGeometry(ABC):
"""
Abstract base class defining a scene geometry.
Warnings
--------
If a ``zgrid`` value is passed to the constructor (instead of letting
the constructor set it automatically), its extent must be
[``ground_altitude``, ``toa_altitude``]. The constructor will raise
a :class:`ValueError` otherwise.
"""
toa_altitude: pint.Quantity = documented(
pinttr.field(default=120.0 * ureg.km, units=ucc.deferred("length")),
doc="Top-of-atmosphere level altitude. "
'Unit-enabled field (default: ``ucc["length"]``).',
default="120 km",
type="pint.Quantity",
init_type="float or quantity",
)
ground_altitude: pint.Quantity = documented(
pinttr.field(default=0.0 * ureg.km, units=ucc.deferred("length")),
doc="Baseline ground altitude. "
'Unit-enabled field (default: ``ucc["length"]``).',
default="0 km",
type="pint.Quantity",
init_type="float or quantity",
)
zgrid: ZGrid = documented(
attrs.field(
default=None,
converter=attrs.converters.optional(
lambda x: ZGrid(x) if not isinstance(x, ZGrid) else x
),
validator=attrs.validators.optional(attrs.validators.instance_of(ZGrid)),
),
doc="The altitude mesh on which the radiative properties of "
"heterogeneous atmosphere components are evaluated. "
"If unset, a default grid with one layer per 100 m (or 10 layers if "
"the atmosphere object height is less than 100 m) is used.",
type=".ZGrid",
init_type=".ZGrid, quantity or ndarray, optional",
)
def __attrs_post_init__(self) -> None:
# Set altitude grid
if self.zgrid is None:
bottom = self.ground_altitude.m_as(ureg.m)
top = self.toa_altitude.m_as(ureg.m)
step = min(100.0, (top - bottom) / 10.0)
self.zgrid = ZGrid(
ureg.convert(
np.arange(bottom, top + step * 0.1, step),
ureg.m,
ucc.get("length"),
)
)
else:
grid_bottom = self.zgrid.levels[0]
if not np.isclose(grid_bottom, self.ground_altitude):
raise ValueError(
"zgrid bottom must match ground_altitude; "
f"expected {self.ground_altitude}, got {grid_bottom}"
)
grid_top = self.zgrid.levels[-1]
if not np.isclose(grid_top, self.toa_altitude):
raise ValueError(
"zgrid top must match toa_altitude; "
f"expected {self.toa_altitude}, got {grid_top}"
)
[docs]
@classmethod
def convert(cls, value: t.Any) -> t.Any:
"""
Attempt conversion of a value to a :class:`.SceneGeometry` subtype.
Parameters
----------
value
Value to attempt conversion of. If a dictionary is passed, its
``"type"`` key is used to route its other entries as keyword
arguments to the appropriate subtype's constructor. If a string is
passed, this method calls itself with the parameter
``{"type": value}``.
Returns
-------
result
If `value` is a dictionary, the constructed :class:`.SceneGeometry`
instance is returned. Otherwise, `value` is returned.
Raises
------
ValueError
A dictionary was passed but the requested type is unknown.
"""
if isinstance(value, str):
return cls.convert({"type": value})
if isinstance(value, dict):
value = value.copy()
geometry_type = value.pop("type")
# Note: if this conditional becomes large, use a dictionary
if geometry_type == "plane_parallel":
geometry_cls = PlaneParallelGeometry
elif geometry_type == "spherical_shell":
geometry_cls = SphericalShellGeometry
else:
raise ValueError(f"unknown geometry type '{geometry_type}'")
return geometry_cls(**pinttr.interpret_units(value, ureg=ureg))
return value
@property
@abstractmethod
def atmosphere_shape(self) -> Shape:
"""
:class:`.Shape`: Stencil of the participating medium representing the
atmosphere.
"""
pass
@property
@abstractmethod
def atmosphere_volume_to_world(self) -> mi.ScalarTransform4f:
"""
:class:`mi.ScalarTransform4f` : Mitsuba transform mapping volume texture
coordinates to world coordinates for heterogeneous atmosphere
components.
"""
pass
@property
@abstractmethod
def surface_shape(self) -> Shape:
"""
:class:`.Shape` : Shape representing the surface.
"""
pass
[docs]
@parse_docs
@attrs.define
class PlaneParallelGeometry(SceneGeometry):
"""
Plane parallel geometry.
A plane parallel atmosphere is translation-invariant in the X and Y
directions. However, Eradiate represents it with a finite 3D geometry
consisting of a cuboid. By default, the cuboid's size is computed
automatically; however, it can also be forced by assigning a value to
the `width` field.
"""
width: pint.Quantity = documented(
pinttr.field(default=1e6 * ureg.km, units=ucc.deferred("length")),
doc="Cuboid shape width.",
type="quantity",
init_type="quantity or float",
default="1,000,000 km",
)
@property
def atmosphere_shape(self) -> CuboidShape:
return CuboidShape.atmosphere(
top=self.toa_altitude, bottom=0.0 * ureg.km, width=self.width
)
@property
def atmosphere_volume_to_world(self) -> mi.ScalarTransform4f:
length_units = uck.get("length")
shape = self.atmosphere_shape
# The bounding box corresponds to the vertices of the cuboid
center = shape.center.m_as(length_units)
edges = shape.edges.m_as(length_units)
xmin, ymin = center[0:2] - edges[0:2] * 0.5
xmax, ymax = center[0:2] + edges[0:2] * 0.5
zmin = self.ground_altitude.m_as(length_units)
zmax = self.toa_altitude.m_as(length_units)
return map_unit_cube(xmin, xmax, ymin, ymax, zmin, zmax)
@property
def surface_shape(self) -> RectangleShape:
return RectangleShape.surface(altitude=self.ground_altitude, width=self.width)
[docs]
@parse_docs
@attrs.define
class SphericalShellGeometry(SceneGeometry):
"""
Spherical shell geometry.
A spherical shell atmosphere has a spherical symmetry. Eradiate represents
it with a finite 3D geometry consisting of a sphere. By default, the
sphere's radius is set equal to Earth's radius.
"""
planet_radius: pint.Quantity = documented(
pinttr.field(default=EARTH_RADIUS, units=ucc.deferred("length")),
doc="Planet radius. Defaults to Earth's radius.",
type="quantity",
init_type="quantity or float",
default=":data:`.EARTH_RADIUS`",
)
@property
def atmosphere_shape(self) -> SphereShape:
return SphereShape.atmosphere(
top=self.toa_altitude, planet_radius=self.planet_radius
)
@property
def atmosphere_volume_to_world(self) -> mi.ScalarTransform4f:
length_units = ucc.get("length")
# The bounding box corresponds to the vertices of the bounding box
bbox = self.atmosphere_shape.bbox
xmin, ymin, zmin = bbox.min.m_as(length_units)
xmax, ymax, zmax = bbox.max.m_as(length_units)
return map_cube(xmin, xmax, ymin, ymax, zmin, zmax)
@property
def atmosphere_volume_rmin(self) -> float:
"""
Value for the ``rmin`` parameter of the ``sphericalcoordsvolume`` plugin
describing the volume data in the spherical shell geometry.
"""
return (self.planet_radius / (self.planet_radius + self.toa_altitude)).m_as(
ureg.dimensionless
)
@property
def surface_shape(self) -> Shape:
return SphereShape.surface(
altitude=self.ground_altitude, planet_radius=self.planet_radius
)