diff --git a/docs/index.rst b/docs/index.rst index 050c1b01e..2d02d42cc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -95,6 +95,13 @@ Activity sbpy/activity/index.rst +Surfaces and Shapes +------------------- + +.. toctree:: :maxdepth: 2 + + sbpy/surfaces + Miscellaneous ------------- diff --git a/docs/sbpy/surfaces.rst b/docs/sbpy/surfaces.rst new file mode 100644 index 000000000..78e390b6f --- /dev/null +++ b/docs/sbpy/surfaces.rst @@ -0,0 +1,172 @@ +Surfaces Module (`sbpy.surfaces`) +================================= + +.. admonition:: warning + + The surface module is being made available on a preview basis. The API is + subject to change. Feedback on the approach used is welcome. + +The ``surfaces`` module describes the interaction of electromagnetic radiation with surfaces. Sbpy uses the :math:`(i, e, \phi)` model (angle of incidence, angle of emittance, and phase angle) to describe how light scatters and emits light. It has a flexible system that can incorporate any surface scattering model that can be described with these three angles. A few built-in surface models are provided. + +.. figure:: ../static/scattering-vectors.svg + :alt: Diagram of surface scattering and emission vectors + + Sbpy's geometric basis for surface scattering and emission: :math:`n` is the surface normal vector, :math:`r_s` is the radial vector to the light source, and :math:`r_o` is the radial vector to the observer. The angle of incidence (:math:`i`), angle of emittance (:math:`e`), phase angle (:math:`\phi`) are labeled. + +A instance of a ``Surface`` will have methods to calculate absorptance, emittance, and reflectance. A radiance method is used to calculate the observed spectral radiance of a surface given incident light. + +Surfaces are expected to require albedo and/or emissivity. Conventions on which property is used and when should be defined by each class. For example, a surface that only calculates reflectance may only require albedo, but one that calculates thermal emission may use the convention of albedo for absorbed sunlight and emissivity for emitted thermal radiation. + + +Built-in surface models +----------------------- + +The model `~sbpy.surfaces.scattered.LambertianSurfaceScatteredSunlight` is used to observe sunlight scattered from a Lambertian surface (light scattered uniformly in all directions). + +Create an instance of the ``LambertianSurfaceScatteredSunlight`` model, and calculate the absorptance, emittance, and reflectance for :math:`(i, e, \phi) = (30^\circ, 60^\circ, 90^\circ)`:: + + >>> import astropy.units as u + >>> from sbpy.surfaces import LambertianSurfaceScatteredSunlight + >>> + >>> surface = LambertianSurfaceScatteredSunlight({"albedo": 0.1}) + >>> + >>> i, e, phi = [30, 60, 90] * u.deg + >>> surface.absorptance(i) # doctest: +FLOAT_CMP + + >>> surface.emittance(e, phi) # doctest: +FLOAT_CMP + + >>> surface.reflectance(i, e, phi) # doctest: +FLOAT_CMP + + + +Radiance of scattered/emitted light +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +All `~sbpy.surfaces.surface.Surface` models have a :meth:`~sbpy.surfaces.surface.Surface.radiance` method that calculates the radiance of scattered/emitted light, given the spectral flux density of incident light at the surface:: + + >>> F_i = 1000 * u.W / u.m**2 / u.um # incident spectral flux density + >>> surface.radiance(F_i, i, e, phi) # doctest: +FLOAT_CMP + + + +Radiance of scattered sunlight +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +``LambertianSurfaceScatteredSunlight`` is derived from the ``ScatteredSunlight`` class, which provides convenience methods for calculating the radiance of sunlight scattered off the surface:: + +.. doctest-requires:: synphot + + >>> wave = 0.55 * u.um + >>> rh = 1 * u.au # heliocentric distance of the surface + >>> surface.scattered_sunlight(wave, rh, i, e, phi) # doctest: +FLOAT_CMP + + +The solar spectrum is configured using Sbpy's calibration system. See :doc:`calib` for details. + + +Radiance from vectors +^^^^^^^^^^^^^^^^^^^^^ + +As an alternative to using :math:`(i, e, \phi)`, radiance may be calculated using vectors that define the normal direction, radial vector of the light source, and radial vector of the observer:: + +.. doctest-requires:: synphot + + >>> # the following vectors are equivalent to (i, e, phi) = (30, 60, 90) deg + >>> n = [1, 0, 0] + >>> rs = [0.866, 0.5, 0] * u.au + >>> ro = [0.5, -0.866, 0] * u.au + >>> + >>> surface.radiance_from_vectors(F_i, n, rs, ro) # doctest: +FLOAT_CMP + + >>> surface.scattered_sunlight_from_vectors(wave, n, rs, ro) # doctest: +FLOAT_CMP + + +Notice that heliocentric distance was not needed in the call to ``scattered_sunlight_from_vectors`` because it was already accounted for by the ``rs`` vector. + + +Building your own surface models +-------------------------------- + +Defining your own surface model is typically done by creating a new class based on `~sbpy.surfaces.surface.Surface`, and defining methods for ``absorptance``, ``emittance``, and ``reflectance``. The `~sbpy.surfaces.lambertian.Lambertian` model serves as a good example. For surface scattering problems, most users will combine their class with the `~sbpy.surfaces.scattered.ScatteredLight` or `~sbpy.surfaces.scattered.ScatteredSunlight` classes, which provide the ``radiance`` method to complete the ``Surface`` model. + +Here, we define a new surface model with surface scattering proportional to :math:`\cos^2` based on the `~sbpy.surfaces.scattered.ScatteredSunlight` class:: + +.. doctest-requires:: synphot + + >>> import numpy as np + >>> from sbpy.surfaces import Surface, ScatteredSunlight + >>> + >>> class Cos2SurfaceScatteredSunlight(ScatteredSunlight): + ... """Absorption and scattering proportional to cos**2.""" + ... + ... def absorptance(self, i): + ... return (1 - self.phys["albedo"]) * np.cos(i)**2 + ... + ... def emittance(self, e, phi): + ... return np.cos(e)**2 + ... + ... def reflectance(self, i, e, phi): + ... return self.phys["albedo"] * np.cos(i)**2 * self.emittance(e, phi) + >>> + >>> surface = Cos2SurfaceScatteredSunlight({"albedo": 0.1}) + >>> surface.reflectance(i, e, phi) # doctest: +FLOAT_CMP + + >>> surface.radiance(F_i, i, e, phi) # doctest: +FLOAT_CMP + + >>> surface.scattered_sunlight(wave, rh, i, e, phi) # doctest: +FLOAT_CMP + + +.. for test runs without synphot +.. testsetup:: + + >>> from sbpy.surfaces import Surface, ScatteredSunlight + +However, if a scattering model will be re-used with other classes, e.g., for scattered light and thermal emission modeling, then the most flexible approach is to base the model on ``Surface`` and have derived classes combine the model with scattering or thermal emission classes:: + + >>> class Cos2Surface(Surface): + ... """Abstract base class for absorption and scattering proportional to cos**2. + ... + ... We document this as an "abstract base class" because the ``radiance`` + ... method required by ``Surface`` is not implemented here, and therefore + ... this class cannot be used directly (i.e., it cannot be instantiated). + ... + ... """ + ... + ... def absorptance(self, i): + ... return (1 - self.phys["albedo"]) * np.cos(i)**2 + ... + ... def emittance(self, e, phi): + ... return np.cos(e)**2 + ... + ... def reflectance(self, i, e, phi): + ... return self.phys["albedo"] * np.cos(i)**2 * self.emittance(e, phi) + >>> + >>> class Cos2SurfaceScatteredSunlightAlt(Cos2Surface, ScatteredSunlight): + ... """Absorption and scattering proportional to cos**2. + ... + ... This class combines the ``absorptance``, ``emittance``, and ``reflectance`` + ... methods from ``Cos2Surface`` with the ``radiance`` and ``scattered_sunlight`` + ... methods of ``ScatteredSunlight``. + ... + ... Parameters + ... ... + ... """ + >>> + >>> class Cos2SurfaceThermalEmission(Cos2Surface, ThermalEmission): # doctest: +SKIP + ... """Absorption and thermal emission proportional to cos**2. + ... + ... This class combines the ``absorptance``, ``emittance``, and ``reflectance`` + ... from ``Cos2Surface`` with the ``radiance`` method of a fictitious + ... ``ThermalEmission`` class. + ... + ... Parameters + ... ... + ... """ + +Thanks to their base classes (``Cos2Surface``, ``ScatteredSunlight``, and ``ThermalEmission``), the ``Cos2SurfaceScatteredSunlightAlt`` and ``Cos2SurfaceThermalEmission`` classes are complete and no additional code is needed, just documentation. + +Reference/API +------------- +.. automodapi:: sbpy.surfaces + :no-heading: + :inherited-members: diff --git a/docs/static/scattering-vectors.svg b/docs/static/scattering-vectors.svg new file mode 100644 index 000000000..0e437bea2 --- /dev/null +++ b/docs/static/scattering-vectors.svg @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + i + e + φ + n + rs + ro + + diff --git a/sbpy/surfaces/__init__.py b/sbpy/surfaces/__init__.py new file mode 100644 index 000000000..2240d50f7 --- /dev/null +++ b/sbpy/surfaces/__init__.py @@ -0,0 +1,7 @@ +from .surface import Surface +from .lambertian import LambertianSurface +from .scattered import ( + ScatteredLight, + ScatteredSunlight, + LambertianSurfaceScatteredSunlight, +) diff --git a/sbpy/surfaces/lambertian.py b/sbpy/surfaces/lambertian.py new file mode 100644 index 000000000..4f11d0359 --- /dev/null +++ b/sbpy/surfaces/lambertian.py @@ -0,0 +1,48 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +import numpy as np +import astropy.units as u + +from .surface import Surface, min_zero_cos +from ..units.typing import SpectralFluxDensityQuantity + + +class LambertianSurface(Surface): + """Lambertian surface absorption, emission, and reflectance.""" + + @staticmethod + @u.quantity_input + def absorption( + F_i: SpectralFluxDensityQuantity, + epsilon: float, + i: u.physical.angle, + ) -> u.Quantity: + # use min_zero_cos(i) to ensure cos(>= 90 deg) = 0 + cos_i = min_zero_cos(i) + return F_i * epsilon * cos_i + + @staticmethod + @u.quantity_input + def emission( + X_e: SpectralFluxDensityQuantity, + epsilon: float, + e: u.physical.angle, + phi: u.physical.angle, + ) -> u.Quantity: + # use min_zero_cos(e) to ensure cos(>= 90 deg) = 0 + cos_e = min_zero_cos(e) + return X_e * epsilon * cos_e / np.pi / u.sr + + @staticmethod + @u.quantity_input + def reflectance( + F_i: SpectralFluxDensityQuantity, + albedo: float, + i: u.physical.angle, + e: u.physical.angle, + phi: u.physical.angle, + ) -> u.Quantity: + # use min_zero_cos(theta) to ensure cos(>= 90 deg) = 0 + cos_i = min_zero_cos(i) + cos_e = min_zero_cos(e) + return F_i * albedo * cos_i * cos_e / np.pi / u.sr diff --git a/sbpy/surfaces/scattered.py b/sbpy/surfaces/scattered.py new file mode 100644 index 000000000..14509d9d6 --- /dev/null +++ b/sbpy/surfaces/scattered.py @@ -0,0 +1,199 @@ +# # Licensed under a 3-clause BSD style license - see LICENSE.rst + +# from abc import ABC, abstractmethod + +# import numpy as np + +# import astropy.units as u + +# from .surface import Surface +# from .lambertian import LambertianSurface +# from ..calib import Sun +# from ..units.typing import SpectralQuantity, SpectralFluxDensityQuantity, UnitLike + + +# class ScatteredLight(ABC): +# """Abstract base class to observe light scattered by a surface.""" + +# @u.quantity_input +# def scattered_light( +# self, +# wave_freq: SpectralQuantity, +# i: u.physical.angle, +# e: u.physical.angle, +# phi: u.physical.angle, +# unit: UnitLike = "W/(m2 sr um)", +# ) -> u.Quantity: +# """Radiance from light scattered by a surface. + + +# Parameters +# ---------- + +# wave_freq : `astropy.units.Quantity` +# Wavelength or frequency at which to evaluate the light source. + +# i : `~astropy.units.Quantity` +# Angle from normal of incident light. + +# e : `~astropy.units.Quantity` +# Angle from normal of emitted light. + +# phi : `~astropy.units.Quantity` +# Source-target-observer (phase) angle. + +# unit : `~astropy.units.Unit`, optional +# Unit of the return value. + + +# Returns +# ------- + +# radiance : `~astropy.units.Quantity` +# Observed radiance. + +# """ + +# @u.quantity_input +# def scattered_light_from_vectors( +# self, +# wave_freq: SpectralQuantity, +# n: np.ndarray, +# rs: u.physical.length, +# ro: u.physical.length, +# unit: UnitLike = "W/(m2 sr um)", +# ) -> u.Quantity: +# """Observed light reflected from a surface. + + +# Parameters +# ---------- + +# wave_freq : `astropy.units.Quantity` +# Wavelength or frequency at which to evaluate the light source. + +# n : `numpy.ndarray` +# Surface normal vector. + +# rs : `~astropy.units.Quantity` +# Radial vector from the surface to the light source. + +# ro : `~astropy.units.Quantity` +# Radial vector from the surface to the observer. + +# unit : `~astropy.units.Unit`, optional +# Unit of the return value. + + +# Returns +# ------- + +# radiance : `~astropy.units.Quantity` +# Observed radiance. + +# """ + + +# class ScatteredSunlight(ScatteredLight): +# """Observe sunlight scattered by a surface.""" + +# @u.quantity_input +# def scattered_light( +# self, +# wave_freq: SpectralQuantity, +# i: u.physical.angle, +# e: u.physical.angle, +# phi: u.physical.angle, +# rh: u.physical.length = 1 * u.au, +# unit: UnitLike = "W/(m2 sr um)", +# ) -> u.Quantity: +# """Radiance from sunlight scattered by a surface. + + +# Parameters +# ---------- + +# wave_freq : `astropy.units.Quantity` +# Wavelength or frequency at which to evaluate the Sun. Arrays are +# evaluated with `sbpy.calib.core.Sun.observe()`. + +# i : `~astropy.units.Quantity` +# Angle from normal of incident light. + +# e : `~astropy.units.Quantity` +# Angle from normal of emitted light. + +# phi : `~astropy.units.Quantity` +# Source-target-observer (phase) angle. + +# rh : `~astropy.units.Quantity` +# Heliocentric distance, default = 1 au. + +# unit : `~astropy.units.Unit`, optional +# Unit of the return value. + + +# Returns +# ------- +# radiance : `~astropy.units.Quantity` +# Observed radiance. + +# """ + +# sun = Sun.from_default() +# flux_density_unit = u.Unit(unit) * u.sr +# if wave_freq.size == 1: +# F_i = sun(wave_freq, unit=flux_density_unit) +# else: +# F_i = sun.observe(wave_freq, unit=flux_density_unit) + +# F_i /= rh.to_value("au") ** 2 +# return self.reflectance(F_i, i, e, phi).to(unit) + +# @u.quantity_input +# def scattered_light_from_vectors( +# self, +# wave_freq: SpectralQuantity, +# n: np.ndarray, +# rs: u.physical.length, +# ro: u.physical.length, +# unit: UnitLike = "W/(m2 sr um)", +# ) -> u.Quantity: +# """Observed sunlight reflected from a surface. + + +# Parameters +# ---------- + +# wave_freq : `astropy.units.Quantity` +# Wavelength or frequency at which to evaluate the Sun. Arrays are +# evaluated with `sbpy.calib.core.Sun.observe()`. + +# n : `numpy.ndarray` +# Surface normal vector. + +# rs : `~astropy.units.Quantity` +# Radial vector from the surface to the light source. + +# ro : `~astropy.units.Quantity` +# Radial vector from the surface to the observer. + +# unit : `~astropy.units.Unit`, optional +# Unit of the return value. + + +# Returns +# ------- + +# radiance : `~astropy.units.Quantity` +# Observed radiance. + +# """ + +# rh = np.linalg.norm(rs).to("au") +# i, e, phi = self._vectors_to_angles(n, rs, ro) +# return self.scattered_sunlight(wave_freq, i, e, phi, rh=rh, unit=unit) + + +# class LambertianSurfaceScatteredSunlight(LambertianSurface, ScatteredSunlight): +# """Sunlight scattered from a Lambertian surface.""" diff --git a/sbpy/surfaces/surface.py b/sbpy/surfaces/surface.py new file mode 100644 index 000000000..3fa74049e --- /dev/null +++ b/sbpy/surfaces/surface.py @@ -0,0 +1,193 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +from abc import ABC, abstractmethod + +import numpy as np +from astropy import units as u + +from ..data.phys import Phys +from ..data.decorators import dataclass_input +from ..units.typing import SpectralFluxDensityQuantity + + +def min_zero_cos(a: u.physical.angle) -> u.Quantity: + """Use to ensure that cos(>=90 deg) equals 0.""" + + # handle scalars separately + if a.ndim == 0 and u.isclose(np.abs(a), 90 * u.deg): + return u.Quantity(0) + + x = np.cos(a) + x[u.isclose(np.abs(a), 90 * u.deg)] = 0 + + return np.maximum(x, 0) + + +class Surface(ABC): + """Abstract base class for all small-body surfaces.""" + + @staticmethod + @abstractmethod + def absorption( + F_i: SpectralFluxDensityQuantity, + epsilon: float, + i: u.physical.angle, + ) -> u.Quantity: + r"""Absorption of directional, incident light. + + The surface is illuminated by incident flux density, :math:`F_i`, at an + angle of :math:`i`, measured from the surface normal direction. + + + Parameters + ---------- + + F_i : `astropy.units.Quantity` + Incident light (spectral flux density / spectral irradiance). + + epsilon : float + Emissivity of the surface. + + i : `~astropy.units.Quantity` + Angle from normal of incident light. + + + Returns + ------- + F_a : `~astropy.units.Quantity` + Absorbed spectral flux density. + + """ + + @staticmethod + @abstractmethod + def emission( + X_e: SpectralFluxDensityQuantity, + epsilon: float, + e: u.physical.angle, + phi: u.physical.angle, + ) -> u.Quantity: + r"""Emission of light from a surface, as seen by a distant observer. + + The surface is observed at an angle of :math:`e`, measured from the + surface normal direction, and at a solar phase angle of :math:`phi`. + + + Parameters + ---------- + X_e : `astropy.units.Quantity` + Emitted spectral radiance. + + epsilon : float + Emissivity of the surface. + + e : `~astropy.units.Quantity` + Observed angle from normal. + + phi : `~astropy.units.Quantity` + Source-target-observer (phase) angle. + + + Returns + ------- + F_e : `~astropy.units.Quantity` + Spectral flux density / spectral irradiance received by the + observer. + + """ + + @staticmethod + @abstractmethod + def reflectance( + F_i: SpectralFluxDensityQuantity, + albedo: float, + i: u.physical.angle, + e: u.physical.angle, + phi: u.physical.angle, + ) -> u.Quantity: + r"""Bidirectional reflectance. + + The surface is illuminated by incident flux density (irradiance), + :math:`F_i`, at an angle of :math:`i`, and emitted toward an angle of + :math:`e`, measured from the surface normal direction. :math:`\phi` is + the source-target-observer (phase) angle. Both the source and the + emitted light are assumed to be collimated. + + + Parameters + ---------- + F_i : `astropy.units.Quantity` + Incident light (spectral flux density). + + albedo : float + Surface albedo. + + i : `~astropy.units.Quantity` + Angle from normal of incident light. + + e : `~astropy.units.Quantity` + Angle from normal of emitted light. + + phi : `~astropy.units.Quantity` + Source-target-observer (phase) angle. + + + Returns + ------- + F_r : `~astropy.units.Quantity` + Spectral flux density / spectral irradiance received by the observer. + + """ + + @staticmethod + def _vectors_to_angles( + n: np.ndarray, + rs: u.physical.length, + ro: np.ndarray, + ) -> tuple: + n_hat = n / np.linalg.norm(n) + rs_hat = rs / np.linalg.norm(rs) + ro_hat = ro / np.linalg.norm(ro) + + i = u.Quantity(np.arccos(np.dot(n_hat, rs_hat)), "rad") + e = u.Quantity(np.arccos(np.dot(n_hat, ro_hat)), "rad") + phi = u.Quantity(np.arccos(np.dot(rs_hat, ro_hat)), "rad") + + return i, e, phi + + @u.quantity_input + def reflectance_from_vectors( + self, + F_i: SpectralFluxDensityQuantity, + n: np.ndarray, + rs: u.physical.length, + ro: np.ndarray, + ) -> u.Quantity: + """Vector based alternative to reflectance(). + + Input vectors do not need to be normalized. + + + Parameters + ---------- + F_i : `astropy.units.Quantity` + Incident light (spectral flux density). + + n : `numpy.ndarray` + Surface normal vector. + + rs : `~astropy.units.Quantity` + Radial vector from the surface to the light source. + + ro : `numpy.ndarray` + Radial vector from the surface to the observer. + + + Returns + ------- + F_r : `~astropy.units.Quantity` + Spectral flux density / spectral irradiance received by the observer. + + """ + + return self.reflectance(F_i, *self._vectors_to_angles(n, rs, ro)) diff --git a/sbpy/surfaces/tests/__init__.py b/sbpy/surfaces/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/sbpy/surfaces/tests/test_lambertian.py b/sbpy/surfaces/tests/test_lambertian.py new file mode 100644 index 000000000..323bf4767 --- /dev/null +++ b/sbpy/surfaces/tests/test_lambertian.py @@ -0,0 +1,51 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +import pytest + +import numpy as np +from astropy.coordinates import Angle +from astropy import units as u + +from ..lambertian import LambertianSurface + + +def test_absorption(): + F_i = 1 * u.Jy + epsilon = 0.9 + i = Angle([0, 30, 45, 60, 90, 100], "deg") + expected = 0.9 * np.array([1, np.sqrt(3) / 2, 1 / np.sqrt(2), 0.5, 0, 0]) * u.Jy + result = LambertianSurface.absorption(F_i, epsilon, i) + assert u.allclose(result, expected) + + +def test_emission(): + X_e = 1 * u.Jy + epsilon = 0.9 + e = Angle([0, 30, 45, 60, 90, 100], "deg") + expected = ( + 0.9 + * np.array([1, np.sqrt(3) / 2, 1 / np.sqrt(2), 0.5, 0, 0]) + / np.pi + * u.Jy + / u.sr + ) + phi = np.random.rand(len(e)) * 360 * u.deg # independent of phi + result = LambertianSurface.emission(X_e, epsilon, e, phi) + assert u.allclose(result, expected) + + +def test_reflectance(): + F_i = 1 * u.Jy + albedo = 0.1 + i = Angle([0, 30, 45, 60, 90, 0, 90, 100] * u.deg) + e = Angle([0, 60, 45, 30, 0, 90, 90, 0] * u.deg) + phi = np.random.rand(len(i)) * 360 * u.deg # independent of phi + result = LambertianSurface.reflectance(F_i, albedo, i, e, phi) + expected = ( + 0.1 + * np.array([1, np.sqrt(3) / 4, 1 / 2, np.sqrt(3) / 4, 0, 0, 0, 0]) + / np.pi + * u.Jy + / u.sr + ) + assert u.allclose(result, expected) diff --git a/sbpy/surfaces/tests/test_scattered.py b/sbpy/surfaces/tests/test_scattered.py new file mode 100644 index 000000000..7824b4653 --- /dev/null +++ b/sbpy/surfaces/tests/test_scattered.py @@ -0,0 +1,50 @@ +# # Licensed under a 3-clause BSD style license - see LICENSE.rst + +# import pytest +# import numpy as np +# from astropy import units as u + +# from ...calib import Sun, solar_spectrum +# from ..scattered import LambertianSurfaceScatteredSunlight + + +# def test_scattered_light(): + +# surface = LambertianSurfaceScatteredSunlight({"albedo": 0.1}) + +# F_i = 1 * u.Unit("W/(m2 um)") +# n = np.array([1, 0, 0]) +# rs = [1, 1, 0] * u.au +# ro = [1, -1, 0] * u.au + +# # albedo * F_i / rh**2 * cos(45)**2 +# # 0.1 * 1 / 2 +# expected = 0.05 * u.W / (u.m**2 * u.um * u.sr) +# result = surface.radiance_from_vectors(F_i, n, rs, ro) +# assert u.isclose(result, expected) + + +# def test_scattered_sunlight(): +# pytest.importorskip("synphot") + +# surface = LambertianSurfaceScatteredSunlight({"albedo": 0.1}) + +# # fake an easy solar spectrum for testing +# wave = [0.5, 0.55, 0.6] * u.um +# spec = [0.5, 1.0, 1.5] * u.W / (u.m**2 * u.um) +# with solar_spectrum.set(Sun.from_array(wave, spec)): +# n = np.array([1, 0, 0]) +# rs = [1, 1, 0] * u.au +# ro = [1, -1, 0] * u.au + +# # albedo * F_i / rh**2 * cos(45)**2 +# # 0.1 * 1 / 2 / 2 +# expected = 0.025 * u.W / (u.m**2 * u.um * u.sr) +# result = surface.scattered_sunlight_from_vectors(0.55 * u.um, n, rs, ro) +# assert u.isclose(result, expected) + +# # again to test branching to Sun.observe +# result = surface.scattered_sunlight_from_vectors( +# (0.549, 0.55, 0.551) * u.um, n, rs, ro +# ) +# assert u.allclose(result[1], expected, rtol=0.01) diff --git a/sbpy/surfaces/tests/test_surface.py b/sbpy/surfaces/tests/test_surface.py new file mode 100644 index 000000000..3a9bff6ed --- /dev/null +++ b/sbpy/surfaces/tests/test_surface.py @@ -0,0 +1,83 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +import numpy as np +from astropy.coordinates import Angle +from astropy import units as u + +from ..surface import Surface + + +class TestingSurface(Surface): + def absorptance(self, i): # pragma: no cover + pass + + def emittance(self, e, phi): # pragma: no cover + pass + + def reflectance(self, i: Angle, e: Angle, phi: Angle) -> u.Quantity: + return np.cos(i) * np.cos(e) * np.sin(phi) + + def radiance(self, F_i, i, e, phi): + return F_i * self.reflectance(i, e, phi) / u.sr + + +def test_min_zero_cos(): + a = Angle([-91, -90, 0, 30, 45, 60, 90, 91], "deg") + result = Surface._min_zero_cos(a) + expected = [0, 0, 1, np.sqrt(3) / 2, 1 / np.sqrt(2), 0.5, 0, 0] + assert np.allclose(result, expected) + + # test scalars + for i in range(len(a)): + assert np.isclose(Surface._min_zero_cos(a[i]), expected[i]) + + +def test_radiance(): + surface = TestingSurface({}) + F_i = 1664 * u.W / (u.m**2 * u.um) + result = surface.radiance(F_i, 30 * u.deg, 30 * u.deg, 60 * u.deg) + assert u.isclose(result, F_i * np.sqrt(27) / 8 / u.sr) + + +def test_vectors_to_angles(): + n = [1, 0, 0] + rs = [1, 1, 0] * u.au + ro = [1, -1, 0] * u.au + i, e, phi = Surface._vectors_to_angles(n, rs, ro) + assert u.isclose(i, 45 * u.deg) + assert u.isclose(e, 45 * u.deg) + assert u.isclose(phi, 90 * u.deg) + + n = [1, 0, 0] + rs = [1 / np.sqrt(3), 1, 0] * u.au + ro = [np.sqrt(3), -1, 0] * u.au + i, e, phi = Surface._vectors_to_angles(n, rs, ro) + assert u.isclose(i, 60 * u.deg) + assert u.isclose(e, 30 * u.deg) + assert u.isclose(phi, 90 * u.deg) + + ro = [1 / np.sqrt(3), -1, 0] * u.au + i, e, phi = Surface._vectors_to_angles(n, rs, ro) + assert u.isclose(e, 60 * u.deg) + assert u.isclose(phi, 120 * u.deg) + + +def test_radiance_from_vectors(): + F_i = 1664 * u.W / (u.m**2 * u.um) + surface = TestingSurface({}) + + n = [1, 0, 0] + rs = [1, 1, 0] * u.au + ro = [1, -1, 0] * u.au + result = surface.radiance_from_vectors(F_i, n, rs, ro) + assert u.isclose(result, F_i / 2 / u.sr) + + n = [1, 0, 0] + rs = [1 / np.sqrt(3), 1, 0] * u.au + ro = [np.sqrt(3), -1, 0] * u.au + result = surface.radiance_from_vectors(F_i, n, rs, ro) + assert u.isclose(result, F_i * np.sqrt(3) / 4 / u.sr) + + ro = [1 / np.sqrt(3), -1, 0] * u.au + result = surface.radiance_from_vectors(F_i, n, rs, ro) + assert u.isclose(result, F_i * np.sqrt(3) / 8 / u.sr) diff --git a/sbpy/units/typing.py b/sbpy/units/typing.py new file mode 100644 index 000000000..e06b39620 --- /dev/null +++ b/sbpy/units/typing.py @@ -0,0 +1,28 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +"""sbpy unit and quantity typing.""" + +from typing import Union +from packaging.version import Version + +import astropy.units as u +from astropy import __version__ as astropy_version + +UnitLike = Union[str, u.Unit] +SpectralQuantity = Union[ + u.Quantity[u.physical.length], u.Quantity[u.physical.frequency] +] +SpectralFluxDensityQuantity = Union[ + u.Quantity[u.physical.spectral_flux_density], + u.Quantity[u.physical.spectral_flux_density_wav], +] + +if Version(astropy_version) < Version("6.1"): + SpectralRadianceQuantity = Union[ + u.Quantity[u.physical.spectral_flux_density / u.sr], + u.Quantity[u.physical.spectral_flux_density_wav / u.sr], + ] +else: + SpectralRadianceQuantity = Union[ + u.Quantity[u.physical.surface_brightness], + u.Quantity[u.physical.surface_brightness_wav], + ]