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_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 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 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 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
1478pub 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