#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum BandShape {
#[default]
GaussianWavelength,
GaussianEnergy,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AbsorptionBand {
pub center_nm: f32,
pub width_nm: f32,
pub peak: f32,
pub shape: BandShape,
}
impl AbsorptionBand {
#[must_use]
pub const fn new(center_nm: f32, width_nm: f32, peak: f32) -> Self {
Self {
center_nm,
width_nm,
peak,
shape: BandShape::GaussianWavelength,
}
}
#[must_use]
pub const fn energy(center_nm: f32, fwhm_cm_inv: f32, peak: f32) -> Self {
const FWHM_TO_SIGMA: f32 = 2.354_82; Self {
center_nm,
width_nm: fwhm_cm_inv / FWHM_TO_SIGMA,
peak,
shape: BandShape::GaussianEnergy,
}
}
#[must_use]
pub fn evaluate(&self, lambda_nm: f32) -> f32 {
match self.shape {
BandShape::GaussianWavelength => {
let t = (lambda_nm - self.center_nm) / self.width_nm;
self.peak * (-0.5 * t * t).exp()
}
BandShape::GaussianEnergy => {
let delta_nm = self.center_nm - lambda_nm;
let nu_diff = 1.0e7 * delta_nm / (lambda_nm * self.center_nm);
let t = nu_diff / self.width_nm;
self.peak * (-0.5 * t * t).exp()
}
}
}
}
#[cfg(test)]
mod band_shape_tests {
use super::*;
#[test]
fn new_defaults_to_gaussian_wavelength() {
let band = AbsorptionBand::new(550.0, 20.0, 2.0);
assert_eq!(band.shape, BandShape::GaussianWavelength);
}
#[test]
fn energy_domain_band_is_symmetric_in_wavenumber_not_wavelength() {
let center_nm = 500.0f32;
let band = AbsorptionBand::energy(center_nm, 400.0, 1.0);
let nu0 = 1.0e7 / center_nm;
let delta_nu = 150.0f32; let lambda_plus = 1.0e7 / (nu0 + delta_nu);
let lambda_minus = 1.0e7 / (nu0 - delta_nu);
let response_plus = band.evaluate(lambda_plus);
let response_minus = band.evaluate(lambda_minus);
assert!(
(response_plus - response_minus).abs() < 5e-6,
"wavenumber-symmetric points must give near-equal response (got {response_plus} vs \
{response_minus})"
);
let actual_asymmetry_nm =
((lambda_plus - center_nm).abs() - (center_nm - lambda_minus).abs()).abs();
let leading_order_asymmetry_nm = 2.0 * delta_nu * delta_nu * 1.0e7 / (nu0 * nu0 * nu0);
assert!(
(actual_asymmetry_nm - leading_order_asymmetry_nm).abs()
< 0.05 * leading_order_asymmetry_nm,
"wavelength asymmetry ({actual_asymmetry_nm} nm) must match the leading-order \
estimate derived from this band's own center_nm/delta_nu \
({leading_order_asymmetry_nm} nm) to within 5%"
);
assert!(
actual_asymmetry_nm < 0.001 * center_nm,
"test premise: the wavelength asymmetry ({actual_asymmetry_nm} nm) must stay a \
tiny fraction of center_nm ({center_nm} nm) for the near-center approximation \
above to hold"
);
}
#[test]
fn both_shapes_agree_at_band_center() {
let wavelength_band = AbsorptionBand::new(620.0, 30.0, 1.5);
let energy_band = AbsorptionBand::energy(620.0, 200.0, 1.5);
assert_eq!(wavelength_band.evaluate(620.0), 1.5);
assert!(
(energy_band.evaluate(620.0) - 1.5).abs() < 1e-6,
"energy-domain band must also read exactly `peak` at its own center"
);
}
#[test]
fn converted_energy_width_agrees_with_wavelength_width_near_center() {
let center_nm = 550.0f32;
let sigma_lambda = 20.0f32;
let peak = 2.0f32;
let wavelength_band = AbsorptionBand::new(center_nm, sigma_lambda, peak);
let sigma_nu = sigma_lambda * 1.0e7 / (center_nm * center_nm);
let energy_band = AbsorptionBand {
center_nm,
width_nm: sigma_nu,
peak,
shape: BandShape::GaussianEnergy,
};
let probe_nm = center_nm + 2.0;
let expected = wavelength_band.evaluate(probe_nm);
let actual = energy_band.evaluate(probe_nm);
assert!(
(actual - expected).abs() < 0.01 * expected,
"converted energy-domain width must agree with the wavelength-domain band to \
within 1% near center (expected {expected}, got {actual})"
);
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AbsorptionTensor {
pub o_ray: Vec<AbsorptionBand>,
pub e_ray: Vec<AbsorptionBand>,
pub beta_ray: Option<Vec<AbsorptionBand>>,
pub is_pleochroic: bool,
}
impl AbsorptionTensor {
#[must_use]
pub fn isotropic(bands: Vec<AbsorptionBand>) -> Self {
Self {
o_ray: bands.clone(),
e_ray: bands,
beta_ray: None,
is_pleochroic: false,
}
}
#[must_use]
pub const fn uniaxial(o: Vec<AbsorptionBand>, e: Vec<AbsorptionBand>) -> Self {
Self {
o_ray: o,
e_ray: e,
beta_ray: None,
is_pleochroic: true,
}
}
#[must_use]
pub const fn biaxial(
alpha: Vec<AbsorptionBand>,
beta: Vec<AbsorptionBand>,
gamma: Vec<AbsorptionBand>,
) -> Self {
Self {
o_ray: alpha,
e_ray: gamma,
beta_ray: Some(beta),
is_pleochroic: true,
}
}
}
#[must_use]
pub fn legacy_rgb_bands(rgb: [f32; 3]) -> Vec<AbsorptionBand> {
vec![
AbsorptionBand::new(620.0, 55.0, rgb[0]),
AbsorptionBand::new(540.0, 45.0, rgb[1]),
AbsorptionBand::new(450.0, 45.0, rgb[2]),
]
}