use crate::basis::projection::{fdata_to_basis, ProjectionBasisType};
use crate::error::FdarError;
use crate::iter_maybe_parallel;
use crate::matrix::FdMatrix;
use rand::rngs::StdRng;
use rand::SeedableRng;
#[cfg(feature = "parallel")]
use rayon::iter::ParallelIterator;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct ItpResult {
pub adjusted_pvalues: Vec<f64>,
pub raw_pvalues: Vec<f64>,
pub basis_type: ProjectionBasisType,
pub n_basis: usize,
pub n_perm: usize,
}
fn rank_transform(t_perm: &[Vec<f64>], p: usize, b: usize) -> Vec<Vec<f64>> {
let mut l = vec![vec![0.0f64; p]; b];
let assignments: Vec<Vec<(usize, f64)>> = iter_maybe_parallel!(0..p)
.map(|k| {
let mut col: Vec<(f64, usize)> = (0..b).map(|i| (t_perm[i][k], i)).collect();
col.sort_unstable_by(|a, b_| {
b_.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)
});
col.iter()
.enumerate()
.map(|(rank_zero_based, &(_, orig_idx))| {
(orig_idx, (rank_zero_based + 1) as f64 / b as f64)
})
.collect::<Vec<_>>()
})
.collect();
for (k, col_assignments) in assignments.iter().enumerate() {
for &(orig_idx, pseudo_p) in col_assignments {
l[orig_idx][k] = pseudo_p;
}
}
l
}
#[inline]
fn fisher_cf(vals: &[f64]) -> f64 {
-2.0 * vals.iter().map(|&v| v.max(1e-300).ln()).sum::<f64>()
}
fn build_pval_matrix(
raw_pvalues: &[f64],
l: &[Vec<f64>],
p: usize,
n_perm: usize,
) -> Vec<Vec<f64>> {
let mut mat = vec![vec![1.0f64; p]; p];
mat[p - 1][..p].copy_from_slice(&raw_pvalues[..p]);
let pval_2x: Vec<f64> = raw_pvalues
.iter()
.chain(raw_pvalues.iter())
.copied()
.collect();
let l_2x: Vec<Vec<f64>> = l
.iter()
.map(|row| row.iter().chain(row.iter()).copied().collect())
.collect();
for interval_len in 2..=p {
let row_idx = p - interval_len; for j in 0..p {
let inf = j; let sup = j + interval_len; let t0_temp = fisher_cf(&pval_2x[inf..sup]);
let n_ge = l_2x
.iter()
.filter(|perm_row| fisher_cf(&perm_row[inf..sup]) >= t0_temp)
.count();
mat[row_idx][j] = n_ge as f64 / n_perm as f64;
}
}
mat
}
fn pval_correct(pval_matrix: &[Vec<f64>], p: usize) -> Vec<f64> {
let get_2x_rev = |row: usize, col: usize| -> f64 {
let orig_col = (2 * p - 1).saturating_sub(col) % p;
pval_matrix[row][orig_col]
};
let mut corrected = vec![0.0f64; p];
for var in 0..p {
let mut pval_var = get_2x_rev(p - 1, var);
let mut fine = var;
for riga_idx in (0..p - 1).rev() {
fine += 1;
for col in var..=fine {
let v = get_2x_rev(riga_idx, col);
if v > pval_var {
pval_var = v;
}
}
}
corrected[var] = pval_var;
}
corrected.reverse();
corrected
}
fn center_one_pop(data: &FdMatrix, mu0: Option<&[f64]>) -> Result<FdMatrix, FdarError> {
let (n, m) = data.shape();
match mu0 {
None => Ok(data.clone()),
Some(mu) => {
if mu.len() != m {
return Err(FdarError::InvalidDimension {
parameter: "mu0",
expected: format!("{m} elements (matching data columns)"),
actual: format!("{} elements", mu.len()),
});
}
let mut centered = FdMatrix::zeros(n, m);
for j in 0..m {
for i in 0..n {
centered[(i, j)] = data[(i, j)] - mu[j];
}
}
Ok(centered)
}
}
}
#[must_use = "the ItpResult contains the adjusted p-values"]
pub fn itp_one_pop(
data: &FdMatrix,
argvals: &[f64],
mu0: Option<&[f64]>,
basis_type: ProjectionBasisType,
nbasis: usize,
n_perm: usize,
seed: u64,
) -> Result<ItpResult, FdarError> {
let (n, m) = data.shape();
if n < 2 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 2 rows (observations)".to_string(),
actual: format!("{n} rows"),
});
}
if argvals.len() != m {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: format!("{m} elements (matching data columns)"),
actual: format!("{} elements", argvals.len()),
});
}
if nbasis < 2 {
return Err(FdarError::InvalidParameter {
parameter: "nbasis",
message: "must be >= 2".to_string(),
});
}
if n_perm == 0 {
return Err(FdarError::InvalidParameter {
parameter: "n_perm",
message: "must be >= 1".to_string(),
});
}
let centered = center_one_pop(data, mu0)?;
let proj = fdata_to_basis(¢ered, argvals, nbasis, basis_type).ok_or_else(|| {
FdarError::InvalidParameter {
parameter: "nbasis",
message: format!("basis projection failed (nbasis={nbasis}, m={m})"),
}
})?;
let coeff = proj.coefficients; let p = proj.n_basis;
let t0: Vec<f64> = (0..p)
.map(|k| {
let mean_k = (0..n).map(|i| coeff[(i, k)]).sum::<f64>() / n as f64;
mean_k.abs()
})
.collect();
let mut rng = StdRng::seed_from_u64(seed);
let mut t_perm: Vec<Vec<f64>> = Vec::with_capacity(n_perm);
for _ in 0..n_perm {
use rand::Rng;
let signs: Vec<f64> = (0..n)
.map(|_| if rng.gen::<bool>() { 1.0 } else { -1.0 })
.collect();
let row: Vec<f64> = (0..p)
.map(|k| {
let mean_k = (0..n).map(|i| coeff[(i, k)] * signs[i]).sum::<f64>() / n as f64;
mean_k.abs()
})
.collect();
t_perm.push(row);
}
let raw_pvalues: Vec<f64> = (0..p)
.map(|k| {
let n_ge = t_perm.iter().filter(|row| row[k] >= t0[k]).count();
(n_ge as f64 + 1.0) / (n_perm as f64 + 1.0)
})
.collect();
let l = rank_transform(&t_perm, p, n_perm);
let pval_matrix = build_pval_matrix(&raw_pvalues, &l, p, n_perm);
let adjusted_pvalues = pval_correct(&pval_matrix, p);
Ok(ItpResult {
adjusted_pvalues,
raw_pvalues,
basis_type,
n_basis: p,
n_perm,
})
}
fn validate_two_samples_itp(
data_a: &FdMatrix,
data_b: &FdMatrix,
argvals: &[f64],
) -> Result<(usize, usize, usize), FdarError> {
let (n_a, m_a) = data_a.shape();
let (n_b, m_b) = data_b.shape();
if m_a == 0 || m_b == 0 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 1 column (grid points)".to_string(),
actual: format!("data_a has {m_a} columns, data_b has {m_b} columns"),
});
}
if m_a != m_b {
return Err(FdarError::InvalidDimension {
parameter: "data_b",
expected: format!("{m_a} columns (matching data_a)"),
actual: format!("{m_b} columns"),
});
}
if argvals.len() != m_a {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: format!("{m_a} elements (matching data columns)"),
actual: format!("{} elements", argvals.len()),
});
}
if n_a < 2 || n_b < 2 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 2 rows per sample".to_string(),
actual: format!("data_a has {n_a} rows, data_b has {n_b} rows"),
});
}
Ok((n_a, n_b, m_a))
}
fn pool_coefficients_itp(
coeff_a: &FdMatrix,
coeff_b: &FdMatrix,
n_a: usize,
n_b: usize,
p: usize,
) -> FdMatrix {
let mut pooled = FdMatrix::zeros(n_a + n_b, p);
for k in 0..p {
for i in 0..n_a {
pooled[(i, k)] = coeff_a[(i, k)];
}
for i in 0..n_b {
pooled[(n_a + i, k)] = coeff_b[(i, k)];
}
}
pooled
}
fn shuffle_itp(v: &mut [usize], rng: &mut StdRng) {
use rand::Rng;
let n = v.len();
for i in (1..n).rev() {
let j = rng.gen_range(0..=i);
v.swap(i, j);
}
}
#[must_use = "the ItpResult contains the adjusted p-values"]
pub fn itp_two_pop(
data_a: &FdMatrix,
data_b: &FdMatrix,
argvals: &[f64],
basis_type: ProjectionBasisType,
nbasis: usize,
n_perm: usize,
seed: u64,
) -> Result<ItpResult, FdarError> {
let (n_a, n_b, m) = validate_two_samples_itp(data_a, data_b, argvals)?;
if nbasis < 2 {
return Err(FdarError::InvalidParameter {
parameter: "nbasis",
message: "must be >= 2".to_string(),
});
}
if n_perm == 0 {
return Err(FdarError::InvalidParameter {
parameter: "n_perm",
message: "must be >= 1".to_string(),
});
}
let proj_a = fdata_to_basis(data_a, argvals, nbasis, basis_type).ok_or_else(|| {
FdarError::InvalidParameter {
parameter: "nbasis",
message: format!("basis projection failed for data_a (nbasis={nbasis}, m={m})"),
}
})?;
let proj_b = fdata_to_basis(data_b, argvals, nbasis, basis_type).ok_or_else(|| {
FdarError::InvalidParameter {
parameter: "nbasis",
message: format!("basis projection failed for data_b (nbasis={nbasis}, m={m})"),
}
})?;
let p = proj_a.n_basis; let coeff_a = proj_a.coefficients; let coeff_b = proj_b.coefficients;
let pooled = pool_coefficients_itp(&coeff_a, &coeff_b, n_a, n_b, p);
let n = n_a + n_b;
let t0: Vec<f64> = (0..p)
.map(|k| {
let m_a = (0..n_a).map(|i| pooled[(i, k)]).sum::<f64>() / n_a as f64;
let m_b = (n_a..n).map(|i| pooled[(i, k)]).sum::<f64>() / n_b as f64;
(m_a - m_b).abs()
})
.collect();
let mut rng = StdRng::seed_from_u64(seed);
let mut perm_idx: Vec<usize> = (0..n).collect();
let mut t_perm: Vec<Vec<f64>> = Vec::with_capacity(n_perm);
for _ in 0..n_perm {
shuffle_itp(&mut perm_idx, &mut rng);
let row: Vec<f64> = (0..p)
.map(|k| {
let m_a = (0..n_a).map(|r| pooled[(r, k)]).sum::<f64>() / n_a as f64;
let m_b = (n_a..n).map(|i| pooled[(perm_idx[i], k)]).sum::<f64>() / n_b as f64;
(m_a - m_b).abs()
})
.collect();
t_perm.push(row);
}
let raw_pvalues: Vec<f64> = (0..p)
.map(|k| {
let n_ge = t_perm.iter().filter(|row| row[k] >= t0[k]).count();
(n_ge as f64 + 1.0) / (n_perm as f64 + 1.0)
})
.collect();
let l = rank_transform(&t_perm, p, n_perm);
let pval_matrix = build_pval_matrix(&raw_pvalues, &l, p, n_perm);
let adjusted_pvalues = pval_correct(&pval_matrix, p);
Ok(ItpResult {
adjusted_pvalues,
raw_pvalues,
basis_type,
n_basis: p,
n_perm,
})
}
fn component_t_stat(y: &[f64], coeff: &FdMatrix, k: usize) -> f64 {
let n = y.len();
let mx: f64 = (0..n).map(|i| coeff[(i, k)]).sum::<f64>() / n as f64;
let my: f64 = y.iter().sum::<f64>() / n as f64;
let sxx: f64 = (0..n).map(|i| (coeff[(i, k)] - mx).powi(2)).sum();
if sxx < 1e-30 {
return 0.0;
}
let sxy: f64 = (0..n).map(|i| (coeff[(i, k)] - mx) * (y[i] - my)).sum();
let beta = sxy / sxx;
let rss: f64 = (0..n)
.map(|i| {
let yhat = my + beta * (coeff[(i, k)] - mx);
(y[i] - yhat).powi(2)
})
.sum();
let se2 = rss / ((n - 2) as f64 * sxx);
if se2 <= 0.0 {
return 0.0;
}
(beta / se2.sqrt()).abs()
}
#[must_use = "the ItpResult contains the adjusted p-values"]
pub fn itp_flm(
data: &FdMatrix,
y: &[f64],
argvals: &[f64],
basis_type: ProjectionBasisType,
nbasis: usize,
n_perm: usize,
seed: u64,
) -> Result<ItpResult, FdarError> {
let (n, m) = data.shape();
if n < 2 {
return Err(FdarError::InvalidDimension {
parameter: "data",
expected: "at least 2 rows (observations)".to_string(),
actual: format!("{n} rows"),
});
}
if y.len() != n {
return Err(FdarError::InvalidDimension {
parameter: "y",
expected: format!("{n} elements (matching data rows)"),
actual: format!("{} elements", y.len()),
});
}
if argvals.len() != m {
return Err(FdarError::InvalidDimension {
parameter: "argvals",
expected: format!("{m} elements (matching data columns)"),
actual: format!("{} elements", argvals.len()),
});
}
if nbasis < 2 {
return Err(FdarError::InvalidParameter {
parameter: "nbasis",
message: "must be >= 2".to_string(),
});
}
if n_perm == 0 {
return Err(FdarError::InvalidParameter {
parameter: "n_perm",
message: "must be >= 1".to_string(),
});
}
let proj = fdata_to_basis(data, argvals, nbasis, basis_type).ok_or_else(|| {
FdarError::InvalidParameter {
parameter: "nbasis",
message: format!("basis projection failed (nbasis={nbasis}, m={m})"),
}
})?;
let coeff = proj.coefficients; let p = proj.n_basis;
let t0: Vec<f64> = (0..p).map(|k| component_t_stat(y, &coeff, k)).collect();
let mut rng = StdRng::seed_from_u64(seed);
let mut perm_idx: Vec<usize> = (0..n).collect();
let mut t_perm: Vec<Vec<f64>> = Vec::with_capacity(n_perm);
let mut y_perm: Vec<f64> = vec![0.0; n];
for _ in 0..n_perm {
shuffle_itp(&mut perm_idx, &mut rng);
for i in 0..n {
y_perm[i] = y[perm_idx[i]];
}
let row: Vec<f64> = (0..p)
.map(|k| component_t_stat(&y_perm, &coeff, k))
.collect();
t_perm.push(row);
}
let raw_pvalues: Vec<f64> = (0..p)
.map(|k| {
let n_ge = t_perm.iter().filter(|row| row[k] >= t0[k]).count();
(n_ge as f64 + 1.0) / (n_perm as f64 + 1.0)
})
.collect();
let l = rank_transform(&t_perm, p, n_perm);
let pval_matrix = build_pval_matrix(&raw_pvalues, &l, p, n_perm);
let adjusted_pvalues = pval_correct(&pval_matrix, p);
Ok(ItpResult {
adjusted_pvalues,
raw_pvalues,
basis_type,
n_basis: p,
n_perm,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::uniform_grid;
#[test]
fn pval_correct_hand_computed() {
let p = 4;
let pval_matrix = vec![
vec![0.30, 0.25, 0.20, 0.15], vec![0.40, 0.35, 0.28, 0.22], vec![0.50, 0.45, 0.38, 0.32], vec![0.60, 0.55, 0.48, 0.42], ];
let adjusted = pval_correct(&pval_matrix, p);
let expected = [0.60, 0.55, 0.48, 0.42];
assert_eq!(adjusted.len(), p);
for (k, (&got, &exp)) in adjusted.iter().zip(expected.iter()).enumerate() {
assert!(
(got - exp).abs() < 1e-12,
"adjusted_pvalues[{k}]: got {got}, expected {exp}"
);
}
}
#[test]
fn fisher_cf_log_safe() {
let v = fisher_cf(&[0.0, 0.5, 1.0]);
assert!(
v.is_finite(),
"fisher_cf must be finite even with 0.0 input: {v}"
);
let expected = -2.0 * (1e-300f64.ln() + 0.5f64.ln() + 1.0f64.ln());
assert!(
(v - expected).abs() < 1e-10,
"fisher_cf value mismatch: {v} vs {expected}"
);
}
fn make_shifted_sample(
n: usize,
argvals: &[f64],
shift: f64,
shift_lo: f64,
shift_hi: f64,
seed: u64,
) -> FdMatrix {
use rand::Rng;
let mut rng = StdRng::seed_from_u64(seed);
let m = argvals.len();
let mut data = FdMatrix::zeros(n, m);
for i in 0..n {
let phase: f64 = rng.gen::<f64>() * std::f64::consts::PI;
for (j, &t) in argvals.iter().enumerate() {
let noise: f64 = rng.gen::<f64>() * 0.05;
let s = if t >= shift_lo && t <= shift_hi {
shift
} else {
0.0
};
data[(i, j)] = (t * 2.0 * std::f64::consts::PI + phase).sin() + noise + s;
}
}
data
}
#[test]
fn one_population_localized() {
let m = 50;
let n = 30;
let argvals = uniform_grid(m);
let data = make_shifted_sample(n, &argvals, 2.0, 0.4, 0.6, 1001);
let result = itp_one_pop(
&data,
&argvals,
None,
ProjectionBasisType::Bspline,
15,
499,
42,
)
.expect("itp_one_pop should succeed");
assert_eq!(result.n_perm, 499);
assert!(!result.adjusted_pvalues.is_empty());
let min_p = result
.adjusted_pvalues
.iter()
.cloned()
.fold(f64::INFINITY, f64::min);
assert!(
min_p < 0.05,
"Expected at least one significant component, min adjusted p = {min_p}"
);
}
#[test]
fn one_population_null() {
let m = 50;
let n = 30;
let argvals = uniform_grid(m);
let data = make_shifted_sample(n, &argvals, 0.0, 0.0, 1.0, 2002);
let result = itp_one_pop(
&data,
&argvals,
None,
ProjectionBasisType::Bspline,
15,
499,
42,
)
.expect("itp_one_pop should succeed");
let max_p = result
.adjusted_pvalues
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
assert!(
max_p > 0.10,
"Expected non-significant result under null, max adjusted p = {max_p}"
);
}
#[test]
fn one_population_deterministic() {
let m = 30;
let n = 15;
let argvals = uniform_grid(m);
let data = make_shifted_sample(n, &argvals, 1.0, 0.3, 0.7, 3003);
let r1 = itp_one_pop(
&data,
&argvals,
None,
ProjectionBasisType::Bspline,
10,
99,
77,
)
.unwrap();
let r2 = itp_one_pop(
&data,
&argvals,
None,
ProjectionBasisType::Bspline,
10,
99,
77,
)
.unwrap();
assert_eq!(r1, r2, "same seed must give bit-identical ItpResult");
}
#[test]
fn one_population_error_paths() {
let m = 20;
let argvals = uniform_grid(m);
let one_row = FdMatrix::zeros(1, m);
assert!(
matches!(
itp_one_pop(
&one_row,
&argvals,
None,
ProjectionBasisType::Bspline,
5,
99,
0
),
Err(FdarError::InvalidDimension { .. })
),
"n < 2 should return InvalidDimension"
);
let data = FdMatrix::zeros(5, m);
let short_argvals = uniform_grid(m - 1);
assert!(
matches!(
itp_one_pop(
&data,
&short_argvals,
None,
ProjectionBasisType::Bspline,
5,
99,
0
),
Err(FdarError::InvalidDimension { .. })
),
"argvals mismatch should return InvalidDimension"
);
assert!(
matches!(
itp_one_pop(
&data,
&argvals,
None,
ProjectionBasisType::Bspline,
1,
99,
0
),
Err(FdarError::InvalidParameter { .. })
),
"nbasis < 2 should return InvalidParameter"
);
assert!(
matches!(
itp_one_pop(&data, &argvals, None, ProjectionBasisType::Bspline, 5, 0, 0),
Err(FdarError::InvalidParameter { .. })
),
"n_perm == 0 should return InvalidParameter"
);
}
fn make_two_pop_sample(
n: usize,
argvals: &[f64],
shift: f64,
shift_lo: f64,
shift_hi: f64,
seed: u64,
) -> FdMatrix {
use rand::Rng;
let mut rng = StdRng::seed_from_u64(seed);
let m = argvals.len();
let mut data = FdMatrix::zeros(n, m);
for i in 0..n {
let phase: f64 = rng.gen::<f64>() * std::f64::consts::PI;
for (j, &t) in argvals.iter().enumerate() {
let noise: f64 = rng.gen::<f64>() * 0.05;
let s = if t >= shift_lo && t <= shift_hi {
shift
} else {
0.0
};
data[(i, j)] = (t * 2.0 * std::f64::consts::PI + phase).sin() + noise + s;
}
}
data
}
#[test]
fn two_population_localized() {
let m = 50;
let n = 30;
let argvals = uniform_grid(m);
let data_a = make_two_pop_sample(n, &argvals, 0.0, 0.4, 0.6, 1001);
let data_b = make_two_pop_sample(n, &argvals, 2.0, 0.4, 0.6, 2002);
let result = itp_two_pop(
&data_a,
&data_b,
&argvals,
ProjectionBasisType::Bspline,
15,
499,
42,
)
.expect("itp_two_pop should succeed");
assert_eq!(result.n_perm, 499);
assert!(!result.adjusted_pvalues.is_empty());
let min_p = result
.adjusted_pvalues
.iter()
.cloned()
.fold(f64::INFINITY, f64::min);
assert!(
min_p < 0.05,
"Expected at least one significant component, min adjusted p = {min_p}"
);
}
#[test]
fn two_population_null() {
let m = 50;
let n = 30;
let argvals = uniform_grid(m);
let data_a = make_two_pop_sample(n, &argvals, 0.0, 0.0, 1.0, 3003);
let data_b = make_two_pop_sample(n, &argvals, 0.0, 0.0, 1.0, 4004);
let result = itp_two_pop(
&data_a,
&data_b,
&argvals,
ProjectionBasisType::Bspline,
15,
499,
42,
)
.expect("itp_two_pop should succeed");
let max_p = result
.adjusted_pvalues
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
assert!(
max_p > 0.10,
"Expected non-significant result under null, max adjusted p = {max_p}"
);
}
#[test]
fn two_population_deterministic() {
let m = 30;
let n = 15;
let argvals = uniform_grid(m);
let data_a = make_two_pop_sample(n, &argvals, 0.0, 0.0, 1.0, 5005);
let data_b = make_two_pop_sample(n, &argvals, 1.0, 0.3, 0.7, 6006);
let r1 = itp_two_pop(
&data_a,
&data_b,
&argvals,
ProjectionBasisType::Bspline,
10,
99,
77,
)
.unwrap();
let r2 = itp_two_pop(
&data_a,
&data_b,
&argvals,
ProjectionBasisType::Bspline,
10,
99,
77,
)
.unwrap();
assert_eq!(r1, r2, "same seed must give bit-identical ItpResult");
}
#[test]
fn two_population_error_paths() {
let m = 20;
let argvals = uniform_grid(m);
let good = FdMatrix::zeros(5, m);
let one_row = FdMatrix::zeros(1, m);
assert!(
matches!(
itp_two_pop(
&one_row,
&good,
&argvals,
ProjectionBasisType::Bspline,
5,
99,
0
),
Err(FdarError::InvalidDimension { .. })
),
"n_a < 2 should return InvalidDimension"
);
assert!(
matches!(
itp_two_pop(
&good,
&one_row,
&argvals,
ProjectionBasisType::Bspline,
5,
99,
0
),
Err(FdarError::InvalidDimension { .. })
),
"n_b < 2 should return InvalidDimension"
);
let wide = FdMatrix::zeros(5, m + 1);
assert!(
matches!(
itp_two_pop(
&good,
&wide,
&argvals,
ProjectionBasisType::Bspline,
5,
99,
0
),
Err(FdarError::InvalidDimension { .. })
),
"m_a != m_b should return InvalidDimension"
);
let short_argvals = uniform_grid(m - 1);
assert!(
matches!(
itp_two_pop(
&good,
&good,
&short_argvals,
ProjectionBasisType::Bspline,
5,
99,
0
),
Err(FdarError::InvalidDimension { .. })
),
"argvals mismatch should return InvalidDimension"
);
assert!(
matches!(
itp_two_pop(
&good,
&good,
&argvals,
ProjectionBasisType::Bspline,
1,
99,
0
),
Err(FdarError::InvalidParameter { .. })
),
"nbasis < 2 should return InvalidParameter"
);
assert!(
matches!(
itp_two_pop(
&good,
&good,
&argvals,
ProjectionBasisType::Bspline,
5,
0,
0
),
Err(FdarError::InvalidParameter { .. })
),
"n_perm == 0 should return InvalidParameter"
);
}
fn make_flm_sample(
n: usize,
argvals: &[f64],
lo: f64,
hi: f64,
seed: u64,
) -> (FdMatrix, Vec<f64>) {
use rand::Rng;
let mut rng = StdRng::seed_from_u64(seed);
let m = argvals.len();
let mut data = FdMatrix::zeros(n, m);
let mut y = vec![0.0f64; n];
for i in 0..n {
let scale: f64 = rng.gen::<f64>() * 3.0 + 1.0; let offset: f64 = (rng.gen::<f64>() - 0.5) * 2.0; let mut local_sum = 0.0f64;
let mut local_cnt = 0usize;
for (j, &t) in argvals.iter().enumerate() {
let noise: f64 = (rng.gen::<f64>() - 0.5) * 0.02;
let v = scale * t + offset + noise;
data[(i, j)] = v;
if t >= lo && t <= hi {
local_sum += v;
local_cnt += 1;
}
}
let y_noise: f64 = (rng.gen::<f64>() - 0.5) * 0.05;
y[i] = if local_cnt > 0 {
local_sum / local_cnt as f64
} else {
0.0
} + y_noise;
}
(data, y)
}
#[test]
fn flm_effect() {
let m = 50;
let n = 30;
let argvals = uniform_grid(m);
let (data, y) = make_flm_sample(n, &argvals, 0.3, 0.7, 7007);
let result = itp_flm(
&data,
&y,
&argvals,
ProjectionBasisType::Bspline,
15,
499,
42,
)
.expect("itp_flm should succeed");
assert_eq!(result.n_perm, 499);
assert!(!result.adjusted_pvalues.is_empty());
let min_p = result
.adjusted_pvalues
.iter()
.cloned()
.fold(f64::INFINITY, f64::min);
assert!(
min_p < 0.05,
"Expected at least one significant component with functional effect, min adjusted p = {min_p}"
);
}
#[test]
fn flm_null() {
use rand::Rng;
let m = 50;
let n = 30;
let argvals = uniform_grid(m);
let mut rng = StdRng::seed_from_u64(8008);
let mut data = FdMatrix::zeros(n, m);
for i in 0..n {
let phase: f64 = rng.gen::<f64>() * std::f64::consts::PI;
for (j, &t) in argvals.iter().enumerate() {
data[(i, j)] = (t * 2.0 * std::f64::consts::PI + phase).sin();
}
}
let y: Vec<f64> = (0..n).map(|_| rng.gen::<f64>()).collect();
let result = itp_flm(
&data,
&y,
&argvals,
ProjectionBasisType::Bspline,
15,
499,
42,
)
.expect("itp_flm should succeed");
let max_p = result
.adjusted_pvalues
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
assert!(
max_p > 0.10,
"Expected non-significant result under null, max adjusted p = {max_p}"
);
}
#[test]
fn flm_error_paths() {
let m = 20;
let argvals = uniform_grid(m);
let data = FdMatrix::zeros(5, m);
let y = vec![0.0f64; 5];
let one_row = FdMatrix::zeros(1, m);
let y1 = vec![0.0f64; 1];
assert!(
matches!(
itp_flm(
&one_row,
&y1,
&argvals,
ProjectionBasisType::Bspline,
5,
99,
0
),
Err(FdarError::InvalidDimension { .. })
),
"n < 2 should return InvalidDimension"
);
let y_wrong = vec![0.0f64; 3];
assert!(
matches!(
itp_flm(
&data,
&y_wrong,
&argvals,
ProjectionBasisType::Bspline,
5,
99,
0
),
Err(FdarError::InvalidDimension { .. })
),
"y.len() != n should return InvalidDimension"
);
let short_argvals = uniform_grid(m - 1);
assert!(
matches!(
itp_flm(
&data,
&y,
&short_argvals,
ProjectionBasisType::Bspline,
5,
99,
0
),
Err(FdarError::InvalidDimension { .. })
),
"argvals mismatch should return InvalidDimension"
);
assert!(
matches!(
itp_flm(&data, &y, &argvals, ProjectionBasisType::Bspline, 1, 99, 0),
Err(FdarError::InvalidParameter { .. })
),
"nbasis < 2 should return InvalidParameter"
);
assert!(
matches!(
itp_flm(&data, &y, &argvals, ProjectionBasisType::Bspline, 5, 0, 0),
Err(FdarError::InvalidParameter { .. })
),
"n_perm == 0 should return InvalidParameter"
);
}
}