Skip to main content

lux_rs/
spdbuild.rs

1use crate::color::{xyz_to_yxy, Observer};
2use crate::error::{LuxError, LuxResult};
3use crate::illuminants::cct_to_xyz;
4use crate::spectrum::{getwlr, Spectrum, WavelengthGrid};
5
6pub const DEFAULT_WL_GRID: WavelengthGrid = WavelengthGrid {
7    start: 360.0,
8    end: 830.0,
9    step: 1.0,
10};
11
12#[derive(Debug, Clone, PartialEq)]
13pub struct RoundedTriangleParams {
14    pub peakwl: f64,
15    pub fwhm: Option<f64>,
16    pub rounding: f64,
17    pub min_v: f64,
18    pub max_v: f64,
19    pub fw: f64,
20    pub rw: f64,
21}
22
23impl Default for RoundedTriangleParams {
24    fn default() -> Self {
25        Self {
26            peakwl: 530.0,
27            fwhm: Some(100.0),
28            rounding: 0.5,
29            min_v: 0.0,
30            max_v: 1.0,
31            fw: 100.0,
32            rw: 100.0,
33        }
34    }
35}
36
37#[derive(Debug, Clone, PartialEq)]
38pub struct MonoLedParams {
39    pub peakwl: f64,
40    pub fwhm: f64,
41    pub strength_shoulder: f64,
42    pub bw_order: f64,
43}
44
45impl Default for MonoLedParams {
46    fn default() -> Self {
47        Self {
48            peakwl: 530.0,
49            fwhm: 20.0,
50            strength_shoulder: 2.0,
51            bw_order: -1.0,
52        }
53    }
54}
55
56#[derive(Debug, Clone, PartialEq)]
57pub struct PhosphorLedParams {
58    pub peakwl: f64,
59    pub fwhm: f64,
60    pub bw_order: f64,
61    pub strength_shoulder: f64,
62    pub strength_ph: Option<f64>,
63    pub peakwl_ph1: f64,
64    pub fwhm_ph1: f64,
65    pub strength_ph1: f64,
66    pub peakwl_ph2: f64,
67    pub fwhm_ph2: f64,
68    pub strength_ph2: Option<f64>,
69    pub use_piecewise_fcn: bool,
70}
71
72impl Default for PhosphorLedParams {
73    fn default() -> Self {
74        Self {
75            peakwl: 450.0,
76            fwhm: 20.0,
77            bw_order: -1.0,
78            strength_shoulder: 2.0,
79            strength_ph: Some(0.0),
80            peakwl_ph1: 530.0,
81            fwhm_ph1: 80.0,
82            strength_ph1: 1.0,
83            peakwl_ph2: 560.0,
84            fwhm_ph2: 80.0,
85            strength_ph2: None,
86            use_piecewise_fcn: false,
87        }
88    }
89}
90
91#[derive(Debug, Clone)]
92pub struct PhosphorLedComponents {
93    pub spd: Spectrum,
94    pub components: Spectrum,
95}
96
97/// Generate Gaussian spectrum.
98pub fn gaussian_spd(
99    peakwls: &[f64],
100    fwhms: &[f64],
101    grid: Option<WavelengthGrid>,
102) -> LuxResult<Spectrum> {
103    if peakwls.is_empty() || fwhms.is_empty() {
104        return Err(LuxError::EmptyInput);
105    }
106    if fwhms.len() != 1 && fwhms.len() != peakwls.len() {
107        return Err(LuxError::InvalidGridSpec);
108    }
109
110    let grid = grid.unwrap_or(DEFAULT_WL_GRID);
111    let wavelengths = getwlr(grid)?;
112    let num_peaks = peakwls.len();
113    let fwhm_to_sig = 1.0 / (2.0 * (2.0 * 2.0f64.ln()).sqrt());
114    let mut spectra = Vec::with_capacity(num_peaks);
115
116    for i in 0..num_peaks {
117        let peakwl = peakwls[i];
118        let fwhm = if fwhms.len() == 1 { fwhms[0] } else { fwhms[i] };
119        let sig = fwhm * fwhm_to_sig;
120
121        let mut values = Vec::with_capacity(wavelengths.len());
122        for &wl in &wavelengths {
123            let val = (-0.5 * ((wl - peakwl) / sig).powi(2)).exp();
124            values.push(val);
125        }
126        spectra.push(values);
127    }
128
129    Spectrum::new(wavelengths, spectra)
130}
131
132/// Generate 2nd order Lorentzian spectrum.
133pub fn lorentzian2_spd(
134    peakwls: &[f64],
135    fwhms: &[f64],
136    grid: Option<WavelengthGrid>,
137) -> LuxResult<Spectrum> {
138    if peakwls.is_empty() || fwhms.is_empty() {
139        return Err(LuxError::EmptyInput);
140    }
141    if fwhms.len() != 1 && fwhms.len() != peakwls.len() {
142        return Err(LuxError::InvalidGridSpec);
143    }
144
145    let grid = grid.unwrap_or(DEFAULT_WL_GRID);
146    let wavelengths = getwlr(grid)?;
147    let num_peaks = peakwls.len();
148    let n = 2.0 * (2.0f64.sqrt() - 1.0).sqrt();
149    let mut spectra = Vec::with_capacity(num_peaks);
150
151    for i in 0..num_peaks {
152        let peakwl = peakwls[i];
153        let fwhm = if fwhms.len() == 1 { fwhms[0] } else { fwhms[i] };
154
155        let mut values = Vec::with_capacity(wavelengths.len());
156        for &wl in &wavelengths {
157            let val = (1.0 + (n * (wl - peakwl) / fwhm).powi(2)).powf(-2.0);
158            values.push(val);
159        }
160        spectra.push(values);
161    }
162
163    Spectrum::new(wavelengths, spectra)
164}
165
166/// Generate Butterworth based spectrum.
167pub fn butterworth_spd(
168    peakwls: &[f64],
169    fwhms: &[f64],
170    bw_orders: &[f64],
171    grid: Option<WavelengthGrid>,
172) -> LuxResult<Spectrum> {
173    if peakwls.is_empty() || fwhms.is_empty() || bw_orders.is_empty() {
174        return Err(LuxError::EmptyInput);
175    }
176    if (fwhms.len() != 1 && fwhms.len() != peakwls.len())
177        || (bw_orders.len() != 1 && bw_orders.len() != peakwls.len())
178    {
179        return Err(LuxError::InvalidGridSpec);
180    }
181
182    let grid = grid.unwrap_or(DEFAULT_WL_GRID);
183    let wavelengths = getwlr(grid)?;
184    let num_peaks = peakwls.len();
185    let mut spectra = Vec::with_capacity(num_peaks);
186
187    for i in 0..num_peaks {
188        let peakwl = peakwls[i];
189        let fwhm = if fwhms.len() == 1 { fwhms[0] } else { fwhms[i] };
190        let bw_order = if bw_orders.len() == 1 { bw_orders[0] } else { bw_orders[i] };
191
192        let mut values = Vec::with_capacity(wavelengths.len());
193        for &wl in &wavelengths {
194            let val = 1.0 / (1.0 + (2.0 * (wl - peakwl) / fwhm).abs().powf(2.0 * bw_order));
195            values.push(val);
196        }
197        spectra.push(values);
198    }
199
200    Spectrum::new(wavelengths, spectra)
201}
202
203/// Generate rounded triangle spectrum.
204pub fn roundedtriangle_spd(
205    params: &[RoundedTriangleParams],
206    grid: Option<WavelengthGrid>,
207) -> LuxResult<Spectrum> {
208    if params.is_empty() {
209        return Err(LuxError::EmptyInput);
210    }
211
212    let grid = grid.unwrap_or(DEFAULT_WL_GRID);
213    let wavelengths = getwlr(grid)?;
214    let num_peaks = params.len();
215    let mut spectra = Vec::with_capacity(num_peaks);
216
217    for param in params {
218        let peakwl = param.peakwl.abs();
219        let rounding = param.rounding.abs();
220        let min_v = param.min_v.abs();
221        let max_v = param.max_v.abs();
222
223        let (fw, rw) = match param.fwhm {
224            Some(fwhm) => {
225                let width = fwhm.abs() / (rounding / 4.0 + 1.0);
226                (width, width)
227            }
228            None => (param.fw.abs(), param.rw.abs()),
229        };
230
231        let eps = 1e-308;
232        let r_param = if rounding == 0.0 { eps } else { rounding };
233
234        let mut values = Vec::with_capacity(wavelengths.len());
235        for &wl in &wavelengths {
236            let wlp = wl - peakwl;
237            let x = if wlp < 0.0 { wlp / fw } else { wlp / rw };
238            let abs_x = x.abs();
239
240            let rraw = if abs_x < r_param / 2.0 {
241                1.0 - r_param / 4.0 - (1.0 / r_param) * x.powi(2)
242            } else if abs_x >= r_param / 2.0 && abs_x < 1.0 - r_param / 2.0 {
243                1.0 - abs_x
244            } else if abs_x >= 1.0 - r_param / 2.0 && abs_x < 1.0 + r_param / 2.0 {
245                1.0 / (2.0 * r_param) * (abs_x - (1.0 + r_param / 2.0)).powi(2)
246            } else {
247                0.0
248            };
249
250            let spd_val = min_v + (max_v - min_v) * rraw / (1.0 - rounding / 4.0);
251            values.push(spd_val);
252        }
253        spectra.push(values);
254    }
255
256    Spectrum::new(wavelengths, spectra)
257}
258
259/// Generate monochromatic LED spectrum.
260pub fn mono_led_spd(
261    params: &[MonoLedParams],
262    grid: Option<WavelengthGrid>,
263) -> LuxResult<Spectrum> {
264    if params.is_empty() {
265        return Err(LuxError::EmptyInput);
266    }
267
268    let grid = grid.unwrap_or(DEFAULT_WL_GRID);
269    let wavelengths = getwlr(grid)?;
270    let num_peaks = params.len();
271    let mut spectra = Vec::with_capacity(num_peaks);
272
273    for param in params {
274        let peakwl = param.peakwl;
275        let fwhm = param.fwhm;
276        let strength_shoulder = param.strength_shoulder;
277        let bw_order = param.bw_order;
278
279        let mut values = Vec::with_capacity(wavelengths.len());
280
281        if bw_order == -2.0 {
282            let spd = lorentzian2_spd(&[peakwl], &[fwhm], Some(grid))?;
283            values.extend_from_slice(spd.spectra()[0].as_slice());
284        } else {
285            let g_spd = gaussian_spd(&[peakwl], &[fwhm], Some(grid))?;
286            let g = g_spd.spectra()[0].as_slice();
287
288            let mut ohno = Vec::with_capacity(wavelengths.len());
289            for &g_val in g {
290                let val = (g_val + strength_shoulder * g_val.powi(5)) / (1.0 + strength_shoulder);
291                ohno.push(val);
292            }
293
294            if bw_order == -1.0 || bw_order == 0.0 {
295                values.extend_from_slice(&ohno);
296            } else if bw_order > 0.0 {
297                let bw_spd = butterworth_spd(&[peakwl], &[fwhm], &[bw_order], Some(grid))?;
298                values.extend_from_slice(bw_spd.spectra()[0].as_slice());
299            } else {
300                // Mix case for general negative bw_order values
301                let bw_spd = butterworth_spd(&[peakwl], &[fwhm], &[bw_order], Some(grid))?;
302                let bw = bw_spd.spectra()[0].as_slice();
303
304                let lz_spd = lorentzian2_spd(&[peakwl], &[fwhm], Some(grid))?;
305                let lz = lz_spd.spectra()[0].as_slice();
306
307                for j in 0..wavelengths.len() {
308                    let mut val = 0.0;
309                    if bw_order >= -1.0 && bw_order <= 0.0 {
310                        val += ohno[j];
311                    }
312                    if bw_order > 0.0 {
313                        val += bw[j];
314                    }
315                    if bw_order >= -2.0 && bw_order < -1.0 {
316                        val += lz[j];
317                    }
318                    values.push(val);
319                }
320            }
321        }
322
323        spectra.push(values);
324    }
325
326    Spectrum::new(wavelengths, spectra)
327}
328
329/// Generate phosphor LED spectrum with up to 2 phosphors.
330pub fn phosphor_led_spd(
331    params: &[PhosphorLedParams],
332    grid: Option<WavelengthGrid>,
333) -> LuxResult<Spectrum> {
334    let res = phosphor_led_spd_with_components(params, grid)?;
335    Ok(res.spd)
336}
337
338/// Generate phosphor LED spectrum and return both combined spectrum and component spectra.
339pub fn phosphor_led_spd_with_components(
340    params: &[PhosphorLedParams],
341    grid: Option<WavelengthGrid>,
342) -> LuxResult<PhosphorLedComponents> {
343    if params.is_empty() {
344        return Err(LuxError::EmptyInput);
345    }
346
347    let grid = grid.unwrap_or(DEFAULT_WL_GRID);
348    let wavelengths = getwlr(grid)?;
349    let num_mixtures = params.len();
350
351    let mut combined_spectra = Vec::with_capacity(num_mixtures);
352
353    // Determine if we have phosphors (if any params has strength_ph > 0)
354    let has_phosphors = params.iter().any(|p| p.strength_ph.unwrap_or(0.0) > 0.0);
355    let num_components = if has_phosphors { 3 } else { 1 };
356
357    // component_rows will store:
358    // Row 0..N: mono_led
359    // Row N..2N: ph1 (if has_phosphors)
360    // Row 2N..3N: ph2 (if has_phosphors)
361    let mut component_rows = vec![vec![0.0; wavelengths.len()]; num_mixtures * num_components];
362
363    for (i, param) in params.iter().enumerate() {
364        // 1. Mono led component
365        let mono_params = MonoLedParams {
366            peakwl: param.peakwl,
367            fwhm: param.fwhm,
368            bw_order: param.bw_order,
369            strength_shoulder: param.strength_shoulder,
370        };
371        let mono_spd = mono_led_spd(&[mono_params], Some(grid))?;
372        let mono_led = mono_spd.spectra()[0].clone();
373
374        let mut spd = mono_led.clone();
375
376        if let Some(s_ph) = param.strength_ph {
377            if s_ph > 0.0 && has_phosphors {
378                // 2. Phosphor 1
379                let ph1_params = MonoLedParams {
380                    peakwl: param.peakwl_ph1,
381                    fwhm: param.fwhm_ph1,
382                    bw_order: -1.0,
383                    strength_shoulder: 1.0,
384                };
385                let ph1_spd = mono_led_spd(&[ph1_params], Some(grid))?;
386                let ph1 = ph1_spd.spectra()[0].clone();
387
388                // 3. Phosphor 2
389                let ph2_params = MonoLedParams {
390                    peakwl: param.peakwl_ph2,
391                    fwhm: param.fwhm_ph2,
392                    bw_order: -1.0,
393                    strength_shoulder: 1.0,
394                };
395                let ph2_spd = mono_led_spd(&[ph2_params], Some(grid))?;
396                let ph2 = ph2_spd.spectra()[0].clone();
397
398                // Mix phosphors
399                let mut phosphors = Vec::with_capacity(wavelengths.len());
400                let s_ph1 = param.strength_ph1;
401
402                if let Some(s_ph2) = param.strength_ph2 {
403                    let sum = s_ph1 + s_ph2;
404                    let denom = if sum == 0.0 { 1e-300 } else { sum };
405                    for j in 0..wavelengths.len() {
406                        let val = (s_ph1 * ph1[j] + s_ph2 * ph2[j]) / denom + 1e-300;
407                        phosphors.push(val);
408                    }
409                } else {
410                    for j in 0..wavelengths.len() {
411                        let val = s_ph1 * ph1[j] + (1.0 - s_ph1) * ph2[j] + 1e-300;
412                        phosphors.push(val);
413                    }
414                }
415
416                // Normalize phosphors to max = 1
417                let max_ph = phosphors
418                    .iter()
419                    .copied()
420                    .fold(f64::NEG_INFINITY, f64::max);
421                let max_ph_val = if max_ph <= 0.0 { 1.0 } else { max_ph };
422                for val in &mut phosphors {
423                    *val /= max_ph_val;
424                }
425
426                // Combined spd
427                for j in 0..wavelengths.len() {
428                    spd[j] = mono_led[j] + s_ph * phosphors[j];
429                }
430
431                // Store in component_rows
432                component_rows[i] = mono_led;
433                component_rows[num_mixtures + i] = ph1;
434                component_rows[2 * num_mixtures + i] = ph2;
435            } else {
436                component_rows[i] = mono_led;
437            }
438        } else {
439            component_rows[i] = mono_led;
440        }
441
442        // Piecewise function modification
443        if param.use_piecewise_fcn {
444            // mono_led is the piecewise multiplier for wl < peakwl, else 1
445            for j in 0..wavelengths.len() {
446                let wl = wavelengths[j];
447                let factor = if wl < param.peakwl {
448                    let mono_val = component_rows[i][j]; // raw mono_led value
449                    mono_val
450                } else {
451                    1.0
452                };
453                spd[j] *= factor;
454
455                // Also apply to component spectra
456                component_rows[i][j] *= factor;
457                if has_phosphors && param.strength_ph.unwrap_or(0.0) > 0.0 {
458                    component_rows[num_mixtures + i][j] *= factor;
459                    component_rows[2 * num_mixtures + i][j] *= factor;
460                }
461            }
462        }
463
464        // Normalize spd to max = 1
465        let max_val = spd
466            .iter()
467            .copied()
468            .fold(f64::NEG_INFINITY, f64::max);
469        let max_scale = if max_val <= 0.0 { 1.0 } else { max_val };
470        for val in &mut spd {
471            *val /= max_scale;
472        }
473
474        combined_spectra.push(spd);
475    }
476
477    // Normalize each row in component_rows to max = 1
478    for row in &mut component_rows {
479        let max_val = row
480            .iter()
481            .copied()
482            .fold(f64::NEG_INFINITY, f64::max);
483        let max_scale = if max_val <= 0.0 { 1.0 } else { max_val };
484        for val in &mut *row {
485            *val /= max_scale;
486        }
487    }
488
489    let spd = Spectrum::new(wavelengths.clone(), combined_spectra)?;
490    let components = Spectrum::new(wavelengths, component_rows)?;
491
492    Ok(PhosphorLedComponents { spd, components })
493}
494
495fn safe_div(val: f64) -> f64 {
496    if val.abs() < 1e-300 {
497        if val >= 0.0 {
498            1e-300
499        } else {
500            -1e-300
501        }
502    } else {
503        val
504    }
505}
506
507/// Calculate fluxes required to obtain target chromaticity additively mixing 3 sources.
508pub fn color3mixer(
509    yxy_target: [f64; 3],
510    yxy1: [f64; 3],
511    yxy2: [f64; 3],
512    yxy3: [f64; 3],
513) -> [f64; 3] {
514    let y1 = yxy1[0];
515    let x1 = yxy1[1];
516    let y1_coord = yxy1[2];
517
518    let y2 = yxy2[0];
519    let x2 = yxy2[1];
520    let y2_coord = yxy2[2];
521
522    let y3 = yxy3[0];
523    let x3 = yxy3[1];
524    let y3_coord = yxy3[2];
525
526    let yt = yxy_target[0];
527    let xt = yxy_target[1];
528    let yt_coord = yxy_target[2];
529
530    let denom = (x3 - x2) * y1_coord + (x2 - x1) * y3_coord + (x1 - x3) * y2_coord;
531    let m1 = y1_coord * ((xt - x3) * y2_coord - (yt_coord - y3_coord) * x2 + x3 * yt_coord - xt * y3_coord)
532        / safe_div(yt_coord * denom);
533
534    let m2 = -y2_coord * ((xt - x3) * y1_coord - (yt_coord - y3_coord) * x1 + x3 * yt_coord - xt * y3_coord)
535        / safe_div(yt_coord * denom);
536
537    let denom3 = (x2 - x1) * y3_coord - (y2_coord - y1_coord) * x3 + x1 * y2_coord - x2 * y1_coord;
538    let m3 = y3_coord * ((x2 - x1) * yt_coord - (y2_coord - y1_coord) * xt + x1 * y2_coord - x2 * y1_coord)
539        / safe_div(yt_coord * denom3);
540
541    [yt * m1 / safe_div(y1), yt * m2 / safe_div(y2), yt * m3 / safe_div(y3)]
542}
543
544/// Helper to solve pseudo-inverse equation Ax = b for 3 x N matrix A (represented by its transpose, N x 3 vector).
545fn solve_pseudo_inverse_3xn(a: &[[f64; 3]], b: [f64; 3]) -> Vec<f64> {
546    let n = a.len();
547    let mut aat = [[0.0; 3]; 3];
548    for i in 0..3 {
549        for k in 0..3 {
550            let mut sum = 0.0;
551            for j in 0..n {
552                sum += a[j][i] * a[j][k];
553            }
554            aat[i][k] = sum;
555        }
556    }
557
558    // Reuse invert_matrix3 from color module
559    let aat_inv = crate::color::invert_matrix3(aat);
560
561    // Compute y = (A A^T)^{-1} b
562    let mut y = [0.0; 3];
563    for i in 0..3 {
564        let mut sum = 0.0;
565        for k in 0..3 {
566            sum += aat_inv[i][k] * b[k];
567        }
568        y[i] = sum;
569    }
570
571    // Compute x = A^T y
572    let mut x = vec![0.0; n];
573    for j in 0..n {
574        let mut sum = 0.0;
575        for i in 0..3 {
576            sum += a[j][i] * y[i];
577        }
578        x[j] = sum;
579    }
580
581    x
582}
583
584/// Additive color mixer of N primaries using Moore-Penrose pseudo-inverse matrix.
585pub fn colormixer_pinv(
586    yxy_target: [f64; 3],
587    yxy_primaries: &[[f64; 3]],
588    input_fmt: &str,
589) -> Vec<f64> {
590    let n = yxy_primaries.len();
591    if input_fmt.to_lowercase() == "xyz" {
592        solve_pseudo_inverse_3xn(yxy_primaries, yxy_target)
593    } else {
594        let yt = yxy_target[0];
595        let xt = yxy_target[1];
596        let yt_coord = yxy_target[2];
597
598        let mut a_cols = vec![[0.0; 3]; n];
599        for j in 0..n {
600            let y_i = yxy_primaries[j][0];
601            let x_i = yxy_primaries[j][1];
602            let y_coord_i = yxy_primaries[j][2];
603
604            let ratio = y_i / y_coord_i.max(1e-300);
605            a_cols[j][0] = ratio * (x_i - xt);
606            a_cols[j][1] = ratio * (y_coord_i - yt_coord);
607            a_cols[j][2] = y_i / yt.max(1e-300);
608        }
609
610        solve_pseudo_inverse_3xn(&a_cols, [0.0, 0.0, 1.0])
611    }
612}
613
614/// Calculate fluxes required to obtain target chromaticity additively mixing N light sources.
615pub fn colormixer(
616    yxy_target: [f64; 3],
617    yxy_primaries: &[[f64; 3]],
618    pair_strengths: &[f64],
619) -> Vec<f64> {
620    let n = yxy_primaries.len();
621    if n <= 3 {
622        // Fall back to color3mixer
623        let p1 = if n > 0 { yxy_primaries[0] } else { [100.0, 1.0/3.0, 1.0/3.0] };
624        let p2 = if n > 1 { yxy_primaries[1] } else { [100.0, 1.0/3.0, 1.0/3.0] };
625        let p3 = if n > 2 { yxy_primaries[2] } else { [100.0, 1.0/3.0, 1.0/3.0] };
626        let m = color3mixer(yxy_target, p1, p2, p3);
627        return m.to_vec();
628    }
629
630    // mlut stores state:
631    // col 0: id
632    // col 1..4: Y, x, y
633    // col 4, 5: parent_A, parent_B
634    // col 6, 7: weight_A, weight_B
635    #[derive(Debug, Clone, Copy)]
636    struct LRow {
637        _id: usize,
638        yxy: [f64; 3],
639        parent_a: usize,
640        parent_b: Option<usize>,
641        weight_a: f64,
642        weight_b: Option<f64>,
643    }
644
645    let mut mlut = Vec::new();
646    for i in 0..n {
647        mlut.push(LRow {
648            _id: i,
649            yxy: yxy_primaries[i],
650            parent_a: i,
651            parent_b: None,
652            weight_a: 1.0,
653            weight_b: None,
654        });
655    }
656
657    let mut so: Vec<usize> = (0..n).collect();
658    let mut ps = pair_strengths.to_vec();
659    if ps.len() < n - 3 {
660        // Fill up to n-3 with 0.5 default
661        ps.resize(n - 3, 0.5);
662    }
663
664    let mut k = 0;
665    let mut kk = 0;
666    let mut su_k = Vec::new();
667    let mut sn_k = Vec::new();
668
669    while so.len() > 3 {
670        let pair_strength_ab = ps[kk];
671        let p_a = so[2 * k];
672        let p_b = so[2 * k + 1];
673
674        // Mix A and B
675        let yxy_a = mlut[p_a].yxy;
676        let yxy_b = mlut[p_b].yxy;
677
678        let y_a = yxy_a[0];
679        let x_a = yxy_a[1];
680        let y_coord_a = yxy_a[2];
681
682        let y_b = yxy_b[0];
683        let x_b = yxy_b[1];
684        let y_coord_b = yxy_b[2];
685
686        let x_val_a = x_a * y_a / y_coord_a.max(1e-300);
687        let x_val_b = x_b * y_b / y_coord_b.max(1e-300);
688        let z_val_a = (1.0 - x_a - y_coord_a) * y_a / y_coord_a.max(1e-300);
689        let z_val_b = (1.0 - x_b - y_coord_b) * y_b / y_coord_b.max(1e-300);
690
691        let xm = pair_strength_ab * x_val_a + (1.0 - pair_strength_ab) * x_val_b;
692        let ym = pair_strength_ab * y_a + (1.0 - pair_strength_ab) * y_b;
693        let zm = pair_strength_ab * z_val_a + (1.0 - pair_strength_ab) * z_val_b;
694
695        let sum = xm + ym + zm;
696        let denom = if sum == 0.0 { 1e-300 } else { sum };
697        let xm_coord = xm / denom;
698        let ym_coord = ym / denom;
699
700        let new_id = mlut.len();
701        mlut.push(LRow {
702            _id: new_id,
703            yxy: [ym, xm_coord, ym_coord],
704            parent_a: p_a,
705            parent_b: Some(p_b),
706            weight_a: pair_strength_ab,
707            weight_b: Some(1.0 - pair_strength_ab),
708        });
709
710        su_k.push(p_a);
711        su_k.push(p_b);
712        sn_k.push(new_id);
713
714        let mut rem_so = Vec::new();
715        for &item in &so {
716            if !su_k.contains(&item) {
717                rem_so.push(item);
718            }
719        }
720        rem_so.extend(&sn_k);
721
722        if rem_so.len() <= 3 {
723            so = rem_so;
724            break;
725        }
726
727        let nn = so.len() / 2;
728        if k == nn - 1 {
729            so = rem_so;
730            su_k.clear();
731            sn_k.clear();
732            k = 0;
733        } else {
734            k += 1;
735        }
736        kk += 1;
737    }
738
739    // Solve color3mixer for last 3 sources
740    let m3 = color3mixer(yxy_target, mlut[so[0]].yxy, mlut[so[1]].yxy, mlut[so[2]].yxy);
741    if m3.iter().any(|&val| val < 0.0 || val.is_nan()) {
742        return vec![f64::NAN; n];
743    }
744
745    // Backward propagation
746    let mut flux_acc = vec![0.0; mlut.len()];
747    flux_acc[so[0]] = m3[0];
748    flux_acc[so[1]] = m3[1];
749    flux_acc[so[2]] = m3[2];
750
751    for i in (n..mlut.len()).rev() {
752        let m_i = flux_acc[i];
753        let p_a = mlut[i].parent_a;
754        let w_a = mlut[i].weight_a;
755        flux_acc[p_a] += w_a * m_i;
756
757        if let Some(p_b) = mlut[i].parent_b {
758            let w_b = mlut[i].weight_b.unwrap_or(0.0);
759            flux_acc[p_b] += w_b * m_i;
760        }
761    }
762
763    flux_acc[0..n].to_vec()
764}
765
766/// Helper function to convert target value to Yxy
767fn target_to_yxy(target: &[f64], tar_type: &str, observer: Observer) -> LuxResult<[f64; 3]> {
768    match tar_type.to_lowercase().as_str() {
769        "cct" => {
770            let cct = target[0];
771            let xyz = cct_to_xyz(cct, observer)?;
772            Ok(xyz_to_yxy(xyz))
773        }
774        "yxy" => {
775            if target.len() < 3 {
776                return Err(LuxError::EmptyInput);
777            }
778            Ok([target[0], target[1], target[2]])
779        }
780        "xyz" => {
781            if target.len() < 3 {
782                return Err(LuxError::EmptyInput);
783            }
784            Ok(xyz_to_yxy([target[0], target[1], target[2]]))
785        }
786        _ => Err(LuxError::InvalidGridSpec),
787    }
788}
789
790/// High-level spectrum builder.
791pub fn spd_builder(
792    flux: Option<&[f64]>,
793    component_spds: Option<&Spectrum>,
794    params: &PhosphorLedParams,
795    pair_strengths: Option<&[f64]>,
796    target: Option<&[f64]>,
797    tar_type: &str,
798    observer: Observer,
799    grid: Option<WavelengthGrid>,
800) -> LuxResult<Spectrum> {
801    let grid = grid.unwrap_or(DEFAULT_WL_GRID);
802    let wavelengths = getwlr(grid)?;
803
804    // 1. Get components
805    let components = match component_spds {
806        Some(s) => s.clone(),
807        None => {
808            let res = phosphor_led_spd_with_components(&[params.clone()], Some(grid))?;
809            res.components
810        }
811    };
812
813    let n_components = components.spectrum_count();
814
815    // 2. Target optimization
816    if let Some(tar) = target {
817        if n_components < 3 {
818            return Err(LuxError::EmptyInput);
819        }
820
821        // Calculate xyz of components
822        let xyz_components = components.spd_to_xyz(&observer.standard()?, false)?;
823        let mut yxy_components = Vec::with_capacity(n_components);
824        for &xyz in &xyz_components {
825            yxy_components.push(xyz_to_yxy(xyz));
826        }
827
828        // Convert target to Yxy
829        let yxy_target = target_to_yxy(tar, tar_type, observer)?;
830
831        // Solve for fluxes
832        let m = if n_components == 3 {
833            color3mixer(
834                yxy_target,
835                yxy_components[0],
836                yxy_components[1],
837                yxy_components[2],
838            )
839            .to_vec()
840        } else {
841            let p_strengths = pair_strengths.unwrap_or(&[]);
842            colormixer(yxy_target, &yxy_components, p_strengths)
843        };
844
845        if m.iter().any(|&val| val.is_nan() || val < 0.0) {
846            // Out of gamut: return NaN spectrum as in Python
847            let spd_values = vec![f64::NAN; wavelengths.len()];
848            return Spectrum::new(wavelengths, vec![spd_values]);
849        }
850
851        // Build combined spectrum: sum(M_j * component_j)
852        let mut spd_values = vec![0.0; wavelengths.len()];
853        for j in 0..n_components {
854            let factor = m[j];
855            let comp_values = &components.spectra()[j];
856            for k in 0..wavelengths.len() {
857                spd_values[k] += factor * comp_values[k];
858            }
859        }
860
861        // Normalize combined spectrum to max = 1
862        let max_val = spd_values
863            .iter()
864            .copied()
865            .fold(f64::NEG_INFINITY, f64::max);
866        let max_scale = if max_val <= 0.0 { 1.0 } else { max_val };
867        for val in &mut spd_values {
868            *val /= max_scale;
869        }
870
871        Spectrum::new(wavelengths, vec![spd_values])
872    } else {
873        // No target, use flux values
874        let flux_vals = flux.unwrap_or(&[]);
875        if flux_vals.is_empty() {
876            // Just return components
877            Ok(components)
878        } else {
879            // Combine components using flux_vals
880            let mut spd_values = vec![0.0; wavelengths.len()];
881            let num_to_mix = n_components.min(flux_vals.len());
882            for j in 0..num_to_mix {
883                let factor = flux_vals[j];
884                let comp_values = &components.spectra()[j];
885                for k in 0..wavelengths.len() {
886                    spd_values[k] += factor * comp_values[k];
887                }
888            }
889
890            // Normalize
891            let max_val = spd_values
892                .iter()
893                .copied()
894                .fold(f64::NEG_INFINITY, f64::max);
895            let max_scale = if max_val <= 0.0 { 1.0 } else { max_val };
896            for val in &mut spd_values {
897                *val /= max_scale;
898            }
899
900            Spectrum::new(wavelengths, vec![spd_values])
901        }
902    }
903}
904
905/// Fit single Gaussian peak parameters (peak wavelength, FWHM) to match target chromaticity (x, y).
906pub fn fit_gaussian_spd_params(
907    target_xy: [f64; 2],
908    init_peak: f64,
909    init_fwhm: f64,
910) -> LuxResult<(f64, f64)> {
911    let obj_func = |params: [f64; 2]| -> f64 {
912        let peak = params[0];
913        let fwhm = params[1];
914        if fwhm <= 0.0 || peak < 360.0 || peak > 830.0 {
915            return 1e10;
916        }
917        let grid = DEFAULT_WL_GRID;
918        let g = match gaussian_spd(&[peak], &[fwhm], Some(grid)) {
919            Ok(s) => s,
920            Err(_) => return 1e10,
921        };
922        let observer = Observer::Cie1931_2;
923        let xyz = match g.spd_to_xyz(&observer.standard().unwrap(), false) {
924            Ok(v) => v[0],
925            Err(_) => return 1e10,
926        };
927        let yxy = xyz_to_yxy(xyz);
928        let x = yxy[1];
929        let y = yxy[2];
930        (x - target_xy[0]).powi(2) + (y - target_xy[1]).powi(2)
931    };
932
933    let opt = nelder_mead_2d(obj_func, [init_peak, init_fwhm], [2.0, 5.0], 1e-12, 1000);
934    Ok((opt[0], opt[1]))
935}
936
937fn nelder_mead_2d<F>(
938    mut obj_func: F,
939    init: [f64; 2],
940    step: [f64; 2],
941    tol: f64,
942    max_iter: usize,
943) -> [f64; 2]
944where
945    F: FnMut([f64; 2]) -> f64,
946{
947    let p0 = init;
948    let p1 = [init[0] + step[0], init[1]];
949    let p2 = [init[0], init[1] + step[1]];
950
951    let mut points = [
952        (p0, obj_func(p0)),
953        (p1, obj_func(p1)),
954        (p2, obj_func(p2)),
955    ];
956
957    for _ in 0..max_iter {
958        points.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
959
960        let diff = (points[2].1 - points[0].1).abs();
961        if diff < tol {
962            break;
963        }
964
965        let centroid = [
966            0.5 * (points[0].0[0] + points[1].0[0]),
967            0.5 * (points[0].0[1] + points[1].0[1]),
968        ];
969
970        let reflected = [
971            centroid[0] + 1.0 * (centroid[0] - points[2].0[0]),
972            centroid[1] + 1.0 * (centroid[1] - points[2].0[1]),
973        ];
974        let r_val = obj_func(reflected);
975
976        if r_val < points[1].1 && r_val >= points[0].1 {
977            points[2] = (reflected, r_val);
978            continue;
979        }
980
981        if r_val < points[0].1 {
982            let expanded = [
983                centroid[0] + 2.0 * (reflected[0] - centroid[0]),
984                centroid[1] + 2.0 * (reflected[1] - centroid[1]),
985            ];
986            let e_val = obj_func(expanded);
987            if e_val < r_val {
988                points[2] = (expanded, e_val);
989            } else {
990                points[2] = (reflected, r_val);
991            }
992            continue;
993        }
994
995        let contracted = [
996            centroid[0] + 0.5 * (points[2].0[0] - centroid[0]),
997            centroid[1] + 0.5 * (points[2].0[1] - centroid[1]),
998        ];
999        let c_val = obj_func(contracted);
1000        if c_val < points[2].1 {
1001            points[2] = (contracted, c_val);
1002            continue;
1003        }
1004
1005        for i in 1..3 {
1006            points[i].0[0] = points[0].0[0] + 0.5 * (points[i].0[0] - points[0].0[0]);
1007            points[i].0[1] = points[0].0[1] + 0.5 * (points[i].0[1] - points[0].0[1]);
1008            points[i].1 = obj_func(points[i].0);
1009        }
1010    }
1011
1012    points[0].0
1013}
1014