const CIE_D65_SPD_380_780_10NM: [f32; 41] = [
49.9755, 54.6482, 82.7549, 91.4860, 93.4318, 86.6823, 104.865, 117.008, 117.812, 114.861,
115.923, 108.811, 109.354, 107.802, 104.790, 107.689, 104.405, 104.046, 100.000, 96.3342,
95.7880, 88.6856, 90.0062, 89.5991, 87.6987, 83.2886, 83.6992, 80.0268, 80.2146, 82.2778,
78.2842, 69.7213, 71.6091, 74.3496, 61.6045, 69.8856, 75.0870, 63.5928, 46.4182, 66.8054,
63.3828,
];
const CIE_D65_SPD_300_380_10NM: [f32; 9] = [
0.0341, 3.2945, 20.236, 37.0535, 39.9488, 44.9117, 46.6383, 52.0891, 49.9755,
];
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "callers clamp the wavelength into the table first, so position is a small non-negative value"
)]
const fn table_index(position: f32) -> usize {
position.floor() as usize
}
#[must_use]
pub fn d65_relative_spectral_power(lambda_nm: f32) -> f32 {
const START_NM: f32 = 380.0;
const STEP_NM: f32 = 10.0;
const LAST_INDEX: usize = CIE_D65_SPD_380_780_10NM.len() - 1;
if lambda_nm < START_NM {
let clamped = lambda_nm.max(300.0);
let position = (clamped - 300.0) / 10.0;
let index0 = table_index(position).min(7);
let frac = position - index0 as f32;
let v0 = CIE_D65_SPD_300_380_10NM[index0];
let v1 = CIE_D65_SPD_300_380_10NM[index0 + 1];
return frac.mul_add(v1 - v0, v0) / 100.0;
}
let clamped = lambda_nm.clamp(START_NM, STEP_NM.mul_add(LAST_INDEX as f32, START_NM));
let position = (clamped - START_NM) / STEP_NM;
let index0 = table_index(position).min(LAST_INDEX - 1);
let index1 = index0 + 1;
let frac = position - index0 as f32;
let v0 = CIE_D65_SPD_380_780_10NM[index0];
let v1 = CIE_D65_SPD_380_780_10NM[index1];
frac.mul_add(v1 - v0, v0) / 100.0
}
const H_C_OVER_K_NM_K: f32 = 14_388_000.0;
#[derive(Clone, Copy, Debug)]
pub(super) struct BlackbodyNorm {
t_k: f32,
denom_560: f32,
}
impl BlackbodyNorm {
pub(super) fn new(temp_k: f32) -> Self {
let t_k = temp_k.max(1000.0);
let exp_560 = (H_C_OVER_K_NM_K / (560.0 * t_k)).min(80.0).exp();
let denom_560 = (exp_560 - 1.0).max(1e-6);
Self { t_k, denom_560 }
}
}
pub(super) fn blackbody_spectrum_with(norm: BlackbodyNorm, lambda_nm: f32) -> f32 {
let exp_val = (H_C_OVER_K_NM_K / (lambda_nm * norm.t_k)).min(80.0).exp();
let denom = (exp_val - 1.0).max(1e-6);
let ratio = norm.denom_560 / denom;
((560.0 / lambda_nm).powi(5) * ratio).clamp(0.01, 20.0)
}
#[must_use]
pub fn blackbody_spectrum(lambda_nm: f32, temp_k: f32) -> f32 {
blackbody_spectrum_with(BlackbodyNorm::new(temp_k), lambda_nm)
}
#[derive(Clone, Copy, Debug)]
pub(super) enum IlluminantSpectrum {
D65,
Blackbody(BlackbodyNorm),
Gaussian { centre_nm: f32, sigma_nm: f32 },
}
impl IlluminantSpectrum {
pub(super) fn power(self, lambda_nm: f32) -> f32 {
match self {
Self::D65 => d65_relative_spectral_power(lambda_nm),
Self::Blackbody(norm) => blackbody_spectrum_with(norm, lambda_nm),
Self::Gaussian {
centre_nm,
sigma_nm,
} => {
let z = (lambda_nm - centre_nm) / sigma_nm;
(-0.5 * z * z).exp()
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn d65_380_only(lambda_nm: f32) -> f32 {
const START_NM: f32 = 380.0;
const STEP_NM: f32 = 10.0;
const LAST_INDEX: usize = CIE_D65_SPD_380_780_10NM.len() - 1;
let clamped = lambda_nm.clamp(START_NM, STEP_NM.mul_add(LAST_INDEX as f32, START_NM));
let position = (clamped - START_NM) / STEP_NM;
let index0 = table_index(position).min(LAST_INDEX - 1);
let index1 = index0 + 1;
let frac = position - index0 as f32;
let v0 = CIE_D65_SPD_380_780_10NM[index0];
let v1 = CIE_D65_SPD_380_780_10NM[index1];
frac.mul_add(v1 - v0, v0) / 100.0
}
#[test]
fn d65_extension_is_bit_identical_at_and_above_380nm() {
for step in 0..=2430 {
let lambda = (step as f32).mul_add(0.173, 380.0);
assert_eq!(
d65_relative_spectral_power(lambda).to_bits(),
d65_380_only(lambda).to_bits(),
"{lambda} nm"
);
}
for lambda in [380.0f32, 400.0, 555.0, 560.0, 780.0, 1000.0] {
assert_eq!(
d65_relative_spectral_power(lambda).to_bits(),
d65_380_only(lambda).to_bits()
);
}
}
#[test]
fn d65_matches_the_cie_table_down_to_300nm() {
let cie = [
(300.0f32, 0.0341f32),
(310.0, 3.2945),
(320.0, 20.236),
(330.0, 37.0535),
(340.0, 39.9488),
(350.0, 44.9117),
(360.0, 46.6383),
(370.0, 52.0891),
(380.0, 49.9755),
];
for (lambda, value) in cie {
let got = d65_relative_spectral_power(lambda);
assert!((got - value / 100.0).abs() < 1e-6, "{lambda}: {got}");
}
for (lambda, value) in [(305.0f32, 1.6643f32), (365.0, 49.3637), (375.0, 51.0323)] {
let got = d65_relative_spectral_power(lambda) * 100.0;
assert!((got - value).abs() < 3.5, "{lambda}: {got} vs {value}");
}
assert_eq!(
d65_relative_spectral_power(250.0),
d65_relative_spectral_power(300.0)
);
let below = d65_relative_spectral_power(379.999);
let at = d65_relative_spectral_power(380.0);
assert!((below - at).abs() < 1e-4);
}
}