#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum DispersionModel {
Sellmeier1 { b1: f32, c1: f32 },
Sellmeier3 { b: [f32; 3], c: [f32; 3] },
Cauchy { a: f32, b: f32, c: f32 },
}
const VISIBLE_MIN_NM: f32 = 380.0;
const VISIBLE_MAX_NM: f32 = 780.0;
const UV_MIN_NM: f32 = 300.0;
const RESONANCE_MAX_NM: f32 = 800.0;
const LINE_D_NM: f32 = 589.3;
const LINE_F_NM: f32 = 486.1;
const LINE_C_NM: f32 = 656.3;
const BAND_STEP_NM: f32 = 5.0;
const BAND_STEPS: u16 = 80;
const INDEX_TOLERANCE: f32 = 1e-6;
const ABBE_RANGE: (f32, f32) = (5.0, 120.0);
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DispersionError {
NonFiniteCoefficient,
ResonanceInBand {
resonance_nm: f32,
},
IndexOutOfRange {
lambda_nm: f32,
n: f32,
},
}
impl std::fmt::Display for DispersionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
Self::NonFiniteCoefficient => f.write_str("A coefficient is not a finite number."),
Self::ResonanceInBand { resonance_nm } => write!(
f,
"A C value puts a resonance at {resonance_nm:.0} nm, inside the 300-800 nm \
range the stone is rendered in. C is in \u{b5}m\u{b2}, so 0.01 means a \
resonance at 100 nm."
),
Self::IndexOutOfRange { lambda_nm, n } if n.is_finite() => write!(
f,
"The refractive index comes out as {n:.3} at {lambda_nm:.0} nm. It must stay \
above 1. Check the signs and sizes of the coefficients."
),
Self::IndexOutOfRange { lambda_nm, .. } => write!(
f,
"The refractive index cannot be calculated at {lambda_nm:.0} nm. Check the \
signs and sizes of the coefficients."
),
}
}
}
impl std::error::Error for DispersionError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct DispersionWarnings {
pub anomalous: bool,
pub abbe_out_of_range: bool,
pub no_dispersion: bool,
}
impl DispersionWarnings {
#[must_use]
pub const fn any(self) -> bool {
self.anomalous || self.abbe_out_of_range || self.no_dispersion
}
}
fn band_samples() -> impl Iterator<Item = f32> {
(0..=BAND_STEPS).map(|step| f32::from(step).mul_add(BAND_STEP_NM, VISIBLE_MIN_NM))
}
impl DispersionModel {
pub const BAND_MIN_NM: f32 = VISIBLE_MIN_NM;
pub const BAND_MAX_NM: f32 = VISIBLE_MAX_NM;
#[must_use]
pub fn min_valid_nm(&self) -> f32 {
let uv_pole_floor = |c: &[f32]| {
c.iter()
.filter(|&&c| c > 0.0)
.map(|&c| c.sqrt() * 1e3)
.filter(|&pole_nm| pole_nm < VISIBLE_MIN_NM)
.fold(UV_MIN_NM, |floor, pole_nm| floor.max(pole_nm * 1.05))
.min(VISIBLE_MIN_NM)
};
match self {
Self::Sellmeier1 { c1, .. } => uv_pole_floor(&[*c1]),
Self::Sellmeier3 { c, .. } => uv_pole_floor(c),
Self::Cauchy { .. } => VISIBLE_MIN_NM,
}
}
#[must_use]
pub fn evaluate(&self, lambda_nm: f32) -> f32 {
if lambda_nm < VISIBLE_MIN_NM {
self.evaluate_unclamped(lambda_nm.max(self.min_valid_nm()))
} else {
self.evaluate_unclamped(lambda_nm)
}
}
fn evaluate_unclamped(&self, lambda_nm: f32) -> f32 {
let wavelength_um = lambda_nm * 1e-3;
let l2 = wavelength_um * wavelength_um;
match self {
Self::Sellmeier1 { b1, c1 } => {
let n2 = 1.0 + (b1 * l2) / (l2 - c1);
n2.max(1.0).sqrt()
}
Self::Sellmeier3 { b, c } => {
let mut n2 = 1.0;
n2 += (b[0] * l2) / (l2 - c[0]);
n2 += (b[1] * l2) / (l2 - c[1]);
n2 += (b[2] * l2) / (l2 - c[2]);
n2.max(1.0).sqrt()
}
Self::Cauchy {
a,
b: b_coeff,
c: c_coeff,
} => {
let l4 = l2 * l2;
(a + (b_coeff / l2) + (c_coeff / l4)).max(1.0)
}
}
}
fn raw_index(&self, lambda_nm: f32) -> f32 {
let wavelength_um = lambda_nm * 1e-3;
let l2 = wavelength_um * wavelength_um;
match self {
Self::Sellmeier1 { b1, c1 } => (1.0 + (b1 * l2) / (l2 - c1)).sqrt(),
Self::Sellmeier3 { b, c } => {
let mut n2 = 1.0;
n2 += (b[0] * l2) / (l2 - c[0]);
n2 += (b[1] * l2) / (l2 - c[1]);
n2 += (b[2] * l2) / (l2 - c[2]);
n2.sqrt()
}
Self::Cauchy { a, b, c } => a + (b / l2) + (c / (l2 * l2)),
}
}
fn coefficients_are_finite(&self) -> bool {
match self {
Self::Sellmeier1 { b1, c1 } => b1.is_finite() && c1.is_finite(),
Self::Sellmeier3 { b, c } => b.iter().chain(c.iter()).all(|v| v.is_finite()),
Self::Cauchy { a, b, c } => a.is_finite() && b.is_finite() && c.is_finite(),
}
}
fn resonance_in_band(&self) -> Option<f32> {
let in_band = |c: f32| -> Option<f32> {
if c <= 0.0 {
return None;
}
let resonance_nm = c.sqrt() * 1e3;
(UV_MIN_NM..=RESONANCE_MAX_NM)
.contains(&resonance_nm)
.then_some(resonance_nm)
};
match self {
Self::Sellmeier1 { c1, .. } => in_band(*c1),
Self::Sellmeier3 { c, .. } => c.iter().find_map(|&c_term| in_band(c_term)),
Self::Cauchy { .. } => None,
}
}
#[must_use]
pub fn n_d(&self) -> f32 {
self.evaluate(LINE_D_NM)
}
#[must_use]
pub fn n_f(&self) -> f32 {
self.evaluate(LINE_F_NM)
}
#[must_use]
pub fn n_c(&self) -> f32 {
self.evaluate(LINE_C_NM)
}
#[must_use]
pub fn delta_f_c(&self) -> f32 {
self.n_f() - self.n_c()
}
#[must_use]
pub fn abbe_number(&self) -> Option<f32> {
let delta = self.delta_f_c();
let n_d = self.n_d();
(delta > 0.0 && delta.is_finite() && n_d.is_finite()).then_some((n_d - 1.0) / delta)
}
pub fn validate(&self) -> Result<(), DispersionError> {
if !self.coefficients_are_finite() {
return Err(DispersionError::NonFiniteCoefficient);
}
if let Some(resonance_nm) = self.resonance_in_band() {
return Err(DispersionError::ResonanceInBand { resonance_nm });
}
for lambda_nm in band_samples() {
let n = self.raw_index(lambda_nm);
let physical = n.is_finite() && n > 1.0;
if !physical {
return Err(DispersionError::IndexOutOfRange { lambda_nm, n });
}
}
Ok(())
}
#[must_use]
pub fn warnings(&self) -> DispersionWarnings {
let mut previous = self.evaluate(VISIBLE_MIN_NM);
let mut anomalous = false;
for lambda_nm in band_samples().skip(1) {
let n = self.evaluate(lambda_nm);
if n > previous + INDEX_TOLERANCE {
anomalous = true;
break;
}
previous = n;
}
DispersionWarnings {
anomalous,
abbe_out_of_range: self
.abbe_number()
.is_some_and(|abbe| !(ABBE_RANGE.0..=ABBE_RANGE.1).contains(&abbe)),
no_dispersion: self.delta_f_c().abs() <= INDEX_TOLERANCE,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::optics::materials::GemMaterial;
#[test]
fn evaluate_is_unchanged_at_and_above_380nm() {
for material in GemMaterial::all_materials() {
let model = material.dispersion;
for step in 0..=108 {
let lambda = (step as f32).mul_add(3.7, 380.0);
assert_eq!(
model.evaluate(lambda).to_bits(),
model.evaluate_unclamped(lambda).to_bits(),
"{} at {lambda} nm",
material.name
);
}
}
}
#[test]
fn evaluate_clamps_to_the_validity_minimum_below_380nm() {
for material in GemMaterial::all_materials() {
let model = material.dispersion;
let min = model.min_valid_nm();
assert!((300.0..=380.0).contains(&min), "{}: {min}", material.name);
if matches!(model, DispersionModel::Cauchy { .. }) {
assert_eq!(min, 380.0, "{}", material.name);
}
for lambda in [300.0f32, 320.0, 345.5, 379.9] {
let n = model.evaluate(lambda);
assert!(
n.is_finite() && n >= 1.0,
"{} at {lambda}: {n}",
material.name
);
assert_eq!(
n.to_bits(),
model.evaluate(lambda.max(min)).to_bits(),
"{} at {lambda}",
material.name
);
}
assert_eq!(
model.evaluate(300.0).to_bits(),
model.evaluate(min).to_bits()
);
}
}
#[test]
fn sellmeier_is_evaluated_in_the_uv_and_cauchy_is_held() {
let sapphire = GemMaterial::by_name("Sapphire").unwrap().dispersion;
assert_eq!(sapphire.min_valid_nm(), 300.0);
let n300 = sapphire.evaluate(300.0);
assert!(
n300 > sapphire.evaluate(380.0) && (1.78..1.9).contains(&n300),
"{n300}"
);
let cauchy = DispersionModel::Cauchy {
a: 1.7,
b: 0.01,
c: 0.0,
};
assert_eq!(cauchy.evaluate(300.0), cauchy.evaluate(380.0));
let near_pole = DispersionModel::Sellmeier1 {
b1: 1.0,
c1: 0.1225,
};
assert!(near_pole.min_valid_nm() > 360.0 && near_pole.min_valid_nm() <= 380.0);
assert!(near_pole.evaluate(300.0).is_finite());
}
#[test]
fn every_builtin_curve_validates() {
for material in GemMaterial::all_materials() {
assert_eq!(
material.dispersion.validate(),
Ok(()),
"{} (ordinary)",
material.name
);
if let Some(extraordinary) = material.uniaxial_extraordinary_dispersion {
assert_eq!(
extraordinary.validate(),
Ok(()),
"{} (extraordinary)",
material.name
);
}
}
}
#[test]
fn line_helpers_and_abbe_number_follow_the_definition() {
let diamond = GemMaterial::diamond().dispersion;
assert_eq!(diamond.n_d().to_bits(), diamond.evaluate(589.3).to_bits());
assert_eq!(diamond.n_f().to_bits(), diamond.evaluate(486.1).to_bits());
assert_eq!(diamond.n_c().to_bits(), diamond.evaluate(656.3).to_bits());
assert_eq!(
diamond.delta_f_c().to_bits(),
(diamond.n_f() - diamond.n_c()).to_bits()
);
let abbe = diamond.abbe_number().expect("diamond disperses");
let by_hand = (diamond.n_d() - 1.0) / (diamond.n_f() - diamond.n_c());
assert_eq!(abbe.to_bits(), by_hand.to_bits());
assert!((abbe - 55.27).abs() < 0.5, "{abbe}");
assert!((diamond.n_d() - 2.417).abs() < 0.005, "{}", diamond.n_d());
}
#[test]
fn a_flat_curve_has_no_abbe_number_and_says_so() {
let flat = DispersionModel::Cauchy {
a: 1.7,
b: 0.0,
c: 0.0,
};
assert_eq!(flat.validate(), Ok(()));
assert_eq!(flat.abbe_number(), None);
assert_eq!(
flat.warnings(),
DispersionWarnings {
anomalous: false,
abbe_out_of_range: false,
no_dispersion: true,
}
);
}
#[test]
fn a_typical_gem_curve_has_no_warnings() {
let sapphire = GemMaterial::sapphire().dispersion;
assert_eq!(sapphire.warnings(), DispersionWarnings::default());
assert!(!sapphire.warnings().any());
}
#[test]
fn a_rising_index_is_anomalous() {
let rising = DispersionModel::Cauchy {
a: 1.5,
b: -0.002,
c: 0.0,
};
assert_eq!(rising.validate(), Ok(()));
let warnings = rising.warnings();
assert!(warnings.anomalous);
assert!(!warnings.no_dispersion);
assert_eq!(rising.abbe_number(), None);
assert!(warnings.any());
}
#[test]
fn an_abbe_number_outside_five_to_one_twenty_warns() {
let weak = DispersionModel::Cauchy {
a: 1.45,
b: 0.0005,
c: 0.0,
};
assert!(weak.abbe_number().expect("disperses") > 120.0);
assert!(weak.warnings().abbe_out_of_range);
let strong = DispersionModel::Cauchy {
a: 2.0,
b: 0.5,
c: 0.0,
};
assert_eq!(strong.validate(), Ok(()));
assert!(strong.abbe_number().expect("disperses") < 5.0);
assert!(strong.warnings().abbe_out_of_range);
assert!(!strong.warnings().anomalous);
}
#[test]
fn a_sellmeier_resonance_in_the_band_is_refused() {
let at = |c1: f32| DispersionModel::Sellmeier1 { b1: 1.0, c1 };
match at(0.36).validate() {
Err(DispersionError::ResonanceInBand { resonance_nm }) => {
assert!((resonance_nm - 600.0).abs() < 0.5, "{resonance_nm}");
}
other => panic!("expected a resonance error, got {other:?}"),
}
assert!(matches!(
at(0.1).validate(),
Err(DispersionError::ResonanceInBand { .. })
));
assert!(matches!(
at(0.62).validate(),
Err(DispersionError::ResonanceInBand { .. })
));
assert_eq!(at(0.0841).validate(), Ok(()));
}
#[test]
fn a_resonance_in_any_sellmeier3_term_is_refused() {
let model = DispersionModel::Sellmeier3 {
b: [1.0, 0.0, 0.5],
c: [0.01, 0.36, 0.02],
};
assert!(matches!(
model.validate(),
Err(DispersionError::ResonanceInBand { .. })
));
let unused = DispersionModel::Sellmeier3 {
b: [1.0, 0.0, 0.0],
c: [0.01, 0.0, 0.0],
};
assert_eq!(unused.validate(), Ok(()));
}
#[test]
fn a_pole_beyond_the_red_edge_drops_the_index_below_one() {
let model = DispersionModel::Sellmeier1 { b1: 1.0, c1: 0.7 };
match model.validate() {
Err(DispersionError::IndexOutOfRange { lambda_nm, n }) => {
assert_eq!(lambda_nm, 380.0);
assert!(n.is_finite() && n < 1.0, "{n}");
}
other => panic!("expected an index error, got {other:?}"),
}
}
#[test]
fn a_negative_index_square_cannot_be_calculated() {
let model = DispersionModel::Sellmeier1 { b1: -3.0, c1: 0.01 };
match model.validate() {
Err(DispersionError::IndexOutOfRange { lambda_nm, n }) => {
assert_eq!(lambda_nm, 380.0);
assert!(n.is_nan(), "{n}");
}
other => panic!("expected an index error, got {other:?}"),
}
assert_eq!(model.evaluate(500.0), 1.0);
}
#[test]
fn non_finite_coefficients_are_refused() {
for model in [
DispersionModel::Cauchy {
a: f32::NAN,
b: 0.0,
c: 0.0,
},
DispersionModel::Sellmeier1 {
b1: f32::INFINITY,
c1: 0.01,
},
DispersionModel::Sellmeier3 {
b: [1.0, 1.0, 1.0],
c: [0.01, f32::NEG_INFINITY, 0.02],
},
] {
assert_eq!(
model.validate(),
Err(DispersionError::NonFiniteCoefficient),
"{model:?}"
);
}
}
#[test]
fn an_index_at_or_below_one_is_refused() {
let low = DispersionModel::Cauchy {
a: 0.9,
b: 0.0,
c: 0.0,
};
assert_eq!(
low.validate(),
Err(DispersionError::IndexOutOfRange {
lambda_nm: 380.0,
n: 0.9
})
);
assert_eq!(low.evaluate(500.0), 1.0);
let violet_dip = DispersionModel::Cauchy {
a: 1.3,
b: -0.1,
c: 0.0,
};
match violet_dip.validate() {
Err(DispersionError::IndexOutOfRange { lambda_nm, n }) => {
assert_eq!(lambda_nm, 380.0);
assert!(n < 1.0);
}
other => panic!("expected an index error, got {other:?}"),
}
}
#[test]
fn error_messages_are_plain_sentences() {
let resonance = DispersionError::ResonanceInBand {
resonance_nm: 600.0,
}
.to_string();
assert!(resonance.contains("600 nm") && resonance.contains("C value"));
let low = DispersionError::IndexOutOfRange {
lambda_nm: 380.0,
n: 0.9,
}
.to_string();
assert!(low.contains("0.900") && low.contains("380 nm"));
let nan = DispersionError::IndexOutOfRange {
lambda_nm: 700.0,
n: f32::NAN,
}
.to_string();
assert!(nan.contains("cannot be calculated"));
assert_ne!(DispersionError::NonFiniteCoefficient.to_string(), "");
}
}