use qtty::angular::Radians;
use qtty::length::Nanometers;
use qtty::unit::{Nanometer, Radian};
use qtty::{Dimensionless, Quantity, Unit};
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct ScatteringFactor;
impl Unit for ScatteringFactor {
const RATIO: f64 = 1.0;
type Dim = Dimensionless;
const SYMBOL: &'static str = "";
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum PhaseError {
#[error("{field} must be finite (got {value})")]
NonFinite {
field: &'static str,
value: f64,
},
#[error("{field} must lie in (-1, 1) (got {value})")]
AsymmetryOutOfRange {
field: &'static str,
value: f64,
},
#[error("{field} must lie in [0, 1] (got {value})")]
WeightOutOfRange {
field: &'static str,
value: f64,
},
}
pub trait PhaseFunction {
fn phase(&self, wavelength: Nanometers, theta: Radians) -> Quantity<ScatteringFactor>;
}
#[derive(Debug, Clone, Copy)]
pub enum PhaseModel<'a> {
Rayleigh,
HenyeyGreenstein(HenyeyGreensteinPhaseFunction),
DoubleHenyeyGreenstein(DoubleHenyeyGreensteinPhaseFunction),
Tabulated(&'a PhaseTable),
}
impl<'a> PhaseFunction for PhaseModel<'a> {
fn phase(&self, wavelength: Nanometers, theta: Radians) -> Quantity<ScatteringFactor> {
match self {
Self::Rayleigh => RayleighPhaseFunction.phase(wavelength, theta),
Self::HenyeyGreenstein(hg) => hg.phase(wavelength, theta),
Self::DoubleHenyeyGreenstein(dhg) => dhg.phase(wavelength, theta),
Self::Tabulated(table) => table.interp_at(wavelength, theta),
}
}
}
pub type PhaseTable = crate::grid::Grid2D<Nanometer, Radian, ScatteringFactor>;
#[derive(Debug, Clone, Copy, Default)]
pub struct RayleighPhaseFunction;
impl PhaseFunction for RayleighPhaseFunction {
fn phase(&self, _wavelength: Nanometers, theta: Radians) -> Quantity<ScatteringFactor> {
rayleigh_phase(theta)
}
}
#[derive(Debug, Clone, Copy)]
pub struct HenyeyGreensteinPhaseFunction {
g: f64,
}
impl HenyeyGreensteinPhaseFunction {
pub fn try_new(g: f64) -> Result<Self, PhaseError> {
check_asymmetry("g", g)?;
Ok(Self { g })
}
#[must_use]
pub fn g(&self) -> f64 {
self.g
}
}
impl PhaseFunction for HenyeyGreensteinPhaseFunction {
fn phase(&self, _wavelength: Nanometers, theta: Radians) -> Quantity<ScatteringFactor> {
let cos_theta = theta.value().cos();
let g2 = self.g * self.g;
let denom = 1.0 + g2 - 2.0 * self.g * cos_theta;
Quantity::new((1.0 - g2) / (4.0 * core::f64::consts::PI * denom.powf(1.5)))
}
}
#[derive(Debug, Clone, Copy)]
pub struct DoubleHenyeyGreensteinPhaseFunction {
g1: f64,
g2: f64,
weight: f64,
}
impl DoubleHenyeyGreensteinPhaseFunction {
pub fn try_new(g1: f64, g2: f64, weight: f64) -> Result<Self, PhaseError> {
check_asymmetry("g1", g1)?;
check_asymmetry("g2", g2)?;
check_weight("weight", weight)?;
Ok(Self { g1, g2, weight })
}
#[must_use]
pub fn g1(&self) -> f64 {
self.g1
}
#[must_use]
pub fn g2(&self) -> f64 {
self.g2
}
#[must_use]
pub fn weight(&self) -> f64 {
self.weight
}
}
impl PhaseFunction for DoubleHenyeyGreensteinPhaseFunction {
fn phase(&self, wavelength: Nanometers, theta: Radians) -> Quantity<ScatteringFactor> {
let hg1 = HenyeyGreensteinPhaseFunction { g: self.g1 }.phase(wavelength, theta);
let hg2 = HenyeyGreensteinPhaseFunction { g: self.g2 }.phase(wavelength, theta);
Quantity::new(self.weight * hg1.value() + (1.0 - self.weight) * hg2.value())
}
}
#[must_use]
pub fn rayleigh_phase(theta: Radians) -> Quantity<ScatteringFactor> {
let cosine = theta.value().cos();
Quantity::<ScatteringFactor>::new(
3.0 / (16.0 * core::f64::consts::PI) * (1.0 + cosine * cosine),
)
}
fn check_asymmetry(field: &'static str, value: f64) -> Result<(), PhaseError> {
if !value.is_finite() {
return Err(PhaseError::NonFinite { field, value });
}
if value <= -1.0 || value >= 1.0 {
return Err(PhaseError::AsymmetryOutOfRange { field, value });
}
Ok(())
}
fn check_weight(field: &'static str, value: f64) -> Result<(), PhaseError> {
if !value.is_finite() {
return Err(PhaseError::NonFinite { field, value });
}
if !(0.0..=1.0).contains(&value) {
return Err(PhaseError::WeightOutOfRange { field, value });
}
Ok(())
}
#[cfg(test)]
mod tests {
use approx::assert_relative_eq;
use super::*;
#[test]
fn rayleigh_matches_closed_form() {
let theta = Radians::new(0.0);
let expected = 3.0 / (8.0 * core::f64::consts::PI);
assert_relative_eq!(rayleigh_phase(theta).value(), expected, epsilon = 1.0e-12);
}
#[test]
fn isotropic_hg_matches_one_over_four_pi() {
let hg = HenyeyGreensteinPhaseFunction::try_new(0.0).unwrap();
let value = hg.phase(Nanometers::new(550.0), Radians::new(1.0));
assert_relative_eq!(
value.value(),
1.0 / (4.0 * core::f64::consts::PI),
epsilon = 1.0e-12
);
}
#[test]
fn hg_rejects_out_of_range_g() {
assert!(matches!(
HenyeyGreensteinPhaseFunction::try_new(1.5),
Err(PhaseError::AsymmetryOutOfRange { .. })
));
assert!(matches!(
HenyeyGreensteinPhaseFunction::try_new(1.0),
Err(PhaseError::AsymmetryOutOfRange { .. })
));
assert!(matches!(
HenyeyGreensteinPhaseFunction::try_new(-1.0),
Err(PhaseError::AsymmetryOutOfRange { .. })
));
assert!(matches!(
HenyeyGreensteinPhaseFunction::try_new(f64::NAN),
Err(PhaseError::NonFinite { .. })
));
}
#[test]
fn double_hg_rejects_out_of_range_weight() {
assert!(matches!(
DoubleHenyeyGreensteinPhaseFunction::try_new(0.5, -0.5, 2.0),
Err(PhaseError::WeightOutOfRange { .. })
));
}
}