Пример #1
0
class ConeLeafCloudParams(LeafCloudParams):
    """
    Advanced parameter checking class for the cone :class:`.LeafCloud`
    generator.

    See Also
    --------
    :meth:`.LeafCloud.cone`
    """

    _radius = documented(
        pinttr.ib(default=1.0 * ureg.m, units=ucc.deferred("length")),
        doc="Leaf cloud radius.\n\nUnit-enabled field (default: ucc[length]).",
        type="float",
        default="1 m",
    )

    _l_vertical = documented(
        pinttr.ib(default=1.0 * ureg.m, units=ucc.deferred("length")),
        doc="Leaf cloud vertical extent.\n\nUnit-enabled field (default: ucc[length]).",
        type="float",
        default="1 m",
    )

    @property
    def radius(self):
        return self._radius

    @property
    def l_vertical(self):
        return self._l_vertical
Пример #2
0
 class MyClass:
     length = pinttr.ib(
         default=0.0 * ureg.m,
         units=ureg.m,
         validator=has_compatible_units,
         converter=None,
     )
     angle = pinttr.ib(
         default=0.0 * ureg.deg,
         units=ureg.deg,
         validator=has_compatible_units,
         converter=None,
     )
Пример #3
0
def test_attrib_metadata():
    """
    Unit tests for :func:`pinttrs._make.attrib` (metadata checks on produced
    attribute specifications).
    """
    # If 'units' argument is not passed, behaviour is similar to that of attr.ib()
    field_no_quantity = pinttr.ib(default=ureg.Quantity(0, "m"))
    assert MetadataKey.UNITS not in field_no_quantity.metadata

    # Units are wrapped into generators and registered as field metadata
    field_distance = pinttr.ib(units=ureg.m)
    assert field_distance.metadata[MetadataKey.UNITS]() == ureg.m

    field_angle = pinttr.ib(units=ureg.deg)
    assert field_angle.metadata[MetadataKey.UNITS]() == ureg.deg

    # Units specified with generators  are directly registered as metadata
    ugen = pinttr.UnitGenerator(ureg.m)
    field_distance = pinttr.ib(units=ugen)
    assert field_distance.metadata[MetadataKey.UNITS]() == ureg.m

    # Units registered with a generator can be overridden
    with ugen.override(ureg.s):
        assert field_distance.metadata[MetadataKey.UNITS]() == ureg.s
    assert field_distance.metadata[MetadataKey.UNITS]() == ureg.m

    # If 'units' argument is not a pint.Unit or a callable returning a pint.Unit, raise
    with pytest.raises(TypeError):
        pinttr.ib(units="km")
Пример #4
0
class MonoMeasureSpectralConfig(MeasureSpectralConfig):
    """
    A data structure specifying the spectral configuration of a :class:`.Measure`
    in monochromatic modes.
    """

    # --------------------------------------------------------------------------
    #                           Fields and properties
    # --------------------------------------------------------------------------

    _wavelengths: pint.Quantity = documented(
        pinttr.ib(
            default=ureg.Quantity([550.0], ureg.nm),
            units=ucc.deferred("wavelength"),
            converter=lambda x: converters.on_quantity(np.atleast_1d)
            (pinttr.converters.ensure_units(x, ucc.get("wavelength"))),
        ),
        doc="List of wavelengths on which to perform the monochromatic spectral "
        "loop.\n\nUnit-enabled field (default: ucc['wavelength']).",
        type="quantity",
        init_type="quantity or array-like",
        default="[550.0] nm",
    )

    @property
    def wavelengths(self):
        return self._wavelengths

    @wavelengths.setter
    def wavelengths(self, value):
        self._wavelengths = value

    def __attrs_post_init__(self):
        # Special post-init check to ensure that wavelengths and spectral
        # response are compatible
        srf = self.srf.eval_mono(self.wavelengths)

        if np.allclose(srf, 0.0):
            raise ValueError(
                "specified spectral response function evaluates to 0 at every "
                "selected wavelength")

    # --------------------------------------------------------------------------
    #                         Spectral context generation
    # --------------------------------------------------------------------------

    def spectral_ctxs(self) -> t.List[MonoSpectralContext]:
        return [
            MonoSpectralContext(wavelength=wavelength)
            for wavelength in self._wavelengths
            if not np.isclose(self.srf.eval_mono(wavelength), 0.0)
        ]
Пример #5
0
class MonoSpectralContext(SpectralContext):
    """
    Monochromatic spectral context data structure.
    """

    _wavelength: pint.Quantity = documented(
        pinttr.ib(
            default=ureg.Quantity(550.0, ureg.nm),
            units=ucc.deferred("wavelength"),
            on_setattr=None,  # frozen classes can't use on_setattr
        ),
        doc="A single wavelength value.\n\nUnit-enabled field "
        "(default: ucc[wavelength]).",
        type="quantity",
        init_type="quantity or float",
        default="550.0 nm",
    )

    @_wavelength.validator
    def _wavelength_validator(self, attribute, value):
        validators.on_quantity(validators.is_scalar)(self, attribute, value)

    @property
    def wavelength(self):
        """quantity : Wavelength associated with spectral context."""
        return self._wavelength

    @property
    def spectral_index(self) -> float:
        """
        Spectral index associated with spectral context, equal to active
        wavelength magnitude in config units.
        """
        return self._wavelength.m_as(ucc.get("wavelength"))

    @property
    def spectral_index_formatted(self) -> str:
        """str : Formatted spectral index (human-readable string)."""
        return f"{self._wavelength:g~P}"
Пример #6
0
class TargetPoint(Target):
    """
    Point target or origin specification.
    """

    # Target point in config units
    xyz: pint.Quantity = documented(
        pinttr.ib(units=ucc.deferred("length")),
        doc=
        "Point coordinates.\n\nUnit-enabled field (default: ucc['length']).",
        type="quantity",
        init_type="array-like",
    )

    @xyz.validator
    def _xyz_validator(self, attribute, value):
        if not is_vector3(value):
            raise ValueError(f"while validating {attribute.name}: must be a "
                             f"3-element vector of numbers")

    def kernel_item(self) -> t.Dict:
        """Return kernel item."""
        return self.xyz.m_as(uck.get("length"))
Пример #7
0
class BoundingBox:
    """
    A basic data class representing an axis-aligned bounding box with
    unit-valued corners.

    Notes
    -----
    Instances are immutable.
    """

    min: pint.Quantity = documented(
        pinttr.ib(
            units=ucc.get("length"),
            on_setattr=None,  # frozen instance: on_setattr must be disabled
        ),
        type="quantity",
        init_type="array-like or quantity",
        doc="Min corner.",
    )

    max: pint.Quantity = documented(
        pinttr.ib(
            units=ucc.get("length"),
            on_setattr=None,  # frozen instance: on_setattr must be disabled
        ),
        type="quantity",
        init_type="array-like or quantity",
        doc="Max corner.",
    )

    @min.validator
    @max.validator
    def _min_max_validator(self, attribute, value):
        if not self.min.shape == self.max.shape:
            raise ValueError(
                f"while validating {attribute.name}: 'min' and 'max' must "
                f"have the same shape (got {self.min.shape} and {self.max.shape})"
            )
        if not np.all(np.less(self.min, self.max)):
            raise ValueError(
                f"while validating {attribute.name}: 'min' must be strictly "
                "less than 'max'"
            )

    @classmethod
    def convert(
        cls, value: t.Union[t.Sequence, t.Mapping, np.typing.ArrayLike, pint.Quantity]
    ) -> t.Any:
        """
        Attempt conversion of a value to a :class:`BoundingBox`.

        Parameters
        ----------
        value
            Value to convert.

        Returns
        -------
        any
            If `value` is an array-like, a quantity or a mapping, conversion will
            be attempted. Otherwise, `value` is returned unmodified.
        """
        if isinstance(value, (np.ndarray, pint.Quantity)):
            return cls(value[0, :], value[1, :])

        elif isinstance(value, Sequence):
            return cls(*value)

        elif isinstance(value, Mapping):
            return cls(**pinttr.interpret_units(value, ureg=ureg))

        else:
            return value

    @property
    def shape(self):
        """
        tuple: Shape of `min` and `max` arrays.
        """
        return self.min.shape

    @property
    def extents(self) -> pint.Quantity:
        """
        :class:`pint.Quantity`: Extent in all dimensions.
        """
        return self.max - self.min

    @property
    def units(self):
        """
        :class:`pint.Unit`: Units of `min` and `max` arrays.
        """
        return self.min.units

    def contains(self, p: np.typing.ArrayLike, strict: bool = False) -> bool:
        """
        Test whether a point lies within the bounding box.

        Parameters
        ----------
        p : quantity or array-like
            An array of shape (3,) (resp. (N, 3)) representing one (resp. N)
            points. If a unitless value is passed, it is interpreted as
            ``ucc["length"]``.

        strict : bool
            If ``True``, comparison is done using strict inequalities (<, >).

        Returns
        -------
        result : array of bool or bool
            ``True`` iff ``p`` in within the bounding box.
        """
        p = np.atleast_2d(ensure_units(p, ucc.get("length")))

        cmp = (
            np.logical_and(p > self.min, p < self.max)
            if strict
            else np.logical_and(p >= self.min, p <= self.max)
        )

        return np.all(cmp, axis=1)
Пример #8
0
class DirectionalIllumination(Illumination):
    """
    Directional illumination scene element [``directional``].

    The illumination is oriented based on the classical angular convention used
    in Earth observation.
    """

    zenith: pint.Quantity = documented(
        pinttr.ib(
            default=0.0 * ureg.deg,
            validator=[is_positive, pinttr.validators.has_compatible_units],
            units=ucc.deferred("angle"),
        ),
        doc="Zenith angle.\n\nUnit-enabled field (default units: ucc[angle]).",
        type="quantity",
        init_type="quantity or float",
        default="0.0 deg",
    )

    azimuth: pint.Quantity = documented(
        pinttr.ib(
            default=0.0 * ureg.deg,
            validator=[is_positive, pinttr.validators.has_compatible_units],
            units=ucc.deferred("angle"),
        ),
        doc="Azimuth angle value.\n"
        "\n"
        "Unit-enabled field (default units: ucc[angle]).",
        type="quantity",
        init_type="quantity or float",
        default="0.0 deg",
    )

    irradiance: Spectrum = documented(
        attr.ib(
            factory=SolarIrradianceSpectrum,
            converter=spectrum_factory.converter("irradiance"),
            validator=[
                attr.validators.instance_of(Spectrum),
                has_quantity("irradiance"),
            ],
        ),
        doc=
        "Emitted power flux in the plane orthogonal to the illumination direction. "
        "Must be an irradiance spectrum (in W/m²/nm or compatible unit). "
        "Can be initialised with a dictionary processed by "
        ":meth:`.SpectrumFactory.convert`.",
        type=":class:`~eradiate.scenes.spectra.Spectrum`",
        init_type=":class:`~eradiate.scenes.spectra.Spectrum` or dict or float",
        default=":class:`SolarIrradianceSpectrum() <.SolarIrradianceSpectrum>`",
    )

    def kernel_dict(self, ctx: KernelDictContext) -> KernelDict:
        return KernelDict({
            self.id: {
                "type":
                "directional",
                "direction":
                list(
                    -angles_to_direction([
                        self.zenith.m_as(ureg.rad),
                        self.azimuth.m_as(ureg.rad)
                    ]).squeeze(), ),
                "irradiance":
                self.irradiance.kernel_dict(ctx=ctx)["spectrum"],
            }
        })
Пример #9
0
class EllipsoidLeafCloudParams(LeafCloudParams):
    """
    Advanced parameter checking class for the ellipsoid :class:`.LeafCloud`
    generator. Parameters ``a``, ``b`` and ``c`` denote the ellipsoid's half
    axes along the x, y, and z directions respectively. If either ``b`` or ``c``
    are not set by the user, they default to being equal to ``a``.
    Accordingly a sphere of radius ``r`` can be parametrized by setting ``a=r``.

    See Also
    --------
    :meth:`.LeafCloud.ellipsoid`
    """

    _a = documented(
        pinttr.ib(default=1.0 * ureg.m, units=ucc.deferred("length")),
        doc="Leaf cloud radius.\n\nUnit-enabled field (default: ucc[length]).",
        type="float",
        default="1 m",
    )

    _b = documented(
        pinttr.ib(default=None, units=ucc.deferred("length")),
        doc="Leaf cloud radius.\n\nUnit-enabled field (default: ucc[length]).",
        type="float",
        default="1 m",
    )

    _c = documented(
        pinttr.ib(default=None, units=ucc.deferred("length")),
        doc="Leaf cloud radius.\n\nUnit-enabled field (default: ucc[length]).",
        type="float",
        default="1 m",
    )

    @property
    def a(self):
        if self._a <= 0:
            raise ValueError(
                "Ellipsoid half axis parameters must be strictly larger than zero!"
            )
        return self._a

    @property
    def b(self):
        if self._b is None:
            self._b = self.a
        elif self._b <= 0:
            raise ValueError(
                "Ellipsoid half axis parameters must be strictly larger than zero!"
            )
        return self._b

    @property
    def c(self):
        if self._c is None:
            self._c = self.a
        elif self._c <= 0:
            raise ValueError(
                "Ellipsoid half axis parameters must be strictly larger than zero!"
            )
        return self._c
