Skip to main content

lux_rs/
indvcmf.rs

1use crate::color::Matrix3;
2use crate::error::{LuxError, LuxResult};
3use crate::spectrum::Spectrum;
4
5const ASANO_LMS_ABSORBANCE: &str = include_str!("../data/indvcmf/asano_cie2006_Alms.dat");
6const ASANO_RELATIVE_MACULAR_DENSITY: &str =
7    include_str!("../data/indvcmf/asano_cie2006_RelativeMacularDensity.dat");
8const ASANO_OCULAR_DENSITY: &str = include_str!("../data/indvcmf/asano_cie2006_docul.dat");
9const ASANO_CAT_OBSERVER_FACTORS: &str = include_str!("../data/indvcmf/asano_CatObsPfctr.dat");
10const ASANO_US_CENSUS_AGE_DISTRIBUTION: &str =
11    include_str!("../data/indvcmf/asano_USCensus2010Population.dat");
12const CIETC197_ABSORBANCES: &str = include_str!("../data/indvcmf/cietc197_absorbances0_1nm.dat");
13const CIETC197_DOCUL2: &str = include_str!("../data/indvcmf/cietc197_docul2.dat");
14const CIE2006_XYZ_2_DEG: &str = include_str!("../data/cmfs/ciexyz_2006_2.dat");
15const CIE2006_XYZ_10_DEG: &str = include_str!("../data/cmfs/ciexyz_2006_10.dat");
16
17const WAVELENGTH_START: usize = 390;
18const WAVELENGTH_END: usize = 780;
19const WAVELENGTH_STEP: usize = 5;
20const FIELD_SIZE_MIN: f64 = 2.0;
21const FIELD_SIZE_MAX: f64 = 10.0;
22const S_CONE_CUTOFF: f64 = 620.0;
23const LCG_MULTIPLIER: u64 = 6_364_136_223_846_793_005;
24const LCG_INCREMENT: u64 = 1;
25
26const LMS_TO_XYZ_2_DEG: Matrix3 = [
27    [0.4151, -0.2424, 0.0425],
28    [0.1355, 0.0833, -0.0043],
29    [-0.0093, 0.0125, 0.2136],
30];
31const LMS_TO_XYZ_10_DEG: Matrix3 = [
32    [0.4499, -0.2630, 0.0460],
33    [0.1617, 0.0726, -0.0011],
34    [-0.0036, 0.0054, 0.2291],
35];
36const STOCKMAN2023_LMS_TO_XYZ_2_DEG: Matrix3 = [
37    [1.947_354_69, -1.414_451_23, 0.364_763_27],
38    [0.689_902_72, 0.348_321_89, 0.0],
39    [0.0, 0.0, 1.934_853_43],
40];
41const STOCKMAN2023_LMS_TO_XYZ_10_DEG: Matrix3 = [
42    [1.939_864_43, -1.346_643_59, 0.430_449_35],
43    [0.692_839_32, 0.349_675_67, 0.0],
44    [0.0, 0.0, 2.146_879_45],
45];
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum IndividualObserverDataSource {
49    Asano,
50    CieTc197,
51    Stockman2023,
52    AicomPlus,
53}
54
55impl Default for IndividualObserverDataSource {
56    fn default() -> Self {
57        Self::Asano
58    }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct IndividualObserverParameters {
63    pub age: f64,
64    pub field_size: f64,
65    pub lens_density_variation: f64,
66    pub macular_density_variation: f64,
67    pub cone_density_variation: [f64; 3],
68    pub cone_peak_shift: [f64; 3],
69    pub allow_negative_xyz_values: bool,
70}
71
72impl Default for IndividualObserverParameters {
73    fn default() -> Self {
74        Self {
75            age: 32.0,
76            field_size: 10.0,
77            lens_density_variation: 0.0,
78            macular_density_variation: 0.0,
79            cone_density_variation: [0.0, 0.0, 0.0],
80            cone_peak_shift: [0.0, 0.0, 0.0],
81            allow_negative_xyz_values: false,
82        }
83    }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq)]
87pub struct IndividualObserverStdDevs {
88    pub lens_density: f64,
89    pub macular_density: f64,
90    pub cone_density: [f64; 3],
91    pub cone_peak_shift: [f64; 3],
92}
93
94#[derive(Debug, Clone, PartialEq)]
95pub struct IndividualObserverCmf {
96    pub lms: Spectrum,
97    pub xyz: Spectrum,
98    pub lens_transmission: Spectrum,
99    pub macular_transmission: Spectrum,
100    pub photopigment_sensitivity: Spectrum,
101    pub lms_to_xyz_matrix: Matrix3,
102}
103
104#[derive(Debug, Clone, PartialEq)]
105pub struct IndividualObserverMonteCarloOptions {
106    pub n_observers: usize,
107    pub field_size: f64,
108    pub age_pool: Vec<f64>,
109    pub std_devs: IndividualObserverStdDevs,
110    pub use_germany_scale_factors: bool,
111    pub allow_negative_xyz_values: bool,
112    pub data_source: IndividualObserverDataSource,
113    pub seed: u64,
114}
115
116impl Default for IndividualObserverMonteCarloOptions {
117    fn default() -> Self {
118        Self {
119            n_observers: 1,
120            field_size: 10.0,
121            age_pool: vec![32.0],
122            std_devs: individual_observer_default_std_devs(),
123            use_germany_scale_factors: true,
124            allow_negative_xyz_values: false,
125            data_source: IndividualObserverDataSource::Asano,
126            seed: 0xDEC0DED,
127        }
128    }
129}
130
131#[derive(Debug, Clone, PartialEq)]
132pub struct IndividualObserverPopulation {
133    pub parameters: Vec<IndividualObserverParameters>,
134    pub cmfs: Vec<IndividualObserverCmf>,
135}
136
137pub type IndividualObserverModel = IndividualObserverDataSource;
138
139#[derive(Debug, Clone, PartialEq)]
140pub struct IndividualObserverRequest {
141    pub model: IndividualObserverModel,
142    pub parameters: IndividualObserverParameters,
143}
144
145impl Default for IndividualObserverRequest {
146    fn default() -> Self {
147        Self {
148            model: IndividualObserverModel::Asano,
149            parameters: IndividualObserverParameters::default(),
150        }
151    }
152}
153
154#[derive(Debug, Clone, PartialEq)]
155pub struct IndividualObserverCategoricalOptions {
156    pub n_categories: usize,
157    pub field_size: f64,
158    pub allow_negative_xyz_values: bool,
159}
160
161impl Default for IndividualObserverCategoricalOptions {
162    fn default() -> Self {
163        Self {
164            n_categories: 10,
165            field_size: 2.0,
166            allow_negative_xyz_values: false,
167        }
168    }
169}
170
171#[derive(Debug, Clone, PartialEq)]
172pub enum IndividualObserverPopulationStrategy {
173    MonteCarlo(IndividualObserverMonteCarloOptions),
174    Categorical(IndividualObserverCategoricalOptions),
175}
176
177#[derive(Debug, Clone, PartialEq)]
178pub struct IndividualObserverPopulationRequest {
179    pub model: IndividualObserverModel,
180    pub strategy: IndividualObserverPopulationStrategy,
181}
182
183impl Default for IndividualObserverPopulationRequest {
184    fn default() -> Self {
185        Self {
186            model: IndividualObserverModel::Asano,
187            strategy: IndividualObserverPopulationStrategy::MonteCarlo(
188                IndividualObserverMonteCarloOptions::default(),
189            ),
190        }
191    }
192}
193
194#[derive(Debug, Clone, PartialEq)]
195struct ObserverSourceData {
196    wavelengths: Vec<f64>,
197    lms_absorbance: [Vec<f64>; 3],
198    relative_macular_density: Vec<f64>,
199    ocular_density: [Vec<f64>; 2],
200}
201
202pub fn individual_observer_default_std_devs() -> IndividualObserverStdDevs {
203    IndividualObserverStdDevs {
204        lens_density: 19.1,
205        macular_density: 37.2,
206        cone_density: [17.9, 17.9, 14.7],
207        cone_peak_shift: [4.0, 3.0, 2.5],
208    }
209}
210
211pub fn individual_observer_lms_to_xyz_matrix(field_size: f64) -> Matrix3 {
212    let clamped = field_size.clamp(FIELD_SIZE_MIN, FIELD_SIZE_MAX);
213    let a = (FIELD_SIZE_MAX - clamped) / (FIELD_SIZE_MAX - FIELD_SIZE_MIN);
214    interpolate_matrix3(LMS_TO_XYZ_2_DEG, LMS_TO_XYZ_10_DEG, 1.0 - a)
215}
216
217pub fn individual_observer_lms_to_xyz_matrix_stockman2023(field_size: f64) -> Matrix3 {
218    let clamped = field_size.clamp(FIELD_SIZE_MIN, FIELD_SIZE_MAX);
219    let alpha_10 = (clamped - FIELD_SIZE_MIN) / (FIELD_SIZE_MAX - FIELD_SIZE_MIN);
220    let alpha_2 = 1.0 - alpha_10;
221    let mut matrix = [[0.0; 3]; 3];
222    for row in 0..3 {
223        for col in 0..3 {
224            matrix[row][col] = STOCKMAN2023_LMS_TO_XYZ_2_DEG[row][col] * alpha_2
225                + STOCKMAN2023_LMS_TO_XYZ_10_DEG[row][col] * alpha_10;
226        }
227    }
228    matrix
229}
230
231pub fn individual_observer_lms_to_xyz(
232    lms: &Spectrum,
233    field_size: f64,
234    allow_negative_values: bool,
235) -> LuxResult<Spectrum> {
236    if lms.spectrum_count() != 3 {
237        return Err(LuxError::InvalidInput(
238            "individual observer LMS input must contain exactly 3 spectra",
239        ));
240    }
241
242    let matrix = individual_observer_lms_to_xyz_matrix(field_size);
243    individual_observer_lms_to_xyz_with_matrix(lms, matrix, allow_negative_values)
244}
245
246fn individual_observer_lms_to_xyz_with_matrix(
247    lms: &Spectrum,
248    matrix: Matrix3,
249    allow_negative_values: bool,
250) -> LuxResult<Spectrum> {
251    if lms.spectrum_count() != 3 {
252        return Err(LuxError::InvalidInput(
253            "individual observer LMS input must contain exactly 3 spectra",
254        ));
255    }
256
257    let wavelengths = lms.wavelengths().to_vec();
258    let mut xyz = (0..3)
259        .map(|_| Vec::with_capacity(wavelengths.len()))
260        .collect::<Vec<_>>();
261
262    for index in 0..wavelengths.len() {
263        let lms_sample = [
264            lms.spectra()[0][index],
265            lms.spectra()[1][index],
266            lms.spectra()[2][index],
267        ];
268        let mut xyz_sample = multiply_matrix3_vector3(matrix, lms_sample);
269        if !allow_negative_values {
270            for value in &mut xyz_sample {
271                if *value < 0.0 {
272                    *value = 0.0;
273                }
274            }
275        }
276        for axis in 0..3 {
277            xyz[axis].push(xyz_sample[axis]);
278        }
279    }
280
281    Spectrum::new(wavelengths, xyz)
282}
283
284pub fn individual_observer_cmf(
285    parameters: IndividualObserverParameters,
286) -> LuxResult<IndividualObserverCmf> {
287    individual_observer_cmf_with_source(parameters, IndividualObserverDataSource::Asano)
288}
289
290pub fn individual_observer_generate(
291    request: IndividualObserverRequest,
292) -> LuxResult<IndividualObserverCmf> {
293    individual_observer_cmf_with_source(request.parameters, request.model)
294}
295
296pub fn individual_observer_cmf_stockman2023(
297    parameters: IndividualObserverParameters,
298) -> LuxResult<IndividualObserverCmf> {
299    individual_observer_cmf_with_source(parameters, IndividualObserverDataSource::Stockman2023)
300}
301
302pub fn individual_observer_cmf_aicom_plus(
303    parameters: IndividualObserverParameters,
304) -> LuxResult<IndividualObserverCmf> {
305    individual_observer_cmf_with_source(parameters, IndividualObserverDataSource::AicomPlus)
306}
307
308pub fn individual_observer_cmf_with_source(
309    parameters: IndividualObserverParameters,
310    data_source: IndividualObserverDataSource,
311) -> LuxResult<IndividualObserverCmf> {
312    validate_parameters(parameters)?;
313
314    let source_data = load_source_data(data_source)?;
315    if data_source == IndividualObserverDataSource::Stockman2023 {
316        return compute_stockman2023_observer(parameters, source_data);
317    }
318    if data_source == IndividualObserverDataSource::AicomPlus {
319        return compute_aicom_plus_observer(parameters, source_data);
320    }
321    let wavelengths = source_data.wavelengths;
322    let relative_macular_density = source_data.relative_macular_density;
323    let ocular_density = source_data.ocular_density;
324    let lms_absorbance = source_data.lms_absorbance;
325
326    ensure_len(&wavelengths, &relative_macular_density)?;
327    ensure_len(&wavelengths, &ocular_density[0])?;
328    ensure_len(&wavelengths, &ocular_density[1])?;
329    ensure_len(&wavelengths, &lms_absorbance[0])?;
330    ensure_len(&wavelengths, &lms_absorbance[1])?;
331    ensure_len(&wavelengths, &lms_absorbance[2])?;
332
333    let shifted_absorbance = (0..3)
334        .map(|axis| {
335            shift_series_with_linear_extrapolation(
336                &wavelengths,
337                &lms_absorbance[axis],
338                parameters.cone_peak_shift[axis],
339            )
340        })
341        .collect::<LuxResult<Vec<Vec<f64>>>>()?;
342
343    let fs = parameters.field_size;
344    let peak_macular_density =
345        0.485 * (-fs / 6.132).exp() * (1.0 + parameters.macular_density_variation / 100.0);
346    let corrected_macular_density: Vec<f64> = relative_macular_density
347        .iter()
348        .map(|value| value * peak_macular_density)
349        .collect::<Vec<_>>();
350
351    let age_scale = if parameters.age <= 60.0 {
352        1.0 + 0.02 * (parameters.age - 32.0)
353    } else {
354        1.56 + 0.0667 * (parameters.age - 60.0)
355    };
356    let corrected_ocular_density: Vec<f64> = ocular_density[0]
357        .iter()
358        .zip(ocular_density[1].iter())
359        .map(|(first, second)| {
360            (first * age_scale + second) * (1.0 + parameters.lens_density_variation / 100.0)
361        })
362        .collect::<Vec<_>>();
363
364    let cone_peak_density = [
365        (0.38 + 0.54 * (-fs / 1.333).exp()) * (1.0 + parameters.cone_density_variation[0] / 100.0),
366        (0.38 + 0.54 * (-fs / 1.333).exp()) * (1.0 + parameters.cone_density_variation[1] / 100.0),
367        (0.30 + 0.45 * (-fs / 1.333).exp()) * (1.0 + parameters.cone_density_variation[2] / 100.0),
368    ];
369
370    let mut alpha_lms = vec![vec![0.0; wavelengths.len()]; 3];
371    for axis in 0..3 {
372        for (index, wavelength) in wavelengths.iter().enumerate() {
373            alpha_lms[axis][index] = 1.0
374                - 10f64
375                    .powf(-cone_peak_density[axis] * 10f64.powf(shifted_absorbance[axis][index]));
376            if axis == 2 && *wavelength >= S_CONE_CUTOFF {
377                alpha_lms[axis][index] = 0.0;
378            }
379        }
380    }
381
382    let mut lms = vec![vec![0.0; wavelengths.len()]; 3];
383    let mut photopigment_sensitivity = vec![vec![0.0; wavelengths.len()]; 3];
384    for axis in 0..3 {
385        for (index, wavelength) in wavelengths.iter().enumerate() {
386            let lms_quantal = alpha_lms[axis][index]
387                * 10f64.powf(-corrected_macular_density[index] - corrected_ocular_density[index]);
388            lms[axis][index] = lms_quantal * wavelength;
389            photopigment_sensitivity[axis][index] = alpha_lms[axis][index] * wavelength;
390        }
391        let area: f64 = lms[axis].iter().sum();
392        if area == 0.0 {
393            return Err(LuxError::InvalidInput(
394                "individual observer LMS normalization area must be non-zero",
395            ));
396        }
397        for value in &mut lms[axis] {
398            *value = 100.0 * *value / area;
399        }
400    }
401
402    let lms_matrix = Spectrum::new(wavelengths.clone(), lms)?;
403    let lms_to_xyz_matrix = match data_source {
404        IndividualObserverDataSource::Asano => {
405            individual_observer_lms_to_xyz_matrix(parameters.field_size)
406        }
407        IndividualObserverDataSource::CieTc197 => {
408            fit_cietc197_lms_to_xyz_matrix(&lms_matrix, parameters.field_size)?
409        }
410        IndividualObserverDataSource::Stockman2023 => {
411            individual_observer_lms_to_xyz_matrix(parameters.field_size)
412        }
413        IndividualObserverDataSource::AicomPlus => {
414            fit_cietc197_lms_to_xyz_matrix(&lms_matrix, parameters.field_size)?
415        }
416    };
417    let xyz_matrix = individual_observer_lms_to_xyz_with_matrix(
418        &lms_matrix,
419        lms_to_xyz_matrix,
420        parameters.allow_negative_xyz_values,
421    )?;
422    let lens_transmission = Spectrum::new(
423        wavelengths.clone(),
424        corrected_ocular_density
425            .iter()
426            .map(|value| 10f64.powf(-value))
427            .collect::<Vec<_>>(),
428    )?;
429    let macular_transmission = Spectrum::new(
430        wavelengths.clone(),
431        corrected_macular_density
432            .iter()
433            .map(|value| 10f64.powf(-value))
434            .collect::<Vec<_>>(),
435    )?;
436    let photopigment_sensitivity = Spectrum::new(wavelengths, photopigment_sensitivity)?;
437
438    Ok(IndividualObserverCmf {
439        lms: lms_matrix,
440        xyz: xyz_matrix,
441        lens_transmission,
442        macular_transmission,
443        photopigment_sensitivity,
444        lms_to_xyz_matrix,
445    })
446}
447
448pub fn individual_observer_us_census_age_distribution() -> LuxResult<Vec<f64>> {
449    let mut ages = Vec::new();
450
451    for line in ASANO_US_CENSUS_AGE_DISTRIBUTION.split(['\n', '\r']) {
452        let trimmed = line.trim();
453        if trimmed.is_empty() {
454            continue;
455        }
456        if trimmed.starts_with("Age") {
457            continue;
458        }
459        let fields = split_numeric_tokens(trimmed);
460        if fields.len() < 2 {
461            return Err(LuxError::ParseError("invalid US census age row"));
462        }
463        let age = fields[0];
464        let population = fields[1];
465        if !(10.0..=70.0).contains(&age) {
466            continue;
467        }
468        let repeats = (population / 1000.0).round();
469        if repeats <= 0.0 {
470            continue;
471        }
472        for _ in 0..repeats as usize {
473            ages.push(age);
474        }
475    }
476
477    if ages.is_empty() {
478        return Err(LuxError::ParseError("empty US census age distribution"));
479    }
480    Ok(ages)
481}
482
483pub fn individual_observer_monte_carlo_parameters(
484    options: &IndividualObserverMonteCarloOptions,
485) -> LuxResult<Vec<IndividualObserverParameters>> {
486    validate_monte_carlo_options(options)?;
487
488    let mut std_devs = options.std_devs;
489    if options.use_germany_scale_factors {
490        std_devs = scale_std_devs(std_devs);
491    }
492
493    let mut rng = LcgRng::new(options.seed);
494    let mut parameters = Vec::with_capacity(options.n_observers);
495
496    for _ in 0..options.n_observers {
497        let age = draw_age(&options.age_pool, &mut rng)?;
498        let lens_density_variation =
499            (std_devs.lens_density * rng.next_standard_normal()).max(-100.0);
500        let macular_density_variation =
501            (std_devs.macular_density * rng.next_standard_normal()).max(-100.0);
502        let cone_density_variation = [
503            (std_devs.cone_density[0] * rng.next_standard_normal()).max(-100.0),
504            (std_devs.cone_density[1] * rng.next_standard_normal()).max(-100.0),
505            (std_devs.cone_density[2] * rng.next_standard_normal()).max(-100.0),
506        ];
507        let cone_peak_shift = [
508            std_devs.cone_peak_shift[0] * rng.next_standard_normal(),
509            std_devs.cone_peak_shift[1] * rng.next_standard_normal(),
510            std_devs.cone_peak_shift[2] * rng.next_standard_normal(),
511        ];
512
513        parameters.push(IndividualObserverParameters {
514            age,
515            field_size: options.field_size,
516            lens_density_variation,
517            macular_density_variation,
518            cone_density_variation,
519            cone_peak_shift,
520            allow_negative_xyz_values: options.allow_negative_xyz_values,
521        });
522    }
523
524    Ok(parameters)
525}
526
527pub fn individual_observer_monte_carlo(
528    options: IndividualObserverMonteCarloOptions,
529) -> LuxResult<IndividualObserverPopulation> {
530    let parameters = individual_observer_monte_carlo_parameters(&options)?;
531    let cmfs = parameters
532        .iter()
533        .copied()
534        .map(|params| individual_observer_cmf_with_source(params, options.data_source))
535        .collect::<LuxResult<Vec<_>>>()?;
536
537    Ok(IndividualObserverPopulation { parameters, cmfs })
538}
539
540pub fn individual_observer_generate_population(
541    request: IndividualObserverPopulationRequest,
542) -> LuxResult<IndividualObserverPopulation> {
543    match request.strategy {
544        IndividualObserverPopulationStrategy::MonteCarlo(mut options) => {
545            options.data_source = request.model;
546            individual_observer_monte_carlo(options)
547        }
548        IndividualObserverPopulationStrategy::Categorical(options) => {
549            individual_observer_categorical_observers(
550                options.n_categories,
551                options.field_size,
552                request.model,
553                options.allow_negative_xyz_values,
554            )
555        }
556    }
557}
558
559pub fn individual_observer_categorical_observers(
560    n_categories: usize,
561    field_size: f64,
562    data_source: IndividualObserverDataSource,
563    allow_negative_xyz_values: bool,
564) -> LuxResult<IndividualObserverPopulation> {
565    if n_categories == 0 {
566        return Err(LuxError::InvalidInput("category count must be positive"));
567    }
568    if !field_size.is_finite() || !(FIELD_SIZE_MIN..=FIELD_SIZE_MAX).contains(&field_size) {
569        return Err(LuxError::InvalidInput(
570            "field size must be finite and within 2..=10 degrees",
571        ));
572    }
573
574    let (ages, factors) = parse_categorical_observer_factors()?;
575    let count = n_categories.min(ages.len());
576
577    let parameters = (0..count)
578        .map(|index| IndividualObserverParameters {
579            age: ages[index],
580            field_size,
581            lens_density_variation: factors[0][index],
582            macular_density_variation: factors[1][index],
583            cone_density_variation: [factors[2][index], factors[3][index], factors[4][index]],
584            cone_peak_shift: [factors[5][index], factors[6][index], factors[7][index]],
585            allow_negative_xyz_values,
586        })
587        .collect::<Vec<_>>();
588
589    let cmfs = parameters
590        .iter()
591        .copied()
592        .map(|params| individual_observer_cmf_with_source(params, data_source))
593        .collect::<LuxResult<Vec<_>>>()?;
594
595    Ok(IndividualObserverPopulation { parameters, cmfs })
596}
597
598fn validate_parameters(parameters: IndividualObserverParameters) -> LuxResult<()> {
599    if !parameters.age.is_finite() || parameters.age <= 0.0 {
600        return Err(LuxError::InvalidInput("age must be positive and finite"));
601    }
602    if !parameters.field_size.is_finite()
603        || parameters.field_size < FIELD_SIZE_MIN
604        || parameters.field_size > FIELD_SIZE_MAX
605    {
606        return Err(LuxError::InvalidInput(
607            "field size must be finite and within 2..=10 degrees",
608        ));
609    }
610    if !parameters.lens_density_variation.is_finite()
611        || !parameters.macular_density_variation.is_finite()
612        || parameters.lens_density_variation <= -100.0
613        || parameters.macular_density_variation <= -100.0
614    {
615        return Err(LuxError::InvalidInput(
616            "lens and macular density variations must be finite and greater than -100%",
617        ));
618    }
619    if parameters
620        .cone_density_variation
621        .iter()
622        .any(|value| !value.is_finite() || *value <= -100.0)
623    {
624        return Err(LuxError::InvalidInput(
625            "cone density variations must be finite and greater than -100%",
626        ));
627    }
628    if parameters
629        .cone_peak_shift
630        .iter()
631        .any(|value| !value.is_finite())
632    {
633        return Err(LuxError::InvalidInput(
634            "cone peak shifts must be finite when provided",
635        ));
636    }
637    Ok(())
638}
639
640fn validate_monte_carlo_options(options: &IndividualObserverMonteCarloOptions) -> LuxResult<()> {
641    if options.n_observers == 0 {
642        return Err(LuxError::InvalidInput("observer count must be positive"));
643    }
644    if options.age_pool.is_empty() {
645        return Err(LuxError::InvalidInput("age pool cannot be empty"));
646    }
647    for age in &options.age_pool {
648        if !age.is_finite() || *age <= 0.0 {
649            return Err(LuxError::InvalidInput(
650                "age pool entries must be positive and finite",
651            ));
652        }
653    }
654
655    // Validate field size and variation bounds through the shared parameter validator.
656    validate_parameters(IndividualObserverParameters {
657        age: options.age_pool[0],
658        field_size: options.field_size,
659        lens_density_variation: 0.0,
660        macular_density_variation: 0.0,
661        cone_density_variation: [0.0, 0.0, 0.0],
662        cone_peak_shift: [0.0, 0.0, 0.0],
663        allow_negative_xyz_values: options.allow_negative_xyz_values,
664    })
665}
666
667fn scale_std_devs(std_devs: IndividualObserverStdDevs) -> IndividualObserverStdDevs {
668    IndividualObserverStdDevs {
669        lens_density: std_devs.lens_density * 0.98,
670        macular_density: std_devs.macular_density * 0.98,
671        cone_density: [
672            std_devs.cone_density[0] * 0.5,
673            std_devs.cone_density[1] * 0.5,
674            std_devs.cone_density[2] * 0.5,
675        ],
676        cone_peak_shift: [
677            std_devs.cone_peak_shift[0] * 0.5,
678            std_devs.cone_peak_shift[1] * 0.5,
679            std_devs.cone_peak_shift[2] * 0.5,
680        ],
681    }
682}
683
684fn draw_age(age_pool: &[f64], rng: &mut LcgRng) -> LuxResult<f64> {
685    if age_pool.is_empty() {
686        return Err(LuxError::InvalidInput("age pool cannot be empty"));
687    }
688    let index = rng.next_usize(age_pool.len());
689    Ok(age_pool[index])
690}
691
692fn base_wavelengths() -> Vec<f64> {
693    (WAVELENGTH_START..=WAVELENGTH_END)
694        .step_by(WAVELENGTH_STEP)
695        .map(|value| value as f64)
696        .collect::<Vec<_>>()
697}
698
699fn load_source_data(source: IndividualObserverDataSource) -> LuxResult<ObserverSourceData> {
700    match source {
701        IndividualObserverDataSource::Asano
702        | IndividualObserverDataSource::Stockman2023
703        | IndividualObserverDataSource::AicomPlus => {
704            let wavelengths = base_wavelengths();
705            let lms_absorbance_columns = parse_columns(ASANO_LMS_ABSORBANCE, 3)?;
706            let rmd_columns = parse_columns(ASANO_RELATIVE_MACULAR_DENSITY, 1)?;
707            let docul_columns = parse_columns(ASANO_OCULAR_DENSITY, 2)?;
708
709            Ok(ObserverSourceData {
710                wavelengths,
711                lms_absorbance: [
712                    lms_absorbance_columns[0].clone(),
713                    lms_absorbance_columns[1].clone(),
714                    lms_absorbance_columns[2].clone(),
715                ],
716                relative_macular_density: rmd_columns[0].clone(),
717                ocular_density: [docul_columns[0].clone(), docul_columns[1].clone()],
718            })
719        }
720        IndividualObserverDataSource::CieTc197 => load_cietc197_source_data(),
721    }
722}
723
724fn load_cietc197_source_data() -> LuxResult<ObserverSourceData> {
725    let mut wavelengths = Vec::new();
726    let mut l_absorbance = Vec::new();
727    let mut m_absorbance = Vec::new();
728    let mut s_absorbance = Vec::new();
729    let mut ocular_sum_32 = Vec::new();
730    let mut relative_macular = Vec::new();
731
732    for line in CIETC197_ABSORBANCES.split(['\n', '\r']) {
733        let trimmed = line.trim();
734        if trimmed.is_empty() {
735            continue;
736        }
737
738        let fields = parse_csv_row_with_empty(trimmed)?;
739        if fields.len() < 7 {
740            return Err(LuxError::ParseError("invalid cietc197 absorbance row"));
741        }
742
743        let wl = fields[0].ok_or(LuxError::ParseError("missing cietc197 wavelength"))?;
744        let l = fields[2].ok_or(LuxError::ParseError("missing cietc197 L absorbance"))?;
745        let m = fields[3].ok_or(LuxError::ParseError("missing cietc197 M absorbance"))?;
746        let s = fields[4].unwrap_or(f64::NEG_INFINITY);
747        let ocular_sum = fields[5].ok_or(LuxError::ParseError("missing cietc197 ocular sum"))?;
748        let macula = fields[6].ok_or(LuxError::ParseError("missing cietc197 macular density"))?;
749
750        wavelengths.push(wl);
751        l_absorbance.push(l);
752        m_absorbance.push(m);
753        s_absorbance.push(s);
754        ocular_sum_32.push(ocular_sum);
755        relative_macular.push(macula / 0.35);
756    }
757
758    ensure_strictly_increasing(&wavelengths)?;
759
760    if let Some(first_invalid) = s_absorbance.iter().position(|value| !value.is_finite()) {
761        for value in &mut s_absorbance[first_invalid..] {
762            *value = f64::NEG_INFINITY;
763        }
764    }
765
766    let (docul2_wavelengths, docul2_values) = parse_two_column_table(CIETC197_DOCUL2)?;
767    let interpolated_docul2 = wavelengths
768        .iter()
769        .map(|wl| interpolate_linear_with_extrapolation(&docul2_wavelengths, &docul2_values, *wl))
770        .collect::<Vec<_>>();
771    let docul1 = ocular_sum_32
772        .iter()
773        .zip(interpolated_docul2.iter())
774        .map(|(sum_32, second)| sum_32 - second)
775        .collect::<Vec<_>>();
776
777    Ok(ObserverSourceData {
778        wavelengths,
779        lms_absorbance: [l_absorbance, m_absorbance, s_absorbance],
780        relative_macular_density: relative_macular,
781        ocular_density: [docul1, interpolated_docul2],
782    })
783}
784
785fn parse_columns(data: &str, expected_columns: usize) -> LuxResult<Vec<Vec<f64>>> {
786    let mut columns = vec![Vec::new(); expected_columns];
787
788    for line in data.split(['\n', '\r']) {
789        let trimmed = line.trim();
790        if trimmed.is_empty() {
791            continue;
792        }
793        let values: Vec<f64> = trimmed
794            .split(|char: char| char == ',' || char.is_ascii_whitespace())
795            .filter(|part| !part.is_empty())
796            .map(|part| {
797                part.parse::<f64>()
798                    .map_err(|_| LuxError::ParseError("invalid indvcmf numeric value"))
799            })
800            .collect::<LuxResult<Vec<f64>>>()?;
801
802        if values.len() > expected_columns {
803            return Err(LuxError::ParseError("unexpected indvcmf column count"));
804        }
805        let mut padded_values = values;
806        while padded_values.len() < expected_columns {
807            padded_values.push(0.0);
808        }
809        for (column, value) in columns.iter_mut().zip(padded_values.into_iter()) {
810            column.push(value);
811        }
812    }
813
814    Ok(columns)
815}
816
817fn parse_two_column_table(data: &str) -> LuxResult<(Vec<f64>, Vec<f64>)> {
818    let mut first = Vec::new();
819    let mut second = Vec::new();
820
821    for line in data.split(['\n', '\r']) {
822        let trimmed = line.trim();
823        if trimmed.is_empty() {
824            continue;
825        }
826        let values = split_numeric_tokens(trimmed);
827        if values.len() < 2 {
828            return Err(LuxError::ParseError("invalid two-column table"));
829        }
830        first.push(values[0]);
831        second.push(values[1]);
832    }
833
834    ensure_strictly_increasing(&first)?;
835    Ok((first, second))
836}
837
838fn parse_three_column_table(data: &str) -> LuxResult<(Vec<f64>, [Vec<f64>; 3])> {
839    let mut wavelengths = Vec::new();
840    let mut first = Vec::new();
841    let mut second = Vec::new();
842    let mut third = Vec::new();
843
844    for line in data.split(['\n', '\r']) {
845        let trimmed = line.trim();
846        if trimmed.is_empty() {
847            continue;
848        }
849        let values = split_numeric_tokens(trimmed);
850        if values.len() < 4 {
851            return Err(LuxError::ParseError("invalid three-column table"));
852        }
853        wavelengths.push(values[0]);
854        first.push(values[1]);
855        second.push(values[2]);
856        third.push(values[3]);
857    }
858    ensure_strictly_increasing(&wavelengths)?;
859    Ok((wavelengths, [first, second, third]))
860}
861
862fn split_numeric_tokens(line: &str) -> Vec<f64> {
863    line.split(|char: char| char == ',' || char.is_ascii_whitespace())
864        .filter(|part| !part.is_empty())
865        .filter_map(|part| part.parse::<f64>().ok())
866        .collect::<Vec<_>>()
867}
868
869fn parse_csv_row_with_empty(line: &str) -> LuxResult<Vec<Option<f64>>> {
870    line.split(',')
871        .map(|cell| {
872            let trimmed = cell.trim();
873            if trimmed.is_empty() {
874                Ok(None)
875            } else {
876                trimmed
877                    .parse::<f64>()
878                    .map(Some)
879                    .map_err(|_| LuxError::ParseError("invalid cietc197 numeric value"))
880            }
881        })
882        .collect::<LuxResult<Vec<Option<f64>>>>()
883}
884
885fn parse_categorical_observer_factors() -> LuxResult<(Vec<f64>, [Vec<f64>; 8])> {
886    let mut rows = Vec::new();
887    for line in ASANO_CAT_OBSERVER_FACTORS.split(['\n', '\r']) {
888        let trimmed = line.trim();
889        if trimmed.is_empty() {
890            continue;
891        }
892        rows.push(split_numeric_tokens(trimmed));
893    }
894
895    if rows.len() < 9 {
896        return Err(LuxError::ParseError(
897            "invalid categorical observer factor table",
898        ));
899    }
900
901    let category_count = rows[0].len();
902    if category_count == 0 {
903        return Err(LuxError::ParseError(
904            "empty categorical observer factor table",
905        ));
906    }
907    for row in &rows[1..9] {
908        if row.len() != category_count {
909            return Err(LuxError::ParseError(
910                "categorical observer factor table has inconsistent row lengths",
911            ));
912        }
913    }
914
915    let ages = rows[0].clone();
916    let factors = [
917        rows[1].clone(),
918        rows[2].clone(),
919        rows[3].clone(),
920        rows[4].clone(),
921        rows[5].clone(),
922        rows[6].clone(),
923        rows[7].clone(),
924        rows[8].clone(),
925    ];
926
927    Ok((ages, factors))
928}
929
930fn compute_stockman2023_observer(
931    parameters: IndividualObserverParameters,
932    source_data: ObserverSourceData,
933) -> LuxResult<IndividualObserverCmf> {
934    let wavelengths = source_data.wavelengths;
935    let relative_macular_density = source_data.relative_macular_density;
936    let ocular_density = source_data.ocular_density;
937    let lms_absorbance = source_data.lms_absorbance;
938
939    ensure_len(&wavelengths, &relative_macular_density)?;
940    ensure_len(&wavelengths, &ocular_density[0])?;
941    ensure_len(&wavelengths, &ocular_density[1])?;
942    ensure_len(&wavelengths, &lms_absorbance[0])?;
943    ensure_len(&wavelengths, &lms_absorbance[1])?;
944    ensure_len(&wavelengths, &lms_absorbance[2])?;
945
946    let shifted_absorbance = (0..3)
947        .map(|axis| {
948            shift_series_log_wavelength(
949                &wavelengths,
950                &lms_absorbance[axis],
951                parameters.cone_peak_shift[axis],
952            )
953        })
954        .collect::<LuxResult<Vec<Vec<f64>>>>()?;
955
956    let fs = parameters.field_size.clamp(FIELD_SIZE_MIN, FIELD_SIZE_MAX);
957    let alpha_10 = (fs - FIELD_SIZE_MIN) / (FIELD_SIZE_MAX - FIELD_SIZE_MIN);
958    let alpha_2 = 1.0 - alpha_10;
959    let cone_peak_density = [
960        (alpha_2 * 0.50 + alpha_10 * 0.38) * (1.0 + parameters.cone_density_variation[0] / 100.0),
961        (alpha_2 * 0.50 + alpha_10 * 0.38) * (1.0 + parameters.cone_density_variation[1] / 100.0),
962        (alpha_2 * 0.40 + alpha_10 * 0.30) * (1.0 + parameters.cone_density_variation[2] / 100.0),
963    ];
964    let k_mac =
965        (alpha_2 * 1.0 + alpha_10 * 0.271) * (1.0 + parameters.macular_density_variation / 100.0);
966    let k_lens = 1.0 * (1.0 + parameters.lens_density_variation / 100.0);
967
968    let corrected_macular_density = relative_macular_density
969        .iter()
970        .map(|value| value * k_mac)
971        .collect::<Vec<_>>();
972    let corrected_ocular_density = ocular_density[0]
973        .iter()
974        .zip(ocular_density[1].iter())
975        .map(|(first, second)| (first + second) * k_lens)
976        .collect::<Vec<_>>();
977
978    let mut absorptance = vec![vec![0.0; wavelengths.len()]; 3];
979    for axis in 0..3 {
980        for (index, wavelength) in wavelengths.iter().enumerate() {
981            absorptance[axis][index] = 1.0
982                - 10f64
983                    .powf(-cone_peak_density[axis] * 10f64.powf(shifted_absorbance[axis][index]));
984            if axis == 2 && *wavelength >= S_CONE_CUTOFF {
985                absorptance[axis][index] = 0.0;
986            }
987        }
988    }
989
990    let mut lms = vec![vec![0.0; wavelengths.len()]; 3];
991    let mut photopigment_sensitivity = vec![vec![0.0; wavelengths.len()]; 3];
992    for axis in 0..3 {
993        for (index, wavelength) in wavelengths.iter().enumerate() {
994            let quantal = absorptance[axis][index]
995                * 10f64.powf(-(corrected_macular_density[index] + corrected_ocular_density[index]));
996            lms[axis][index] = quantal * wavelength;
997            photopigment_sensitivity[axis][index] = absorptance[axis][index] * wavelength;
998        }
999        let area: f64 = lms[axis].iter().sum();
1000        if area == 0.0 {
1001            return Err(LuxError::InvalidInput(
1002                "individual observer LMS normalization area must be non-zero",
1003            ));
1004        }
1005        for value in &mut lms[axis] {
1006            *value = 100.0 * *value / area;
1007        }
1008    }
1009
1010    let lms_matrix = Spectrum::new(wavelengths.clone(), lms)?;
1011    let lms_to_xyz_matrix =
1012        individual_observer_lms_to_xyz_matrix_stockman2023(parameters.field_size);
1013    let xyz_matrix = individual_observer_lms_to_xyz_with_matrix(
1014        &lms_matrix,
1015        lms_to_xyz_matrix,
1016        parameters.allow_negative_xyz_values,
1017    )?;
1018
1019    let lens_transmission = Spectrum::new(
1020        wavelengths.clone(),
1021        corrected_ocular_density
1022            .iter()
1023            .map(|value| 10f64.powf(-value))
1024            .collect::<Vec<_>>(),
1025    )?;
1026    let macular_transmission = Spectrum::new(
1027        wavelengths.clone(),
1028        corrected_macular_density
1029            .iter()
1030            .map(|value| 10f64.powf(-value))
1031            .collect::<Vec<_>>(),
1032    )?;
1033    let photopigment_sensitivity = Spectrum::new(wavelengths, photopigment_sensitivity)?;
1034
1035    Ok(IndividualObserverCmf {
1036        lms: lms_matrix,
1037        xyz: xyz_matrix,
1038        lens_transmission,
1039        macular_transmission,
1040        photopigment_sensitivity,
1041        lms_to_xyz_matrix,
1042    })
1043}
1044
1045fn compute_aicom_plus_observer(
1046    parameters: IndividualObserverParameters,
1047    source_data: ObserverSourceData,
1048) -> LuxResult<IndividualObserverCmf> {
1049    let wavelengths = source_data.wavelengths;
1050    let relative_macular_density = source_data.relative_macular_density;
1051    let ocular_density = source_data.ocular_density;
1052    let lms_absorbance = source_data.lms_absorbance;
1053
1054    ensure_len(&wavelengths, &relative_macular_density)?;
1055    ensure_len(&wavelengths, &ocular_density[0])?;
1056    ensure_len(&wavelengths, &ocular_density[1])?;
1057    ensure_len(&wavelengths, &lms_absorbance[0])?;
1058    ensure_len(&wavelengths, &lms_absorbance[1])?;
1059    ensure_len(&wavelengths, &lms_absorbance[2])?;
1060
1061    let shifted_absorbance = (0..3)
1062        .map(|axis| {
1063            shift_series_with_linear_extrapolation(
1064                &wavelengths,
1065                &lms_absorbance[axis],
1066                parameters.cone_peak_shift[axis],
1067            )
1068        })
1069        .collect::<LuxResult<Vec<Vec<f64>>>>()?;
1070
1071    let fs = parameters.field_size;
1072    let peak_macular_density =
1073        0.485 * (-fs / 6.132).exp() * (1.0 + parameters.macular_density_variation / 100.0);
1074    let corrected_macular_density: Vec<f64> = relative_macular_density
1075        .iter()
1076        .map(|value| value * peak_macular_density)
1077        .collect::<Vec<_>>();
1078
1079    // AICOM+ adaption: replace the default ocular media template with the CIE 203 style template.
1080    let cie203_docul = cie203_ocular_density_at(&wavelengths)?;
1081    let age_scale = if parameters.age <= 60.0 {
1082        1.0 + 0.02 * (parameters.age - 32.0)
1083    } else {
1084        1.56 + 0.0667 * (parameters.age - 60.0)
1085    };
1086    let corrected_ocular_density: Vec<f64> = ocular_density[0]
1087        .iter()
1088        .zip(cie203_docul.iter())
1089        .map(|(first, second)| {
1090            (first * age_scale + second) * (1.0 + parameters.lens_density_variation / 100.0)
1091        })
1092        .collect::<Vec<_>>();
1093
1094    let cone_peak_density = [
1095        (0.38 + 0.54 * (-fs / 1.333).exp()) * (1.0 + parameters.cone_density_variation[0] / 100.0),
1096        (0.38 + 0.54 * (-fs / 1.333).exp()) * (1.0 + parameters.cone_density_variation[1] / 100.0),
1097        (0.30 + 0.45 * (-fs / 1.333).exp()) * (1.0 + parameters.cone_density_variation[2] / 100.0),
1098    ];
1099
1100    let mut alpha_lms = vec![vec![0.0; wavelengths.len()]; 3];
1101    for axis in 0..3 {
1102        for (index, wavelength) in wavelengths.iter().enumerate() {
1103            alpha_lms[axis][index] = 1.0
1104                - 10f64
1105                    .powf(-cone_peak_density[axis] * 10f64.powf(shifted_absorbance[axis][index]));
1106            if axis == 2 && *wavelength >= S_CONE_CUTOFF {
1107                alpha_lms[axis][index] = 0.0;
1108            }
1109        }
1110    }
1111
1112    let mut lms_raw = vec![vec![0.0; wavelengths.len()]; 3];
1113    let mut lms_normalized = vec![vec![0.0; wavelengths.len()]; 3];
1114    let mut photopigment_sensitivity = vec![vec![0.0; wavelengths.len()]; 3];
1115    for axis in 0..3 {
1116        for (index, wavelength) in wavelengths.iter().enumerate() {
1117            let lms_quantal = alpha_lms[axis][index]
1118                * 10f64.powf(-corrected_macular_density[index] - corrected_ocular_density[index]);
1119            let energy = lms_quantal * wavelength;
1120            lms_raw[axis][index] = energy;
1121            lms_normalized[axis][index] = energy;
1122            photopigment_sensitivity[axis][index] = alpha_lms[axis][index] * wavelength;
1123        }
1124        let area: f64 = lms_normalized[axis].iter().sum();
1125        if area == 0.0 {
1126            return Err(LuxError::InvalidInput(
1127                "individual observer LMS normalization area must be non-zero",
1128            ));
1129        }
1130        for value in &mut lms_normalized[axis] {
1131            *value = 100.0 * *value / area;
1132        }
1133    }
1134
1135    // AICOM+ adaption: skip LMS normalization before LMS->XYZ conversion.
1136    let lms_for_xyz = Spectrum::new(wavelengths.clone(), lms_raw)?;
1137    let lms_output = Spectrum::new(wavelengths.clone(), lms_normalized)?;
1138    let lms_to_xyz_matrix = fit_cietc197_lms_to_xyz_matrix(&lms_for_xyz, parameters.field_size)?;
1139    let xyz_matrix = individual_observer_lms_to_xyz_with_matrix(
1140        &lms_for_xyz,
1141        lms_to_xyz_matrix,
1142        parameters.allow_negative_xyz_values,
1143    )?;
1144    let lens_transmission = Spectrum::new(
1145        wavelengths.clone(),
1146        corrected_ocular_density
1147            .iter()
1148            .map(|value| 10f64.powf(-value))
1149            .collect::<Vec<_>>(),
1150    )?;
1151    let macular_transmission = Spectrum::new(
1152        wavelengths.clone(),
1153        corrected_macular_density
1154            .iter()
1155            .map(|value| 10f64.powf(-value))
1156            .collect::<Vec<_>>(),
1157    )?;
1158    let photopigment_sensitivity = Spectrum::new(wavelengths, photopigment_sensitivity)?;
1159
1160    Ok(IndividualObserverCmf {
1161        lms: lms_output,
1162        xyz: xyz_matrix,
1163        lens_transmission,
1164        macular_transmission,
1165        photopigment_sensitivity,
1166        lms_to_xyz_matrix,
1167    })
1168}
1169
1170fn cie203_ocular_density_at(wavelengths: &[f64]) -> LuxResult<Vec<f64>> {
1171    let (docul2_wavelengths, docul2_values) = parse_two_column_table(CIETC197_DOCUL2)?;
1172    Ok(wavelengths
1173        .iter()
1174        .map(|wl| interpolate_linear_with_extrapolation(&docul2_wavelengths, &docul2_values, *wl))
1175        .collect::<Vec<_>>())
1176}
1177
1178fn shift_series_log_wavelength(
1179    wavelengths: &[f64],
1180    values: &[f64],
1181    shift_nm: f64,
1182) -> LuxResult<Vec<f64>> {
1183    ensure_len(wavelengths, values)?;
1184    if wavelengths.is_empty() {
1185        return Ok(Vec::new());
1186    }
1187    if wavelengths.len() == 1 || shift_nm == 0.0 {
1188        return Ok(values.to_vec());
1189    }
1190
1191    let peak_index = values
1192        .iter()
1193        .enumerate()
1194        .filter(|(_, value)| value.is_finite())
1195        .max_by(|lhs, rhs| {
1196            lhs.1
1197                .partial_cmp(rhs.1)
1198                .unwrap_or(std::cmp::Ordering::Equal)
1199        })
1200        .map(|(index, _)| index)
1201        .ok_or(LuxError::InvalidInput(
1202            "cannot infer cone absorbance peak for log-shift",
1203        ))?;
1204    let lambda_max = wavelengths[peak_index];
1205    let lambda_max_shifted = lambda_max + shift_nm;
1206    if lambda_max_shifted <= 0.0 {
1207        return Err(LuxError::InvalidInput(
1208            "cone peak shift leads to non-positive shifted lambda_max",
1209        ));
1210    }
1211    let scale = lambda_max / lambda_max_shifted;
1212
1213    let mut shifted = Vec::with_capacity(wavelengths.len());
1214    for wavelength in wavelengths {
1215        let query = wavelength * scale;
1216        let mut value = interpolate_linear_with_extrapolation(wavelengths, values, query);
1217        if !value.is_finite() {
1218            value = f64::NEG_INFINITY;
1219        }
1220        shifted.push(value);
1221    }
1222    Ok(shifted)
1223}
1224
1225fn fit_cietc197_lms_to_xyz_matrix(lms: &Spectrum, field_size: f64) -> LuxResult<Matrix3> {
1226    let target_xyz = cie2006_xyz_reference(field_size, lms.wavelengths())?;
1227    fit_lms_to_xyz_matrix(lms, &target_xyz)
1228}
1229
1230fn cie2006_xyz_reference(field_size: f64, wavelengths: &[f64]) -> LuxResult<[Vec<f64>; 3]> {
1231    let (wl2, xyz2) = parse_three_column_table(CIE2006_XYZ_2_DEG)?;
1232    let (wl10, xyz10) = parse_three_column_table(CIE2006_XYZ_10_DEG)?;
1233
1234    let fs = field_size.clamp(FIELD_SIZE_MIN, FIELD_SIZE_MAX);
1235    let alpha_10 = (fs - FIELD_SIZE_MIN) / (FIELD_SIZE_MAX - FIELD_SIZE_MIN);
1236    let alpha_2 = 1.0 - alpha_10;
1237
1238    let mut out = [Vec::new(), Vec::new(), Vec::new()];
1239    for wl in wavelengths {
1240        let x2 = interpolate_linear_with_extrapolation(&wl2, &xyz2[0], *wl);
1241        let y2 = interpolate_linear_with_extrapolation(&wl2, &xyz2[1], *wl);
1242        let z2 = interpolate_linear_with_extrapolation(&wl2, &xyz2[2], *wl);
1243        let x10 = interpolate_linear_with_extrapolation(&wl10, &xyz10[0], *wl);
1244        let y10 = interpolate_linear_with_extrapolation(&wl10, &xyz10[1], *wl);
1245        let z10 = interpolate_linear_with_extrapolation(&wl10, &xyz10[2], *wl);
1246
1247        out[0].push(alpha_2 * x2 + alpha_10 * x10);
1248        out[1].push(alpha_2 * y2 + alpha_10 * y10);
1249        out[2].push(alpha_2 * z2 + alpha_10 * z10);
1250    }
1251    Ok(out)
1252}
1253
1254fn fit_lms_to_xyz_matrix(lms: &Spectrum, target_xyz: &[Vec<f64>; 3]) -> LuxResult<Matrix3> {
1255    if lms.spectrum_count() != 3 {
1256        return Err(LuxError::InvalidInput(
1257            "individual observer LMS input must contain exactly 3 spectra",
1258        ));
1259    }
1260    for channel in target_xyz {
1261        ensure_len(lms.wavelengths(), channel)?;
1262    }
1263
1264    let l = &lms.spectra()[0];
1265    let m = &lms.spectra()[1];
1266    let s = &lms.spectra()[2];
1267
1268    let mut ata = [[0.0; 3]; 3];
1269    for index in 0..l.len() {
1270        let row = [l[index], m[index], s[index]];
1271        for r in 0..3 {
1272            for c in 0..3 {
1273                ata[r][c] += row[r] * row[c];
1274            }
1275        }
1276    }
1277
1278    let mut atb_rows = [[0.0; 3]; 3];
1279    for row in 0..3 {
1280        for index in 0..l.len() {
1281            let target = target_xyz[row][index];
1282            atb_rows[row][0] += l[index] * target;
1283            atb_rows[row][1] += m[index] * target;
1284            atb_rows[row][2] += s[index] * target;
1285        }
1286    }
1287
1288    let trace = ata[0][0] + ata[1][1] + ata[2][2];
1289    let base_lambda = (trace / 3.0).max(1e-18);
1290
1291    for attempt in 0..10 {
1292        let lambda = base_lambda * 10f64.powi(attempt - 12);
1293        let regularized = [
1294            [ata[0][0] + lambda, ata[0][1], ata[0][2]],
1295            [ata[1][0], ata[1][1] + lambda, ata[1][2]],
1296            [ata[2][0], ata[2][1], ata[2][2] + lambda],
1297        ];
1298        if let Some(ata_inverse) = invert_3x3(regularized) {
1299            let mut matrix = [[0.0; 3]; 3];
1300            for row in 0..3 {
1301                matrix[row] = multiply_matrix3_vector3(ata_inverse, atb_rows[row]);
1302            }
1303            if matrix
1304                .iter()
1305                .flat_map(|row| row.iter())
1306                .all(|value| value.is_finite())
1307            {
1308                return Ok(matrix);
1309            }
1310        }
1311    }
1312
1313    Err(LuxError::InvalidInput(
1314        "unable to solve stable cietc197 lms->xyz matrix fit",
1315    ))
1316}
1317
1318fn invert_3x3(matrix: Matrix3) -> Option<Matrix3> {
1319    let m = matrix;
1320    let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
1321        - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
1322        + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
1323    if det.abs() < 1e-20 {
1324        return None;
1325    }
1326    let inv_det = 1.0 / det;
1327    Some([
1328        [
1329            (m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv_det,
1330            (m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv_det,
1331            (m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv_det,
1332        ],
1333        [
1334            (m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv_det,
1335            (m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv_det,
1336            (m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv_det,
1337        ],
1338        [
1339            (m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv_det,
1340            (m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv_det,
1341            (m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv_det,
1342        ],
1343    ])
1344}
1345
1346fn ensure_len(wavelengths: &[f64], values: &[f64]) -> LuxResult<()> {
1347    if wavelengths.len() != values.len() {
1348        return Err(LuxError::MismatchedLengths {
1349            wavelengths: wavelengths.len(),
1350            values: values.len(),
1351        });
1352    }
1353    Ok(())
1354}
1355
1356fn ensure_strictly_increasing(values: &[f64]) -> LuxResult<()> {
1357    for pair in values.windows(2) {
1358        if pair[1] <= pair[0] {
1359            return Err(LuxError::NonMonotonicWavelengths);
1360        }
1361    }
1362    Ok(())
1363}
1364
1365fn shift_series_with_linear_extrapolation(
1366    wavelengths: &[f64],
1367    values: &[f64],
1368    shift_nm: f64,
1369) -> LuxResult<Vec<f64>> {
1370    ensure_len(wavelengths, values)?;
1371
1372    if wavelengths.is_empty() {
1373        return Ok(Vec::new());
1374    }
1375    if wavelengths.len() == 1 {
1376        return Ok(vec![values[0]]);
1377    }
1378
1379    let mut shifted = Vec::with_capacity(wavelengths.len());
1380    for wavelength in wavelengths {
1381        let mut value =
1382            interpolate_linear_with_extrapolation(wavelengths, values, wavelength - shift_nm);
1383        if !value.is_finite() {
1384            value = f64::NEG_INFINITY;
1385        }
1386        shifted.push(value);
1387    }
1388    Ok(shifted)
1389}
1390
1391fn interpolate_linear_with_extrapolation(x: &[f64], y: &[f64], query: f64) -> f64 {
1392    if query <= x[0] {
1393        return linear_segment(x[0], y[0], x[1], y[1], query);
1394    }
1395    let last = x.len() - 1;
1396    if query >= x[last] {
1397        return linear_segment(x[last - 1], y[last - 1], x[last], y[last], query);
1398    }
1399
1400    for index in 0..last {
1401        if query <= x[index + 1] {
1402            return linear_segment(x[index], y[index], x[index + 1], y[index + 1], query);
1403        }
1404    }
1405    y[last]
1406}
1407
1408fn linear_segment(x0: f64, y0: f64, x1: f64, y1: f64, query: f64) -> f64 {
1409    if x1 == x0 {
1410        return y0;
1411    }
1412    y0 + (query - x0) * (y1 - y0) / (x1 - x0)
1413}
1414
1415fn interpolate_matrix3(lhs: Matrix3, rhs: Matrix3, lhs_weight: f64) -> Matrix3 {
1416    let rhs_weight = 1.0 - lhs_weight;
1417    let mut matrix = [[0.0; 3]; 3];
1418    for row in 0..3 {
1419        for col in 0..3 {
1420            matrix[row][col] = lhs[row][col] * lhs_weight + rhs[row][col] * rhs_weight;
1421        }
1422    }
1423    matrix
1424}
1425
1426fn multiply_matrix3_vector3(matrix: Matrix3, vector: [f64; 3]) -> [f64; 3] {
1427    [
1428        matrix[0][0] * vector[0] + matrix[0][1] * vector[1] + matrix[0][2] * vector[2],
1429        matrix[1][0] * vector[0] + matrix[1][1] * vector[1] + matrix[1][2] * vector[2],
1430        matrix[2][0] * vector[0] + matrix[2][1] * vector[1] + matrix[2][2] * vector[2],
1431    ]
1432}
1433
1434#[derive(Debug, Clone)]
1435struct LcgRng {
1436    state: u64,
1437}
1438
1439impl LcgRng {
1440    fn new(seed: u64) -> Self {
1441        let state = if seed == 0 { 1 } else { seed };
1442        Self { state }
1443    }
1444
1445    fn next_u64(&mut self) -> u64 {
1446        self.state = self
1447            .state
1448            .wrapping_mul(LCG_MULTIPLIER)
1449            .wrapping_add(LCG_INCREMENT);
1450        self.state
1451    }
1452
1453    fn next_f64(&mut self) -> f64 {
1454        // [0, 1)
1455        let value = self.next_u64() >> 11;
1456        (value as f64) / ((1u64 << 53) as f64)
1457    }
1458
1459    fn next_usize(&mut self, upper_bound: usize) -> usize {
1460        if upper_bound <= 1 {
1461            return 0;
1462        }
1463        (self.next_u64() as usize) % upper_bound
1464    }
1465
1466    fn next_standard_normal(&mut self) -> f64 {
1467        let mut u1 = self.next_f64();
1468        while u1 <= f64::MIN_POSITIVE {
1469            u1 = self.next_f64();
1470        }
1471        let u2 = self.next_f64();
1472        let r = (-2.0 * u1.ln()).sqrt();
1473        let theta = 2.0 * std::f64::consts::PI * u2;
1474        r * theta.cos()
1475    }
1476}
1477
1478// --- Measured individual observer support ---
1479
1480pub const LMS_TO_XYZ_2DEG_FIXED: Matrix3 = [
1481    [ 1.94735469, -1.41445123,  0.36476327],
1482    [ 0.68990272,  0.34832189,  0.00000000],
1483    [ 0.00000000,  0.00000000,  1.93485343],
1484];
1485
1486pub const LMS_TO_XYZ_10DEG_FIXED: Matrix3 = [
1487    [ 1.93906444, -1.37420684,  0.39960583],
1488    [ 0.69784280,  0.34538187,  0.00000000],
1489    [ 0.00000000,  0.00000000,  2.03077344],
1490];
1491
1492#[derive(Debug, Clone, Copy, PartialEq)]
1493pub struct IndividualObserverMeasuredParameters {
1494    pub lshift: f64,
1495    pub mshift: f64,
1496    pub sshift: f64,
1497    pub lod: f64,
1498    pub mod_: f64,
1499    pub sod: f64,
1500    pub mac: f64,
1501    pub lens: f64,
1502    pub field_size: f64,
1503}
1504
1505const LSER_COEFFS: [f64; 18] = [
1506    -42.417608560, -2.656791612, 75.011093607, 56.477062776, 7.509397607, 9.061442173,
1507    -38.068488495, -20.974610259, -6.642746250, -3.785039126, 9.322071459, 3.134494745,
1508    1.603799055, 0.439302358, -0.676958684, -0.072988371, -0.078857510, -0.004264105
1509];
1510
1511const MCONE_COEFFS: [f64; 18] = [
1512    -210.6568853069, -0.1458073553, 386.7319763250, 305.4710584670, 5.0218382813, 6.8386224350,
1513    -208.2062335724, -118.4890200521, -5.7625866330, -3.7973553168, 55.1803460639, 19.9728512548,
1514    1.8990456325, 0.6913410864, -5.0891806213, -0.7070689492, -0.1419926703, 0.0005894876
1515];
1516
1517const SCONE_COEFFS: [f64; 18] = [
1518    207.3880950935, -6.3065623516, -393.7100478026, -315.6650602846, 19.2917535553, 19.6414743488,
1519    214.2211570447, 121.8584683485, -15.1820737886, -8.6774057156, -56.7596380441, -20.6318720369,
1520    3.6934875040, 1.0483022480, 5.3656615075, 0.7898783086, -0.1480357836, 0.0002358232
1521];
1522
1523const MACULAR_COEFFS: [f64; 24] = [
1524    3712.2037792986, 374.1811575175, -7007.6989637831, -5887.2857515364, -633.0475233043,
1525    -716.0429039473, 4386.8811254914, 2882.1092658881, 638.1347550701, 468.4980700497,
1526    -1653.7567388120, -817.1240899995, -286.4038978705, -144.7996457395, 340.3364828167,
1527    115.5652804221, 59.1650826447, 18.6678197694, -30.2344535413, -5.4683753172, -4.1335064207,
1528    -0.5043959566, 0.5094171266, 1.0050048550
1529];
1530
1531const LENS_COEFFS: [f64; 20] = [
1532    -313.9508632762, -70.3216819666, 585.4719725809, 471.5395862431, 117.3539102044,
1533    127.0168222865, -324.4700544731, -188.1638078982, -104.5512488013, -68.3078486904,
1534    89.7815373733, 33.4498264952, 35.2723638870, 13.6524086627, -8.7568168893, -1.2825766708,
1535    -3.5126531075, -0.4477840959, 0.0428291365, 1.0091871745
1536];
1537
1538fn evaluate_fourier_series(x: f64, c: &[f64]) -> f64 {
1539    let mut sum = c[0];
1540    for k in 1..=8 {
1541        let k_f = k as f64;
1542        sum += c[2 * k - 1] * (k_f * x).cos() + c[2 * k] * (k_f * x).sin();
1543    }
1544    sum + c[17]
1545}
1546
1547fn evaluate_macular_fourier(x: f64, c: &[f64; 24]) -> f64 {
1548    let mut sum = c[0];
1549    for k in 1..=11 {
1550        let k_f = k as f64;
1551        sum += c[2 * k - 1] * (k_f * x).cos() + c[2 * k] * (k_f * x).sin();
1552    }
1553    sum * c[23]
1554}
1555
1556fn evaluate_lens_fourier(x: f64, c: &[f64; 20]) -> f64 {
1557    let mut sum = c[0];
1558    for k in 1..=9 {
1559        let k_f = k as f64;
1560        sum += c[2 * k - 1] * (k_f * x).cos() + c[2 * k] * (k_f * x).sin();
1561    }
1562    sum * c[19]
1563}
1564
1565fn lserconelog(nm: f64, lshift: f64) -> f64 {
1566    let x = (nm.log10() - 2.5563025007672873) / 0.11876664675818423;
1567    let xshift = (553.1 / (553.1 + lshift)).log10() / 0.11876664675818423;
1568    evaluate_fourier_series(x + xshift, &LSER_COEFFS)
1569}
1570
1571fn mconelog(nm: f64, mshift: f64) -> f64 {
1572    let x = (nm.log10() - 2.5563025007672873) / 0.11876664675818423;
1573    let xshift = (529.9 / (529.9 + mshift)).log10() / 0.11876664675818423;
1574    evaluate_fourier_series(x + xshift, &MCONE_COEFFS)
1575}
1576
1577fn sconelog(nm: f64, sshift: f64) -> f64 {
1578    let x = (nm.log10() - 2.5563025007672873) / 0.11876664675818423;
1579    let xshift = (416.9 / (416.9 + sshift)).log10() / 0.11876664675818423;
1580    evaluate_fourier_series(x + xshift, &SCONE_COEFFS)
1581}
1582
1583fn macular_density_template(nm: f64) -> f64 {
1584    let x = (nm - 375.0) / 55.70423008;
1585    if x >= 0.0 && x <= ((550.0 - 375.0) / 55.70423008) {
1586        evaluate_macular_fourier(x, &MACULAR_COEFFS)
1587    } else {
1588        0.0
1589    }
1590}
1591
1592fn lens_density_template(nm: f64) -> f64 {
1593    let x = (nm - 360.0) / 95.49296586;
1594    if x >= 0.0 && x <= ((660.0 - 360.0) / 95.49296586) {
1595        evaluate_lens_fourier(x, &LENS_COEFFS)
1596    } else {
1597        0.0
1598    }
1599}
1600
1601pub fn individual_observer_cmf_from_measured(
1602    wavelengths: &[f64],
1603    params: IndividualObserverMeasuredParameters,
1604    custom_matrix: Option<Matrix3>,
1605) -> LuxResult<IndividualObserverCmf> {
1606    if wavelengths.is_empty() {
1607        return Err(LuxError::EmptyInput);
1608    }
1609
1610    let n = wavelengths.len();
1611    let mut lms_quantal = vec![vec![0.0; n]; 3];
1612    let mut lens_trans = Vec::with_capacity(n);
1613    let mut macular_trans = Vec::with_capacity(n);
1614
1615    let mac_scale = params.mac / 0.35;
1616    let lens_scale = params.lens / 1.7649;
1617
1618    for (i, &w) in wavelengths.iter().enumerate() {
1619        let l_log_abs = lserconelog(w, params.lshift);
1620        let m_log_abs = mconelog(w, params.mshift);
1621        let s_log_abs = sconelog(w, params.sshift);
1622
1623        let l_abs = 10f64.powf(l_log_abs);
1624        let m_abs = 10f64.powf(m_log_abs);
1625        let s_abs = 10f64.powf(s_log_abs);
1626
1627        let l_retina = (1.0 - 10f64.powf(-params.lod * l_abs)) / (1.0 - 10f64.powf(-params.lod));
1628        let m_retina = (1.0 - 10f64.powf(-params.mod_ * m_abs)) / (1.0 - 10f64.powf(-params.mod_));
1629        let s_retina = (1.0 - 10f64.powf(-params.sod * s_abs)) / (1.0 - 10f64.powf(-params.sod));
1630
1631        let mac_temp = macular_density_template(w);
1632        let lens_temp = lens_density_template(w);
1633
1634        let mac_d = mac_temp * mac_scale;
1635        let lens_d = lens_temp * lens_scale;
1636
1637        let mac_transmission = 10f64.powf(-mac_d);
1638        let lens_transmission = 10f64.powf(-lens_d);
1639
1640        lens_trans.push(lens_transmission);
1641        macular_trans.push(mac_transmission);
1642
1643        let mac_lens_factor = mac_transmission * lens_transmission;
1644        lms_quantal[0][i] = l_retina * mac_lens_factor;
1645        lms_quantal[1][i] = m_retina * mac_lens_factor;
1646        lms_quantal[2][i] = s_retina * mac_lens_factor;
1647    }
1648
1649    for axis in 0..3 {
1650        let max_val = lms_quantal[axis].iter().copied().fold(f64::NEG_INFINITY, f64::max);
1651        if max_val > 0.0 {
1652            for val in &mut lms_quantal[axis] {
1653                *val /= max_val;
1654            }
1655        }
1656    }
1657
1658    let mut lms_energy = vec![vec![0.0; n]; 3];
1659    let mut photopigment = vec![vec![0.0; n]; 3];
1660    for axis in 0..3 {
1661        for i in 0..n {
1662            lms_energy[axis][i] = lms_quantal[axis][i] * wavelengths[i];
1663        }
1664        let max_val = lms_energy[axis].iter().copied().fold(f64::NEG_INFINITY, f64::max);
1665        if max_val > 0.0 {
1666            for val in &mut lms_energy[axis] {
1667                *val /= max_val;
1668            }
1669        }
1670
1671        for i in 0..n {
1672            let w = wavelengths[i];
1673            let abs_log = match axis {
1674                0 => lserconelog(w, params.lshift),
1675                1 => mconelog(w, params.mshift),
1676                2 => sconelog(w, params.sshift),
1677                _ => 0.0,
1678            };
1679            let abs_lin = 10f64.powf(abs_log);
1680            let od = match axis {
1681                0 => params.lod,
1682                1 => params.mod_,
1683                2 => params.sod,
1684                _ => 0.0,
1685            };
1686            let retina = (1.0 - 10f64.powf(-od * abs_lin)) / (1.0 - 10f64.powf(-od));
1687            photopigment[axis][i] = retina * w;
1688        }
1689    }
1690
1691    let lms_spectrum = Spectrum::new(wavelengths.to_vec(), lms_energy)?;
1692
1693    let matrix = custom_matrix.unwrap_or_else(|| {
1694        if params.field_size <= 4.0 {
1695            LMS_TO_XYZ_2DEG_FIXED
1696        } else {
1697            LMS_TO_XYZ_10DEG_FIXED
1698        }
1699    });
1700
1701    let mut xyz_values = vec![vec![0.0; n]; 3];
1702    for i in 0..n {
1703        let lms_val = [
1704            lms_spectrum.spectra()[0][i],
1705            lms_spectrum.spectra()[1][i],
1706            lms_spectrum.spectra()[2][i],
1707        ];
1708        let xyz_val = multiply_matrix3_vector3(matrix, lms_val);
1709        for axis in 0..3 {
1710            xyz_values[axis][i] = xyz_val[axis];
1711        }
1712    }
1713
1714    let xyz_spectrum = Spectrum::new(wavelengths.to_vec(), xyz_values)?;
1715    let lens_transmission = Spectrum::new(wavelengths.to_vec(), lens_trans)?;
1716    let macular_transmission = Spectrum::new(wavelengths.to_vec(), macular_trans)?;
1717    let photopigment_sensitivity = Spectrum::new(wavelengths.to_vec(), photopigment)?;
1718
1719    Ok(IndividualObserverCmf {
1720        lms: lms_spectrum,
1721        xyz: xyz_spectrum,
1722        lens_transmission,
1723        macular_transmission,
1724        photopigment_sensitivity,
1725        lms_to_xyz_matrix: matrix,
1726    })
1727}
1728