Skip to main content

qualia_core_db/render/
spectral_kernel.rs

1//! P7.0 — Spectral-space kernel: SPD/CMF POD types + the CIE linear-projection
2//! contract.
3//!
4//! This module defines the foundational spectral types and the linear
5//! projection from a Spectral Power Distribution (SPD) to CIE 1931 XYZ
6//! tristimulus values via tabulated Colour Matching Functions (CMFs).
7//!
8//! ## EMF → Colour pipeline
9//!
10//! The Qualia 10D tensor's spectral axes `[α, μ, σ]` represent the EMF
11//! (electromagnetic field) payload:
12//! - **α** (amplitude): total radiant power scaling
13//! - **μ** (modulation): spectral bandwidth / phase modulation
14//! - **σ** (signature): peak wavelength selector (400–700 nm)
15//!
16//! The pipeline is:
17//! ```text
18//! EMF [α, μ, σ] → SPD(λ) → CIE XYZ → linear sRGB → display sRGB
19//! ```
20//!
21//! 1. `emf_to_spd(α, μ, σ)` constructs an SPD from the EMF payload.
22//! 2. `spd_to_xyz(spd)` projects through the tabulated CIE 1931 CMFs.
23//! 3. `xyz_to_linear_srgb(xyz)` applies the standard XYZ→sRGB matrix.
24//!
25//! ## CIE 1931 2-degree observer
26//!
27//! The CMFs are tabulated at 10 nm intervals from 380–780 nm (41 samples).
28//! This replaces the Gaussian approximation in `render/spectral.rs` with
29//! the authoritative tabulated data. The ΔE between the two is documented.
30//!
31//! ## Determinism
32//!
33//! All operations are deterministic: identical input → bit-identical output.
34//! The CMF tables are compile-time constants.
35
36use bytemuck::{Pod, Zeroable};
37
38// ───────────────────────────────────────────────────────────────────────────
39//  Constants
40// ───────────────────────────────────────────────────────────────────────────
41
42/// Number of spectral samples (380–780 nm at 10 nm intervals).
43pub const SPD_SAMPLES: usize = 41;
44
45/// Starting wavelength (nm).
46pub const LAMBDA_MIN: f32 = 380.0;
47
48/// Ending wavelength (nm).
49pub const LAMBDA_MAX: f32 = 780.0;
50
51/// Wavelength step (nm).
52pub const LAMBDA_STEP: f32 = 10.0;
53
54/// CIE 1931 2-degree observer colour matching functions, tabulated at
55/// 10 nm intervals from 380–780 nm.
56///
57/// Data source: CIE technical report (interpolated to 10 nm grid).
58/// These are the standard x̄(λ), ȳ(λ), z̄(λ) values.
59pub const CIE_1931_CMF_X: [f32; SPD_SAMPLES] = [
60    0.001368, 0.004243, 0.014310, 0.043510, 0.134380, 0.283900, 0.348280, 0.336200, 0.290800,
61    0.195360, 0.095640, 0.032010, 0.004900, 0.009300, 0.063270, 0.165500, 0.290400, 0.433450,
62    0.594500, 0.762100, 0.916300, 1.026300, 1.062200, 1.002600, 0.854450, 0.642400, 0.447900,
63    0.283500, 0.164900, 0.087400, 0.046770, 0.022700, 0.011359, 0.005790, 0.002899, 0.001440,
64    0.000690, 0.000332, 0.000166, 0.000083, 0.000042,
65];
66
67pub const CIE_1931_CMF_Y: [f32; SPD_SAMPLES] = [
68    0.000039, 0.000120, 0.000396, 0.001210, 0.004000, 0.011600, 0.023000, 0.038000, 0.060000,
69    0.090980, 0.139020, 0.208020, 0.323000, 0.503000, 0.710000, 0.862000, 0.954000, 0.994950,
70    0.995000, 0.952000, 0.870000, 0.757000, 0.631000, 0.503000, 0.381000, 0.265000, 0.175000,
71    0.107000, 0.061000, 0.032000, 0.017000, 0.008210, 0.004102, 0.002091, 0.001047, 0.000520,
72    0.000249, 0.000120, 0.000060, 0.000030, 0.000015,
73];
74
75pub const CIE_1931_CMF_Z: [f32; SPD_SAMPLES] = [
76    0.006450, 0.020050, 0.067850, 0.207400, 0.645600, 1.282500, 1.453000, 1.562100, 1.562700,
77    1.385600, 1.114600, 0.777500, 0.445600, 0.198700, 0.068100, 0.019800, 0.004100, 0.000500,
78    0.000200, 0.000010, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000,
79    0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000, 0.000000,
80    0.000000, 0.000000, 0.000000, 0.000000, 0.000000,
81];
82
83/// CIE D65 illuminant white point (normalised, Y=1).
84pub const CIE_D65_X: f32 = 0.95047;
85pub const CIE_D65_Y: f32 = 1.00000;
86pub const CIE_D65_Z: f32 = 1.08883;
87
88// ───────────────────────────────────────────────────────────────────────────
89//  Types
90// ───────────────────────────────────────────────────────────────────────────
91
92/// Spectral Power Distribution: radiant power at 41 wavelength samples
93/// (380–780 nm, 10 nm steps). POD, zero-heap, stack-allocated.
94///
95/// Manual `Pod`/`Zeroable`: bytemuck only auto-implements `[f32; N]` for N ≤ 32.
96#[repr(C)]
97#[derive(Debug, Clone, Copy, PartialEq)]
98pub struct Spd {
99    /// Power values at 380, 390, ..., 780 nm.
100    pub samples: [f32; SPD_SAMPLES],
101}
102
103// SAFETY: `Spd` is `repr(C)` with only `f32` fields; every bit pattern is valid.
104unsafe impl Zeroable for Spd {}
105unsafe impl Pod for Spd {}
106
107impl Default for Spd {
108    #[inline]
109    fn default() -> Self {
110        Self {
111            samples: [0.0; SPD_SAMPLES],
112        }
113    }
114}
115
116impl Spd {
117    /// Create an SPD from a raw sample array.
118    #[inline]
119    pub fn from_samples(samples: [f32; SPD_SAMPLES]) -> Self {
120        Self { samples }
121    }
122
123    /// Create a flat (equal-energy) SPD with all samples set to `value`.
124    #[inline]
125    pub fn flat(value: f32) -> Self {
126        Self {
127            samples: [value; SPD_SAMPLES],
128        }
129    }
130
131    /// Create a single-wavelength delta SPD: all power at the sample
132    /// closest to `lambda_nm`, zero elsewhere.
133    #[inline]
134    pub fn delta(lambda_nm: f32) -> Self {
135        let mut spd = Self::default();
136        if lambda_nm < LAMBDA_MIN || lambda_nm > LAMBDA_MAX {
137            return spd;
138        }
139        let idx = ((lambda_nm - LAMBDA_MIN) / LAMBDA_STEP).round() as usize;
140        let idx = idx.min(SPD_SAMPLES - 1);
141        spd.samples[idx] = 1.0;
142        spd
143    }
144
145    /// Create a Gaussian-peaked SPD centred at `lambda_nm` with width `width_nm`,
146    /// scaled by `amplitude`.
147    #[inline]
148    pub fn gaussian_peak(lambda_nm: f32, width_nm: f32, amplitude: f32) -> Self {
149        let mut spd = Self::default();
150        for i in 0..SPD_SAMPLES {
151            let lambda = LAMBDA_MIN + i as f32 * LAMBDA_STEP;
152            let d = (lambda - lambda_nm) / width_nm;
153            spd.samples[i] = amplitude * (-0.5 * d * d).exp();
154        }
155        spd
156    }
157
158    /// Scale all samples by a scalar.
159    #[inline]
160    pub fn scale(&self, s: f32) -> Self {
161        let mut out = *self;
162        for v in &mut out.samples {
163            *v *= s;
164        }
165        out
166    }
167
168    /// Element-wise addition.
169    #[inline]
170    pub fn add(&self, other: &Self) -> Self {
171        let mut out = *self;
172        for i in 0..SPD_SAMPLES {
173            out.samples[i] += other.samples[i];
174        }
175        out
176    }
177
178    /// Linear interpolation: `self * (1-t) + other * t`.
179    #[inline]
180    pub fn lerp(&self, other: &Self, t: f32) -> Self {
181        let mut out = Self::default();
182        for i in 0..SPD_SAMPLES {
183            out.samples[i] = self.samples[i] * (1.0 - t) + other.samples[i] * t;
184        }
185        out
186    }
187
188    /// Total power (sum of all samples).
189    #[inline]
190    pub fn total_power(&self) -> f32 {
191        self.samples.iter().copied().sum()
192    }
193}
194
195/// CIE XYZ tristimulus values. POD, f32.
196#[repr(C)]
197#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)]
198pub struct Xyz {
199    pub x: f32,
200    pub y: f32,
201    pub z: f32,
202}
203
204impl Xyz {
205    #[inline]
206    pub fn new(x: f32, y: f32, z: f32) -> Self {
207        Self { x, y, z }
208    }
209}
210
211/// Linear sRGB (no gamma encoding). POD, f32.
212#[repr(C)]
213#[derive(Debug, Clone, Copy, PartialEq, Pod, Zeroable, Default)]
214pub struct LinearRgb {
215    pub r: f32,
216    pub g: f32,
217    pub b: f32,
218}
219
220impl LinearRgb {
221    #[inline]
222    pub fn new(r: f32, g: f32, b: f32) -> Self {
223        Self { r, g, b }
224    }
225}
226
227// ───────────────────────────────────────────────────────────────────────────
228//  EMF → SPD bridge
229// ───────────────────────────────────────────────────────────────────────────
230
231/// Convert an EMF payload `[α, μ, σ]` to a Spectral Power Distribution.
232///
233/// The mapping is:
234/// - **σ** (signature): selects the peak wavelength via `λ = 400 + σ·300` nm
235///   (σ=0 → 400 nm blue, σ=0.5 → 550 nm green, σ=1 → 700 nm red)
236/// - **α** (amplitude): scales the total radiant power
237/// - **μ** (modulation): controls the spectral bandwidth
238///   (μ=0 → narrow/monochromatic, μ=1 → broad/white)
239///
240/// The SPD is a Gaussian peak centred at λ with width proportional to μ,
241/// scaled by α. When μ is large, the peak broadens toward a flat spectrum.
242#[inline]
243pub fn emf_to_spd(alpha: f32, mu: f32, sigma: f32) -> Spd {
244    let lambda = 400.0 + sigma.clamp(0.0, 1.0) * 300.0;
245    // Bandwidth: μ=0 → 10nm (narrow), μ=1 → 150nm (broad/white).
246    let width = 10.0 + mu.clamp(0.0, 1.0) * 140.0;
247    let amplitude = alpha.max(0.0);
248
249    Spd::gaussian_peak(lambda, width, amplitude)
250}
251
252// ───────────────────────────────────────────────────────────────────────────
253//  SPD → XYZ projection (CIE linear contract)
254// ───────────────────────────────────────────────────────────────────────────
255
256/// Project an SPD through the CIE 1931 2-degree CMFs to obtain XYZ
257/// tristimulus values.
258///
259/// This is the linear projection: `X = Σ S(λ)·x̄(λ)·Δλ`, etc.
260/// The result is normalised so that an equal-energy SPD yields the D65
261/// white point.
262#[inline]
263pub fn spd_to_xyz(spd: &Spd) -> Xyz {
264    let mut x = 0.0f32;
265    let mut y = 0.0f32;
266    let mut z = 0.0f32;
267
268    for i in 0..SPD_SAMPLES {
269        let s = spd.samples[i];
270        x += s * CIE_1931_CMF_X[i];
271        y += s * CIE_1931_CMF_Y[i];
272        z += s * CIE_1931_CMF_Z[i];
273    }
274
275    // Normalise so that the D65 white point has Y=1.
276    // The normalisation factor is 1 / Σȳ(λ).
277    let norm = 1.0 / CIE_1931_CMF_Y.iter().copied().sum::<f32>();
278    Xyz::new(x * norm, y * norm, z * norm)
279}
280
281/// Project a flat (equal-energy) SPD to verify the D65 white point.
282#[inline]
283pub fn flat_spd_to_xyz() -> Xyz {
284    let spd = Spd::flat(1.0);
285    spd_to_xyz(&spd)
286}
287
288// ───────────────────────────────────────────────────────────────────────────
289//  XYZ → linear sRGB
290// ───────────────────────────────────────────────────────────────────────────
291
292/// Convert CIE XYZ to linear sRGB using the standard sRGB matrix.
293#[inline]
294pub fn xyz_to_linear_srgb(xyz: &Xyz) -> LinearRgb {
295    let r = 3.2404542 * xyz.x - 1.5371385 * xyz.y - 0.4985314 * xyz.z;
296    let g = -0.9692660 * xyz.x + 1.8760108 * xyz.y + 0.0415560 * xyz.z;
297    let b = 0.0556434 * xyz.x - 0.2040259 * xyz.y + 1.0572252 * xyz.z;
298    LinearRgb::new(r.max(0.0), g.max(0.0), b.max(0.0))
299}
300
301/// Full EMF → linear sRGB pipeline.
302#[inline]
303pub fn emf_to_linear_rgb(alpha: f32, mu: f32, sigma: f32) -> LinearRgb {
304    let spd = emf_to_spd(alpha, mu, sigma);
305    let xyz = spd_to_xyz(&spd);
306    xyz_to_linear_srgb(&xyz)
307}
308
309// ───────────────────────────────────────────────────────────────────────────
310//  ΔE (CIE76) colour difference
311// ───────────────────────────────────────────────────────────────────────────
312
313/// CIE76 ΔE colour difference between two XYZ values (in Lab space).
314///
315/// ΔE₇₆ = √(ΔL² + Δa² + Δb²)
316#[inline]
317pub fn delta_e_76(xyz1: &Xyz, xyz2: &Xyz) -> f32 {
318    let lab1 = xyz_to_lab(xyz1);
319    let lab2 = xyz_to_lab(xyz2);
320    let dl = lab1.0 - lab2.0;
321    let da = lab1.1 - lab2.1;
322    let db = lab1.2 - lab2.2;
323    (dl * dl + da * da + db * db).sqrt()
324}
325
326/// Convert XYZ to CIELAB (D65 reference white).
327#[inline]
328pub fn xyz_to_lab(xyz: &Xyz) -> (f32, f32, f32) {
329    let xr = xyz.x / CIE_D65_X;
330    let yr = xyz.y / CIE_D65_Y;
331    let zr = xyz.z / CIE_D65_Z;
332
333    let f = |t: f32| -> f32 {
334        if t > 0.008856 {
335            t.cbrt()
336        } else {
337            7.787 * t + 16.0 / 116.0
338        }
339    };
340
341    let fx = f(xr);
342    let fy = f(yr);
343    let fz = f(zr);
344
345    let l = 116.0 * fy - 16.0;
346    let a = 500.0 * (fx - fy);
347    let b = 200.0 * (fy - fz);
348    (l, a, b)
349}
350
351// ───────────────────────────────────────────────────────────────────────────
352//  Gaussian approximation comparison
353// ───────────────────────────────────────────────────────────────────────────
354
355/// Compute the ΔE between the tabulated CMF projection and the existing
356/// Gaussian approximation in `render::spectral.rs`.
357///
358/// This documents the accuracy gap between the two approaches.
359#[inline]
360pub fn gaussian_vs_tabulated_delta_e(sigma: f32) -> f32 {
361    // Tabulated projection via this module.
362    let spd = emf_to_spd(1.0, 0.0, sigma);
363    let xyz_tabulated = spd_to_xyz(&spd);
364
365    // Gaussian approximation from render::spectral.rs.
366    let s = sigma - sigma.floor();
367    let lambda = 400.0 + s * 300.0;
368    let gauss = |lambda: f32, center: f32, width: f32| -> f32 {
369        let d = (lambda - center) / width;
370        (-0.5 * d * d).exp()
371    };
372    let x = 1.056 * gauss(lambda, 599.8, 43.2) + 0.362 * gauss(lambda, 442.0, 32.0)
373        - 0.065 * gauss(lambda, 501.1, 20.4);
374    let y = 0.821 * gauss(lambda, 568.8, 46.9) + 0.286 * gauss(lambda, 530.9, 16.3);
375    let z = 1.217 * gauss(lambda, 437.0, 11.8) + 0.681 * gauss(lambda, 459.0, 26.0);
376    let xyz_gaussian = Xyz::new(x, y, z);
377
378    delta_e_76(&xyz_tabulated, &xyz_gaussian)
379}
380
381// ───────────────────────────────────────────────────────────────────────────
382//  sRGB gamma encoding
383// ───────────────────────────────────────────────────────────────────────────
384
385/// Apply sRGB gamma encoding to a single linear channel.
386#[inline]
387pub fn linear_to_srgb_channel(c: f32) -> f32 {
388    if c <= 0.0031308 {
389        12.92 * c
390    } else {
391        1.055 * c.powf(1.0 / 2.4) - 0.055
392    }
393}
394
395/// Convert linear sRGB to 8-bit display sRGB (gamma-encoded, normalised).
396#[inline]
397pub fn linear_rgb_to_display(rgb: &LinearRgb) -> (u8, u8, u8) {
398    let scale = 1.0 / rgb.r.max(rgb.g).max(rgb.b).max(1e-6);
399    let nr = (rgb.r * scale).min(1.0);
400    let ng = (rgb.g * scale).min(1.0);
401    let nb = (rgb.b * scale).min(1.0);
402    (
403        (linear_to_srgb_channel(nr) * 255.0).round() as u8,
404        (linear_to_srgb_channel(ng) * 255.0).round() as u8,
405        (linear_to_srgb_channel(nb) * 255.0).round() as u8,
406    )
407}
408
409// ───────────────────────────────────────────────────────────────────────────
410//  Tests
411// ───────────────────────────────────────────────────────────────────────────
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn flat_spd_yields_equal_energy_white_point() {
419        // A flat (equal-energy) SPD yields illuminant E, not D65.
420        // For illuminant E, X/Y ≈ Σx̄/Σȳ and Z/Y ≈ Σz̄/Σȳ.
421        let xyz = flat_spd_to_xyz();
422        // Y should be 1.0 (by normalisation).
423        assert!(
424            (xyz.y - 1.0).abs() < 0.01,
425            "Y should be ~1.0, got {}",
426            xyz.y
427        );
428        // X/Y should equal Σx̄/Σȳ (equal-energy white).
429        let sum_x: f32 = CIE_1931_CMF_X.iter().copied().sum();
430        let sum_y: f32 = CIE_1931_CMF_Y.iter().copied().sum();
431        let sum_z: f32 = CIE_1931_CMF_Z.iter().copied().sum();
432        let expected_x_ratio = sum_x / sum_y;
433        let expected_z_ratio = sum_z / sum_y;
434        assert!(
435            (xyz.x / xyz.y - expected_x_ratio).abs() < 0.01,
436            "X/Y ratio {} should match equal-energy {}",
437            xyz.x / xyz.y,
438            expected_x_ratio
439        );
440        assert!(
441            (xyz.z / xyz.y - expected_z_ratio).abs() < 0.01,
442            "Z/Y ratio {} should match equal-energy {}",
443            xyz.z / xyz.y,
444            expected_z_ratio
445        );
446    }
447
448    #[test]
449    fn single_lambda_delta_reproduces_cmf() {
450        // A delta at 550 nm should produce XYZ proportional to the CMF at 550 nm.
451        let spd = Spd::delta(550.0);
452        let xyz = spd_to_xyz(&spd);
453        // At 550 nm (index 17), ȳ is high (~0.9), z̄ is very low.
454        let idx = ((550.0 - LAMBDA_MIN) / LAMBDA_STEP).round() as usize;
455        let expected_x = CIE_1931_CMF_X[idx];
456        let expected_y = CIE_1931_CMF_Y[idx];
457        let expected_z = CIE_1931_CMF_Z[idx];
458        // XYZ should be proportional to the CMF values at that wavelength.
459        let norm = 1.0 / CIE_1931_CMF_Y.iter().copied().sum::<f32>();
460        assert!(
461            (xyz.x - expected_x * norm).abs() < 0.01,
462            "X should match CMF"
463        );
464        assert!(
465            (xyz.y - expected_y * norm).abs() < 0.01,
466            "Y should match CMF"
467        );
468        assert!(
469            (xyz.z - expected_z * norm).abs() < 0.01,
470            "Z should match CMF"
471        );
472    }
473
474    #[test]
475    fn spd_determinism() {
476        let spd1 = emf_to_spd(1.0, 0.3, 0.5);
477        let spd2 = emf_to_spd(1.0, 0.3, 0.5);
478        assert_eq!(spd1, spd2, "SPD must be deterministic");
479    }
480
481    #[test]
482    fn xyz_projection_determinism() {
483        let spd = emf_to_spd(0.8, 0.2, 0.6);
484        let xyz1 = spd_to_xyz(&spd);
485        let xyz2 = spd_to_xyz(&spd);
486        assert_eq!(xyz1, xyz2, "XYZ projection must be deterministic");
487    }
488
489    #[test]
490    fn emf_to_linear_rgb_determinism() {
491        let rgb1 = emf_to_linear_rgb(1.0, 0.3, 0.5);
492        let rgb2 = emf_to_linear_rgb(1.0, 0.3, 0.5);
493        assert_eq!(rgb1, rgb2, "EMF→RGB must be deterministic");
494    }
495
496    #[test]
497    fn green_band_dominates_mid_sigma() {
498        // σ=0.5 → λ=550 nm (green) → G should dominate.
499        // Use a small μ to ensure narrow peak at the right wavelength.
500        let rgb = emf_to_linear_rgb(1.0, 0.1, 0.5);
501        assert!(
502            rgb.g >= rgb.r,
503            "G should dominate R at σ=0.5: r={} g={}",
504            rgb.r,
505            rgb.g
506        );
507        assert!(
508            rgb.g >= rgb.b,
509            "G should dominate B at σ=0.5: g={} b={}",
510            rgb.g,
511            rgb.b
512        );
513    }
514
515    #[test]
516    fn blue_band_dominates_low_sigma() {
517        // σ=0.0 → λ=400 nm (blue) → B should dominate.
518        let rgb = emf_to_linear_rgb(1.0, 0.0, 0.0);
519        assert!(rgb.b >= rgb.r, "B should dominate R at σ=0.0");
520        assert!(rgb.b >= rgb.g, "B should dominate G at σ=0.0");
521    }
522
523    #[test]
524    fn red_band_dominates_high_sigma() {
525        // σ=1.0 → λ=700 nm (red) → R should dominate.
526        // Use a small μ for narrow peak.
527        let rgb = emf_to_linear_rgb(1.0, 0.1, 1.0);
528        assert!(
529            rgb.r >= rgb.g,
530            "R should dominate G at σ=1.0: r={} g={}",
531            rgb.r,
532            rgb.g
533        );
534        assert!(
535            rgb.r >= rgb.b,
536            "R should dominate B at σ=1.0: r={} b={}",
537            rgb.r,
538            rgb.b
539        );
540    }
541
542    #[test]
543    fn amplitude_scales_power() {
544        let spd_low = emf_to_spd(0.5, 0.0, 0.5);
545        let spd_high = emf_to_spd(1.0, 0.0, 0.5);
546        assert!(
547            spd_high.total_power() > spd_low.total_power(),
548            "higher α should produce more total power"
549        );
550    }
551
552    #[test]
553    fn mu_broadens_spectrum() {
554        let spd_narrow = emf_to_spd(1.0, 0.0, 0.5);
555        let spd_broad = emf_to_spd(1.0, 1.0, 0.5);
556        // Broad SPD should have more non-zero samples at the extremes.
557        let narrow_nonzero = spd_narrow.samples.iter().filter(|&&v| v > 0.01).count();
558        let broad_nonzero = spd_broad.samples.iter().filter(|&&v| v > 0.01).count();
559        assert!(
560            broad_nonzero >= narrow_nonzero,
561            "broad μ should have >= non-zero samples: {} vs {}",
562            broad_nonzero,
563            narrow_nonzero
564        );
565    }
566
567    #[test]
568    fn spd_lerp_endpoints() {
569        let a = Spd::delta(450.0);
570        let b = Spd::delta(650.0);
571        let at_0 = a.lerp(&b, 0.0);
572        let at_1 = a.lerp(&b, 1.0);
573        assert_eq!(at_0, a, "lerp at t=0 should return first SPD");
574        assert_eq!(at_1, b, "lerp at t=1 should return second SPD");
575    }
576
577    #[test]
578    fn delta_e_self_is_zero() {
579        let xyz = spd_to_xyz(&emf_to_spd(1.0, 0.3, 0.5));
580        assert!(delta_e_76(&xyz, &xyz) < 1e-6, "ΔE to self should be ~0");
581    }
582
583    #[test]
584    fn gaussian_vs_tabulated_delta_e_finite() {
585        for i in 0..=10 {
586            let sigma = i as f32 / 10.0;
587            let de = gaussian_vs_tabulated_delta_e(sigma);
588            assert!(de.is_finite(), "ΔE must be finite at σ={}", sigma);
589            assert!(de >= 0.0, "ΔE must be non-negative at σ={}", sigma);
590        }
591    }
592
593    #[test]
594    fn xyz_to_lab_d65_white_is_l100() {
595        let white = Xyz::new(CIE_D65_X, CIE_D65_Y, CIE_D65_Z);
596        let (l, _a, _b) = xyz_to_lab(&white);
597        assert!(
598            (l - 100.0).abs() < 0.5,
599            "D65 white should have L≈100, got {}",
600            l
601        );
602    }
603
604    #[test]
605    fn display_rgb_in_range() {
606        for i in 0..=10 {
607            let sigma = i as f32 / 10.0;
608            let rgb = emf_to_linear_rgb(1.0, 0.0, sigma);
609            let (_r, _g, _b) = linear_rgb_to_display(&rgb);
610            // Display RGB returns u8, so it is always <= 255.
611        }
612    }
613}