Пример #10
0
class AbstractTree(Tree):
    """
    A container class for abstract trees in discrete canopies.
    Holds a :class:`.LeafCloud` and the parameters characterizing a cylindrical
    trunk. The entire tree is described in local coordinates and can be placed
    in the scene using :class:`.InstancedCanopyElement`.

    The trunk starts at [0, 0, -0.1] and extends
    to [0, 0, trunk_height]. The trunk extends below ``z=0`` to avoid intersection
    issues at the intersection of the trunk and the ground the tree is usually placed on.

    The leaf cloud will by default be offset such that its local coordinate
    origin coincides with the upper end of the trunk. If this is not desired,
    e.g. the leaf cloud is centered around its coordinate origin and the trunk
    should not extend into it, the parameter ``leaf_cloud_extra_offset`` can be
    used to shift the leaf cloud **in addition** to the trunk's extent.
    """

    id: t.Optional[str] = documented(
        attr.ib(
            default="abstract_tree",
            validator=attr.validators.optional(
                attr.validators.instance_of(str)),
        ),
        doc=get_doc(SceneElement, "id", "doc"),
        type=get_doc(SceneElement, "id", "type"),
        init_type=get_doc(SceneElement, "id", "init_type"),
        default='"abstract_tree"',
    )

    leaf_cloud: t.Optional[LeafCloud] = documented(
        attr.ib(
            default=None,
            converter=attr.converters.optional(_leaf_cloud_converter),
            validator=attr.validators.optional(
                attr.validators.instance_of(LeafCloud)),
        ),
        doc="Instanced leaf cloud. Can be specified as a dictionary, which will "
        "be interpreted by :data:`.biosphere_factory`. If the latter case, the "
        '``"type"`` parameter, if omitted, will implicitly be set to '
        '``"leaf_cloud"``.',
        type=":class:`.LeafCloud`, optional",
        init_type=":class:`.LeafCloud` or dict, optional",
    )

    trunk_height: pint.Quantity = documented(
        pinttr.ib(default=1.0 * ureg.m, units=ucc.deferred("length")),
        doc="Trunk height.\n\nUnit-enabled field (default: ucc['length']).",
        type="quantity",
        init_type="quantity or float",
        default="1.0 m",
    )

    trunk_radius: pint.Quantity = documented(
        pinttr.ib(default=0.1 * ureg.m, units=ucc.deferred("length")),
        doc="Trunk radius.\n\nUnit-enabled field (default: ucc['length']).",
        type="quantity",
        init_type="quantity or float",
        default="0.1 m",
    )

    trunk_reflectance: Spectrum = documented(
        attr.ib(
            default=0.5,
            converter=spectrum_factory.converter("reflectance"),
            validator=[
                attr.validators.instance_of(Spectrum),
                validators.has_quantity("reflectance"),
            ],
        ),
        doc="Reflectance spectrum of the trunk. "
        "Must be a reflectance spectrum (dimensionless).",
        type=":class:`.Spectrum`",
        init_type=":class:`.Spectrum` or dict",
        default="0.5",
    )

    leaf_cloud_extra_offset: pint.Quantity = documented(
        pinttr.ib(factory=lambda: [0, 0, 0], units=ucc.deferred("length")),
        doc="Additional offset for the leaf cloud. 3-vector.\n"
        "\n"
        "Unit-enabled field (default: ucc['length'])",
        type="quantity",
        init_type="array-like",
        default="[0, 0, 0]",
    )

    # --------------------------------------------------------------------------
    #                       Kernel dictionary generation
    # --------------------------------------------------------------------------

    def bsdfs(self, ctx: KernelDictContext) -> t.Dict:
        """
        Return BSDF plugin specifications.

        Parameters
        ----------
        ctx : :class:`.KernelDictContext`
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        dict
            Return a dictionary suitable for merge with a :class:`.KernelDict`
            containing all the BSDFs attached to the shapes
            in the abstract tree.
        """

        bsdfs_dict = self.leaf_cloud.bsdfs(ctx=ctx)

        bsdfs_dict[f"bsdf_{self.id}"] = {
            "type": "diffuse",
            "reflectance":
            self.trunk_reflectance.kernel_dict(ctx=ctx)["spectrum"],
        }

        return bsdfs_dict

    def shapes(self, ctx: KernelDictContext) -> t.Dict:
        """
        Return shape plugin specifications.

        Parameters
        ----------
        ctx : :class:`.KernelDictContext`
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        dict
            A dictionary suitable for merge with a
            :class:`~eradiate.scenes.core.KernelDict` containing all the shapes
            in the abstract tree.
        """
        from mitsuba.core import ScalarTransform4f

        kernel_length = uck.get("length")

        kernel_height = self.trunk_height.m_as(kernel_length)
        kernel_radius = self.trunk_radius.m_as(kernel_length)

        leaf_cloud = self.leaf_cloud.translated(
            [0.0, 0.0, kernel_height] * kernel_length +
            self.leaf_cloud_extra_offset.to(kernel_length))

        if ctx.ref:
            bsdf = {"type": "ref", "id": f"bsdf_{self.id}"}
        else:
            bsdf = self.bsdfs(ctx=ctx)[f"bsdf_{self.id}"]

        shapes_dict = leaf_cloud.shapes(ctx=ctx)

        shapes_dict[f"trunk_cyl_{self.id}"] = {
            "type": "cylinder",
            "bsdf": bsdf,
            "radius": kernel_radius,
            "p0": [0, 0, -0.1],
            "p1": [0, 0, kernel_height],
        }

        shapes_dict[f"trunk_cap_{self.id}"] = {
            "type":
            "disk",
            "bsdf":
            bsdf,
            "to_world":
            ScalarTransform4f.scale(kernel_radius) *
            ScalarTransform4f.translate(((0, 0, kernel_height))),
        }

        return shapes_dict
Пример #11
0
class Canopy(SceneElement, ABC):
    """
    An abstract base class defining a base type for all canopies.
    """

    id: t.Optional[str] = documented(
        attr.ib(
            default="canopy",
            validator=attr.validators.optional(
                attr.validators.instance_of(str)),
        ),
        doc=get_doc(SceneElement, "id", "doc"),
        type=get_doc(SceneElement, "id", "type"),
        init_type=get_doc(SceneElement, "id", "init_type"),
        default='"canopy"',
    )

    size: t.Optional[pint.Quantity] = documented(
        pinttr.ib(
            default=None,
            validator=attr.validators.optional([
                pinttr.validators.has_compatible_units,
                validators.on_quantity(validators.is_vector3),
            ]),
            units=ucc.deferred("length"),
        ),
        doc=
        "Canopy size as a 3-vector.\n\nUnit-enabled field (default: ucc['length']).",
        type="quantity",
        init_type="array-like",
    )

    @abstractmethod
    def bsdfs(self, ctx: KernelDictContext) -> t.MutableMapping:
        """
        Return BSDF plugin specifications only.

        Parameters
        ----------
        ctx : :class:`.KernelDictContext`
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        dict
            A dictionary suitable for merge with a
            :class:`~eradiate.scenes.core.KernelDict` containing all the BSDFs
            attached to the shapes in the canopy.
        """
        pass

    @abstractmethod
    def shapes(self, ctx: KernelDictContext) -> t.MutableMapping:
        """
        Return shape plugin specifications only.

        Parameters
        ----------
        ctx : :class:`.KernelDictContext`
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        dict
            A dictionary suitable for merge with a
            :class:`~eradiate.scenes.core.KernelDict` containing all the shapes
            in the canopy.
        """
        pass
Пример #12
0
class ParticleLayer(AbstractHeterogeneousAtmosphere):
    """
    Particle layer scene element [``particle_layer``].

    The particle layer has a vertical extension specified by a bottom altitude
    (set by `bottom`) and a top altitude (set by `top`).
    Inside the layer, the particles number is distributed according to a
    distribution (set by `distribution`).
    See :mod:`~eradiate.scenes.atmosphere.particle_dist` for the available
    distribution types and corresponding parameters.
    The particle layer is itself divided into a number of (sub-)layers
    (`n_layers`) to allow to describe the variations of the particles number
    with altitude.
    The total number of particles in the layer is adjusted so that the
    particle layer's optical thickness at 550 nm meet a specified value
    (`tau_550`).
    The particles radiative properties are specified by a data set
    (`dataset`).
    """

    _bottom: pint.Quantity = documented(
        pinttr.ib(
            default=ureg.Quantity(0.0, ureg.km),
            validator=[
                is_positive,
                pinttr.validators.has_compatible_units,
            ],
            units=ucc.deferred("length"),
        ),
        doc="Bottom altitude of the particle layer.\n"
        "\n"
        "Unit-enabled field (default: ucc[length])",
        type="quantity",
        init_type="float or quantity",
        default="0 km",
    )

    _top: pint.Quantity = documented(
        pinttr.ib(
            units=ucc.deferred("length"),
            default=ureg.Quantity(1.0, ureg.km),
            validator=[
                is_positive,
                pinttr.validators.has_compatible_units,
            ],
        ),
        doc="Top altitude of the particle layer.\n"
        "\n"
        "Unit-enabled field (default: ucc[length]).",
        type="quantity",
        init_type="float or quantity",
        default="1 km.",
    )

    @_bottom.validator
    @_top.validator
    def _bottom_top_validator(self, attribute, value):
        if self.bottom >= self.top:
            raise ValueError(
                f"while validating '{attribute.name}': bottom altitude must be "
                "lower than top altitude "
                f"(got bottom={self.bottom}, top={self.top})")

    distribution: ParticleDistribution = documented(
        attr.ib(
            default="uniform",
            converter=_particle_layer_distribution_converter,
            validator=attr.validators.instance_of(ParticleDistribution),
        ),
        doc="Particle distribution. Simple defaults can be set using a string: "
        '``"uniform"`` (resp. ``"gaussian"``, ``"exponential"``) is converted to '
        ":class:`UniformParticleDistribution() <.UniformParticleDistribution>` "
        "(resp. :class:`GaussianParticleDistribution() <.GaussianParticleDistribution>`, "
        ":class:`ExponentialParticleDistribution() <.ExponentialParticleDistribution>`).",
        init_type=":class:`.ParticleDistribution` or dict or "
        '{"uniform", "gaussian", "exponential"}, optional',
        type=":class:`.ParticleDistribution`",
        default='"uniform"',
    )

    tau_550: pint.Quantity = documented(
        pinttr.ib(
            units=ucc.deferred("dimensionless"),
            default=ureg.Quantity(0.2, ureg.dimensionless),
            validator=[
                is_positive,
                pinttr.validators.has_compatible_units,
            ],
        ),
        doc="Extinction optical thickness at the wavelength of 550 nm.\n"
        "\n"
        "Unit-enabled field (default: ucc[dimensionless]).",
        type="quantity",
        init_type="quantity or float",
        default="0.2",
    )

    n_layers: int = documented(
        attr.ib(
            default=16,
            converter=int,
            validator=attr.validators.instance_of(int),
        ),
        doc="Number of layers in which the particle layer is discretised.",
        type="int",
        default="16",
    )

    # TODO: replace with actual data set and load through data interface
    # TODO: change defaults to production data
    dataset: pathlib.Path = documented(
        attr.ib(
            default=path_resolver.resolve(
                "tests/radprops/rtmom_aeronet_desert.nc"),
            converter=path_resolver.resolve,
            validator=[
                attr.validators.instance_of(pathlib.Path), validators.is_file
            ],
        ),
        doc=
        "Path to particle radiative property data set. Defaults to testing data.",
        type="Path",
        init_type="path-like",
    )

    _phase: t.Optional[TabulatedPhaseFunction] = attr.ib(default=None,
                                                         init=False)

    def update(self) -> None:
        super().update()

        with xr.open_dataset(self.dataset) as ds:
            self._phase = TabulatedPhaseFunction(id=self.id_phase,
                                                 data=ds.phase)

    # --------------------------------------------------------------------------
    #                    Spatial and thermophysical properties
    # --------------------------------------------------------------------------

    @property
    def top(self) -> pint.Quantity:
        return self._top

    @property
    def bottom(self) -> pint.Quantity:
        return self._bottom

    def eval_width(self, ctx: KernelDictContext) -> pint.Quantity:
        if ctx.override_scene_width is not None:
            return ctx.override_scene_width

        else:
            if self.width is AUTO:
                min_sigma_s = self.eval_sigma_s(
                    spectral_ctx=ctx.spectral_ctx).min()
                width = 10.0 / min_sigma_s if min_sigma_s != 0.0 else np.inf * ureg.m
                return min(width, 1000 * ureg.km)
            else:
                return self.width

    @property
    def z_level(self) -> pint.Quantity:
        """
        Compute the level altitude mesh within the particle layer.

        The level altitude mesh corresponds to a regular level altitude mesh
        from the layer's bottom altitude to the layer's top altitude with
        a number of points specified by ``n_layer + 1``.

        Returns
        -------
        quantity
            Level altitude mesh.
        """
        return np.linspace(start=self.bottom,
                           stop=self.top,
                           num=self.n_layers + 1)

    @property
    def z_layer(self) -> pint.Quantity:
        """
        Compute the layer altitude mesh within the particle layer.

        The layer altitude mesh corresponds to a regular level altitude mesh
        from the layer's bottom altitude to the layer's top altitude with
        a number of points specified by ``n_layer``.

        Returns
        -------
        quantity
            Layer altitude mesh.
        """
        z_level = self.z_level
        return 0.5 * (z_level[:-1] + z_level[1:])

    def eval_fractions(self) -> np.ndarray:
        """
        Compute the particle number fraction in the particle layer.

        Returns
        -------
        ndarray
            Particle fractions.
        """
        x = (self.z_layer - self.bottom) / (self.top - self.bottom)
        fractions = self.distribution(x.magnitude)
        fractions /= np.sum(fractions)
        return fractions

    # --------------------------------------------------------------------------
    #                       Radiative properties
    # --------------------------------------------------------------------------

    def eval_albedo(self, spectral_ctx: SpectralContext) -> pint.Quantity:
        """
        Evaluate albedo spectrum based on a spectral context. This method
        dispatches evaluation to specialised methods depending on the active
        mode.

        Parameters
        ----------
        spectral_ctx : :class:`.SpectralContext`
            A spectral context data structure containing relevant spectral
            parameters (*e.g.* wavelength in monochromatic mode, bin and
            quadrature point index in CKD mode).

        Returns
        -------
        quantity
            Evaluated spectrum as an array with length equal to the number of
            layers.
        """
        if eradiate.mode().has_flags(ModeFlags.ANY_MONO):
            return self.eval_albedo_mono(spectral_ctx.wavelength).squeeze()

        elif eradiate.mode().has_flags(ModeFlags.ANY_CKD):
            return self.eval_albedo_ckd(spectral_ctx.bindex).squeeze()

        else:
            raise UnsupportedModeError(supported=("monochromatic", "ckd"))

    def eval_albedo_mono(self, w: pint.Quantity) -> pint.Quantity:
        with xr.open_dataset(self.dataset) as ds:
            wavelengths = w.m_as(ds.w.attrs["units"])
            interpolated_albedo = ds.albedo.interp(w=wavelengths)

        albedo = to_quantity(interpolated_albedo)
        albedo_array = albedo * np.ones(self.n_layers)
        return albedo_array

    def eval_albedo_ckd(self, *bindexes: Bindex) -> pint.Quantity:
        w_units = ureg.nm
        w = [bindex.bin.wcenter.m_as(w_units) for bindex in bindexes] * w_units
        return self.eval_albedo_mono(w)

    def eval_sigma_t(self, spectral_ctx: SpectralContext) -> pint.Quantity:
        """
        Evaluate extinction coefficient given a spectral context.

        Parameters
        ----------
        spectral_ctx : :class:`.SpectralContext`
            A spectral context data structure containing relevant spectral
            parameters (*e.g.* wavelength in monochromatic mode, bin and
            quadrature point index in CKD mode).

        Returns
        -------
        quantity
            Particle layer extinction coefficient.
        """
        if eradiate.mode().has_flags(ModeFlags.ANY_MONO):
            return self.eval_sigma_t_mono(spectral_ctx.wavelength).squeeze()

        elif eradiate.mode().has_flags(ModeFlags.ANY_CKD):
            return self.eval_sigma_t_ckd(spectral_ctx.bindex).squeeze()

        else:
            raise UnsupportedModeError(supported=("monochromatic", "ckd"))

    def eval_sigma_t_mono(self, w: pint.Quantity) -> pint.Quantity:
        with xr.open_dataset(self.dataset) as ds:
            ds_w_units = ureg(ds.w.attrs["units"])

            # find the extinction data variable
            for dv in ds.data_vars:
                standard_name = ds[dv].standard_name
                if "extinction" in standard_name:
                    extinction = ds[dv]

        wavelength = w.m_as(ds_w_units)
        xs_t = to_quantity(extinction.interp(w=wavelength))
        xs_t_550 = to_quantity(
            extinction.interp(w=ureg.convert(550.0, ureg.nm, ds_w_units)))
        fractions = self.eval_fractions()
        sigma_t_array = xs_t_550 * fractions
        dz = (self.top - self.bottom) / self.n_layers
        normalized_sigma_t_array = self._normalize_to_tau(
            ki=sigma_t_array.magnitude,
            dz=dz,
            tau=self.tau_550,
        )
        return normalized_sigma_t_array * xs_t / xs_t_550

    def eval_sigma_t_ckd(self, *bindexes: Bindex) -> pint.Quantity:
        w_units = ureg.nm
        w = [bindex.bin.wcenter.m_as(w_units) for bindex in bindexes] * w_units
        return self.eval_sigma_t_mono(w)

    def eval_sigma_a(self, spectral_ctx: SpectralContext) -> pint.Quantity:
        """
        Evaluate absorption coefficient given a spectral context.

        Parameters
        ----------
        spectral_ctx : :class:`.SpectralContext`
            A spectral context data structure containing relevant spectral
            parameters (*e.g.* wavelength in monochromatic mode, bin and
            quadrature point index in CKD mode).

        Returns
        -------
        quantity
            Particle layer extinction coefficient.
        """
        if eradiate.mode().has_flags(ModeFlags.ANY_MONO):
            return self.eval_sigma_a_mono(spectral_ctx.wavelength).squeeze()

        elif eradiate.mode().has_flags(ModeFlags.ANY_CKD):
            return self.eval_sigma_a_ckd(spectral_ctx.bindex).squeeze()

        else:
            raise UnsupportedModeError(supported=("monochromatic", "ckd"))

    def eval_sigma_a_mono(self, w: pint.Quantity) -> pint.Quantity:
        return self.eval_sigma_t_mono(w) - self.eval_sigma_s_mono(w)

    def eval_sigma_a_ckd(self, *bindexes: Bindex):
        return self.eval_sigma_t_ckd(*bindexes) - self.eval_sigma_s_ckd(
            *bindexes)

    def eval_sigma_s(self, spectral_ctx: SpectralContext) -> pint.Quantity:
        """
        Evaluate scattering coefficient given a spectral context.

        Parameters
        ----------
        spectral_ctx : :class:`.SpectralContext`
            A spectral context data structure containing relevant spectral
            parameters (*e.g.* wavelength in monochromatic mode, bin and
            quadrature point index in CKD mode).

        Returns
        -------
        quantity
            Particle layer scattering coefficient.
        """
        if eradiate.mode().has_flags(ModeFlags.ANY_MONO):
            return self.eval_sigma_s_mono(spectral_ctx.wavelength).squeeze()

        elif eradiate.mode().has_flags(ModeFlags.ANY_CKD):
            return self.eval_sigma_s_ckd(spectral_ctx.bindex).squeeze()

        else:
            raise UnsupportedModeError(supported=("monochromatic", "ckd"))

    def eval_sigma_s_mono(self, w: pint.Quantity) -> pint.Quantity:
        return self.eval_sigma_t_mono(w) * self.eval_albedo_mono(w)

    def eval_sigma_s_ckd(self, *bindexes: Bindex):
        return self.eval_sigma_t_ckd(*bindexes) * self.eval_albedo_ckd(
            *bindexes)

    def eval_radprops(self, spectral_ctx: SpectralContext) -> xr.Dataset:
        """
        Return a dataset that holds the radiative properties profile of the
        particle layer.

        Parameters
        ----------
        spectral_ctx : :class:`.SpectralContext`
            A spectral context data structure containing relevant spectral
            parameters (*e.g.* wavelength in monochromatic mode, bin and
            quadrature point index in CKD mode).

        Returns
        -------
        Dataset
            Particle layer radiative properties profile dataset.
        """
        if eradiate.mode().has_flags(ModeFlags.ANY_MONO | ModeFlags.ANY_CKD):
            sigma_t = self.eval_sigma_t(spectral_ctx=spectral_ctx)
            albedo = self.eval_albedo(spectral_ctx=spectral_ctx)
            wavelength = spectral_ctx.wavelength

            return xr.Dataset(
                data_vars={
                    "sigma_t": (
                        "z_layer",
                        np.atleast_1d(sigma_t.magnitude),
                        dict(
                            standard_name="extinction_coefficient",
                            long_name="extinction coefficient",
                            units=f"{sigma_t.units:~P}",
                        ),
                    ),
                    "albedo": (
                        "z_layer",
                        np.atleast_1d(albedo.magnitude),
                        dict(
                            standard_name="albedo",
                            long_name="albedo",
                            units=f"{albedo.units:~P}",
                        ),
                    ),
                },
                coords={
                    "z_layer": (
                        "z_layer",
                        self.z_layer.magnitude,
                        dict(
                            standard_name="layer_altitude",
                            long_name="layer altitude",
                            units=f"{self.z_layer.units:~P}",
                        ),
                    ),
                    "z_level": (
                        "z_level",
                        self.z_level.magnitude,
                        dict(
                            standard_name="level_altitude",
                            long_name="level altitude",
                            units=f"{self.z_level.units:~P}",
                        ),
                    ),
                    "w": (
                        "w",
                        [wavelength.magnitude],
                        dict(
                            standard_name="wavelength",
                            long_name="wavelength",
                            units=f"{wavelength.units:~P}",
                        ),
                    ),
                },
            ).isel(w=0)

        else:
            raise UnsupportedModeError(supported=("monochromatic", "ckd"))

    @staticmethod
    @ureg.wraps(ret="km^-1", args=("", "km", ""), strict=False)
    def _normalize_to_tau(ki: np.ndarray, dz: np.ndarray,
                          tau: float) -> pint.Quantity:
        r"""
        Normalise extinction coefficient values :math:`k_i` so that:

        .. math::
           \sum_i k_i \Delta z = \tau_{550}

        where :math:`\tau` is the particle layer optical thickness.

        Parameters
        ----------
        ki : quantity or ndarray
            Dimensionless extinction coefficients values [].

        dz : quantity or ndarray
            Layer divisions thickness [km].

        tau : float
            Layer optical thickness (dimensionless).

        Returns
        -------
        quantity
            Normalised extinction coefficients.
        """
        return ki * tau / (np.sum(ki) * dz)

    # --------------------------------------------------------------------------
    #                       Kernel dictionary generation
    # --------------------------------------------------------------------------

    @property
    def phase(self) -> TabulatedPhaseFunction:
        return self._phase

    def kernel_phase(self, ctx: KernelDictContext) -> KernelDict:
        return self.phase.kernel_dict(ctx)
