use std::f64::consts::PI;
#[derive(Debug)]
pub enum GeoError {
DimensionMismatch { expected: usize, got: usize },
InvalidGraph(String),
NumericalError(String),
InvalidDegree(usize),
}
impl std::fmt::Display for GeoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
GeoError::DimensionMismatch { expected, got } => {
write!(f, "Dimension mismatch: expected {expected}, got {got}")
}
GeoError::InvalidGraph(msg) => write!(f, "Invalid graph: {msg}"),
GeoError::NumericalError(msg) => write!(f, "Numerical error: {msg}"),
GeoError::InvalidDegree(node) => {
write!(
f,
"Node {node} has zero degree where a non-zero degree is required"
)
}
}
}
}
impl std::error::Error for GeoError {}
#[derive(Debug, Clone)]
pub struct AdjacencyMatrix {
n: usize,
data: Vec<f64>,
}
impl AdjacencyMatrix {
pub fn new(n: usize) -> Self {
Self {
n,
data: vec![0.0_f64; n * n],
}
}
pub fn from_edges(n: usize, edges: &[(usize, usize)]) -> Self {
let mut adj = Self::new(n);
for &(i, j) in edges {
adj.data[i * n + j] = 1.0;
adj.data[j * n + i] = 1.0;
}
adj
}
pub fn from_edges_weighted(n: usize, edges: &[(usize, usize, f64)]) -> Self {
let mut adj = Self::new(n);
for &(i, j, w) in edges {
adj.data[i * n + j] = w;
adj.data[j * n + i] = w;
}
adj
}
pub fn set(&mut self, i: usize, j: usize, val: f64) {
self.data[i * self.n + j] = val;
}
pub fn get(&self, i: usize, j: usize) -> f64 {
self.data[i * self.n + j]
}
pub fn n(&self) -> usize {
self.n
}
pub fn degree(&self, i: usize) -> f64 {
let start = i * self.n;
self.data[start..start + self.n].iter().sum()
}
pub fn is_symmetric(&self) -> bool {
for i in 0..self.n {
for j in (i + 1)..self.n {
if (self.data[i * self.n + j] - self.data[j * self.n + i]).abs() > 1e-12 {
return false;
}
}
}
true
}
pub fn symmetrize(&mut self) {
for i in 0..self.n {
for j in (i + 1)..self.n {
let avg = (self.data[i * self.n + j] + self.data[j * self.n + i]) * 0.5;
self.data[i * self.n + j] = avg;
self.data[j * self.n + i] = avg;
}
}
}
pub fn add_self_loops(&mut self) {
for i in 0..self.n {
self.data[i * self.n + i] += 1.0;
}
}
}
#[derive(Debug, Clone)]
pub enum LaplacianType {
Unnormalized,
Symmetric,
RandomWalk,
AddSelfLoops,
}
#[derive(Debug, Clone)]
pub struct LaplacianMatrix {
pub data: Vec<f64>,
pub n: usize,
pub laplacian_type: LaplacianType,
}
pub fn graph_laplacian(
adj: &AdjacencyMatrix,
lap_type: LaplacianType,
) -> Result<LaplacianMatrix, GeoError> {
let n = adj.n();
if n == 0 {
return Err(GeoError::InvalidGraph(
"Graph must have at least one node".to_string(),
));
}
let mut data = vec![0.0_f64; n * n];
match lap_type {
LaplacianType::Unnormalized => {
for i in 0..n {
for j in 0..n {
if i == j {
data[i * n + i] = adj.degree(i);
} else {
data[i * n + j] = -adj.get(i, j);
}
}
}
}
LaplacianType::Symmetric => {
let degrees: Vec<f64> = (0..n).map(|i| adj.degree(i)).collect();
for i in 0..n {
for j in 0..n {
let unnorm = if i == j { degrees[i] } else { -adj.get(i, j) };
if degrees[i] > 1e-12 && degrees[j] > 1e-12 {
data[i * n + j] = unnorm / (degrees[i].sqrt() * degrees[j].sqrt());
} else {
data[i * n + j] = 0.0;
}
}
}
}
LaplacianType::RandomWalk => {
for i in 0..n {
let d = adj.degree(i);
if d < 1e-12 {
continue;
}
for j in 0..n {
let a_ij = adj.get(i, j);
if i == j {
data[i * n + j] = 1.0 - a_ij / d;
} else {
data[i * n + j] = -a_ij / d;
}
}
}
}
LaplacianType::AddSelfLoops => {
let mut a_tilde = adj.clone();
a_tilde.add_self_loops();
let d_tilde: Vec<f64> = (0..n).map(|i| a_tilde.degree(i)).collect();
for i in 0..n {
for j in 0..n {
let a_ij = a_tilde.get(i, j);
if d_tilde[i] > 1e-12 && d_tilde[j] > 1e-12 {
data[i * n + j] = a_ij / (d_tilde[i].sqrt() * d_tilde[j].sqrt());
} else {
data[i * n + j] = 0.0;
}
}
}
}
}
Ok(LaplacianMatrix {
data,
n,
laplacian_type: lap_type,
})
}
#[derive(Debug, Clone, Copy)]
pub enum GcnActivation {
ReLU,
Sigmoid,
Tanh,
Linear,
}
impl GcnActivation {
fn apply(self, x: f64) -> f64 {
match self {
GcnActivation::ReLU => x.max(0.0),
GcnActivation::Sigmoid => 1.0 / (1.0 + (-x).exp()),
GcnActivation::Tanh => x.tanh(),
GcnActivation::Linear => x,
}
}
}
pub fn mat_mul(a: &[Vec<f64>], b: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, GeoError> {
if a.is_empty() || b.is_empty() {
return Err(GeoError::InvalidGraph(
"Matrix must be non-empty".to_string(),
));
}
let m = a.len();
let k = a[0].len();
let k2 = b.len();
let n = b[0].len();
for (row_idx, row) in a.iter().enumerate() {
if row.len() != k {
return Err(GeoError::InvalidGraph(format!(
"Matrix A has ragged rows: row 0 has {k} cols but row {row_idx} has {} cols",
row.len()
)));
}
}
for (row_idx, row) in b.iter().enumerate() {
if row.len() != n {
return Err(GeoError::InvalidGraph(format!(
"Matrix B has ragged rows: row 0 has {n} cols but row {row_idx} has {} cols",
row.len()
)));
}
}
if k != k2 {
return Err(GeoError::DimensionMismatch {
expected: k,
got: k2,
});
}
let mut result = vec![vec![0.0_f64; n]; m];
for i in 0..m {
for l in 0..k {
let a_il = a[i][l];
if a_il == 0.0 {
continue;
}
for j in 0..n {
result[i][j] += a_il * b[l][j];
}
}
}
Ok(result)
}
pub fn gcn_layer(
adj: &AdjacencyMatrix,
node_features: &[Vec<f64>],
weights: &[Vec<f64>],
activation: GcnActivation,
) -> Result<Vec<Vec<f64>>, GeoError> {
let n = adj.n();
if node_features.len() != n {
return Err(GeoError::DimensionMismatch {
expected: n,
got: node_features.len(),
});
}
if node_features.is_empty() {
return Err(GeoError::InvalidGraph(
"node_features must be non-empty".to_string(),
));
}
let d_in = node_features[0].len();
if weights.len() != d_in {
return Err(GeoError::DimensionMismatch {
expected: d_in,
got: weights.len(),
});
}
let laplacian = graph_laplacian(adj, LaplacianType::AddSelfLoops)?;
let l_hat: Vec<Vec<f64>> = (0..n)
.map(|i| laplacian.data[i * n..(i + 1) * n].to_vec())
.collect();
let lh = mat_mul(&l_hat, node_features)?;
let lhw = mat_mul(&lh, weights)?;
let output = lhw
.into_iter()
.map(|row| row.into_iter().map(|x| activation.apply(x)).collect())
.collect();
Ok(output)
}
#[derive(Debug, Clone)]
pub struct Rotation3 {
matrix: [[f64; 3]; 3],
}
impl Rotation3 {
pub fn identity() -> Self {
Self {
matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
}
}
pub fn from_axis_angle(axis: [f64; 3], angle_rad: f64) -> Self {
let norm = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
if norm < 1e-12 {
return Self::identity();
}
let kx = axis[0] / norm;
let ky = axis[1] / norm;
let kz = axis[2] / norm;
let c = angle_rad.cos();
let s = angle_rad.sin();
let t = 1.0 - c;
#[rustfmt::skip]
let matrix = [
[t * kx * kx + c, t * kx * ky - s * kz, t * kx * kz + s * ky],
[t * kx * ky + s * kz, t * ky * ky + c, t * ky * kz - s * kx],
[t * kx * kz - s * ky, t * ky * kz + s * kx, t * kz * kz + c ],
];
Self { matrix }
}
pub fn from_euler_xyz(roll: f64, pitch: f64, yaw: f64) -> Self {
let rx = Self::from_axis_angle([1.0, 0.0, 0.0], roll);
let ry = Self::from_axis_angle([0.0, 1.0, 0.0], pitch);
let rz = Self::from_axis_angle([0.0, 0.0, 1.0], yaw);
rz.compose(&ry).compose(&rx)
}
pub fn from_quaternion(w: f64, x: f64, y: f64, z: f64) -> Result<Self, GeoError> {
let norm_sq = w * w + x * x + y * y + z * z;
if (norm_sq - 1.0).abs() > 1e-6 {
return Err(GeoError::NumericalError(format!(
"Quaternion norm squared is {norm_sq:.6}, expected 1.0"
)));
}
let n = norm_sq.sqrt();
let (w, x, y, z) = (w / n, x / n, y / n, z / n);
#[rustfmt::skip]
let matrix = [
[1.0 - 2.0*(y*y + z*z), 2.0*(x*y - w*z), 2.0*(x*z + w*y) ],
[2.0*(x*y + w*z), 1.0 - 2.0*(x*x + z*z), 2.0*(y*z - w*x) ],
[2.0*(x*z - w*y), 2.0*(y*z + w*x), 1.0 - 2.0*(x*x + y*y) ],
];
Ok(Self { matrix })
}
pub fn to_quaternion(&self) -> (f64, f64, f64, f64) {
let m = &self.matrix;
let trace = m[0][0] + m[1][1] + m[2][2];
if trace > 0.0 {
let s = (trace + 1.0).sqrt() * 2.0; let w = 0.25 * s;
let x = (m[2][1] - m[1][2]) / s;
let y = (m[0][2] - m[2][0]) / s;
let z = (m[1][0] - m[0][1]) / s;
(w, x, y, z)
} else if (m[0][0] > m[1][1]) && (m[0][0] > m[2][2]) {
let s = (1.0 + m[0][0] - m[1][1] - m[2][2]).sqrt() * 2.0; let w = (m[2][1] - m[1][2]) / s;
let x = 0.25 * s;
let y = (m[0][1] + m[1][0]) / s;
let z = (m[0][2] + m[2][0]) / s;
(w, x, y, z)
} else if m[1][1] > m[2][2] {
let s = (1.0 + m[1][1] - m[0][0] - m[2][2]).sqrt() * 2.0; let w = (m[0][2] - m[2][0]) / s;
let x = (m[0][1] + m[1][0]) / s;
let y = 0.25 * s;
let z = (m[1][2] + m[2][1]) / s;
(w, x, y, z)
} else {
let s = (1.0 + m[2][2] - m[0][0] - m[1][1]).sqrt() * 2.0; let w = (m[1][0] - m[0][1]) / s;
let x = (m[0][2] + m[2][0]) / s;
let y = (m[1][2] + m[2][1]) / s;
let z = 0.25 * s;
(w, x, y, z)
}
}
pub fn apply(&self, v: [f64; 3]) -> [f64; 3] {
let m = &self.matrix;
[
m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
]
}
pub fn compose(&self, other: &Rotation3) -> Rotation3 {
let a = &self.matrix;
let b = &other.matrix;
let mut m = [[0.0_f64; 3]; 3];
for i in 0..3 {
for j in 0..3 {
for k in 0..3 {
m[i][j] += a[i][k] * b[k][j];
}
}
}
Rotation3 { matrix: m }
}
pub fn inverse(&self) -> Rotation3 {
let m = &self.matrix;
Rotation3 {
matrix: [
[m[0][0], m[1][0], m[2][0]],
[m[0][1], m[1][1], m[2][1]],
[m[0][2], m[1][2], m[2][2]],
],
}
}
pub fn is_valid(&self) -> bool {
let rt_r = self.inverse().compose(self);
for i in 0..3 {
for j in 0..3 {
let expected = if i == j { 1.0 } else { 0.0 };
if (rt_r.matrix[i][j] - expected).abs() > 1e-9 {
return false;
}
}
}
let det = self.determinant();
(det - 1.0).abs() < 1e-9
}
fn determinant(&self) -> f64 {
let m = &self.matrix;
m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
}
}
pub fn sph_harm(l: usize, m: i32, theta: f64, phi: f64) -> f64 {
let cos_theta = theta.cos();
let sin_theta = theta.sin();
match (l, m) {
(0, 0) => {
1.0 / (2.0 * PI.sqrt())
}
(1, -1) => {
(3.0 / (4.0 * PI)).sqrt() * sin_theta * phi.sin()
}
(1, 0) => {
(3.0 / (4.0 * PI)).sqrt() * cos_theta
}
(1, 1) => {
(3.0 / (4.0 * PI)).sqrt() * sin_theta * phi.cos()
}
(2, -2) => {
0.5 * (15.0 / (4.0 * PI)).sqrt() * sin_theta * sin_theta * (2.0 * phi).sin()
}
(2, -1) => {
(15.0 / (4.0 * PI)).sqrt() * sin_theta * cos_theta * phi.sin()
}
(2, 0) => {
(5.0 / (16.0 * PI)).sqrt() * (3.0 * cos_theta * cos_theta - 1.0)
}
(2, 1) => {
(15.0 / (4.0 * PI)).sqrt() * sin_theta * cos_theta * phi.cos()
}
(2, 2) => {
(15.0 / (16.0 * PI)).sqrt() * sin_theta * sin_theta * (2.0 * phi).cos()
}
_ => 0.0,
}
}
pub fn spherical_harmonics(theta: f64, phi: f64, max_degree: usize) -> Vec<f64> {
let total = (max_degree + 1) * (max_degree + 1);
let mut values = Vec::with_capacity(total);
for l in 0..=max_degree {
let l_i32 = l as i32;
for m in -l_i32..=l_i32 {
values.push(sph_harm(l, m, theta, phi));
}
}
values
}
#[cfg(test)]
mod tests {
use super::*;
const EPS: f64 = 1e-9;
#[test]
fn test_adjacency_new_all_zeros() {
let adj = AdjacencyMatrix::new(4);
for i in 0..4 {
for j in 0..4 {
assert_eq!(adj.get(i, j), 0.0, "Expected 0 at ({i},{j})");
}
}
}
#[test]
fn test_from_edges_symmetric() {
let adj = AdjacencyMatrix::from_edges(4, &[(0, 1), (1, 2), (2, 3)]);
assert!(
adj.is_symmetric(),
"from_edges should produce symmetric matrix"
);
assert_eq!(adj.get(0, 1), 1.0);
assert_eq!(adj.get(1, 0), 1.0);
assert_eq!(adj.get(0, 2), 0.0);
}
#[test]
fn test_degree_sums_row() {
let adj = AdjacencyMatrix::from_edges(3, &[(0, 1), (1, 2), (0, 2)]);
for i in 0..3 {
assert!(
(adj.degree(i) - 2.0).abs() < EPS,
"degree of node {i} should be 2"
);
}
}
#[test]
fn test_add_self_loops_sets_diagonal() {
let mut adj = AdjacencyMatrix::new(3);
adj.add_self_loops();
for i in 0..3 {
assert!(
(adj.get(i, i) - 1.0).abs() < EPS,
"diagonal {i} should be 1"
);
}
assert_eq!(adj.get(0, 1), 0.0);
}
#[test]
fn test_laplacian_unnormalized_row_sums_zero() {
let adj = AdjacencyMatrix::from_edges(4, &[(0, 1), (1, 2), (2, 3)]);
let lap = graph_laplacian(&adj, LaplacianType::Unnormalized).unwrap();
for i in 0..4 {
let row_sum: f64 = (0..4).map(|j| lap.data[i * 4 + j]).sum();
assert!(
row_sum.abs() < 1e-10,
"Row {i} sum should be 0, got {row_sum}"
);
}
}
#[test]
fn test_laplacian_symmetric_trace_equals_sum_of_degrees_div_n() {
let adj = AdjacencyMatrix::from_edges(3, &[(0, 1), (1, 2), (0, 2)]);
let lap = graph_laplacian(&adj, LaplacianType::Symmetric).unwrap();
let trace: f64 = (0..3).map(|i| lap.data[i * 3 + i]).sum();
assert!(
(trace - 3.0).abs() < 1e-10,
"Trace should be 3, got {trace}"
);
}
#[test]
fn test_laplacian_random_walk_row_sums_zero() {
let adj = AdjacencyMatrix::from_edges(4, &[(0, 1), (1, 2), (2, 3), (0, 3)]);
let lap = graph_laplacian(&adj, LaplacianType::RandomWalk).unwrap();
for i in 0..4 {
let row_sum: f64 = (0..4).map(|j| lap.data[i * 4 + j]).sum();
assert!(
row_sum.abs() < 1e-10,
"RandomWalk row {i} sum should be 0, got {row_sum}"
);
}
}
#[test]
fn test_isolated_node_symmetric_laplacian_no_nan() {
let adj = AdjacencyMatrix::from_edges(3, &[(1, 2)]);
let lap = graph_laplacian(&adj, LaplacianType::Symmetric).unwrap();
for val in &lap.data {
assert!(
val.is_finite(),
"Symmetric Laplacian must not contain NaN/Inf for isolated nodes"
);
}
for j in 0..3 {
assert_eq!(lap.data[j], 0.0, "Row 0 should be 0 for isolated node");
assert_eq!(lap.data[j * 3], 0.0, "Col 0 should be 0 for isolated node");
}
}
#[test]
fn test_gcn_layer_output_shape() {
let adj = AdjacencyMatrix::from_edges(3, &[(0, 1), (1, 2)]);
let features = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]]; let weights = vec![vec![1.0, 0.0, 0.0], vec![0.0, 1.0, 0.0]]; let out = gcn_layer(&adj, &features, &weights, GcnActivation::Linear).unwrap();
assert_eq!(out.len(), 3, "Output should have n=3 rows");
assert_eq!(out[0].len(), 3, "Output should have d_out=3 cols");
}
#[test]
fn test_gcn_layer_relu_clips_negatives() {
let adj = AdjacencyMatrix::from_edges(2, &[(0, 1)]);
let features = vec![vec![-2.0], vec![-3.0]];
let weights = vec![vec![1.0]]; let out = gcn_layer(&adj, &features, &weights, GcnActivation::ReLU).unwrap();
for row in &out {
for &val in row {
assert!(val >= 0.0, "ReLU output must be non-negative, got {val}");
}
}
}
#[test]
fn test_gcn_layer_linear_no_clipping() {
let adj = AdjacencyMatrix::from_edges(2, &[(0, 1)]);
let features = vec![vec![1.0], vec![2.0]];
let weights = vec![vec![2.0]];
let out_lin = gcn_layer(&adj, &features, &weights, GcnActivation::Linear).unwrap();
let out_relu = gcn_layer(&adj, &features, &weights, GcnActivation::ReLU).unwrap();
for i in 0..2 {
for j in 0..1 {
assert!(
(out_lin[i][j] - out_relu[i][j]).abs() < 1e-10,
"Linear and ReLU should agree for positive inputs"
);
}
}
}
#[test]
fn test_gcn_layer_identity_weights_equals_l_hat_h() {
let adj = AdjacencyMatrix::from_edges(3, &[(0, 1), (1, 2)]);
let features = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]];
let weights = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let out = gcn_layer(&adj, &features, &weights, GcnActivation::Linear).unwrap();
let laplacian = graph_laplacian(&adj, LaplacianType::AddSelfLoops).unwrap();
let l_hat: Vec<Vec<f64>> = (0..3)
.map(|i| laplacian.data[i * 3..(i + 1) * 3].to_vec())
.collect();
let expected = mat_mul(&l_hat, &features).unwrap();
for i in 0..3 {
for j in 0..2 {
assert!(
(out[i][j] - expected[i][j]).abs() < 1e-10,
"GCN with identity W should equal L̂·H at ({i},{j})"
);
}
}
}
#[test]
fn test_mat_mul_dimension_mismatch_returns_error() {
let a = vec![vec![1.0, 2.0, 3.0]]; let b = vec![vec![1.0, 2.0], vec![3.0, 4.0]]; let result = mat_mul(&a, &b);
assert!(result.is_err(), "Should return error on dimension mismatch");
}
#[test]
fn test_mat_mul_identity() {
let identity = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let m = vec![vec![3.0, 7.0], vec![2.0, 5.0]];
let result = mat_mul(&identity, &m).unwrap();
for i in 0..2 {
for j in 0..2 {
assert!((result[i][j] - m[i][j]).abs() < EPS, "I·M should equal M");
}
}
}
#[test]
fn test_rotation_identity_apply() {
let r = Rotation3::identity();
let v = [1.0, 2.0, 3.0];
let rv = r.apply(v);
for (a, b) in v.iter().zip(rv.iter()) {
assert!(
(a - b).abs() < EPS,
"Identity rotation should preserve vector"
);
}
}
#[test]
fn test_rotation_axis_angle_z_90_rotates_x_to_y() {
let r = Rotation3::from_axis_angle([0.0, 0.0, 1.0], PI / 2.0);
let x = [1.0, 0.0, 0.0];
let y = r.apply(x);
assert!(
(y[0] - 0.0).abs() < 1e-10,
"x component should be ≈0, got {}",
y[0]
);
assert!(
(y[1] - 1.0).abs() < 1e-10,
"y component should be ≈1, got {}",
y[1]
);
assert!(
(y[2] - 0.0).abs() < 1e-10,
"z component should be ≈0, got {}",
y[2]
);
}
#[test]
fn test_rotation_euler_zero_is_identity() {
let r = Rotation3::from_euler_xyz(0.0, 0.0, 0.0);
let v = [1.0, 2.0, 3.0];
let rv = r.apply(v);
for (a, b) in v.iter().zip(rv.iter()) {
assert!(
(a - b).abs() < 1e-10,
"Zero Euler angles should give identity"
);
}
}
#[test]
fn test_rotation_quaternion_roundtrip() {
let original = Rotation3::from_axis_angle([1.0, 1.0, 0.0], PI / 3.0);
let (w, x, y, z) = original.to_quaternion();
let reconstructed = Rotation3::from_quaternion(w, x, y, z).unwrap();
let v = [1.0, 0.0, 0.0];
let v1 = original.apply(v);
let v2 = reconstructed.apply(v);
for i in 0..3 {
assert!(
(v1[i] - v2[i]).abs() < 1e-9,
"Quaternion roundtrip failed at component {i}: {v1:?} vs {v2:?}"
);
}
}
#[test]
fn test_rotation_compose_associativity() {
let r1 = Rotation3::from_axis_angle([1.0, 0.0, 0.0], PI / 4.0);
let r2 = Rotation3::from_axis_angle([0.0, 1.0, 0.0], PI / 6.0);
let v = [1.0, 2.0, 3.0];
let composed = r1.compose(&r2).apply(v);
let sequential = r1.apply(r2.apply(v));
for i in 0..3 {
assert!(
(composed[i] - sequential[i]).abs() < 1e-10,
"Compose mismatch at {i}: {composed:?} vs {sequential:?}"
);
}
}
#[test]
fn test_rotation_inverse_compose_identity() {
let r = Rotation3::from_axis_angle([1.0, 2.0, 3.0], 0.7);
let r_inv = r.inverse();
let identity_approx = r_inv.compose(&r);
let id = Rotation3::identity();
for i in 0..3 {
for j in 0..3 {
assert!(
(identity_approx.matrix[i][j] - id.matrix[i][j]).abs() < 1e-10,
"R^{{-1}} R should be identity at ({i},{j})"
);
}
}
}
#[test]
fn test_rotation_is_valid_true_for_valid_rotation() {
let r = Rotation3::from_axis_angle([0.0, 1.0, 0.0], PI / 5.0);
assert!(r.is_valid(), "Valid rotation should pass is_valid()");
}
#[test]
fn test_sph_harm_l0_m0_is_constant() {
let expected = 1.0 / (2.0 * PI.sqrt());
for (theta, phi) in [(0.0, 0.0), (1.0, 2.0), (PI / 2.0, PI), (0.3, 4.5)] {
let val = sph_harm(0, 0, theta, phi);
assert!(
(val - expected).abs() < EPS,
"Y_0^0 should be constant {expected}, got {val} at (θ={theta}, φ={phi})"
);
}
}
#[test]
fn test_spherical_harmonics_returns_correct_count() {
for max_deg in 0..=2 {
let vals = spherical_harmonics(0.5, 1.0, max_deg);
let expected_count = (max_deg + 1) * (max_deg + 1);
assert_eq!(
vals.len(),
expected_count,
"spherical_harmonics({max_deg}) should return {expected_count} values"
);
}
}
#[test]
fn test_sph_harm_l1_m0_at_north_pole() {
let expected = (3.0 / (4.0 * PI)).sqrt();
let val = sph_harm(1, 0, 0.0, 0.0);
assert!(
(val - expected).abs() < EPS,
"Y_1^0 at north pole should be {expected}, got {val}"
);
}
#[test]
fn test_sph_harm_unsupported_degree_returns_zero() {
let val = sph_harm(3, 0, 0.5, 1.0);
assert_eq!(val, 0.0, "Unsupported degree should return 0");
}
#[test]
fn test_sph_harm_l2_m0_equator() {
let expected = -(5.0_f64 / (16.0 * PI)).sqrt();
let val = sph_harm(2, 0, PI / 2.0, 0.0);
assert!(
(val - expected).abs() < 1e-10,
"Y_2^0 at equator should be {expected}, got {val}"
);
}
}