Source code for eradiate.experiments._canopy

from __future__ import annotations

import typing as t

import attrs

from ._core import EarthObservationExperiment, Experiment
from ._helpers import surface_converter
from .. import validators
from ..attrs import documented, get_doc, parse_docs
from ..scenes.biosphere import Canopy, biosphere_factory
from ..scenes.bsdfs import LambertianBSDF
from ..scenes.core import SceneElement
from ..scenes.integrators import Integrator, PathIntegrator, integrator_factory
from ..scenes.measure import Measure
from ..scenes.shapes import RectangleShape
from ..scenes.surface import BasicSurface


[docs] @parse_docs @attrs.define class CanopyExperiment(EarthObservationExperiment): """ Simulate radiation in a scene with an explicit canopy and no atmosphere. This experiment assumes that the surface is plane and accounts for ground unit cell padding. Notes ----- A post-initialisation step will constrain the measure setup if a distant measure is used and no target is defined: * if a canopy is defined, the target will be set to the top of the canopy unit cell (*i.e.* without its padding); * if no canopy is defined, the target will be set to [0, 0, 0]. """ canopy: Canopy | None = documented( attrs.field( default=None, converter=attrs.converters.optional(biosphere_factory.convert), validator=attrs.validators.optional(attrs.validators.instance_of(Canopy)), ), doc="Canopy specification. " "This parameter can be specified as a dictionary which will be " "interpreted by :data:`.biosphere_factory`.", type=":class:`.Canopy` or None", init_type=":class:`.Canopy` or dict or None", default="None", ) padding: int = documented( attrs.field(default=0, converter=int, validator=validators.is_positive), doc="Padding level. The scene will be padded with copies to account for " "adjacency effects. This, in practice, has effects similar to " "making the scene periodic." "A value of 0 will yield only the defined scene. A value of 1 " "will add one copy in every direction, yielding a 3×3 patch. A " "value of 2 will yield a 5×5 patch, etc. The optimal padding level " "depends on the scene.", type="int", default="0", ) surface: None | BasicSurface = documented( attrs.field( factory=lambda: LambertianBSDF(), converter=attrs.converters.optional(surface_converter), validator=attrs.validators.optional( attrs.validators.instance_of(BasicSurface) ), ), doc="Surface specification. If set to ``None``, no surface will be " "added. This parameter can be specified as a dictionary which will be " "interpreted by :data:`.surface_factory` and :data:`.bsdf_factory`. " "**If relevant, the surface size will be adjusted automatically upon " "kernel. dictionary generation.**", type=".BasicSurface or None", init_type=".BasicSurface or .BSDF or dict, optional", default=":class:`BasicSurface(bsdf=LambertianBSDF()) <.BasicSurface>`", ) # Override parent _integrator: Integrator = documented( attrs.field( factory=PathIntegrator, converter=integrator_factory.convert, validator=attrs.validators.instance_of(Integrator), ), doc=get_doc(Experiment, attrib="_integrator", field="doc"), type=get_doc(Experiment, attrib="_integrator", field="type"), init_type=get_doc(Experiment, attrib="_integrator", field="init_type"), default=":class:`PathIntegrator() <.PathIntegrator>`", ) def __attrs_post_init__(self): self._normalize_spectral() self._normalize_measures() def _normalize_measures(self) -> None: """ Ensure that distant measure targets are set to appropriate values. Processed measures will have its ray target and origin parameters overridden if relevant. """ for measure in self.measures: # Override ray target location if relevant if ( measure.is_distant() and measure.target is None ): # No target specified: add one if self.canopy is None: # No canopy: target origin point measure.target = {"type": "point", "xyz": [0, 0, 0]} else: # Canopy: target top of canopy measure.target = { "type": "rectangle", "xmin": -0.5 * self.canopy.size[0], "xmax": 0.5 * self.canopy.size[0], "ymin": -0.5 * self.canopy.size[1], "ymax": 0.5 * self.canopy.size[1], "z": self.canopy.size[2], } def _dataset_metadata(self, measure: Measure) -> dict[str, str]: result = super()._dataset_metadata(measure) if measure.is_distant(): result["title"] = "Top-of-canopy simulation results" return result @property def _context_kwargs(self) -> dict[str, t.Any]: return {} @property def scene_objects(self) -> dict[str, SceneElement]: # Inherit docstring objects = {} # Process canopy and surface if self.canopy is not None: scene_width = max(self.canopy.size[:2]) if self.padding > 0: # Add extra instances if padding is requested scene_width *= 2.0 * self.padding + 1.0 objects["canopy"] = self.canopy.padded_copy(self.padding) else: objects["canopy"] = self.canopy if self.surface is not None: # Adjust surface to match canopy objects["surface"] = attrs.evolve( self.surface, shape=RectangleShape(center=[0, 0, 0], edges=scene_width), ) else: if self.surface is not None: # Leave surface unchanged objects["surface"] = self.surface objects.update( { "illumination": self.illumination, **{measure.id: measure for measure in self.measures}, "integrator": self.integrator, } ) return objects