Пример #13
0
class HemisphericalDistantMeasure(Measure):
    """
    Hemispherical distant radiance measure scene element
    [``hdistant``, ``hemispherical_distant``].

    This scene element records radiance leaving the scene in a hemisphere
    defined by its ``direction`` parameter. A distinctive feature of this
    measure is that it samples continuously the direction space instead of
    computing radiance values for a fixed set of directions, thus potentially
    capturing effects much harder to distinguish using *e.g.* the
    :class:`.MultiDistantMeasure` class. On the other side, features located
    at a precise angle will not be captured very well by this measure.

    This measure is useful to get a global view of leaving radiance patterns
    over a surface.

    Notes
    -----
    * Setting the ``target`` parameter is required to get meaningful results.
      Experiment classes should take care of setting it appropriately.
    """

    # --------------------------------------------------------------------------
    #                           Fields and properties
    # --------------------------------------------------------------------------

    _film_resolution: t.Tuple[int, int] = documented(
        attr.ib(
            default=(32, 32),
            validator=attr.validators.deep_iterable(
                member_validator=attr.validators.instance_of(int),
                iterable_validator=validators.has_len(2),
            ),
        ),
        doc="Film resolution as a (width, height) 2-tuple. "
        "If the height is set to 1, direction sampling will be restricted to a "
        "plane.",
        type="array-like",
        default="(32, 32)",
    )

    orientation: pint.Quantity = documented(
        pinttr.ib(
            default=ureg.Quantity(0.0, ureg.deg),
            validator=[
                validators.is_positive, pinttr.validators.has_compatible_units
            ],
            units=ucc.deferred("angle"),
        ),
        doc="Azimuth angle defining the orientation of the sensor in the "
        "horizontal plane.\n"
        "\n"
        "Unit-enabled field (default: ucc['angle']).",
        type="float",
        default="0.0 deg",
    )

    direction = documented(
        attr.ib(
            default=[0, 0, 1],
            converter=np.array,
            validator=validators.is_vector3,
        ),
        doc="A 3-vector orienting the hemisphere mapped by the measure.",
        type="array-like",
        default="[0, 0, 1]",
    )

    flip_directions = documented(
        attr.ib(default=None, converter=attr.converters.optional(bool)),
        doc=" If ``True``, sampled directions will be flipped.",
        type="bool",
        default="False",
    )

    target: t.Optional[Target] = documented(
        attr.ib(
            default=None,
            converter=attr.converters.optional(Target.convert),
            validator=attr.validators.optional(
                attr.validators.instance_of((
                    TargetPoint,
                    TargetRectangle,
                ))),
            on_setattr=attr.setters.pipe(attr.setters.convert,
                                         attr.setters.validate),
        ),
        doc="Target specification. The target can be specified using an "
        "array-like with 3 elements (which will be converted to a "
        ":class:`.TargetPoint`) or a dictionary interpreted by "
        ":meth:`Target.convert() <.Target.convert>`. If set to "
        "``None`` (not recommended), the default target point selection "
        "method is used: rays will not target a particular region of the "
        "scene.",
        type=":class:`.Target` or None",
        init_type=":class:`.Target` or dict or array-like, optional",
    )

    @property
    def film_resolution(self):
        return self._film_resolution

    flags: MeasureFlags = documented(
        attr.ib(default=MeasureFlags.DISTANT,
                converter=MeasureFlags,
                init=False),
        doc=get_doc(Measure, "flags", "doc"),
        type=get_doc(Measure, "flags", "type"),
    )

    @property
    def viewing_angles(self) -> pint.Quantity:
        """
        quantity: Viewing angles computed from stored film coordinates as a
            (width, height, 2) array. The last dimension is ordered as
            (zenith, azimuth).
        """
        # Compute viewing angles at pixel locations
        # Angle computation must match the kernel plugin's direction sampling
        # routine
        angle_units = ucc.get("angle")

        # Compute pixel locations in film coordinates
        xs = (np.linspace(0, 1, self.film_resolution[0], endpoint=False) +
              0.5 / self.film_resolution[0])
        ys = (np.linspace(0, 1, self.film_resolution[1], endpoint=False) +
              0.5 / self.film_resolution[1])

        # Compute corresponding angles
        xy = np.array([(x, y) for x in xs for y in ys])
        angles = direction_to_angles(
            square_to_uniform_hemisphere(xy)).to(angle_units)

        # Normalise azimuth to [0, 2π]
        angles[:, 1] %= 360.0 * ureg.deg

        # Reshape array to match film size on first 2 dimensions
        return angles.reshape((len(xs), len(ys), 2))

    # --------------------------------------------------------------------------
    #                       Kernel dictionary generation
    # --------------------------------------------------------------------------

    def _kernel_dict(self, sensor_id, spp):
        result = {
            "type":
            "distant",
            "id":
            sensor_id,
            "direction":
            self.direction,
            "orientation": [
                np.cos(self.orientation.m_as(ureg.rad)),
                np.sin(self.orientation.m_as(ureg.rad)),
                0.0,
            ],
            "sampler": {
                "type": "independent",
                "sample_count": spp,
            },
            "film": {
                "type": "hdrfilm",
                "width": self.film_resolution[0],
                "height": self.film_resolution[1],
                "pixel_format": "luminance",
                "component_format": "float32",
                "rfilter": {
                    "type": "box"
                },
            },
        }

        if self.target is not None:
            result["ray_target"] = self.target.kernel_item()

        if self.flip_directions is not None:
            result["flip_directions"] = self.flip_directions

        return result

    def kernel_dict(self, ctx: KernelDictContext) -> KernelDict:
        sensor_ids = self._sensor_ids()
        sensor_spps = self._sensor_spps()
        result = KernelDict()

        for spp, sensor_id in zip(sensor_spps, sensor_ids):
            result.data[sensor_id] = self._kernel_dict(sensor_id, spp)

        return result

    # --------------------------------------------------------------------------
    #                        Post-processing information
    # --------------------------------------------------------------------------

    @property
    def var(self) -> t.Tuple[str, t.Dict]:
        return "radiance", {
            "standard_name": "radiance",
            "long_name": "radiance",
            "units": symbol(uck.get("radiance")),
        }
Пример #14
0
class Bin:
    """
    A data class representing a spectral bin in CKD modes.
    """

    id: str = documented(
        attr.ib(converter=str),
        doc="Bin identifier.",
        type="str",
    )

    wmin: pint.Quantity = documented(
        pinttr.ib(
            units=ucc.deferred("wavelength"),
            on_setattr=None,  # frozen instance: on_setattr must be disabled
        ),
        doc=
        'Bin lower spectral bound.\n\nUnit-enabled field (default: ucc["wavelength"]).',
        type="quantity",
        init_type="quantity or float",
    )

    wmax: pint.Quantity = documented(
        pinttr.ib(
            units=ucc.deferred("wavelength"),
            on_setattr=None,  # frozen instance: on_setattr must be disabled
        ),
        doc=
        'Bin upper spectral bound.\n\nUnit-enabled field (default: ucc["wavelength"]).',
        type="quantity",
        init_type="quantity or float",
    )

    @wmin.validator
    @wmax.validator
    def _wbounds_validator(self, attribute, value):
        if not self.wmin < self.wmax:
            raise ValueError(
                f"while validating {attribute.name}: wmin must be lower than wmax"
            )

    quad: Quad = documented(
        attr.ib(repr=lambda x: x.str_summary,
                validator=attr.validators.instance_of(Quad)),
        doc="Quadrature rule attached to the CKD bin.",
        type=":class:`.Quad`",
    )

    @property
    def width(self) -> pint.Quantity:
        """quantity : Bin spectral width."""
        return self.wmax - self.wmin

    @property
    def wcenter(self) -> pint.Quantity:
        """quantity : Bin central wavelength."""
        return 0.5 * (self.wmin + self.wmax)

    @property
    def bindexes(self) -> t.List[Bindex]:
        """list of :class:`.Bindex` : List of associated bindexes."""
        return [
            Bindex(bin=self, index=i) for i, _ in enumerate(self.quad.nodes)
        ]

    @classmethod
    def convert(cls, value: t.Any) -> t.Any:
        """
        If ``value`` is a tuple or a dictionary, try to construct a
        :class:`.Bin` instance from it. Otherwise, return ``value`` unchanged.
        """
        if isinstance(value, tuple):
            return cls(*value)

        if isinstance(value, dict):
            return cls(**value)

        return value
Пример #15
0
class MultiRadiancemeterMeasure(Measure):
    """
    Radiance meter array measure scene element [``mradiancemeter``,
    ``multi_radiancemeter``].

    This measure scene element is a thin wrapper around the ``mradiancemeter``
    sensor kernel plugin. It records the incident power per unit area per unit
    solid angle along a number of rays defined by its ``origins`` and
    ``directions`` parameters.
    """

    # --------------------------------------------------------------------------
    #                           Fields and properties
    # --------------------------------------------------------------------------

    origins: pint.Quantity = documented(
        pinttr.ib(
            default=ureg.Quantity([[0.0, 0.0, 0.0]], ureg.m),
            units=ucc.deferred("length"),
        ),
        doc="A sequence of points specifying radiance meter array positions.\n"
        "\n"
        "Unit-enabled field (default: ucc['length']).",
        type="quantity",
        init_type="array-like",
        default="[[0, 0, 0]] m",
    )

    directions: np.ndarray = documented(
        attr.ib(
            default=np.array([[0.0, 0.0, 1.0]]),
            converter=np.array,
        ),
        doc="A sequence of 3-vectors specifying radiance meter array directions.",
        type="quantity",
        init_type="array-like",
        default="[[0, 0, 1]]",
    )

    @directions.validator
    @origins.validator
    def _target_origin_validator(self, attribute, value):
        if value.shape[1] != 3:
            raise ValueError(
                f"While initializing {attribute}: "
                f"Expected shape (N, 3), got {value.shape}"
            )

        if not self.origins.shape == self.directions.shape:
            raise ValueError(
                f"While initializing {attribute}: "
                f"Origin and direction arrays must have the same shape, "
                f"got origins.shape = {self.origins.shape}, "
                f"directions.shape = {self.directions.shape}"
            )

    @property
    def film_resolution(self) -> t.Tuple[int, int]:
        return (self.origins.shape[0], 1)

    # --------------------------------------------------------------------------
    #                       Kernel dictionary generation
    # --------------------------------------------------------------------------

    def _kernel_dict(self, sensor_id, spp):
        origins = self.origins.m_as(uck.get("length"))
        directions = self.directions

        result = {
            "type": "mradiancemeter",
            "id": sensor_id,
            "origins": ",".join(map(str, origins.ravel(order="C"))),
            "directions": ",".join(map(str, directions.ravel(order="C"))),
            "sampler": {
                "type": "independent",
                "sample_count": spp,
            },
            "film": {
                "type": "hdrfilm",
                "width": self.film_resolution[0],
                "height": self.film_resolution[1],
                "pixel_format": "luminance",
                "component_format": "float32",
                "rfilter": {"type": "box"},
            },
        }

        return result

    def kernel_dict(self, ctx: KernelDictContext) -> KernelDict:
        sensor_ids = self._sensor_ids()
        sensor_spps = self._sensor_spps()
        result = KernelDict()

        for spp, sensor_id in zip(sensor_spps, sensor_ids):
            if ctx.atmosphere_medium_id is not None:
                result_dict = self._kernel_dict(sensor_id, spp)
                result_dict["medium"] = {
                    "type": "ref",
                    "id": ctx.atmosphere_medium_id,
                }
                result.data[sensor_id] = result_dict
            else:
                result.data[sensor_id] = self._kernel_dict(sensor_id, spp)

        return result

    # --------------------------------------------------------------------------
    #                        Post-processing information
    # --------------------------------------------------------------------------

    @property
    def var(self) -> t.Tuple[str, t.Dict]:
        return "radiance", {
            "standard_name": "radiance",
            "long_name": "radiance",
            "units": symbol(uck.get("radiance")),
        }
