1use crate::error::FdarError;
72use crate::helpers::{cumulative_trapz, linear_interp, trapz};
73use crate::matrix::FdMatrix;
74use crate::regression::{fdata_to_pc_1d, FpcaResult};
75
76#[derive(Debug, Clone, PartialEq)]
86#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
87#[non_exhaustive]
88pub struct LqdFpcaResult {
89 pub fpca: FpcaResult,
95 pub fve: Vec<f64>,
100}
101
102pub fn normalize_density(vals: &[f64], argvals: &[f64]) -> Result<Vec<f64>, FdarError> {
128 if vals.len() != argvals.len() {
129 return Err(FdarError::InvalidDimension {
130 parameter: "vals",
131 expected: format!("{}", argvals.len()),
132 actual: format!("{}", vals.len()),
133 });
134 }
135 if argvals.len() < 2 {
136 return Err(FdarError::InvalidParameter {
137 parameter: "argvals",
138 message: "argvals must have at least 2 elements".to_string(),
139 });
140 }
141 if argvals.windows(2).any(|w| w[1] <= w[0]) {
142 return Err(FdarError::InvalidParameter {
143 parameter: "argvals",
144 message: "argvals must be strictly increasing".to_string(),
145 });
146 }
147 if vals.iter().any(|&v| v < 0.0) {
148 return Err(FdarError::InvalidParameter {
149 parameter: "vals",
150 message: "density values must be non-negative".to_string(),
151 });
152 }
153 let integral = trapz(vals, argvals);
154 if integral < 1e-15 {
155 return Err(FdarError::InvalidParameter {
156 parameter: "vals",
157 message: "density integrates to zero or is all-zero".to_string(),
158 });
159 }
160 Ok(vals.iter().map(|&v| v / integral).collect())
161}
162
163pub fn lqd_transform(
202 density: &[f64],
203 argvals: &[f64],
204 n_quantile_pts: Option<usize>,
205) -> Result<Vec<f64>, FdarError> {
206 if density.len() != argvals.len() {
208 return Err(FdarError::InvalidDimension {
209 parameter: "density",
210 expected: format!("{}", argvals.len()),
211 actual: format!("{}", density.len()),
212 });
213 }
214 if argvals.len() < 2 {
215 return Err(FdarError::InvalidParameter {
216 parameter: "argvals",
217 message: "argvals must have at least 2 elements".to_string(),
218 });
219 }
220 if argvals.windows(2).any(|w| w[1] <= w[0]) {
221 return Err(FdarError::InvalidParameter {
222 parameter: "argvals",
223 message: "argvals must be strictly increasing".to_string(),
224 });
225 }
226 if density.iter().any(|&v| v <= 0.0) {
228 return Err(FdarError::InvalidParameter {
229 parameter: "density",
230 message: "density values must be strictly positive for the LQD transform (zero/negative density produces ±∞)".to_string(),
231 });
232 }
233
234 let n_q = n_quantile_pts.unwrap_or_else(|| argvals.len().max(101));
235 if n_q < 2 {
236 return Err(FdarError::InvalidParameter {
237 parameter: "n_quantile_pts",
238 message: "n_quantile_pts must be at least 2".to_string(),
239 });
240 }
241
242 let integral = trapz(density, argvals);
244 let dens_norm: Vec<f64> = density.iter().map(|&d| d / integral).collect();
245
246 let cdf = cumulative_trapz(&dens_norm, argvals);
248
249 let lqd_raw: Vec<f64> = dens_norm.iter().map(|&d| -d.ln()).collect();
251
252 let t_grid: Vec<f64> = (0..n_q).map(|i| i as f64 / (n_q - 1) as f64).collect();
254 let psi: Vec<f64> = t_grid
255 .iter()
256 .map(|&t| linear_interp(&cdf, &lqd_raw, t))
257 .collect();
258
259 if psi.iter().any(|v| !v.is_finite()) {
261 return Err(FdarError::ComputationFailed {
262 operation: "lqd_transform",
263 detail: "non-finite ψ values produced; possible cause: a density value \
264 underflowed to 0 after normalization (input density too small \
265 relative to its maximum on this grid)"
266 .to_string(),
267 });
268 }
269
270 Ok(psi)
271}
272
273pub fn inverse_lqd(
302 psi: &[f64],
303 t_grid: &[f64],
304 target_argvals: &[f64],
305) -> Result<Vec<f64>, FdarError> {
306 if psi.len() != t_grid.len() {
308 return Err(FdarError::InvalidDimension {
309 parameter: "psi",
310 expected: format!("{}", t_grid.len()),
311 actual: format!("{}", psi.len()),
312 });
313 }
314 if t_grid.len() < 2 {
315 return Err(FdarError::InvalidParameter {
316 parameter: "t_grid",
317 message: "t_grid must have at least 2 elements".to_string(),
318 });
319 }
320 if target_argvals.len() < 2 {
321 return Err(FdarError::InvalidParameter {
322 parameter: "target_argvals",
323 message: "target_argvals must have at least 2 elements".to_string(),
324 });
325 }
326 if t_grid.windows(2).any(|w| w[1] <= w[0]) {
327 return Err(FdarError::InvalidParameter {
328 parameter: "t_grid",
329 message: "t_grid must be strictly increasing".to_string(),
330 });
331 }
332 if target_argvals.windows(2).any(|w| w[1] <= w[0]) {
333 return Err(FdarError::InvalidParameter {
334 parameter: "target_argvals",
335 message: "target_argvals must be strictly increasing".to_string(),
336 });
337 }
338 if psi.iter().any(|v| !v.is_finite()) {
339 return Err(FdarError::InvalidParameter {
340 parameter: "psi",
341 message: "psi must contain only finite values".to_string(),
342 });
343 }
344
345 let exp_psi: Vec<f64> = psi.iter().map(|&p| p.exp()).collect();
347 let q_raw_cumtrapz = cumulative_trapz(&exp_psi, t_grid);
348 let lb = target_argvals[0];
349 let q_raw: Vec<f64> = q_raw_cumtrapz.iter().map(|&v| lb + v).collect();
350
351 let q_range = q_raw[q_raw.len() - 1] - q_raw[0]; let d_range = target_argvals[target_argvals.len() - 1] - lb;
354 if q_range < 1e-15 {
355 return Err(FdarError::ComputationFailed {
356 operation: "inverse_lqd",
357 detail: "quantile function range is zero; degenerate ψ (all-constant)".to_string(),
358 });
359 }
360 let scale = d_range / q_range;
361 let q_scaled: Vec<f64> = q_raw.iter().map(|&v| (v - q_raw[0]) * scale + lb).collect();
362
363 let dens_raw: Vec<f64> = psi.iter().map(|&p| (-p).exp()).collect();
365
366 let (q_dedup, dens_dedup) = dedup_adjacent(&q_scaled, &dens_raw);
368
369 let dens: Vec<f64> = target_argvals
371 .iter()
372 .map(|&x| linear_interp(&q_dedup, &dens_dedup, x))
373 .collect();
374
375 let integral = trapz(&dens, target_argvals);
377 if integral < 1e-15 {
378 return Err(FdarError::ComputationFailed {
379 operation: "inverse_lqd",
380 detail: "reconstructed density integrates to zero; check ψ admissibility".to_string(),
381 });
382 }
383 Ok(dens.iter().map(|&d| d / integral).collect())
384}
385
386pub fn wasserstein_barycenter(
408 density_matrix: &FdMatrix,
409 argvals: &[f64],
410 weights: Option<&[f64]>,
411) -> Result<Vec<f64>, FdarError> {
412 let (n, m) = density_matrix.shape();
413 if n == 0 {
414 return Err(FdarError::InvalidDimension {
415 parameter: "density_matrix",
416 expected: "at least 1 row".to_string(),
417 actual: "0 rows".to_string(),
418 });
419 }
420 if m == 0 {
421 return Err(FdarError::InvalidDimension {
422 parameter: "density_matrix",
423 expected: "at least 1 column".to_string(),
424 actual: "0 columns".to_string(),
425 });
426 }
427 if argvals.len() != m {
428 return Err(FdarError::InvalidDimension {
429 parameter: "argvals",
430 expected: format!("{m} elements (matching density_matrix columns)"),
431 actual: format!("{} elements", argvals.len()),
432 });
433 }
434 if argvals.windows(2).any(|w| w[1] <= w[0]) {
435 return Err(FdarError::InvalidParameter {
436 parameter: "argvals",
437 message: "argvals must be strictly increasing".to_string(),
438 });
439 }
440
441 let w_vec: Vec<f64> = if let Some(w) = weights {
443 if w.len() != n {
444 return Err(FdarError::InvalidDimension {
445 parameter: "weights",
446 expected: format!("{n}"),
447 actual: format!("{}", w.len()),
448 });
449 }
450 if w.iter().any(|&wi| wi < 0.0 || !wi.is_finite()) {
451 return Err(FdarError::InvalidParameter {
452 parameter: "weights",
453 message: "weights must be non-negative and finite".to_string(),
454 });
455 }
456 let s: f64 = w.iter().sum();
457 if s < 1e-15 {
458 return Err(FdarError::InvalidParameter {
459 parameter: "weights",
460 message: "weights sum to zero".to_string(),
461 });
462 }
463 w.iter().map(|&wi| wi / s).collect()
464 } else {
465 vec![1.0 / n as f64; n]
466 };
467
468 let n_q = m.max(101);
470 let t_grid: Vec<f64> = (0..n_q).map(|i| i as f64 / (n_q - 1) as f64).collect();
471
472 let mut q_bar = vec![0.0_f64; n_q];
474 for i in 0..n {
475 let row: Vec<f64> = (0..m).map(|j| density_matrix[(i, j)]).collect();
476 if row.iter().any(|&v| v < 0.0) {
477 return Err(FdarError::InvalidParameter {
478 parameter: "density_matrix",
479 message: format!(
480 "row {i} contains negative values; densities must be non-negative"
481 ),
482 });
483 }
484 let integral = trapz(&row, argvals);
485 if integral < 1e-15 {
486 return Err(FdarError::InvalidParameter {
487 parameter: "density_matrix",
488 message: format!("row {i} integrates to zero (all-zero density)"),
489 });
490 }
491 let norm_row: Vec<f64> = row.iter().map(|&v| v / integral).collect();
492 let cdf_i = cumulative_trapz(&norm_row, argvals);
493 let wi = w_vec[i];
494 for j in 0..n_q {
495 q_bar[j] += wi * linear_interp(&cdf_i, argvals, t_grid[j]);
496 }
497 }
498
499 let lb = argvals[0];
503 let ub = argvals[m - 1];
504 let q_range = q_bar[n_q - 1] - q_bar[0];
505 if q_range < 1e-15 {
506 return Err(FdarError::ComputationFailed {
507 operation: "wasserstein_barycenter",
508 detail: "quantile average has zero range; degenerate input densities".to_string(),
509 });
510 }
511 let d_range = ub - lb;
512 let q_scaled: Vec<f64> = q_bar
513 .iter()
514 .map(|&v| (v - q_bar[0]) * d_range / q_range + lb)
515 .collect();
516
517 let dens_raw = quantile_density_from_q(&q_scaled, &t_grid);
519
520 let (q_dedup, dens_dedup) = dedup_adjacent(&q_scaled, &dens_raw);
522 let dens: Vec<f64> = argvals
523 .iter()
524 .map(|&x| linear_interp(&q_dedup, &dens_dedup, x))
525 .collect();
526
527 let integral = trapz(&dens, argvals);
529 if integral < 1e-15 {
530 return Err(FdarError::ComputationFailed {
531 operation: "wasserstein_barycenter",
532 detail: "barycenter density integrates to zero".to_string(),
533 });
534 }
535 Ok(dens.iter().map(|&d| d / integral).collect())
536}
537
538#[must_use = "expensive SVD computation — store or use the returned LqdFpcaResult"]
563pub fn lqd_fpca(
564 density_matrix: &FdMatrix,
565 argvals: &[f64],
566 ncomp: usize,
567 n_quantile_pts: Option<usize>,
568) -> Result<LqdFpcaResult, FdarError> {
569 let (n_dens, m) = density_matrix.shape();
570 if n_dens == 0 {
571 return Err(FdarError::InvalidDimension {
572 parameter: "density_matrix",
573 expected: "at least 1 row".to_string(),
574 actual: "0 rows".to_string(),
575 });
576 }
577 if m == 0 || argvals.len() != m {
578 return Err(FdarError::InvalidDimension {
579 parameter: "argvals",
580 expected: format!("{m} elements"),
581 actual: format!("{} elements", argvals.len()),
582 });
583 }
584 if ncomp == 0 {
585 return Err(FdarError::InvalidParameter {
586 parameter: "ncomp",
587 message: "ncomp must be at least 1".to_string(),
588 });
589 }
590
591 let n_q = n_quantile_pts.unwrap_or_else(|| argvals.len().max(101));
592 let t_grid: Vec<f64> = (0..n_q).map(|i| i as f64 / (n_q - 1) as f64).collect();
593
594 let mut lqd_data = FdMatrix::zeros(n_dens, n_q);
596 for i in 0..n_dens {
597 let row: Vec<f64> = (0..m).map(|j| density_matrix[(i, j)]).collect();
598 let psi = lqd_transform(&row, argvals, Some(n_q))?;
599 for (j, &val) in psi.iter().enumerate() {
600 lqd_data[(i, j)] = val;
601 }
602 }
603
604 let fpca = fdata_to_pc_1d(&lqd_data, ncomp, &t_grid)?;
606
607 let sv_sq: Vec<f64> = fpca.singular_values.iter().map(|&s| s * s).collect();
609 let total: f64 = sv_sq.iter().sum();
610 let mut cumsum = 0.0_f64;
611 let fve: Vec<f64> = sv_sq
612 .iter()
613 .map(|&s| {
614 cumsum += s;
615 if total > 0.0 {
616 cumsum / total
617 } else {
618 0.0
619 }
620 })
621 .collect();
622
623 Ok(LqdFpcaResult { fpca, fve })
624}
625
626pub(crate) fn dedup_adjacent(x: &[f64], y: &[f64]) -> (Vec<f64>, Vec<f64>) {
644 let mut xd = Vec::with_capacity(x.len());
645 let mut yd = Vec::with_capacity(y.len());
646 for (i, (&xi, &yi)) in x.iter().zip(y.iter()).enumerate() {
647 if i == 0 || xi > xd[xd.len() - 1] {
648 xd.push(xi);
649 yd.push(yi);
650 }
651 }
654 (xd, yd)
655}
656
657pub(crate) fn quantile_density_from_q(q: &[f64], t: &[f64]) -> Vec<f64> {
662 let n = q.len();
663 let mut qd = vec![0.0_f64; n];
664 if n < 2 {
665 return qd;
666 }
667 qd[0] = (q[1] - q[0]) / (t[1] - t[0]);
669 for i in 1..n - 1 {
671 qd[i] = (q[i + 1] - q[i - 1]) / (t[i + 1] - t[i - 1]);
672 }
673 qd[n - 1] = (q[n - 1] - q[n - 2]) / (t[n - 1] - t[n - 2]);
675 let eps = 1e-6_f64;
680 qd.iter().map(|&dq| 1.0 / dq.max(eps)).collect()
681}
682
683#[cfg(test)]
686mod tests {
687 use super::*;
688 use crate::helpers::trapz;
689
690 fn truncated_gaussian(argvals: &[f64], mu: f64) -> Vec<f64> {
692 let raw: Vec<f64> = argvals
693 .iter()
694 .map(|&x| (-(x - mu).powi(2) / 2.0).exp())
695 .collect();
696 let integral = trapz(&raw, argvals);
697 raw.iter().map(|&d| d / integral).collect()
698 }
699
700 #[test]
703 fn normalize_density_integral_to_one() {
704 let argvals: Vec<f64> = (0..101).map(|i| i as f64 / 100.0).collect();
705 let vals: Vec<f64> = argvals.iter().map(|&x| 2.0 * x + 0.5).collect(); let normed = normalize_density(&vals, &argvals).unwrap();
707 let integral = trapz(&normed, &argvals);
708 assert!(
709 (integral - 1.0).abs() < 1e-10,
710 "integral = {integral}, expected 1.0"
711 );
712 assert!(normed.iter().all(|&v| v >= 0.0), "negative values");
713 }
714
715 #[test]
718 fn lqd_uniform_is_zero() {
719 let argvals: Vec<f64> = (0..201).map(|i| i as f64 / 200.0).collect();
721 let uniform = vec![1.0_f64; 201];
722 let psi = lqd_transform(&uniform, &argvals, Some(101)).unwrap();
723 let max_abs = psi.iter().map(|&v| v.abs()).fold(0.0_f64, f64::max);
724 assert!(
725 max_abs < 1e-5,
726 "lqd of uniform should be ≈0 everywhere, got max |ψ| = {max_abs}"
727 );
728 }
729
730 #[test]
731 fn lqd_transform_finite() {
732 let argvals: Vec<f64> = (0..201).map(|i| -3.0 + i as f64 * 6.0 / 200.0).collect();
733 let dens = truncated_gaussian(&argvals, 0.0);
734 let psi = lqd_transform(&dens, &argvals, Some(101)).unwrap();
735 assert_eq!(psi.len(), 101);
736 assert!(
737 psi.iter().all(|v| v.is_finite()),
738 "ψ contains non-finite values"
739 );
740 }
741
742 #[test]
745 fn round_trip_lqd_density_within_tolerance() {
746 let argvals: Vec<f64> = (0..201).map(|i| -3.0 + i as f64 * 6.0 / 200.0).collect();
753 let dens = truncated_gaussian(&argvals, 0.0);
754
755 let n_q = 201usize;
757 let psi = lqd_transform(&dens, &argvals, Some(n_q)).unwrap();
758 let t_grid: Vec<f64> = (0..n_q).map(|i| i as f64 / (n_q - 1) as f64).collect();
759
760 let dens2 = inverse_lqd(&psi, &t_grid, &argvals).unwrap();
762
763 let max_err = dens
771 .iter()
772 .zip(dens2.iter())
773 .map(|(&a, &b)| (a - b).abs())
774 .fold(0.0_f64, f64::max);
775 assert!(
776 max_err < 1.5e-2,
777 "round-trip L∞ error = {max_err} (tolerance 1.5e-2)"
778 );
779
780 let integral = trapz(&dens2, &argvals);
782 assert!(
783 (integral - 1.0).abs() < 1e-6,
784 "reconstructed integral = {integral}"
785 );
786
787 assert!(
789 dens2.iter().all(|&v| v >= -1e-9),
790 "negative density values found"
791 );
792 }
793
794 #[test]
797 fn inverse_lqd_normalized_nonneg() {
798 let argvals: Vec<f64> = (0..101).map(|i| -3.0 + i as f64 * 6.0 / 100.0).collect();
800 let dens = truncated_gaussian(&argvals, 0.5);
801 let t_grid: Vec<f64> = (0..101).map(|i| i as f64 / 100.0).collect();
802 let psi = lqd_transform(&dens, &argvals, Some(101)).unwrap();
803 let rec = inverse_lqd(&psi, &t_grid, &argvals).unwrap();
804
805 let integral = trapz(&rec, &argvals);
806 assert!((integral - 1.0).abs() < 1e-6, "integral = {integral}");
807 assert!(rec.iter().all(|&v| v >= -1e-9), "negative density values");
808 }
809
810 #[test]
813 fn error_negative_density() {
814 let argvals = vec![0.0, 0.5, 1.0];
815 let vals = vec![1.0, -0.1, 1.0]; assert!(
817 matches!(
818 normalize_density(&vals, &argvals),
819 Err(FdarError::InvalidParameter { .. })
820 ),
821 "expected InvalidParameter for negative density"
822 );
823 assert!(
824 matches!(
825 lqd_transform(&vals, &argvals, None),
826 Err(FdarError::InvalidParameter { .. })
827 ),
828 "expected InvalidParameter for negative density in lqd_transform"
829 );
830 }
831
832 #[test]
833 fn error_length_mismatch() {
834 let argvals = vec![0.0, 0.5, 1.0];
835 let vals = vec![1.0, 1.0]; assert!(
837 matches!(
838 normalize_density(&vals, &argvals),
839 Err(FdarError::InvalidDimension { .. })
840 ),
841 "expected InvalidDimension for length mismatch"
842 );
843 assert!(
844 matches!(
845 lqd_transform(&vals, &argvals, None),
846 Err(FdarError::InvalidDimension { .. })
847 ),
848 "expected InvalidDimension for length mismatch in lqd_transform"
849 );
850 }
851
852 #[test]
853 fn error_non_monotone_grid() {
854 let argvals = vec![0.0, 1.0, 0.5]; let vals = vec![1.0, 1.0, 1.0];
856 assert!(
857 matches!(
858 normalize_density(&vals, &argvals),
859 Err(FdarError::InvalidParameter { .. })
860 ),
861 "expected InvalidParameter for non-monotone argvals"
862 );
863 }
864
865 #[test]
866 fn error_all_zero_density() {
867 let argvals = vec![0.0, 0.5, 1.0];
868 let vals = vec![0.0, 0.0, 0.0];
869 assert!(
870 matches!(
871 normalize_density(&vals, &argvals),
872 Err(FdarError::InvalidParameter { .. })
873 ),
874 "expected InvalidParameter for all-zero density"
875 );
876 }
877
878 #[test]
879 fn error_inverse_lqd_length_mismatch() {
880 let psi = vec![0.0, 0.0, 0.0];
881 let t_grid = vec![0.0, 0.5]; let target = vec![0.0, 0.5, 1.0];
883 assert!(
884 matches!(
885 inverse_lqd(&psi, &t_grid, &target),
886 Err(FdarError::InvalidDimension { .. })
887 ),
888 "expected InvalidDimension"
889 );
890 }
891
892 #[test]
893 fn error_inverse_lqd_non_monotone_t_grid() {
894 let psi = vec![0.0, 0.0];
895 let t_grid = vec![1.0, 0.0]; let target = vec![0.0, 1.0];
897 assert!(
898 matches!(
899 inverse_lqd(&psi, &t_grid, &target),
900 Err(FdarError::InvalidParameter { .. })
901 ),
902 "expected InvalidParameter for non-monotone t_grid"
903 );
904 }
905
906 #[test]
909 fn barycenter_singleton_reduction() {
910 let argvals: Vec<f64> = (0..101).map(|i| -3.0 + i as f64 * 6.0 / 100.0).collect();
912 let dens = truncated_gaussian(&argvals, 0.0);
913 let mut data = FdMatrix::zeros(1, 101);
914 for (j, &v) in dens.iter().enumerate() {
915 data[(0, j)] = v;
916 }
917 let bary = wasserstein_barycenter(&data, &argvals, None).unwrap();
918 let max_err = dens
919 .iter()
920 .zip(bary.iter())
921 .map(|(&a, &b)| (a - b).abs())
922 .fold(0.0_f64, f64::max);
923 assert!(max_err < 1e-2, "singleton barycenter L∞ error = {max_err}");
924 }
925
926 #[test]
927 fn barycenter_two_density_midpoint() {
928 let argvals: Vec<f64> = (0..201).map(|i| -5.0 + i as f64 * 10.0 / 200.0).collect();
930 let d1 = truncated_gaussian(&argvals, -1.0);
931 let d2 = truncated_gaussian(&argvals, 1.0);
932 let mut data = FdMatrix::zeros(2, 201);
933 for (j, &v) in d1.iter().enumerate() {
934 data[(0, j)] = v;
935 }
936 for (j, &v) in d2.iter().enumerate() {
937 data[(1, j)] = v;
938 }
939 let bary = wasserstein_barycenter(&data, &argvals, None).unwrap();
940 let bary_integral = trapz(&bary, &argvals);
942 assert!(
943 (bary_integral - 1.0).abs() < 1e-6,
944 "barycenter integral = {bary_integral}"
945 );
946 assert!(bary.iter().all(|&v| v >= -1e-9), "negative barycenter");
947 }
948
949 #[test]
950 fn error_empty_barycenter() {
951 let data = FdMatrix::zeros(0, 101);
952 let argvals: Vec<f64> = (0..101).map(|i| i as f64 / 100.0).collect();
953 assert!(
954 matches!(
955 wasserstein_barycenter(&data, &argvals, None),
956 Err(FdarError::InvalidDimension { .. })
957 ),
958 "expected InvalidDimension for empty matrix"
959 );
960 }
961
962 #[test]
965 fn lqd_fpca_fve_monotone_and_bounded() {
966 let argvals: Vec<f64> = (0..101).map(|i| -3.0 + i as f64 * 6.0 / 100.0).collect();
967 let mut data = FdMatrix::zeros(20, 101);
969 for i in 0..20usize {
970 let mu = -2.0 + i as f64 * 0.2;
971 let dens = truncated_gaussian(&argvals, mu);
972 for (j, &v) in dens.iter().enumerate() {
973 data[(i, j)] = v;
974 }
975 }
976 let result = lqd_fpca(&data, &argvals, 5, Some(101)).unwrap();
977
978 for k in 1..result.fve.len() {
980 assert!(
981 result.fve[k] >= result.fve[k - 1] - 1e-12,
982 "FVE not monotone at k={k}: {} < {}",
983 result.fve[k],
984 result.fve[k - 1]
985 );
986 }
987 assert!(
989 result.fve.iter().all(|&v| (0.0..=1.0 + 1e-9).contains(&v)),
990 "FVE out of [0, 1] range"
991 );
992 }
993
994 #[test]
995 fn lqd_fpca_leading_pc_captures_shift() {
996 let argvals: Vec<f64> = (0..201).map(|i| -5.0 + i as f64 * 10.0 / 200.0).collect();
998 let mut data = FdMatrix::zeros(20, 201);
999 for i in 0..20usize {
1000 let mu = -2.0 + i as f64 * 4.0 / 19.0;
1001 let dens = truncated_gaussian(&argvals, mu);
1002 for (j, &v) in dens.iter().enumerate() {
1003 data[(i, j)] = v;
1004 }
1005 }
1006 let result = lqd_fpca(&data, &argvals, 3, Some(101)).unwrap();
1007 assert!(
1008 result.fve[0] > 0.80,
1009 "leading PC should explain >80% of variance for a shift family, got FVE[0] = {}",
1010 result.fve[0]
1011 );
1012 }
1013
1014 #[test]
1015 fn barycenter_weighted_extreme() {
1016 let argvals: Vec<f64> = (0..201).map(|i| -5.0 + i as f64 * 10.0 / 200.0).collect();
1018 let d1 = truncated_gaussian(&argvals, -1.0);
1019 let d2 = truncated_gaussian(&argvals, 1.0);
1020 let mut data = FdMatrix::zeros(2, 201);
1021 for (j, (&a, &b)) in d1.iter().zip(d2.iter()).enumerate() {
1022 data[(0, j)] = a;
1023 data[(1, j)] = b;
1024 }
1025 let bary = wasserstein_barycenter(&data, &argvals, Some(&[1.0, 0.0])).unwrap();
1026 let d1n = normalize_density(&d1, &argvals).unwrap();
1027 let d2n = normalize_density(&d2, &argvals).unwrap();
1028 let l1 = |a: &[f64], b: &[f64]| -> f64 {
1032 a.iter().zip(b).map(|(&x, &y)| (x - y).abs()).sum::<f64>()
1033 };
1034 let err_d1 = l1(&bary, &d1n);
1035 let err_d2 = l1(&bary, &d2n);
1036 assert!(
1037 err_d1 < 0.4 * err_d2,
1038 "all-weight-on-d1 barycenter should track d1 (L1 to d1 = {err_d1}, to d2 = {err_d2})"
1039 );
1040 }
1041
1042 #[test]
1043 fn barycenter_normalized_nonneg() {
1044 let argvals: Vec<f64> = (0..201).map(|i| -5.0 + i as f64 * 10.0 / 200.0).collect();
1046 let mut data = FdMatrix::zeros(3, 201);
1047 for i in 0..3usize {
1048 let dens = truncated_gaussian(&argvals, -1.5 + i as f64 * 1.5);
1049 for (j, &v) in dens.iter().enumerate() {
1050 data[(i, j)] = v;
1051 }
1052 }
1053 let bary = wasserstein_barycenter(&data, &argvals, None).unwrap();
1054 let integral = trapz(&bary, &argvals);
1055 assert!((integral - 1.0).abs() < 1e-6, "integral = {integral}");
1056 assert!(
1057 bary.iter().all(|&v| v >= -1e-9),
1058 "negative barycenter value"
1059 );
1060 }
1061
1062 #[test]
1063 fn error_barycenter_bad_weights() {
1064 let argvals: Vec<f64> = (0..201).map(|i| -5.0 + i as f64 * 10.0 / 200.0).collect();
1066 let mut data = FdMatrix::zeros(2, 201);
1067 for i in 0..2usize {
1068 let dens = truncated_gaussian(&argvals, -1.0 + 2.0 * i as f64);
1069 for (j, &v) in dens.iter().enumerate() {
1070 data[(i, j)] = v;
1071 }
1072 }
1073 let err = wasserstein_barycenter(&data, &argvals, Some(&[-0.5, 1.5]));
1074 assert!(
1075 matches!(err, Err(FdarError::InvalidParameter { .. })),
1076 "negative weight should return InvalidParameter, got {err:?}"
1077 );
1078 }
1079
1080 #[test]
1081 fn lqd_fpca_full_rank_fve_reaches_one() {
1082 let argvals: Vec<f64> = (0..101).map(|i| -3.0 + i as f64 * 6.0 / 100.0).collect();
1084 let mut data = FdMatrix::zeros(5, 101);
1085 for i in 0..5usize {
1086 let dens = truncated_gaussian(&argvals, -1.5 + i as f64 * 0.75);
1087 for (j, &v) in dens.iter().enumerate() {
1088 data[(i, j)] = v;
1089 }
1090 }
1091 let result = lqd_fpca(&data, &argvals, 4, Some(101)).unwrap();
1093 let last = *result.fve.last().unwrap();
1094 assert!(
1095 (last - 1.0).abs() < 1e-6,
1096 "full-rank cumulative FVE should reach 1, got {last}"
1097 );
1098 }
1099
1100 #[test]
1101 fn error_lqd_fpca_empty() {
1102 let argvals: Vec<f64> = (0..101).map(|i| -3.0 + i as f64 * 6.0 / 100.0).collect();
1104 let data = FdMatrix::zeros(0, 101);
1105 let err = lqd_fpca(&data, &argvals, 2, Some(101));
1106 assert!(err.is_err(), "empty density matrix should return an error");
1107 }
1108
1109 #[test]
1110 fn error_lqd_fpca_zero_ncomp() {
1111 let argvals: Vec<f64> = (0..101).map(|i| -3.0 + i as f64 * 6.0 / 100.0).collect();
1114 let mut data = FdMatrix::zeros(5, 101);
1115 for i in 0..5usize {
1116 let dens = truncated_gaussian(&argvals, -1.0 + i as f64 * 0.5);
1117 for (j, &v) in dens.iter().enumerate() {
1118 data[(i, j)] = v;
1119 }
1120 }
1121 let err = lqd_fpca(&data, &argvals, 0, Some(101));
1122 assert!(
1123 matches!(
1124 err,
1125 Err(FdarError::InvalidParameter {
1126 parameter: "ncomp",
1127 ..
1128 })
1129 ),
1130 "ncomp=0 should return InvalidParameter, got {err:?}"
1131 );
1132 }
1133}