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
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, )
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")
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) ]
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}"
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"))
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)
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"], } })
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
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
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
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)
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")), }
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
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")), }
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")), }
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)
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
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, } } )
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")), }
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)
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
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")), }
class MyClass: field = pinttr.ib(default=None, units=ugen)
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), })
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", )
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)
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}
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"))
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)})"