Пример #16
0
class PerspectiveCameraMeasure(Measure):
    """
    Perspective camera scene element [``perspective``].

    This scene element is a thin wrapper around the ``perspective`` sensor
    kernel plugin. It positions a perspective camera based on a set of vectors,
    specifying the origin, viewing direction and 'up' direction of the camera.
    """

    # --------------------------------------------------------------------------
    #                           Fields and properties
    # --------------------------------------------------------------------------

    spp: int = documented(
        attr.ib(default=32, converter=int, validator=validators.is_positive),
        doc="Number of samples per pixel.",
        type="int",
        default="32",
    )

    _film_resolution: t.Tuple[int, int] = documented(
        attr.ib(
            default=(32, 32),
            converter=tuple,
            validator=attr.validators.deep_iterable(
                member_validator=attr.validators.instance_of(int),
                iterable_validator=validators.has_len(2),
            ),
        ),
        doc="Film resolution as a (width, height) 2-tuple.",
        type="tuple of int",
        init_type="array-like",
        default="(32, 32)",
    )

    @property
    def film_resolution(self) -> t.Tuple[int, int]:
        return self._film_resolution

    origin: pint.Quantity = documented(
        pinttr.ib(
            factory=lambda: [1, 1, 1] * ureg.m,
            validator=[
                validators.has_len(3), pinttr.validators.has_compatible_units
            ],
            units=ucc.deferred("length"),
        ),
        doc="A 3-vector specifying the position of the camera.\n"
        "\n"
        "Unit-enabled field (default: ucc['length']).",
        type="quantity",
        init_type="array-like",
        default="[1, 1, 1] m",
    )

    target: pint.Quantity = documented(
        pinttr.ib(
            factory=lambda: [0, 0, 0] * ureg.m,
            validator=[
                validators.has_len(3), pinttr.validators.has_compatible_units
            ],
            units=ucc.deferred("length"),
        ),
        doc="Point location targeted by the camera.\n"
        "\n"
        "Unit-enabled field (default: ucc['length']).",
        type="quantity",
        init_type="array-like",
        default="[0, 0, 0] m",
    )

    @target.validator
    @origin.validator
    def _target_origin_validator(self, attribute, value):
        if np.allclose(self.target, self.origin):
            raise ValueError(
                f"While initializing {attribute}: "
                f"Origin and target must not be equal, "
                f"got target = {self.target}, origin = {self.origin}")

    up: np.ndarray = documented(
        attr.ib(
            factory=lambda: [0, 0, 1],
            converter=np.array,
            validator=validators.has_len(3),
        ),
        doc="A 3-vector specifying the up direction of the camera.\n"
        "This vector must be different from the camera's viewing direction,\n"
        "which is given by ``target - origin``.",
        type="array",
        default="[0, 0, 1]",
    )

    @up.validator
    def _up_validator(self, attribute, value):
        direction = self.target - self.origin
        if np.allclose(np.cross(direction, value), 0):
            raise ValueError(
                f"While initializing '{attribute.name}': "
                f"up direction must not be colinear with viewing direction, "
                f"got up = {self.up}, direction = {direction}")

    far_clip: pint.Quantity = documented(
        pinttr.ib(
            default=1e4 * ureg.km,
            units=ucc.deferred("length"),
        ),
        doc="Distance to the far clip plane.\n"
        "\n"
        "Unit-enabled field (default: ucc[length]).",
        type="quantity",
        init_type="quantity of float",
        default="10 000 km",
    )

    fov: pint.Quantity = documented(
        pinttr.ib(default=50.0 * ureg.deg, units=ureg.deg),
        doc="Camera field of view.\n\nUnit-enabled field (default: degree).",
        type="quantity",
        init_type="quantity or float",
        default="50°",
    )

    # --------------------------------------------------------------------------
    #                       Kernel dictionary generation
    # --------------------------------------------------------------------------

    def _kernel_dict(self, sensor_id, spp):
        from mitsuba.core import ScalarTransform4f

        target = self.target.m_as(uck.get("length"))
        origin = self.origin.m_as(uck.get("length"))

        result = {
            "type":
            "perspective",
            "id":
            sensor_id,
            "far_clip":
            self.far_clip.m_as(uck.get("length")),
            "fov":
            self.fov.m_as(ureg.deg),
            "to_world":
            ScalarTransform4f.look_at(origin=origin, target=target,
                                      up=self.up),
            "sampler": {
                "type": "independent",
                "sample_count": spp,
            },
            "film": {
                "type": "hdrfilm",
                "width": self.film_resolution[0],
                "height": self.film_resolution[1],
                "pixel_format": "luminance",
                "component_format": "float32",
                "rfilter": {
                    "type": "box"
                },
            },
        }

        return result

    def kernel_dict(self, ctx: KernelDictContext) -> KernelDict:
        sensor_ids = self._sensor_ids()
        sensor_spps = self._sensor_spps()
        result = KernelDict()

        for spp, sensor_id in zip(sensor_spps, sensor_ids):
            if ctx.atmosphere_medium_id is not None:
                result_dict = self._kernel_dict(sensor_id, spp)
                result_dict["medium"] = {
                    "type": "ref",
                    "id": ctx.atmosphere_medium_id,
                }
                result.data[sensor_id] = result_dict
            else:
                result.data[sensor_id] = self._kernel_dict(sensor_id, spp)

        return result

    # --------------------------------------------------------------------------
    #                        Post-processing information
    # --------------------------------------------------------------------------

    @property
    def var(self) -> t.Tuple[str, t.Dict]:
        return "radiance", {
            "standard_name": "radiance",
            "long_name": "radiance",
            "units": symbol(uck.get("radiance")),
        }
Пример #17
0
class LeafCloud(CanopyElement):
    """
    A container class for leaf clouds in abstract discrete canopies.
    Holds parameters completely characterising the leaf cloud's leaves.

    In practice, this class should rarely be instantiated directly using its
    constructor. Instead, several class method constructors are available:

    * generators create leaf clouds from a set of parameters:

      * :meth:`.LeafCloud.cone`;
      * :meth:`.LeafCloud.cuboid`;
      * :meth:`.LeafCloud.cylinder`;
      * :meth:`.LeafCloud.ellipsoid`;
      * :meth:`.LeafCloud.sphere`;

    * :meth:`.LeafCloud.from_file` loads leaf positions and orientations from a
      text file.

    .. admonition:: Class method constructors

       .. autosummary::

          cuboid
          cylinder
          ellipsoid
          from_file
          sphere
    """

    # --------------------------------------------------------------------------
    #                                 Fields
    # --------------------------------------------------------------------------

    id: t.Optional[str] = documented(
        attr.ib(
            default="leaf_cloud",
            validator=attr.validators.optional(attr.validators.instance_of(str)),
        ),
        doc=get_doc(SceneElement, "id", "doc"),
        type=get_doc(SceneElement, "id", "type"),
        init_type=get_doc(SceneElement, "id", "init_type"),
        default="'leaf_cloud'",
    )

    leaf_positions: pint.Quantity = documented(
        pinttr.ib(factory=list, units=ucc.deferred("length")),
        doc="Leaf positions in cartesian coordinates as a (n, 3)-array.\n"
        "\n"
        "Unit-enabled field (default: ucc['length']).",
        type="quantity",
        init_type="array-like",
        default="[]",
    )

    leaf_orientations: np.ndarray = documented(
        attr.ib(factory=list, converter=np.array),
        doc="Leaf orientations (normal vectors) in Cartesian coordinates as a "
        "(n, 3)-array.",
        type="ndarray",
        default="[]",
    )

    leaf_radii: pint.Quantity = documented(
        pinttr.ib(
            factory=list,
            validator=[
                pinttr.validators.has_compatible_units,
                attr.validators.deep_iterable(member_validator=validators.is_positive),
            ],
            units=ucc.deferred("length"),
        ),
        doc="Leaf radii as a n-array.\n\nUnit-enabled field (default: ucc[length]).",
        init_type="array-like",
        type="quantity",
        default="[]",
    )

    @leaf_positions.validator
    @leaf_orientations.validator
    def _positions_orientations_validator(self, attribute, value):
        if not len(value):
            return

        if not value.ndim == 2 or value.shape[1] != 3:
            raise ValueError(
                f"While validating {attribute.name}: shape should be (N, 3), "
                f"got {value.shape}"
            )

    @leaf_positions.validator
    @leaf_orientations.validator
    @leaf_radii.validator
    def _positions_orientations_radii_validator(self, attribute, value):
        if not (
            len(self.leaf_positions)
            == len(self.leaf_orientations)
            == len(self.leaf_radii)
        ):
            raise ValueError(
                f"While validating {attribute.name}: "
                f"leaf_positions, leaf_orientations and leaf_radii must have the "
                f"same length. Got "
                f"len(leaf_positions) = {len(self.leaf_positions)}, "
                f"len(leaf_orientations) = {len(self.leaf_orientations)}, "
                f"len(leaf_radii) = {len(self.leaf_radii)}."
            )

    leaf_reflectance: Spectrum = documented(
        attr.ib(
            default=0.5,
            converter=spectrum_factory.converter("reflectance"),
            validator=[
                attr.validators.instance_of(Spectrum),
                validators.has_quantity("reflectance"),
            ],
        ),
        doc="Reflectance spectrum of the leaves in the cloud. "
        "Must be a reflectance spectrum (dimensionless).",
        type=":class:`.Spectrum`",
        init_type=":class:`.Spectrum` or dict",
        default="0.5",
    )

    leaf_transmittance: Spectrum = documented(
        attr.ib(
            default=0.5,
            converter=spectrum_factory.converter("transmittance"),
            validator=[
                attr.validators.instance_of(Spectrum),
                validators.has_quantity("transmittance"),
            ],
        ),
        doc="Transmittance spectrum of the leaves in the cloud. "
        "Must be a transmittance spectrum (dimensionless).",
        type=":class:`.Spectrum`",
        init_type=":class:`.Spectrum` or dict",
        default="0.5",
    )

    # --------------------------------------------------------------------------
    #                          Properties and accessors
    # --------------------------------------------------------------------------

    def n_leaves(self) -> int:
        """
        int : Number of leaves in the leaf cloud.
        """
        return len(self.leaf_positions)

    def surface_area(self) -> pint.Quantity:
        """
        quantity : Total surface area as a :class:`~pint.Quantity`.
        """
        return np.sum(np.pi * self.leaf_radii * self.leaf_radii).squeeze()

    # --------------------------------------------------------------------------
    #                              Constructors
    # --------------------------------------------------------------------------

    @classmethod
    def cuboid(
        cls, seed: int = 12345, avoid_overlap: bool = False, **kwargs
    ) -> LeafCloud:
        """
        Generate a leaf cloud with an axis-aligned cuboid shape (and a square
        footprint on the ground). Parameters are checked by the
        :class:`.CuboidLeafCloudParams` class, which allows for many parameter
        combinations.

        The produced leaf cloud uniformly covers the
        :math:`(x, y, z) \\in \\left[ -\\dfrac{l_h}{2}, + \\dfrac{l_h}{2} \\right] \\times \\left[ -\\dfrac{l_h}{2}, + \\dfrac{l_h}{2} \\right] \\times [0, l_v]`
        region. Leaf orientation is controlled by the ``mu`` and ``nu`` parameters
        of an approximated inverse beta distribution
        :cite:`Ross1991MonteCarloMethods`.

        Finally, extra parameters control the random number generator and a
        basic and conservative leaf collision detection algorithm.

        Parameters
        ----------
        seed : int
            Seed for the random number generator.

        avoid_overlap : bool
            If ``True``, generate leaf positions with strict collision checks to
            avoid overlapping.

        n_attempts : int
            If ``avoid_overlap`` is ``True``, number of attempts made at placing
            a leaf without collision before giving up. Default: 1e5.

        **kwargs
            Keyword arguments interpreted by :class:`.CuboidLeafCloudParams`.

        Returns
        -------
        :class:`.LeafCloud`:
            Generated leaf cloud.

        See Also
        --------
        :class:`.CuboidLeafCloudParams`
        """
        rng = np.random.default_rng(seed=seed)
        n_attempts = kwargs.pop("n_attempts", int(1e5))

        params = CuboidLeafCloudParams(**kwargs)

        if avoid_overlap:
            leaf_positions = _leaf_cloud_positions_cuboid_avoid_overlap(
                params.n_leaves,
                params.l_horizontal,
                params.l_vertical,
                params.leaf_radius,
                n_attempts,
                rng,
            )
        else:
            leaf_positions = _leaf_cloud_positions_cuboid(
                params.n_leaves, params.l_horizontal, params.l_vertical, rng
            )

        leaf_orientations = _leaf_cloud_orientations(
            params.n_leaves, params.mu, params.nu, rng
        )

        leaf_radii = _leaf_cloud_radii(params.n_leaves, params.leaf_radius)

        # Create leaf cloud object
        return cls(
            id=params.id,
            leaf_positions=leaf_positions,
            leaf_orientations=leaf_orientations,
            leaf_radii=leaf_radii,
            leaf_reflectance=params.leaf_reflectance,
            leaf_transmittance=params.leaf_transmittance,
        )

    @classmethod
    def sphere(cls, seed: int = 12345, **kwargs) -> LeafCloud:
        """
        Generate a leaf cloud with spherical shape. Parameters are checked by
        the :class:`.SphereLeafCloudParams` class.

        The produced leaf cloud covers uniformly the :math:`r < \\mathtt{radius}`
        region. Leaf orientation is controlled by the ``mu`` and ``nu`` parameters
        of an approximated inverse beta distribution
        :cite:`Ross1991MonteCarloMethods`.

        An additional parameter controls the random number generator.

        Parameters
        ----------
        seed : int
            Seed for the random number generator.

        **kwargs
            Keyword arguments interpreted by :class:`.SphereLeafCloudParams`.

        Returns
        -------
        :class:`.LeafCloud`
            Generated leaf cloud.

        See Also
        --------
        :class:`.SphereLeafCloudParams`
        """
        rng = np.random.default_rng(seed=seed)
        params = SphereLeafCloudParams(**kwargs)
        leaf_positions = _leaf_cloud_positions_ellipsoid(
            params.n_leaves,
            rng,
            params.radius,
        )
        leaf_orientations = _leaf_cloud_orientations(
            params.n_leaves, params.mu, params.nu, rng
        )
        leaf_radii = _leaf_cloud_radii(params.n_leaves, params.leaf_radius)

        # Create leaf cloud object
        return cls(
            id=params.id,
            leaf_positions=leaf_positions,
            leaf_orientations=leaf_orientations,
            leaf_radii=leaf_radii,
            leaf_reflectance=params.leaf_reflectance,
            leaf_transmittance=params.leaf_transmittance,
        )

    @classmethod
    def ellipsoid(cls, seed: int = 12345, **kwargs) -> LeafCloud:
        """
        Generate a leaf cloud with ellipsoid shape. Parameters are checked by
        the :class:`.EllipsoidLeafCloudParams` class.

        The produced leaf cloud covers uniformly the volume enclosed by
        :math:`\\frac{x^2}{a^2} + \\frac{y^2}{b^2} + \\frac{z^2}{c^2}= 1` .

        Leaf orientation is controlled by the ``mu`` and ``nu`` parameters
        of an approximated inverse beta distribution
        :cite:`Ross1991MonteCarloMethods`.

        An additional parameter controls the random number generator.

        Parameters
        ----------
        seed : int
            Seed for the random number generator.

        **kwargs
            Keyword arguments interpreted by :class:`.EllipsoidLeafCloudParams`.

        Returns
        -------
        :class:`.LeafCloud`
            Generated leaf cloud.

        See Also
        --------
        :class:`.EllipsoidLeafCloudParams`
        """
        rng = np.random.default_rng(seed=seed)
        params = EllipsoidLeafCloudParams(**kwargs)
        leaf_positions = _leaf_cloud_positions_ellipsoid(
            params.n_leaves, rng, params.a, params.b, params.c
        )
        leaf_orientations = _leaf_cloud_orientations(
            params.n_leaves, params.mu, params.nu, rng
        )
        leaf_radii = _leaf_cloud_radii(params.n_leaves, params.leaf_radius)

        # Create leaf cloud object
        return cls(
            id=params.id,
            leaf_positions=leaf_positions,
            leaf_orientations=leaf_orientations,
            leaf_radii=leaf_radii,
            leaf_reflectance=params.leaf_reflectance,
            leaf_transmittance=params.leaf_transmittance,
        )

    @classmethod
    def cylinder(cls, seed: int = 12345, **kwargs) -> LeafCloud:
        """
        Generate a leaf cloud with a cylindrical shape (vertical orientation).
        Parameters are checked by the :class:`.CylinderLeafCloudParams` class.

        The produced leaf cloud covers uniformly the
        :math:`r < \\mathtt{radius}, z \\in [0, l_v]`
        region. Leaf orientation is controlled by the ``mu`` and ``nu`` parameters
        of an approximated inverse beta distribution
        :cite:`Ross1991MonteCarloMethods`.

        An additional parameter controls the random number generator.

        Parameters
        ----------
        seed : int
            Seed for the random number generator.

        **kwargs
            Keyword arguments interpreted by :class:`.CylinderLeafCloudParams`.

        Returns
        -------
        :class:`.LeafCloud`
            Generated leaf cloud.

        See Also
        --------
        :class:`.CylinderLeafCloudParams`
        """
        rng = np.random.default_rng(seed=seed)
        params = CylinderLeafCloudParams(**kwargs)
        leaf_positions = _leaf_cloud_positions_cylinder(
            params.n_leaves, params.radius, params.l_vertical, rng
        )
        leaf_orientations = _leaf_cloud_orientations(
            params.n_leaves, params.mu, params.nu, rng
        )
        leaf_radii = _leaf_cloud_radii(params.n_leaves, params.leaf_radius)

        # Create leaf cloud object
        return cls(
            id=params.id,
            leaf_positions=leaf_positions,
            leaf_orientations=leaf_orientations,
            leaf_radii=leaf_radii,
            leaf_reflectance=params.leaf_reflectance,
            leaf_transmittance=params.leaf_transmittance,
        )

    @classmethod
    def cone(cls, seed: int = 12345, **kwargs) -> LeafCloud:
        """
        Generate a leaf cloud with a right conical shape (vertical orientation).
        Parameters are checked by the :class:`.ConeLeafCloudParams` class.

        The produced leaf cloud covers uniformly the
        :math:`r < \\mathtt{radius} \\cdot \\left( 1 - \\frac{z}{l_v} \\right), z \\in [0, l_v]`
        region. Leaf orientation is controlled by the ``mu`` and ``nu`` parameters
        of an approximated inverse beta distribution
        :cite:`Ross1991MonteCarloMethods`.

        An additional parameter controls the random number generator.

        Parameters
        ----------
        seed : int
            Seed for the random number generator.

        **kwargs
            Keyword arguments interpreted by :class:`.ConeLeafCloudParams`.

        Returns
        -------
        :class:`.LeafCloud`
            Generated leaf cloud.

        See Also
        --------
        :class:`.ConeLeafCloudParams`
        """
        rng = np.random.default_rng(seed=seed)
        params = ConeLeafCloudParams(**kwargs)
        leaf_positions = _leaf_cloud_positions_cone(
            params.n_leaves, params.radius, params.l_vertical, rng
        )
        leaf_orientations = _leaf_cloud_orientations(
            params.n_leaves, params.mu, params.nu, rng
        )

        leaf_radii = _leaf_cloud_radii(params.n_leaves, params.leaf_radius)

        # Create leaf cloud object
        return cls(
            id=params.id,
            leaf_positions=leaf_positions,
            leaf_orientations=leaf_orientations,
            leaf_radii=leaf_radii,
            leaf_reflectance=params.leaf_reflectance,
            leaf_transmittance=params.leaf_transmittance,
        )

    @classmethod
    def from_file(
        cls,
        filename,
        leaf_transmittance: t.Union[float, Spectrum] = 0.5,
        leaf_reflectance: t.Union[float, Spectrum] = 0.5,
        id: str = "leaf_cloud",
    ) -> LeafCloud:
        """
        Construct a :class:`.LeafCloud` from a text file specifying the leaf
        positions and orientations.

        .. admonition:: File format

           Each line defines a single leaf with the following 7 numerical
           parameters separated by one or more spaces:

           * leaf radius;
           * leaf center (x, y and z coordinates);
           * leaf orientation (x, y and z of normal vector).

        .. important::

           Location coordinates are assumed to be given in meters.

        Parameters
        ----------
        filename : path-like
            Path to the text file specifying the leaves in the leaf cloud.
            Can be absolute or relative.

        leaf_reflectance : :class:`.Spectrum` or float
            Reflectance spectrum of the leaves in the cloud. Must be a reflectance
            spectrum (dimensionless). Default: 0.5.

        leaf_transmittance : :class:`.Spectrum` of float
            Transmittance spectrum of the leaves in the cloud. Must be a
            transmittance spectrum (dimensionless). Default: 0.5.

        id : str
            ID of the created :class:`.LeafCloud` instance.

        Returns
        -------
        :class:`.LeafCloud`:
            Generated leaf cloud.

        Raises
        ------
        Raises
        ------
        FileNotFoundError
            If ``filename`` does not point to an existing file.
        """
        if not os.path.isfile(filename):
            raise FileNotFoundError(f"no file at {filename} found.")

        radii_ = []
        positions_ = []
        orientations_ = []
        with open(os.path.abspath(filename), "r") as definition_file:
            for i, line in enumerate(definition_file):
                values = [float(x) for x in line.split()]
                radii_.append(values[0])
                positions_.append(values[1:4])
                orientations_.append(values[4:7])

        radii = np.array(radii_) * ureg.m
        positions = np.array(positions_) * ureg.m
        orientations = np.array(orientations_)

        return cls(
            id=id,
            leaf_positions=positions,
            leaf_orientations=orientations,
            leaf_radii=radii,
            leaf_reflectance=leaf_reflectance,
            leaf_transmittance=leaf_transmittance,
        )

    # --------------------------------------------------------------------------
    #                       Kernel dictionary generation
    # --------------------------------------------------------------------------

    def bsdfs(self, ctx: KernelDictContext) -> t.Dict:
        """
        Return BSDF plugin specifications.

        Parameters
        ----------
        ctx : :class:`.KernelDictContext`
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        dict
            Return a dictionary suitable for merge with a :class:`.KernelDict`
            containing all the BSDFs attached to the shapes in the leaf cloud.
        """
        return {
            f"bsdf_{self.id}": {
                "type": "bilambertian",
                "reflectance": self.leaf_reflectance.kernel_dict(ctx=ctx)["spectrum"],
                "transmittance": self.leaf_transmittance.kernel_dict(ctx=ctx)[
                    "spectrum"
                ],
            }
        }

    def shapes(self, ctx: KernelDictContext) -> t.Dict:
        """
        Return shape plugin specifications.

        Parameters
        ----------
        ctx : :class:`.KernelDictContext`
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        dict
            A dictionary suitable for merge with a :class:`.KernelDict`
            containing all the shapes in the leaf cloud.
        """
        from mitsuba.core import ScalarTransform4f, coordinate_system

        kernel_length = uck.get("length")
        shapes_dict = {}

        if ctx.ref:
            bsdf = {"type": "ref", "id": f"bsdf_{self.id}"}
        else:
            bsdf = self.bsdfs(ctx=ctx)[f"bsdf_{self.id}"]

        for i_leaf, (position, normal, radius) in enumerate(
            zip(
                self.leaf_positions.m_as(kernel_length),
                self.leaf_orientations,
                self.leaf_radii.m_as(kernel_length),
            )
        ):
            _, up = coordinate_system(normal)
            to_world = ScalarTransform4f.look_at(
                origin=position, target=position + normal, up=up
            ) * ScalarTransform4f.scale(radius)

            shapes_dict[f"{self.id}_leaf_{i_leaf}"] = {
                "type": "disk",
                "bsdf": bsdf,
                "to_world": to_world,
            }

        return shapes_dict

    # --------------------------------------------------------------------------
    #                               Other methods
    # --------------------------------------------------------------------------

    def translated(self, xyz: pint.Quantity) -> LeafCloud:
        """
        Return a copy of self translated by the vector ``xyz``.

        Parameters
        ----------
        xyz : :class:`pint.Quantity`
            A 3-vector or a (N, 3)-array by which leaves will be translated. If
            (N, 3) variant is used, the array shape must match that of
            ``leaf_positions``.

        Returns
        -------
        :class:`LeafCloud`
            Translated copy of self.

        Raises
        ------
        ValueError
            Sizes of ``xyz`` and ``self.leaf_positions`` are incompatible.
        """
        if xyz.ndim <= 1:
            xyz = xyz.reshape((1, 3))
        elif xyz.shape != self.leaf_positions.shape:
            raise ValueError(
                f"shapes xyz {xyz.shape} and self.leaf_positions "
                f"{self.leaf_positions.shape} do not match"
            )

        return attr.evolve(self, leaf_positions=self.leaf_positions + xyz)
Пример #18
0
class LeafCloudParams:
    """
    Base class to implement advanced parameter checking for :class:`.LeafCloud`
    generators.
    """

    _id = documented(
        attr.ib(default="leaf_cloud"),
        doc="Leaf cloud identifier.",
        type="str",
        default='"leaf_cloud"',
    )

    _leaf_reflectance = documented(
        attr.ib(default=0.5), doc="Leaf reflectance.", type="float", default="0.5"
    )

    _leaf_transmittance = documented(
        attr.ib(default=0.5), doc="Leaf transmittance.", type="float", default="0.5"
    )

    _mu = documented(
        attr.ib(default=1.066),
        doc="First parameter of the inverse beta distribution approximation used "
        "to generate leaf orientations.",
        type="float",
        default="1.066",
    )

    _nu = documented(
        attr.ib(default=1.853),
        doc="Second parameter of the inverse beta distribution approximation used "
        "to generate leaf orientations.",
        type="float",
        default="1.853",
    )

    _n_leaves = documented(attr.ib(default=None), doc="Number of leaves.", type="int")

    _leaf_radius = documented(
        pinttr.ib(default=None, units=ucc.deferred("length")),
        doc="Leaf radius.\n\nUnit-enabled field (default: ucc['length']).",
        type="float",
    )

    def update(self):
        try:
            for field in [x.name.lstrip("_") for x in self.__attrs_attrs__]:
                self.__getattribute__(field)
        except Exception as e:
            raise Exception(
                f"cannot compute field '{field}', parameter set is likely under-constrained"
            ) from e

    def __attrs_post_init__(self):
        self.update()

    @property
    def id(self):
        return self._id

    @property
    def leaf_reflectance(self):
        return self._leaf_reflectance

    @property
    def leaf_transmittance(self):
        return self._leaf_transmittance

    @property
    def nu(self):
        return self._nu

    @property
    def mu(self):
        return self._mu

    @property
    def n_leaves(self):
        return self._n_leaves

    @property
    def leaf_radius(self):
        return self._leaf_radius
Пример #19
0
class HomogeneousAtmosphere(Atmosphere):
    """
    Homogeneous atmosphere scene element [``homogeneous``].

    This class builds an atmosphere consisting of a homogeneous medium with
    customisable collision coefficients and phase function, attached to a
    cuboid shape.
    """

    _bottom: pint.Quantity = documented(
        pinttr.ib(
            default=ureg.Quantity(0.0, ureg.km),
            units=ucc.deferred("length"),
        ),
        doc="Atmosphere's bottom altitude.\n\nUnit-enabled field (default: ucc[length])",
        type="quantity",
        init_type="quantity or float",
        default="0 km",
    )

    _top: pint.Quantity = documented(
        pinttr.ib(
            default=ureg.Quantity(10.0, ureg.km),
            units=ucc.deferred("length"),
        ),
        doc="Atmosphere's top altitude.\n\nUnit-enabled field (default: ucc[length]).",
        type="quantity",
        init_type="quantity or float",
        default="10 km.",
    )

    @_bottom.validator
    @_top.validator
    def _validate_bottom_and_top(instance, attribute, value):
        if instance.bottom >= instance.top:
            raise ValueError("bottom altitude must be lower than top altitude")

    sigma_s: Spectrum = documented(
        attr.ib(
            factory=AirScatteringCoefficientSpectrum,
            converter=spectrum_factory.converter("collision_coefficient"),
            validator=[
                attr.validators.instance_of(Spectrum),
                has_quantity("collision_coefficient"),
            ],
        ),
        doc="Atmosphere scattering coefficient value.\n"
        "\n"
        "Can be initialised with a dictionary processed by "
        ":data:`~eradiate.scenes.spectra.spectrum_factory`.",
        type=":class:`~eradiate.scenes.spectra.Spectrum` or float",
        default=":class:`AirScatteringCoefficient() <.AirScatteringCoefficient>`",
    )

    sigma_a: Spectrum = documented(
        attr.ib(
            default=0.0,
            converter=spectrum_factory.converter("collision_coefficient"),
            validator=[
                attr.validators.instance_of(Spectrum),
                has_quantity("collision_coefficient"),
            ],
        ),
        doc="Atmosphere absorption coefficient value. Defaults disable "
        "absorption.\n"
        "\n"
        "Can be initialised with a dictionary processed by "
        ":data:`~eradiate.scenes.spectra.spectrum_factory`.",
        type=":class:`~eradiate.scenes.spectra.Spectrum`",
        default="0.0 ucc[collision_coefficient]",
    )

    phase: PhaseFunction = documented(
        attr.ib(
            factory=lambda: RayleighPhaseFunction(),
            converter=phase_function_factory.convert,
            validator=attr.validators.instance_of(PhaseFunction),
        )
    )

    def __attrs_post_init__(self) -> None:
        self.update()

    def update(self) -> None:
        self.phase.id = self.id_phase

    # --------------------------------------------------------------------------
    #                               Properties
    # --------------------------------------------------------------------------

    @property
    def bottom(self) -> pint.Quantity:
        return self._bottom

    @property
    def top(self) -> pint.Quantity:
        return self._top

    # --------------------------------------------------------------------------
    #                           Evaluation methods
    # --------------------------------------------------------------------------

    def eval_width(self, ctx: KernelDictContext) -> pint.Quantity:
        if self.width is AUTO:
            spectral_ctx = ctx.spectral_ctx
            return 10.0 / self.eval_sigma_s(spectral_ctx)
        else:
            return self.width

    def eval_albedo(self, spectral_ctx: SpectralContext) -> pint.Quantity:
        """
        Return albedo.

        Parameters
        ----------
        spectral_ctx : :class:`.SpectralContext`
            A spectral context data structure containing relevant spectral
            parameters (*e.g.* wavelength in monochromatic mode).

        Returns
        -------
        quantity
            Albedo.
        """
        return self.eval_sigma_s(spectral_ctx) / (
            self.eval_sigma_s(spectral_ctx) + self.eval_sigma_a(spectral_ctx)
        )

    def eval_sigma_a(self, spectral_ctx: SpectralContext) -> pint.Quantity:
        """
        Return absorption coefficient.

        Parameters
        ----------
        spectral_ctx : :class:`.SpectralContext`
            A spectral context data structure containing relevant spectral
            parameters (*e.g.* wavelength in monochromatic mode).

        Returns
        -------
        quantity
            Absorption coefficient.
        """
        return self.sigma_a.eval(spectral_ctx)

    def eval_sigma_s(self, spectral_ctx: SpectralContext) -> pint.Quantity:
        """
        Return scattering coefficient.

        Parameters
        ----------
        spectral_ctx : :class:`.SpectralContext`
            A spectral context data structure containing relevant spectral
            parameters (*e.g.* wavelength in monochromatic mode).

        Returns
        -------
        quantity
            Scattering coefficient.
        """
        return self.sigma_s.eval(spectral_ctx)

    def eval_sigma_t(self, spectral_ctx: SpectralContext) -> pint.Quantity:
        """
        Return extinction coefficient.

        Parameters
        ----------
        spectral_ctx : :class:`.SpectralContext`
            A spectral context data structure containing relevant spectral
            parameters (*e.g.* wavelength in monochromatic mode).

        Returns
        -------
        quantity
            Extinction coefficient.
        """
        return self.eval_sigma_a(spectral_ctx) + self.eval_sigma_s(spectral_ctx)

    # --------------------------------------------------------------------------
    #                       Kernel dictionary generation
    # --------------------------------------------------------------------------

    def kernel_phase(self, ctx: KernelDictContext) -> KernelDict:
        return self.phase.kernel_dict(ctx=ctx)

    def kernel_media(self, ctx: KernelDictContext) -> KernelDict:
        if ctx.ref:
            phase = {"type": "ref", "id": self.phase.id}
        else:
            phase = onedict_value(self.kernel_phase(ctx=ctx))

        return KernelDict(
            {
                self.id_medium: {
                    "type": "homogeneous",
                    "phase": phase,
                    "sigma_t": self.eval_sigma_t(ctx.spectral_ctx).m_as(
                        uck.get("collision_coefficient")
                    ),
                    "albedo": self.eval_albedo(ctx.spectral_ctx).m_as(
                        uck.get("albedo")
                    ),
                }
            }
        )

    def kernel_shapes(self, ctx: KernelDictContext) -> KernelDict:
        if ctx.ref:
            medium = {"type": "ref", "id": self.id_medium}
        else:
            medium = self.kernel_media(ctx=ctx)[self.id_medium]

        length_units = uck.get("length")
        width = self.kernel_width(ctx=ctx).m_as(length_units)
        top = self.top.m_as(length_units)
        bottom = self.bottom.m_as(length_units)
        offset = self.kernel_offset(ctx=ctx).m_as(length_units)
        trafo = map_cube(
            xmin=-width / 2.0,
            xmax=width / 2.0,
            ymin=-width / 2.0,
            ymax=width / 2.0,
            zmin=bottom - offset,
            zmax=top,
        )

        return KernelDict(
            {
                f"shape_{self.id}": {
                    "type": "cube",
                    "to_world": trafo,
                    "bsdf": {"type": "null"},
                    "interior": medium,
                }
            }
        )
Пример #20
0
class MultiDistantMeasure(Measure):
    """
    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::

          from_viewing_angles

    Notes
    -----
    * Setting the ``target`` parameter is required to get meaningful results.
      Experiment classes should take care of setting it appropriately.
    """

    # --------------------------------------------------------------------------
    #                           Fields and properties
    # --------------------------------------------------------------------------

    hplane: t.Optional[pint.Quantity] = documented(
        pinttr.ib(default=None, units=ucc.deferred("angle")),
        doc=
        "If all directions are expected to be within a hemisphere plane cut, "
        "the azimuth value of that plane. Unitless values are converted to "
        "``ucc['angle']``.",
        type="quantity or None",
        init_type="float or quantity, optional",
        default="None",
    )

    @hplane.validator
    def hplane_validator(self, attribute, value):
        if value is None:
            return

        # Check that all specified directions are in the requested plane
        angles = self.viewing_angles.m_as(ureg.deg)
        try:
            angles_in_hplane(
                value.m_as(ureg.deg),
                angles[:, :, 0],
                angles[:, :, 1],
                raise_exc=True,
            )
        except ValueError as e:
            raise ValueError(
                f"while validating '{attribute.name}': 'directions' are not all "
                "part of the same hemisphere plane cut") from e

    directions: np.ndarray = documented(
        attr.ib(
            default=np.array([[0.0, 0.0, -1.0]]),
            converter=np.array,
        ),
        doc="A sequence of 3-vectors specifying distant sensing directions.",
        type="ndarray",
        init_type="array-like",
        default="[[0, 0, -1]]",
    )

    target: t.Optional[Target] = documented(
        attr.ib(
            default=None,
            converter=attr.converters.optional(Target.convert),
            validator=attr.validators.optional(
                attr.validators.instance_of((
                    TargetPoint,
                    TargetRectangle,
                ))),
            on_setattr=attr.setters.pipe(attr.setters.convert,
                                         attr.setters.validate),
        ),
        doc="Target specification. The target can be specified using an "
        "array-like with 3 elements (which will be converted to a "
        ":class:`.TargetPoint`) or a dictionary interpreted by "
        ":meth:`Target.convert() <.Target.convert>`. If set to "
        "``None`` (not recommended), the default target point selection "
        "method is used: rays will not target a particular region of the "
        "scene.",
        type=":class:`.Target` or None",
        init_type=":class:`.Target` or dict or array-like, optional",
    )

    @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).
        """
        angle_units = ucc.get("angle")
        angles = direction_to_angles(-self.directions).to(angle_units)

        # Snap zero values to avoid close-to-360° azimuths
        angles[:, 1] = np.where(np.isclose(angles[:, 1], 0.0), 0.0, angles[:,
                                                                           1])

        # Normalise azimuth to [0, 2π]
        angles[:, 1] %= 360.0 * ureg.deg

        return angles.reshape((-1, 1, 2))

    @property
    def film_resolution(self) -> t.Tuple[int, int]:
        return (self.directions.shape[0], 1)

    flags: MeasureFlags = documented(
        attr.ib(default=MeasureFlags.DISTANT,
                converter=MeasureFlags,
                init=False),
        doc=get_doc(Measure, "flags", "doc"),
        type=get_doc(Measure, "flags", "type"),
    )

    # --------------------------------------------------------------------------
    #                         Additional constructors
    # --------------------------------------------------------------------------

    @classmethod
    def from_viewing_angles(
        cls,
        zeniths: np.typing.ArrayLike,
        azimuths: np.typing.ArrayLike,
        auto_hplane: bool = True,
        **kwargs,
    ):
        """
        Construct a :class:`.MultiDistantMeasure` using viewing angles instead
        of raw directions.

        Parameters
        ----------
        zeniths : array-like
            List of zenith values (can be a quantity). Scalar values are
            broadcast to the same shape as `azimuths`. Unitless values are
            converted to ``ucc['angle']``.

        azimuths : array-like
            List of azimuth values (can be a quantity). Scalar values are
            broadcast to the same shape as `zeniths`. Unitless values are
            converted to ``ucc['angle']``.

        auto_hplane : bool, optional
            If ``True``, passing a scalar as `azimuths` will automatically set
            the measure's `hplane` parameter, unless an `hplane` keyword
            argument is also passed.

        **kwargs
            Any keyword argument (except `direction`) to be forwarded to
            :class:`MultiDistantMeasure() <.MultiDistantMeasure>`. The `hplane`
            keyword argument takes precedence over `auto_hplane`.

        Returns
        -------
        MultiDistantMeasure
        """
        if "directions" in kwargs:
            raise TypeError(
                "from_viewing_angles() got an unexpected keyword argument 'directions'"
            )

        angle_units = ucc.get("angle")

        # Basic unit conversion and array reshaping
        zeniths = pinttr.util.ensure_units(
            np.atleast_1d(zeniths).reshape(
                (-1, 1)), default_units=angle_units).m_as(angle_units)
        azimuths = pinttr.util.ensure_units(
            np.atleast_1d(azimuths).reshape((-1, 1)),
            default_units=angle_units).m_as(angle_units)

        # Broadcast arrays if relevant
        if len(zeniths) == 1:
            zeniths = np.full_like(azimuths, zeniths[0])
        if len(azimuths) == 1:
            azimuths = np.full_like(zeniths, azimuths[0])

            # Auto-set 'hplane' if relevant
            if auto_hplane and "hplane" not in kwargs:
                kwargs["hplane"] = azimuths[0] * angle_units

        # Compute directions
        angles = np.hstack((zeniths, azimuths)) * angle_units
        directions = -angles_to_direction(angles)

        # Create instance
        return cls(directions=directions, **kwargs)

    # --------------------------------------------------------------------------
    #                       Kernel dictionary generation
    # --------------------------------------------------------------------------

    def _kernel_dict(self, sensor_id, spp):
        result = {
            "type": "mdistant",
            "id": sensor_id,
            "directions": ",".join(map(str, self.directions.ravel(order="C"))),
            "sampler": {
                "type": "independent",
                "sample_count": spp,
            },
            "film": {
                "type": "hdrfilm",
                "width": self.film_resolution[0],
                "height": self.film_resolution[1],
                "pixel_format": "luminance",
                "component_format": "float32",
                "rfilter": {
                    "type": "box"
                },
            },
        }

        if self.target is not None:
            result["target"] = self.target.kernel_item()

        return result

    def kernel_dict(self, ctx: KernelDictContext) -> KernelDict:
        sensor_ids = self._sensor_ids()
        sensor_spps = self._sensor_spps()
        result = KernelDict()

        for spp, sensor_id in zip(sensor_spps, sensor_ids):
            result.data[sensor_id] = self._kernel_dict(sensor_id, spp)

        return result

    # --------------------------------------------------------------------------
    #                        Post-processing information
    # --------------------------------------------------------------------------

    @property
    def var(self) -> t.Tuple[str, t.Dict]:
        return "radiance", {
            "standard_name": "radiance",
            "long_name": "radiance",
            "units": symbol(uck.get("radiance")),
        }
Пример #21
0
class Surface(SceneElement, ABC):
    """
    An abstract base class defining common facilities for all surfaces.
    All these surfaces consist of a square parametrised by its width.
    """

    id: t.Optional[str] = documented(
        attr.ib(
            default="surface",
            validator=attr.validators.optional(
                attr.validators.instance_of(str)),
        ),
        doc=get_doc(SceneElement, "id", "doc"),
        type=get_doc(SceneElement, "id", "type"),
        init_type=get_doc(SceneElement, "id", "init_type"),
        default='"surface"',
    )

    altitude: pint.Quantity = documented(
        pinttr.ib(
            default=ureg.Quantity(0.0, "km"),
            units=ucc.deferred("length"),
            validator=[
                validators.is_positive, pinttr.validators.has_compatible_units
            ],
        ),
        doc=
        "Surface geopotential altitude (referenced to Earth's mean sea level).",
        type="quantity",
        init_type="quantity or float",
        default="0.0 km",
    )

    width: t.Union[pint.Quantity, AutoType] = documented(
        pinttr.ib(
            default=AUTO,
            converter=converters.auto_or(
                pinttr.converters.to_units(ucc.deferred("length"))),
            validator=validators.auto_or(
                validators.is_positive,
                pinttr.validators.has_compatible_units),
            units=ucc.deferred("length"),
        ),
        doc="Surface size. During kernel dictionary construction, ``AUTO`` "
        "defaults to 100 km, unless a contextual constraint (*e.g.* to match "
        "the size of an atmosphere or canopy) is applied.\n"
        "\n"
        "Unit-enabled field (default: ucc['length']).",
        type="float or AUTO",
        default="AUTO",
    )

    @abstractmethod
    def bsdfs(self, ctx: KernelDictContext) -> KernelDict:
        """
        Return BSDF plugin specifications only.

        Parameters
        ----------
        ctx : :class:`.KernelDictContext`
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        :class:`.KernelDict`
            A kernel dictionary containing all the BSDFs attached to the
            surface.
        """
        pass

    def shapes(self, ctx: KernelDictContext) -> KernelDict:
        """
        Return shape plugin specifications only.

        Parameters
        ----------
        ctx : :class:`.KernelDictContext`
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        :class:`.KernelDict`
            A kernel dictionary containing all the shapes attached to the
            surface.
        """
        from mitsuba.core import ScalarTransform4f, ScalarVector3f

        if ctx.ref:
            bsdf = {"type": "ref", "id": f"bsdf_{self.id}"}
        else:
            bsdf = self.bsdfs(ctx)[f"bsdf_{self.id}"]

        w = self.kernel_width(ctx).m_as(uck.get("length"))
        z = self.altitude.m_as(uck.get("length"))
        translate_trafo = ScalarTransform4f.translate(
            ScalarVector3f(0.0, 0.0, z))
        scale_trafo = ScalarTransform4f.scale(
            ScalarVector3f(w / 2.0, w / 2.0, 1.0))
        trafo = translate_trafo * scale_trafo

        return KernelDict({
            f"shape_{self.id}": {
                "type": "rectangle",
                "to_world": trafo,
                "bsdf": bsdf,
            }
        })

    def kernel_width(self, ctx: KernelDictContext) -> pint.Quantity:
        """
        Return width of kernel object, possibly overridden by
        ``ctx.override_scene_width``.

        Parameters
        ----------
        ctx : :class:`.KernelDictContext`
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        quantity
            Kernel object width.
        """
        if ctx.override_scene_width is not None:
            return ctx.override_scene_width
        else:
            if self.width is not AUTO:
                return self.width
            else:
                return 100.0 * ureg.km

    def kernel_dict(self, ctx: KernelDictContext) -> KernelDict:
        kernel_dict = {}

        if not ctx.ref:
            kernel_dict[self.id] = self.shapes(ctx)[f"shape_{self.id}"]
        else:
            kernel_dict[f"bsdf_{self.id}"] = self.bsdfs(ctx)[f"bsdf_{self.id}"]
            kernel_dict[self.id] = self.shapes(ctx)[f"shape_{self.id}"]

        return KernelDict(kernel_dict)

    def scaled(self, factor: float) -> Surface:
        """
        Return a copy of self scaled by a given factor.

        Parameters
        ----------
        factor : float
            Scaling factor.

        Returns
        -------
        :class:`.Surface`
            Scaled copy of self.
        """
        if self.width is AUTO:
            warnings.warn(
                ConfigWarning("Surface width set to 'auto', cannot be scaled"))
            new_width = self.width
        else:
            new_width = self.width * factor

        return attr.evolve(self, width=new_width)
Пример #22
0
class Atmosphere(SceneElement, ABC):
    """
    An abstract base class defining common facilities for all atmospheres.

    An atmosphere consists of a kernel medium (with a phase function) attached
    to a kernel shape.

    Notes
    -----
    The only allowed stencil for :class:`.Atmosphere` objects is currently a
    cuboid.
    """

    id: t.Optional[str] = documented(
        attr.ib(
            default="atmosphere",
            validator=attr.validators.optional(
                attr.validators.instance_of(str)),
        ),
        doc=get_doc(SceneElement, "id", "doc"),
        type=get_doc(SceneElement, "id", "type"),
        init_type=get_doc(SceneElement, "id", "init_type"),
        default='"atmosphere"',
    )

    width: pint.Quantity = documented(
        pinttr.ib(
            default=AUTO,
            converter=converters.auto_or(
                pinttr.converters.to_units(ucc.deferred("length"))),
            validator=validators.auto_or(
                pinttr.validators.has_compatible_units,
                validators.is_positive),
            units=ucc.deferred("length"),
        ),
        doc="Atmosphere width. If set to ``AUTO``, a value will be estimated to "
        "ensure that the medium is optically thick. The implementation of "
        "this estimate depends on the concrete class inheriting from this "
        "one.\n"
        "\n"
        "Unit-enabled field (default units: ucc['length']).",
        type="quantity or AUTO",
        init_type="quantity or float, optional",
        default="AUTO",
    )

    # --------------------------------------------------------------------------
    #                               Properties
    # --------------------------------------------------------------------------

    @property
    @abstractmethod
    def bottom(self) -> pint.Quantity:
        """
        pint.Quantity: Atmosphere bottom altitude.
        """
        pass

    @property
    @abstractmethod
    def top(self) -> pint.Quantity:
        """
        pint.Quantity: Atmosphere top altitude.
        """
        pass

    @property
    def height(self) -> pint.Quantity:
        """
        pint.Quantity: Atmosphere height.
        """
        return self.top - self.bottom

    # --------------------------------------------------------------------------
    #                           Evaluation methods
    # --------------------------------------------------------------------------

    @abstractmethod
    def eval_width(self, ctx: KernelDictContext) -> pint.Quantity:
        """
        Return the Atmosphere's width.

        Parameters
        ----------
        ctx : .KernelDictContext
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        width : quantity
            Atmosphere width.
        """
        pass

    def eval_bbox(self, ctx: KernelDictContext) -> BoundingBox:
        """
        Evaluate the bounding box enclosing the atmosphere's volume.

        Parameters
        ----------
        ctx : .KernelDictContext
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        bbox : .BoundingBox
            Calculated bounding box.
        """
        length_units = ucc.get("length")

        k_width = self.eval_width(ctx).m_as(length_units)
        k_bottom = (self.bottom - self.kernel_offset(ctx)).m_as(length_units)
        k_top = self.top.m_as(length_units)

        return BoundingBox(
            [-k_width / 2.0, -k_width / 2.0, k_bottom] * length_units,
            [k_width / 2.0, k_width / 2.0, k_top] * length_units,
        )

    # --------------------------------------------------------------------------
    #                       Kernel dictionary generation
    # --------------------------------------------------------------------------

    @property
    def id_shape(self):
        """
        str: Kernel dictionary key of the atmosphere's shape object.
        """
        return f"shape_{self.id}"

    @property
    def id_medium(self):
        """
        str: Kernel dictionary key of the atmosphere's medium object.
        """
        return f"medium_{self.id}"

    @property
    def id_phase(self):
        """
        str: Kernel dictionary key of the atmosphere's phase function object.
        """
        return f"phase_{self.id}"

    @abstractmethod
    def kernel_phase(self, ctx: KernelDictContext) -> KernelDict:
        """
        Return phase function plugin specifications only.

        Parameters
        ----------
        ctx : .KernelDictContext
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        kernel_dict : .KernelDict
            A kernel dictionary containing all the phase functions attached to
            the atmosphere.
        """
        pass

    @abstractmethod
    def kernel_media(self, ctx: KernelDictContext) -> KernelDict:
        """
        Return medium plugin specifications only.

        Parameters
        ----------
        ctx : .KernelDictContext
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        kernel_dict : .KernelDict
            A kernel dictionary containing all the media attached to the
            atmosphere.
        """
        pass

    @abstractmethod
    def kernel_shapes(self, ctx: KernelDictContext) -> KernelDict:
        """
        Return shape plugin specifications only.

        Parameters
        ----------
        ctx : .KernelDictContext
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        kernel_dict : .KernelDict
            A kernel dictionary containing all the shapes attached to the
            atmosphere.
        """
        pass

    def kernel_height(self, ctx: KernelDictContext) -> pint.Quantity:
        """
        Return the height of the kernel object delimiting the atmosphere.

        Parameters
        ----------
        ctx : .KernelDictContext
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        height : quantity
            Height of the kernel object delimiting the atmosphere
        """
        return self.height + self.kernel_offset(ctx=ctx)

    def kernel_offset(self, ctx: KernelDictContext) -> pint.Quantity:
        """
        Return vertical offset used to position the kernel object delimiting the
        atmosphere. The created cuboid shape will be shifted towards negative
        Z values by this amount.

        Parameters
        ----------
        ctx : .KernelDictContext
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        offset : quantity
            Vertical offset of cuboid shape.

        Notes
        -----
        This offset is required to ensure that the surface is the only shape
        which can be intersected at ground level during ray tracing.
        """
        return self.height * 1e-3

    def kernel_width(self, ctx: KernelDictContext) -> pint.Quantity:
        """
        Return width of the kernel object delimiting the atmosphere.

        Parameters
        ----------
        ctx : .KernelDictContext
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        width : quantity
            Width of the kernel object delimiting the atmosphere.
        """
        return self.eval_width(ctx=ctx)

    def kernel_dict(self, ctx: KernelDictContext) -> KernelDict:
        kernel_dict = KernelDict()

        if ctx.ref:
            kernel_phase = self.kernel_phase(ctx=ctx)
            kernel_dict.data[self.id_phase] = onedict_value(kernel_phase)
            kernel_media = self.kernel_media(ctx=ctx)
            kernel_dict.data[self.id_medium] = onedict_value(kernel_media)

        kernel_shapes = self.kernel_shapes(ctx=ctx)
        kernel_dict.data[self.id] = onedict_value(kernel_shapes)

        return kernel_dict
Пример #23
0
class RadiancemeterMeasure(Measure):
    """
    Radiance meter measure scene element [``radiancemeter``].

    This measure scene element is a thin wrapper around the ``radiancemeter``
    sensor kernel plugin. It records the incident power per unit area per unit
    solid angle along a certain ray.
    """

    # --------------------------------------------------------------------------
    #                           Fields and properties
    # --------------------------------------------------------------------------

    origin: pint.Quantity = documented(
        pinttr.ib(
            default=ureg.Quantity([0.0, 0.0, 0.0], ureg.m),
            validator=[
                validators.has_len(3), pinttr.validators.has_compatible_units
            ],
            units=ucc.deferred("length"),
        ),
        doc="A 3-element vector specifying the position of the radiance meter.\n"
        "\n"
        "Unit-enabled field (default: ucc['length']).",
        type="quantity",
        init_type="array-like",
        default="[0, 0, 0] m",
    )

    target: pint.Quantity = documented(
        pinttr.ib(
            default=ureg.Quantity([0.0, 0.0, 1.0], ureg.m),
            validator=[
                validators.has_len(3), pinttr.validators.has_compatible_units
            ],
            units=ucc.deferred("length"),
        ),
        doc=
        "A 3-element vector specifying the location targeted by the sensor.\n"
        "\n"
        "Unit-enabled field (default: ucc['length']).",
        type="quantity",
        init_type="array-like",
        default="[0, 0, 1] m",
    )

    @target.validator
    @origin.validator
    def _target_origin_validator(self, attribute, value):
        if np.allclose(self.target, self.origin):
            raise ValueError(
                f"While initializing {attribute}: "
                f"Origin and target must not be equal, "
                f"got target = {self.target}, origin = {self.origin}")

    @property
    def film_resolution(self) -> t.Tuple[int, int]:
        return (1, 1)

    # --------------------------------------------------------------------------
    #                       Kernel dictionary generation
    # --------------------------------------------------------------------------

    def _kernel_dict(self, sensor_id, spp):
        target = self.target.m_as(uck.get("length"))
        origin = self.origin.m_as(uck.get("length"))
        direction = target - origin

        result = {
            "type": "radiancemeter",
            "id": sensor_id,
            "origin": origin,
            "direction": direction,
            "sampler": {
                "type": "independent",
                "sample_count": spp,
            },
            "film": {
                "type": "hdrfilm",
                "width": self.film_resolution[0],
                "height": self.film_resolution[1],
                "pixel_format": "luminance",
                "component_format": "float32",
                "rfilter": {
                    "type": "box"
                },
            },
        }

        return result

    def kernel_dict(self, ctx: KernelDictContext) -> KernelDict:
        sensor_ids = self._sensor_ids()
        sensor_spps = self._sensor_spps()
        result = KernelDict()

        for spp, sensor_id in zip(sensor_spps, sensor_ids):
            if ctx.atmosphere_medium_id is not None:
                result_dict = self._kernel_dict(sensor_id, spp)
                result_dict["medium"] = {
                    "type": "ref",
                    "id": ctx.atmosphere_medium_id,
                }
                result.data[sensor_id] = result_dict
            else:
                result.data[sensor_id] = self._kernel_dict(sensor_id, spp)

        return result

    # --------------------------------------------------------------------------
    #                        Post-processing information
    # --------------------------------------------------------------------------

    @property
    def var(self) -> t.Tuple[str, t.Dict]:
        return "radiance", {
            "standard_name": "radiance",
            "long_name": "radiance",
            "units": symbol(uck.get("radiance")),
        }
Пример #24
0
 class MyClass:
     field = pinttr.ib(default=None, units=ugen)
Пример #25
0
class InstancedCanopyElement(SceneElement):
    """
    Instanced canopy element [``instanced``].

    This class wraps a canopy element and defines locations where to position
    instances (*i.e.* clones) of it.

    .. admonition:: Class method constructors

       .. autosummary::

          from_file
    """

    canopy_element: t.Optional[CanopyElement] = documented(
        attr.ib(
            default=None,
            validator=attr.validators.optional(
                attr.validators.instance_of(CanopyElement)),
            converter=biosphere_factory.convert,
        ),
        doc="Instanced canopy element. Can be specified as a dictionary, which "
        "will be converted by :data:`.biosphere_factory`.",
        type=":class:`.CanopyElement`, optional",
    )

    instance_positions: pint.Quantity = documented(
        pinttr.ib(
            factory=list,
            units=ucc.deferred("length"),
        ),
        doc="Instance positions as an (n, 3)-array.\n"
        "\n"
        "Unit-enabled field (default: ucc['length'])",
        type="quantity",
        init_type="array-like",
        default="[]",
    )

    @instance_positions.validator
    def _instance_positions_validator(self, attribute, value):
        if value.shape and value.shape[0] > 0 and value.shape[1] != 3:
            raise ValueError(
                f"while validating {attribute.name}, must be an array of shape "
                f"(n, 3), got {value.shape}")

    # --------------------------------------------------------------------------
    #                               Constructors
    # --------------------------------------------------------------------------

    @classmethod
    def from_file(
        cls,
        filename: os.PathLike,
        canopy_element: t.Optional[CanopyElement] = None,
    ):
        """
        Construct a :class:`.InstancedCanopyElement` from a text file specifying
        instance positions.

        .. admonition:: File format

           Each line defines an instance position as a whitespace-separated
           3-vector of Cartesian coordinates.

        .. important::

           Location coordinates are assumed to be given in meters.

        Parameters
        ----------
        filename : path-like
            Path to the text file specifying the leaves in the canopy.
            Can be absolute or relative.

        canopy_element : :class:`.CanopyElement` or dict, optional
            :class:`.CanopyElement` to be instanced. If a dictionary is passed,
            if is interpreted by :data:`.biosphere_factory`. If set to
            ``None``, an empty leaf cloud will be created.

        Returns
        -------
        :class:`.InstancedCanopyElement`
            Created :class:`.InstancedCanopyElement`.

        Raises
        ------
        ValueError
            If ``filename`` is set to ``None``.

        FileNotFoundError
            If ``filename`` does not point to an existing file.
        """
        if not os.path.isfile(filename):
            raise FileNotFoundError(f"no file at {filename} found.")

        if canopy_element is None:
            canopy_element = {"type": "leaf_cloud"}

        canopy_element = biosphere_factory.convert(canopy_element)

        instance_positions = []

        with open(filename, "r") as f:
            for i_line, line in enumerate(f):
                try:
                    coords = np.array(line.split(), dtype=float)
                except ValueError as e:
                    raise ValueError(
                        f"while reading {filename}, on line {i_line + 1}: "
                        f"cannot convert {line} to a 3-vector!") from e

                if len(coords) != 3:
                    raise ValueError(
                        f"while reading {filename}, on line {i_line + 1}: "
                        f"cannot convert {line} to a 3-vector!")

                instance_positions.append(coords)

        instance_positions = np.array(instance_positions) * ureg.m
        return cls(canopy_element=canopy_element,
                   instance_positions=instance_positions)

    # --------------------------------------------------------------------------
    #                        Kernel dictionary generation
    # --------------------------------------------------------------------------

    def bsdfs(self, ctx: KernelDictContext) -> t.MutableMapping:
        """
        Return BSDF plugin specifications.

        Parameters
        ----------
        ctx : :class:`.KernelDictContext`
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        dict
            Return a dictionary suitable for merge with a :class:`.KernelDict`
            containing all the BSDFs attached to the shapes in the leaf cloud.
        """
        return self.canopy_element.bsdfs(ctx=ctx)

    def shapes(self, ctx: KernelDictContext) -> t.Dict:
        """
        Return shape plugin specifications.

        Parameters
        ----------
        ctx : :class:`.KernelDictContext`
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        dict
            A dictionary suitable for merge with a
            :class:`~eradiate.scenes.core.KernelDict` containing all the shapes
            in the canopy.
        """
        return {
            self.canopy_element.id: {
                "type": "shapegroup",
                **self.canopy_element.shapes(ctx=ctx),
            }
        }

    def instances(self, ctx: KernelDictContext) -> t.Dict:
        """
        Return instance plugin specifications.

        Parameters
        ----------
        ctx : :class:`.KernelDictContext`
            A context data structure containing parameters relevant for kernel
            dictionary generation.

        Returns
        -------
        dict
            A dictionary suitable for merge with a
            :class:`~eradiate.scenes.core.KernelDict` containing instances.
        """
        from mitsuba.core import ScalarTransform4f

        kernel_length = uck.get("length")

        return {
            f"{self.canopy_element.id}_instance_{i}": {
                "type":
                "instance",
                "group": {
                    "type": "ref",
                    "id": self.canopy_element.id
                },
                "to_world":
                ScalarTransform4f.translate(position.m_as(kernel_length)),
            }
            for i, position in enumerate(self.instance_positions)
        }

    def kernel_dict(self, ctx: KernelDictContext) -> KernelDict:
        return KernelDict({
            **self.bsdfs(ctx=ctx),
            **self.shapes(ctx=ctx),
            **self.instances(ctx=ctx),
        })
Пример #26
0
class KernelDictContext(Context):
    """
    Kernel dictionary evaluation context data structure. This class is used
    *e.g.* to store information about the spectral configuration to apply
    when generating kernel dictionaries associated with a :class:`.SceneElement`
    instance.
    """

    spectral_ctx: SpectralContext = documented(
        attr.ib(
            factory=SpectralContext.new,
            converter=SpectralContext.convert,
            validator=attr.validators.instance_of(SpectralContext),
        ),
        doc="Spectral context (used to evaluate quantities with any degree "
        "or kind of dependency vs spectrally varying quantities).",
        type=":class:`.SpectralContext`",
        init_type=":class:`.SpectralContext` or dict",
        default=":meth:`SpectralContext.new() <.SpectralContext.new>`",
    )

    ref: bool = documented(
        attr.ib(default=True, converter=bool),
        doc="If ``True``, use references when relevant during kernel dictionary "
        "generation.",
        type="bool",
        default="True",
    )

    atmosphere_medium_id: t.Optional[str] = documented(
        attr.ib(default=None, converter=attr.converters.optional(str)),
        doc="If relevant, a kernel medium ID to reference by other kernel components.",
        type="str or None",
        init_type="str, optional",
        default="None",
    )

    override_scene_width: t.Optional[pint.Quantity] = documented(
        pinttr.ib(
            default=None,
            units=ucc.deferred("length"),
            on_setattr=None,  # frozen classes can't use on_setattr
        ),
        doc="If relevant, value which must be used as the scene width "
        "(*e.g.* when surface size must match atmosphere or canopy size).\n"
        "\n"
        "Unit-enabled field (default: ucc['length']).",
        type="quantity, optional",
        init_type="quantity or float, optional",
    )

    override_canopy_width: t.Optional[pint.Quantity] = documented(
        pinttr.ib(
            default=None,
            units=ucc.deferred("length"),
            on_setattr=None,  # frozen classes can't use on_setattr
        ),
        doc="If relevant, value which must be used as the canopy width "
        "(*e.g.* when the size of the central patch in a :class:`.CentralPatchSurface` "
        "has to match a padded canopy).\n"
        "\n"
        "Unit-enabled field (default: ucc['length']).",
        type="quantity or None",
        init_type="quantity or float, optional",
    )
Пример #27
0
class ArrayRadProfile(RadProfile):
    """
    A flexible 1D radiative property profile whose level altitudes, albedo
    and extinction coefficient are specified as numpy arrays.
    """

    levels: pint.Quantity = documented(
        pinttr.ib(units=ucc.deferred("length")),
        doc="Level altitudes. **Required, no default**.\n"
        "\n"
        "Unit-enabled field (default: ucc['length']).",
        type="array",
    )

    albedo_values: pint.Quantity = documented(
        pinttr.ib(
            validator=[
                validators.all_positive, pinttr.validators.has_compatible_units
            ],
            units=ureg.dimensionless,
        ),
        doc="An array specifying albedo values. **Required, no default**.\n"
        "\n"
        "Unit-enabled field (dimensionless).",
        type="array",
    )

    sigma_t_values: pint.Quantity = documented(
        pinttr.ib(
            validator=[
                validators.all_positive, pinttr.validators.has_compatible_units
            ],
            units=ucc.deferred("collision_coefficient"),
        ),
        doc="An array specifying extinction coefficient values. **Required, no "
        "default**.\n"
        "\n"
        "Unit-enabled field (default: ucc['collision_coefficient']).",
        type="array",
    )

    @albedo_values.validator
    @sigma_t_values.validator
    def _validator_values(instance, attribute, value):
        if value.ndim != 1:
            raise ValueError(f"while setting {attribute.name}: "
                             f"must have 1 dimension only "
                             f"(got shape {value.shape})")

        if instance.albedo_values.shape != instance.sigma_t_values.shape:
            raise ValueError(f"while setting {attribute.name}: "
                             f"'albedo_values' and 'sigma_t_values' must have "
                             f"the same length")

    def __attrs_pre_init__(self):
        if not eradiate.mode().has_flags(ModeFlags.ANY_MONO):
            raise UnsupportedModeError(supported="monochromatic")

    def eval_albedo_mono(self, w: pint.Quantity) -> pint.Quantity:
        return self.albedo_values

    def eval_sigma_t_mono(self, w: pint.Quantity) -> pint.Quantity:
        return self.sigma_t_values

    def eval_sigma_a_mono(self, w: pint.Quantity) -> pint.Quantity:
        return self.eval_sigma_t_mono(w) * (1.0 - self.eval_albedo_mono(w))

    def eval_sigma_s_mono(self, w: pint.Quantity) -> pint.Quantity:
        return self.eval_sigma_t_mono(w) * self.eval_albedo_mono(w)

    def eval_dataset_mono(self, w: pint.Quantity) -> xr.Dataset:
        return make_dataset(
            wavelength=w,
            z_level=self.levels,
            sigma_t=self.eval_sigma_t_mono(w),
            albedo=self.eval_albedo_mono(w),
        ).squeeze()

    @classmethod
    def from_dataset(cls, path: t.Union[str, pathlib.Path]) -> ArrayRadProfile:
        with xr.open_dataset(path_resolver.resolve(path)) as ds:
            z_level = to_quantity(ds.z_level)
            albedo = to_quantity(ds.albedo)
            sigma_t = to_quantity(ds.sigma_t)
        return cls(albedo_values=albedo,
                   sigma_t_values=sigma_t,
                   levels=z_level)
Пример #28
0
class TargetRectangle(Target):
    """
    Rectangle target origin specification.

    This class defines an axis-aligned rectangular zone where ray targets will
    be sampled or ray origins will be projected.
    """

    xmin: pint.Quantity = documented(
        pinttr.ib(
            converter=_target_point_rectangle_xyz_converter,
            units=ucc.deferred("length"),
        ),
        doc="Lower bound on the X axis.\n"
        "\n"
        "Unit-enabled field (default: ucc['length']).",
        type="quantity",
        init_type="quantity or float",
    )

    xmax: pint.Quantity = documented(
        pinttr.ib(
            converter=_target_point_rectangle_xyz_converter,
            units=ucc.deferred("length"),
        ),
        doc="Upper bound on the X axis.\n"
        "\n"
        "Unit-enabled field (default: ucc['length']).",
        type="quantity",
        init_type="quantity or float",
    )

    ymin: pint.Quantity = documented(
        pinttr.ib(
            converter=_target_point_rectangle_xyz_converter,
            units=ucc.deferred("length"),
        ),
        doc="Lower bound on the Y axis.\n"
        "\n"
        "Unit-enabled field (default: ucc['length']).",
        type="quantity",
        init_type="quantity or float",
    )

    ymax: pint.Quantity = documented(
        pinttr.ib(
            converter=_target_point_rectangle_xyz_converter,
            units=ucc.deferred("length"),
        ),
        doc="Upper bound on the Y axis.\n"
        "\n"
        "Unit-enabled field (default: ucc['length']).",
        type="quantity",
        init_type="quantity or float",
    )

    z: pint.Quantity = documented(
        pinttr.ib(
            default=0.0,
            converter=_target_point_rectangle_xyz_converter,
            units=ucc.deferred("length"),
        ),
        doc="Altitude of the plane enclosing the rectangle.\n"
        "\n"
        "Unit-enabled field (default: ucc['length']).",
        type="quantity",
        init_type="quantity or float",
        default="0.0",
    )

    @xmin.validator
    @xmax.validator
    @ymin.validator
    @ymax.validator
    @z.validator
    def _xyz_validator(self, attribute, value):
        validators.on_quantity(validators.is_number)(self, attribute, value)

    @xmin.validator
    @xmax.validator
    def _x_validator(self, attribute, value):
        if not self.xmin < self.xmax:
            raise ValueError(f"while validating {attribute.name}: 'xmin' must "
                             f"be lower than 'xmax")

    @ymin.validator
    @ymax.validator
    def _y_validator(self, attribute, value):
        if not self.ymin < self.ymax:
            raise ValueError(f"while validating {attribute.name}: 'ymin' must "
                             f"be lower than 'ymax")

    def kernel_item(self) -> t.Dict:
        """Return kernel item."""
        from mitsuba.core import ScalarTransform4f

        xmin = self.xmin.m_as(uck.get("length"))
        xmax = self.xmax.m_as(uck.get("length"))
        ymin = self.ymin.m_as(uck.get("length"))
        ymax = self.ymax.m_as(uck.get("length"))
        z = self.z.m_as(uck.get("length"))

        dx = xmax - xmin
        dy = ymax - ymin

        to_world = ScalarTransform4f.translate([
            0.5 * dx + xmin, 0.5 * dy + ymin, z
        ]) * ScalarTransform4f.scale([0.5 * dx, 0.5 * dy, 1.0])

        return {"type": "rectangle", "to_world": to_world}
Пример #29
0
class InterpolatedSpectrum(Spectrum):
    """
    Linearly interpolated spectrum. Interpolation uses :func:`numpy.interp`.

    Evaluation is as follows:

    * in ``mono_*`` modes, the spectrum is evaluated at the spectral context
      wavelength;
    * in ``ckd_*`` modes, the spectrum is evaluated as the average value over
      the spectral context bin (the integral is computed using a trapezoid
      rule).
    """

    wavelengths: pint.Quantity = documented(
        pinttr.ib(
            units=ucc.deferred("wavelength"),
            kw_only=True,
        ),
        doc="Wavelengths defining the interpolation grid.",
        type="quantity",
    )

    values: pint.Quantity = documented(
        attr.ib(
            converter=converters.on_quantity(np.atleast_1d),
            kw_only=True,
        ),
        doc=
        "Uniform spectrum value. If a float is passed and ``quantity`` is not "
        "``None``, it is automatically converted to appropriate configuration "
        "default units. If a :class:`~pint.Quantity` is passed and ``quantity`` "
        "is not ``None``, units will be checked for consistency.",
        type="quantity",
        init_type="array-like",
    )

    @values.validator
    def _values_validator(self, attribute, value):
        if isinstance(value, pint.Quantity):
            expected_units = ucc.get(self.quantity)

            if not pinttr.util.units_compatible(expected_units, value.units):
                raise pinttr.exceptions.UnitsError(
                    value.units,
                    expected_units,
                    extra_msg=f"while validating '{attribute.name}', got units "
                    f"'{value.units}' incompatible with quantity {self.quantity} "
                    f"(expected '{expected_units}')",
                )

    @values.validator
    @wavelengths.validator
    def _values_wavelengths_validator(self, attribute, value):
        # Check that attribute is an array
        validators.on_quantity(attr.validators.instance_of(np.ndarray))(
            self, attribute, value)

        # Check size
        if value.ndim > 1:
            f"while validating '{attribute.name}': '{attribute.name}' must be a 1D array"

        if len(value) < 2:
            raise ValueError(
                f"while validating '{attribute.name}': '{attribute.name}' must "
                f"have length >= 2")

        if self.wavelengths.shape != self.values.shape:
            raise ValueError(
                f"while validating '{attribute.name}': 'wavelengths' and 'values' "
                f"must have the same shape, got {self.wavelengths.shape} and "
                f"{self.values.shape}")

    def __attrs_post_init__(self):
        self.update()

    def update(self):
        # Apply appropriate units to values field
        self.values = pinttr.converters.ensure_units(self.values,
                                                     ucc.get(self.quantity))

    def eval_mono(self, w: pint.Quantity) -> pint.Quantity:
        return np.interp(w, self.wavelengths, self.values, left=0.0, right=0.0)

    def eval_ckd(self, *bindexes: Bindex) -> pint.Quantity:
        # Spectrum is averaged over spectral bin
        result = np.zeros((len(bindexes), ))
        wavelength_units = ucc.get("wavelength")
        quantity_units = self.values.units

        for i_bindex, bindex in enumerate(bindexes):
            bin = bindex.bin

            wmin_m = bin.wmin.m_as(wavelength_units)
            wmax_m = bin.wmax.m_as(wavelength_units)

            # -- Collect relevant spectral coordinate values
            w_m = self.wavelengths.m_as(wavelength_units)
            w = (np.hstack((
                [wmin_m],
                w_m[np.where(np.logical_and(wmin_m < w_m, w_m < wmax_m))[0]],
                [wmax_m],
            )) * wavelength_units)

            # -- Evaluate spectrum at wavelengths
            interp = self.eval_mono(w)

            # -- Average spectrum on bin extent
            integral = np.trapz(interp, w)
            result[i_bindex] = (integral / bin.width).m_as(quantity_units)

        return result * quantity_units

    def integral(self, wmin: pint.Quantity,
                 wmax: pint.Quantity) -> pint.Quantity:
        # Collect spectral coordinates
        wavelength_units = self.wavelengths.units
        s_w = self.wavelengths.m
        s_wmin = s_w.min()
        s_wmax = s_w.max()

        # Select all spectral mesh vertices between wmin and wmax, as well as
        # the bounds themselves
        wmin = wmin.m_as(wavelength_units)
        wmax = wmax.m_as(wavelength_units)
        w = [
            wmin, *s_w[np.where(np.logical_and(wmin < s_w, s_w < wmax))[0]],
            wmax
        ]

        # Make explicit the fact that the function underlying this spectrum has
        # a finite support by padding the s_wmin and s_wmax values with a very
        # small margin
        eps = 1e-12  # nm

        try:
            w.insert(w.index(s_wmin), s_wmin - eps)
        except ValueError:
            pass

        try:
            w.insert(w.index(s_wmax) + 1, s_wmax + eps)
        except ValueError:
            pass

        # Evaluate spectrum at wavelengths
        w.sort()
        w = w * wavelength_units
        interp = self.eval_mono(w)

        # Compute integral
        return np.trapz(interp, w)

    def kernel_dict(self, ctx: KernelDictContext) -> KernelDict:
        if eradiate.mode().has_flags(ModeFlags.ANY_MONO | ModeFlags.ANY_CKD):
            return KernelDict({
                "spectrum": {
                    "type":
                    "uniform",
                    "value":
                    float(
                        self.eval(ctx.spectral_ctx).m_as(uck.get(
                            self.quantity))),
                }
            })

        else:
            raise UnsupportedModeError(supported=("monochromatic", "ckd"))
Пример #30
0
class CuboidLeafCloudParams(LeafCloudParams):
    """
    Advanced parameter checking class for the cuboid :class:`.LeafCloud`
    generator. Some of the parameters can be inferred from each other.

    Parameters defined below can be used (without leading underscore) as
    keyword arguments to the :meth:`.LeafCloud.cuboid` class method
    constructor. Parameters without defaults are connected by a dependency
    graph used to compute required parameters (outlined in the figure below).

    The following parameter sets are valid:

    * ``n_leaves``, ``leaf_radius``, ``l_horizontal``, ``l_vertical``;
    * ``lai``, ``leaf_radius``, ``l_horizontal``, ``l_vertical``;
    * ``lai``, ``leaf_radius``, ``l_horizontal``, ``hdo``, ``hvr``;
    * and more!

    .. only:: latex

       .. figure:: ../../../../fig/cuboid_leaf_cloud_params.png

    .. only:: not latex

       .. figure:: ../../../../fig/cuboid_leaf_cloud_params.svg

    Warnings
    --------
    In case of over-specification, no consistency check is
    performed.

    See Also
    --------
    :meth:`.LeafCloud.cuboid`
    """

    _l_horizontal = documented(
        pinttr.ib(default=None, units=ucc.deferred("length")),
        doc="Leaf cloud horizontal extent. *Suggested default: 30 m.*\n"
        "\n"
        "Unit-enabled field (default: ucc['length']).",
        type="float",
    )

    _l_vertical = documented(
        pinttr.ib(default=None, units=ucc.deferred("length")),
        doc="Leaf cloud vertical extent. *Suggested default: 3 m.*\n"
        "\n"
        "Unit-enabled field (default: ucc['length']).",
        type="float",
    )

    _lai = documented(
        pinttr.ib(default=None, units=ureg.dimensionless),
        doc="Leaf cloud leaf area index (LAI). *Physical range: [0, 10]; "
        "suggested default: 3.*\n"
        "\n"
        "Unit-enabled field (default: ucc['dimensionless']).",
        type="float",
    )

    _hdo = documented(
        pinttr.ib(default=None, units=ucc.deferred("length")),
        doc="Mean horizontal distance between leaves.\n"
        "\n"
        "Unit-enabled field (default: ucc['length']).",
        type="float",
    )

    _hvr = documented(
        pinttr.ib(default=None),
        doc="Ratio of mean horizontal leaf distance and vertical leaf cloud extent. "
        "*Suggested default: 0.1.*",
        type="float",
    )

    @property
    def n_leaves(self):
        if self._n_leaves is None:
            self._n_leaves = int(
                self.lai * (self.l_horizontal / self.leaf_radius) ** 2 / np.pi
            )
        return self._n_leaves

    @property
    def lai(self):
        if self._lai is None:
            self._lai = (
                np.pi * (self.leaf_radius / self.l_horizontal) ** 2 * self.n_leaves
            )
        return self._lai

    @property
    def leaf_radius(self):
        if self._leaf_radius is None:
            self._leaf_radius = (
                np.sqrt(self.lai / (self.n_leaves * np.pi)) * self.l_horizontal
            )
        return self._leaf_radius

    @property
    def l_horizontal(self):
        if self._l_horizontal is None:
            self._l_horizontal = (
                np.pi * self.leaf_radius ** 2 * self.n_leaves / self.lai
            )
        return self._l_horizontal

    @property
    def l_vertical(self):
        if self._l_vertical is None:
            self._l_vertical = (
                self.lai * self.hdo ** 3 / (np.pi * self.leaf_radius ** 2 * self.hvr)
            )
        return self._l_vertical

    @property
    def hdo(self):
        return self._hdo

    @property
    def hvr(self):
        return self._hvr

    def __str__(self):
        result = []

        for field in [
            "id",
            "lai",
            "leaf_radius",
            "l_horizontal",
            "l_vertical",
            "n_leaves",
            "leaf_reflectance",
            "leaf_transmittance",
        ]:
            value = self.__getattribute__(field)
            result.append(f"{field}={value.__repr__()}")

        return f"CuboidLeafCloudParams({', '.join(result)})"