bem/core/assembly/
slfmm.rs

1//! Single-Level Fast Multipole Method (SLFMM) assembly
2//!
3//! Direct port of NC_BuildSystemFMBEM (SLFMM mode) from NC_EquationSystem.cpp.
4//!
5//! The SLFMM decomposes the BEM interaction matrix as:
6//! ```text
7//! [A] = [N] + [S][D][T]
8//! ```
9//! where:
10//! - [N] is the near-field matrix (direct interactions between nearby elements)
11//! - [T] is the T-matrix (element-to-cluster multipole expansion)
12//! - [D] is the D-matrix (cluster-to-cluster translation)
13//! - [S] is the S-matrix (cluster-to-element local expansion)
14
15use ndarray::{Array1, Array2};
16use num_complex::Complex64;
17use rayon::prelude::*;
18
19use crate::core::greens::spherical::spherical_hankel_first_kind;
20use crate::core::integration::{regular_integration, singular_integration, unit_sphere_quadrature};
21use crate::core::types::{BoundaryCondition, Cluster, Element, PhysicsParams};
22
23/// Result of SLFMM assembly
24pub struct SlfmmSystem {
25    /// Near-field coefficient matrix (sparse, stored as dense blocks)
26    pub near_matrix: Vec<NearFieldBlock>,
27    /// T-matrix for each cluster (element DOFs → multipole expansion)
28    pub t_matrices: Vec<Array2<Complex64>>,
29    /// T-vector for RHS contribution
30    pub t_vector: Array1<Complex64>,
31    /// D-matrix entries for far cluster pairs
32    pub d_matrices: Vec<DMatrixEntry>,
33    /// S-matrix for each cluster (multipole expansion → field DOFs)
34    pub s_matrices: Vec<Array2<Complex64>>,
35    /// RHS vector
36    pub rhs: Array1<Complex64>,
37    /// Number of DOFs
38    pub num_dofs: usize,
39    /// Number of integration points on unit sphere
40    pub num_sphere_points: usize,
41    /// Number of expansion terms
42    pub num_expansion_terms: usize,
43    /// Number of clusters
44    pub num_clusters: usize,
45    /// Cluster DOF mappings: for each cluster, the global DOF indices
46    pub cluster_dof_indices: Vec<Vec<usize>>,
47}
48
49/// Near-field block between two clusters
50#[derive(Debug, Clone)]
51pub struct NearFieldBlock {
52    /// Source cluster index
53    pub source_cluster: usize,
54    /// Field cluster index
55    pub field_cluster: usize,
56    /// Dense coefficient matrix for this block
57    pub coefficients: Array2<Complex64>,
58}
59
60/// D-matrix entry for a far cluster pair
61#[derive(Debug, Clone)]
62pub struct DMatrixEntry {
63    /// Source cluster index
64    pub source_cluster: usize,
65    /// Field cluster index
66    pub field_cluster: usize,
67    /// Translation coefficients
68    pub coefficients: Array2<Complex64>,
69}
70
71impl SlfmmSystem {
72    /// Create a new empty SLFMM system
73    pub fn new(
74        num_dofs: usize,
75        num_clusters: usize,
76        num_sphere_points: usize,
77        num_expansion_terms: usize,
78    ) -> Self {
79        Self {
80            near_matrix: Vec::new(),
81            t_matrices: Vec::with_capacity(num_clusters),
82            t_vector: Array1::zeros(num_sphere_points * num_clusters),
83            d_matrices: Vec::new(),
84            s_matrices: Vec::with_capacity(num_clusters),
85            rhs: Array1::zeros(num_dofs),
86            num_dofs,
87            num_sphere_points,
88            num_expansion_terms,
89            num_clusters,
90            cluster_dof_indices: Vec::with_capacity(num_clusters),
91        }
92    }
93
94    /// Extract the near-field matrix as a dense matrix for preconditioning
95    ///
96    /// This assembles only the near-field blocks into a dense matrix,
97    /// which is much smaller than the full matrix (O(N) vs O(N²) entries).
98    pub fn extract_near_field_matrix(&self) -> Array2<Complex64> {
99        let mut matrix = Array2::zeros((self.num_dofs, self.num_dofs));
100
101        for block in &self.near_matrix {
102            let src_dofs = &self.cluster_dof_indices[block.source_cluster];
103            let fld_dofs = &self.cluster_dof_indices[block.field_cluster];
104
105            // Copy block to global matrix
106            for (local_i, &global_i) in src_dofs.iter().enumerate() {
107                for (local_j, &global_j) in fld_dofs.iter().enumerate() {
108                    matrix[[global_i, global_j]] += block.coefficients[[local_i, local_j]];
109                }
110            }
111
112            // Handle symmetric storage: also fill the (fld, src) part
113            if block.source_cluster != block.field_cluster {
114                for (local_i, &global_i) in src_dofs.iter().enumerate() {
115                    for (local_j, &global_j) in fld_dofs.iter().enumerate() {
116                        matrix[[global_j, global_i]] += block.coefficients[[local_i, local_j]];
117                    }
118                }
119            }
120        }
121
122        matrix
123    }
124
125    /// Apply the SLFMM operator: y = ([N] + [S][D][T]) * x
126    ///
127    /// This is used in iterative solvers (CGS, BiCGSTAB).
128    ///
129    /// The decomposition is:
130    /// - Near-field: Direct element-to-element interactions for nearby clusters
131    /// - Far-field: Multipole expansions with S*D*T factorization
132    ///
133    /// # Arguments
134    /// * `x` - Input vector (length = num_dofs)
135    ///
136    /// # Returns
137    /// * `y` - Output vector (length = num_dofs), y = A*x
138    pub fn matvec(&self, x: &Array1<Complex64>) -> Array1<Complex64> {
139        // === Near-field contribution: y += [N] * x (parallelized) ===
140        // Compute all near-field block contributions in parallel, then sum them
141        let near_contributions: Vec<Vec<(usize, Complex64)>> = self
142            .near_matrix
143            .par_iter()
144            .flat_map(|block| {
145                let src_dofs = &self.cluster_dof_indices[block.source_cluster];
146                let fld_dofs = &self.cluster_dof_indices[block.field_cluster];
147
148                let mut contributions = Vec::new();
149
150                // Gather x values from field cluster
151                let x_local: Array1<Complex64> =
152                    Array1::from_iter(fld_dofs.iter().map(|&i| x[i]));
153
154                // Apply block matrix
155                let y_local = block.coefficients.dot(&x_local);
156
157                // Collect contributions for source DOFs
158                for (local_i, &global_i) in src_dofs.iter().enumerate() {
159                    contributions.push((global_i, y_local[local_i]));
160                }
161
162                // Handle symmetric storage
163                if block.source_cluster != block.field_cluster {
164                    let x_src: Array1<Complex64> =
165                        Array1::from_iter(src_dofs.iter().map(|&i| x[i]));
166                    let y_fld = block.coefficients.t().dot(&x_src);
167                    for (local_j, &global_j) in fld_dofs.iter().enumerate() {
168                        contributions.push((global_j, y_fld[local_j]));
169                    }
170                }
171
172                vec![contributions]
173            })
174            .collect();
175
176        // Accumulate near-field contributions
177        let mut y = Array1::zeros(self.num_dofs);
178        for contributions in near_contributions {
179            for (idx, val) in contributions {
180                y[idx] += val;
181            }
182        }
183
184        // === Far-field contribution: y += [S][D][T] * x (parallelized) ===
185
186        // Step 1: Compute multipole expansions for each cluster in parallel: t[c] = T[c] * x[c]
187        let multipoles: Vec<Array1<Complex64>> = self
188            .t_matrices
189            .par_iter()
190            .enumerate()
191            .map(|(cluster_idx, t_mat)| {
192                let cluster_dofs = &self.cluster_dof_indices[cluster_idx];
193                if cluster_dofs.is_empty() || t_mat.is_empty() {
194                    Array1::zeros(self.num_sphere_points)
195                } else {
196                    let x_local: Array1<Complex64> =
197                        Array1::from_iter(cluster_dofs.iter().map(|&i| x[i]));
198                    t_mat.dot(&x_local)
199                }
200            })
201            .collect();
202
203        // Step 2: Translate multipoles between far clusters (parallelized)
204        // Compute all D*multipole products in parallel
205        let d_contributions: Vec<(usize, Array1<Complex64>)> = self
206            .d_matrices
207            .par_iter()
208            .map(|d_entry| {
209                let src_mult = &multipoles[d_entry.source_cluster];
210                let translated = d_entry.coefficients.dot(src_mult);
211                (d_entry.field_cluster, translated)
212            })
213            .collect();
214
215        // Accumulate D-matrix contributions into locals (sequential to avoid race)
216        let mut locals: Vec<Array1<Complex64>> = (0..self.num_clusters)
217            .map(|_| Array1::zeros(self.num_sphere_points))
218            .collect();
219
220        for (field_cluster, translated) in d_contributions {
221            for i in 0..self.num_sphere_points {
222                locals[field_cluster][i] += translated[i];
223            }
224        }
225
226        // Step 3: Evaluate locals at field points in parallel: y[c] += S[c] * locals[c]
227        let far_contributions: Vec<Vec<(usize, Complex64)>> = self
228            .s_matrices
229            .par_iter()
230            .enumerate()
231            .filter_map(|(cluster_idx, s_mat)| {
232                let cluster_dofs = &self.cluster_dof_indices[cluster_idx];
233                if cluster_dofs.is_empty() || s_mat.is_empty() {
234                    return None;
235                }
236                let y_local = s_mat.dot(&locals[cluster_idx]);
237                let contributions: Vec<(usize, Complex64)> = cluster_dofs
238                    .iter()
239                    .enumerate()
240                    .map(|(local_j, &global_j)| (global_j, y_local[local_j]))
241                    .collect();
242                Some(contributions)
243            })
244            .collect();
245
246        // Accumulate far-field contributions
247        for contributions in far_contributions {
248            for (idx, val) in contributions {
249                y[idx] += val;
250            }
251        }
252
253        y
254    }
255
256    /// Apply the SLFMM operator transpose: y = A^T * x (parallelized)
257    ///
258    /// Used by some iterative solvers (e.g., BiCGSTAB).
259    pub fn matvec_transpose(&self, x: &Array1<Complex64>) -> Array1<Complex64> {
260        // === Near-field contribution (transposed, parallelized) ===
261        let near_contributions: Vec<Vec<(usize, Complex64)>> = self
262            .near_matrix
263            .par_iter()
264            .flat_map(|block| {
265                let src_dofs = &self.cluster_dof_indices[block.source_cluster];
266                let fld_dofs = &self.cluster_dof_indices[block.field_cluster];
267
268                let mut contributions = Vec::new();
269
270                // For transpose: gather from source DOFs, scatter to field DOFs
271                let x_local: Array1<Complex64> =
272                    Array1::from_iter(src_dofs.iter().map(|&i| x[i]));
273                let y_local = block.coefficients.t().dot(&x_local);
274                for (local_j, &global_j) in fld_dofs.iter().enumerate() {
275                    contributions.push((global_j, y_local[local_j]));
276                }
277
278                // Symmetric storage
279                if block.source_cluster != block.field_cluster {
280                    let x_fld: Array1<Complex64> =
281                        Array1::from_iter(fld_dofs.iter().map(|&i| x[i]));
282                    let y_src = block.coefficients.dot(&x_fld);
283                    for (local_i, &global_i) in src_dofs.iter().enumerate() {
284                        contributions.push((global_i, y_src[local_i]));
285                    }
286                }
287
288                vec![contributions]
289            })
290            .collect();
291
292        // Accumulate near-field contributions
293        let mut y = Array1::zeros(self.num_dofs);
294        for contributions in near_contributions {
295            for (idx, val) in contributions {
296                y[idx] += val;
297            }
298        }
299
300        // === Far-field contribution (transposed, parallelized): y += T^T * D^T * S^T * x ===
301
302        // Step 1: S^T * x (parallelized)
303        let locals: Vec<Array1<Complex64>> = self
304            .s_matrices
305            .par_iter()
306            .enumerate()
307            .map(|(cluster_idx, s_mat)| {
308                let cluster_dofs = &self.cluster_dof_indices[cluster_idx];
309                if cluster_dofs.is_empty() || s_mat.is_empty() {
310                    Array1::zeros(self.num_sphere_points)
311                } else {
312                    let x_local: Array1<Complex64> =
313                        Array1::from_iter(cluster_dofs.iter().map(|&i| x[i]));
314                    s_mat.t().dot(&x_local)
315                }
316            })
317            .collect();
318
319        // Step 2: D^T translation (parallelized)
320        let d_contributions: Vec<(usize, Array1<Complex64>)> = self
321            .d_matrices
322            .par_iter()
323            .map(|d_entry| {
324                let fld_local = &locals[d_entry.field_cluster];
325                let translated = d_entry.coefficients.t().dot(fld_local);
326                (d_entry.source_cluster, translated)
327            })
328            .collect();
329
330        // Accumulate D-matrix contributions
331        let mut multipoles: Vec<Array1<Complex64>> = (0..self.num_clusters)
332            .map(|_| Array1::zeros(self.num_sphere_points))
333            .collect();
334
335        for (source_cluster, translated) in d_contributions {
336            for i in 0..self.num_sphere_points {
337                multipoles[source_cluster][i] += translated[i];
338            }
339        }
340
341        // Step 3: T^T * multipoles (parallelized)
342        let far_contributions: Vec<Vec<(usize, Complex64)>> = self
343            .t_matrices
344            .par_iter()
345            .enumerate()
346            .filter_map(|(cluster_idx, t_mat)| {
347                let cluster_dofs = &self.cluster_dof_indices[cluster_idx];
348                if cluster_dofs.is_empty() || t_mat.is_empty() {
349                    return None;
350                }
351                let y_local = t_mat.t().dot(&multipoles[cluster_idx]);
352                let contributions: Vec<(usize, Complex64)> = cluster_dofs
353                    .iter()
354                    .enumerate()
355                    .map(|(local_i, &global_i)| (global_i, y_local[local_i]))
356                    .collect();
357                Some(contributions)
358            })
359            .collect();
360
361        // Accumulate far-field contributions
362        for contributions in far_contributions {
363            for (idx, val) in contributions {
364                y[idx] += val;
365            }
366        }
367
368        y
369    }
370}
371
372/// Build the SLFMM system matrices
373///
374/// # Arguments
375/// * `elements` - Vector of mesh elements
376/// * `nodes` - Node coordinates (num_nodes × 3)
377/// * `clusters` - Vector of clusters
378/// * `physics` - Physics parameters
379/// * `n_theta` - Number of integration points in theta direction
380/// * `n_phi` - Number of integration points in phi direction
381/// * `n_terms` - Number of expansion terms
382pub fn build_slfmm_system(
383    elements: &[Element],
384    nodes: &Array2<f64>,
385    clusters: &[Cluster],
386    physics: &PhysicsParams,
387    n_theta: usize,
388    n_phi: usize,
389    n_terms: usize,
390) -> SlfmmSystem {
391    let num_dofs = count_dofs(elements);
392    let num_clusters = clusters.len();
393    let num_sphere_points = n_theta * n_phi;
394
395    let mut system = SlfmmSystem::new(num_dofs, num_clusters, num_sphere_points, n_terms);
396
397    // Build cluster DOF mappings: for each cluster, collect the global DOF indices
398    // of elements that belong to this cluster
399    build_cluster_dof_mappings(&mut system, elements, clusters);
400
401    // Compute unit sphere quadrature points
402    let (sphere_coords, sphere_weights) = unit_sphere_quadrature(n_theta, n_phi);
403
404    // Build near-field matrix
405    build_near_field(&mut system, elements, nodes, clusters, physics);
406
407    // Build T-matrices (element-to-cluster expansion)
408    build_t_matrices(
409        &mut system,
410        elements,
411        clusters,
412        physics,
413        &sphere_coords,
414        &sphere_weights,
415    );
416
417    // Build D-matrices (cluster-to-cluster translation)
418    build_d_matrices(&mut system, clusters, physics, &sphere_coords, n_terms);
419
420    // Build S-matrices (cluster-to-element evaluation)
421    build_s_matrices(
422        &mut system,
423        elements,
424        clusters,
425        physics,
426        &sphere_coords,
427        &sphere_weights,
428    );
429
430    system
431}
432
433/// Build the mapping from clusters to global DOF indices
434fn build_cluster_dof_mappings(
435    system: &mut SlfmmSystem,
436    elements: &[Element],
437    clusters: &[Cluster],
438) {
439    for cluster in clusters {
440        let mut dof_indices = Vec::new();
441        for &elem_idx in &cluster.element_indices {
442            let elem = &elements[elem_idx];
443            if elem.property.is_evaluation() {
444                continue;
445            }
446            // Collect all DOF addresses for this element
447            dof_indices.extend(elem.dof_addresses.iter().copied());
448        }
449        system.cluster_dof_indices.push(dof_indices);
450    }
451}
452
453/// Count total number of DOFs
454fn count_dofs(elements: &[Element]) -> usize {
455    elements
456        .iter()
457        .filter(|e| !e.property.is_evaluation())
458        .map(|e| e.dof_addresses.len())
459        .sum()
460}
461
462/// Build near-field matrix blocks (parallelized)
463fn build_near_field(
464    system: &mut SlfmmSystem,
465    elements: &[Element],
466    nodes: &Array2<f64>,
467    clusters: &[Cluster],
468    physics: &PhysicsParams,
469) {
470    let gamma = Complex64::new(physics.gamma(), 0.0);
471    let tau = Complex64::new(physics.tau, 0.0);
472    let beta = physics.burton_miller_beta();
473
474    // Collect all cluster pairs that need processing
475    let mut cluster_pairs: Vec<(usize, usize, bool)> = Vec::new();
476
477    for (i, cluster_i) in clusters.iter().enumerate() {
478        // Self-interaction
479        cluster_pairs.push((i, i, true));
480
481        // Interaction with near clusters (only upper triangle)
482        for &j in &cluster_i.near_clusters {
483            if j > i {
484                cluster_pairs.push((i, j, false));
485            }
486        }
487    }
488
489    // Compute all near-field blocks in parallel
490    let near_blocks: Vec<NearFieldBlock> = cluster_pairs
491        .par_iter()
492        .map(|&(i, j, is_self)| {
493            let cluster_i = &clusters[i];
494            let cluster_j = &clusters[j];
495
496            let mut block = compute_near_block(
497                elements,
498                nodes,
499                &cluster_i.element_indices,
500                &cluster_j.element_indices,
501                physics,
502                gamma,
503                tau,
504                beta,
505                is_self,
506            );
507
508            // Add free terms to diagonal for self-interaction blocks
509            if is_self {
510                for local_idx in 0..cluster_i.element_indices.len() {
511                    let elem_idx = cluster_i.element_indices[local_idx];
512                    let elem = &elements[elem_idx];
513                    if elem.property.is_evaluation() {
514                        continue;
515                    }
516                    match &elem.boundary_condition {
517                        BoundaryCondition::Velocity(_)
518                        | BoundaryCondition::VelocityWithAdmittance { .. } => {
519                            block[[local_idx, local_idx]] += gamma * 0.5;
520                        }
521                        BoundaryCondition::Pressure(_) => {
522                            block[[local_idx, local_idx]] += beta * tau * 0.5;
523                        }
524                        _ => {}
525                    }
526                }
527            }
528
529            NearFieldBlock {
530                source_cluster: i,
531                field_cluster: j,
532                coefficients: block,
533            }
534        })
535        .collect();
536
537    system.near_matrix = near_blocks;
538}
539
540/// Compute a near-field block between two sets of elements
541fn compute_near_block(
542    elements: &[Element],
543    nodes: &Array2<f64>,
544    source_indices: &[usize],
545    field_indices: &[usize],
546    physics: &PhysicsParams,
547    gamma: Complex64,
548    tau: Complex64,
549    beta: Complex64,
550    is_self: bool,
551) -> Array2<Complex64> {
552    let n_source = source_indices.len();
553    let n_field = field_indices.len();
554    let mut block = Array2::zeros((n_source, n_field));
555
556    for (i, &src_idx) in source_indices.iter().enumerate() {
557        let source_elem = &elements[src_idx];
558        if source_elem.property.is_evaluation() {
559            continue;
560        }
561
562        for (j, &fld_idx) in field_indices.iter().enumerate() {
563            let field_elem = &elements[fld_idx];
564            if field_elem.property.is_evaluation() {
565                continue;
566            }
567
568            let element_coords = get_element_coords(field_elem, nodes);
569
570            let result = if is_self && src_idx == fld_idx {
571                // Singular integration
572                singular_integration(
573                    &source_elem.center,
574                    &source_elem.normal,
575                    &element_coords,
576                    field_elem.element_type,
577                    physics,
578                    None,
579                    0,
580                    false,
581                )
582            } else {
583                // Regular integration
584                regular_integration(
585                    &source_elem.center,
586                    &source_elem.normal,
587                    &element_coords,
588                    field_elem.element_type,
589                    field_elem.area,
590                    physics,
591                    None,
592                    0,
593                    false,
594                )
595            };
596
597            // Assemble using Burton-Miller formulation
598            let coeff =
599                result.dg_dn_integral * gamma * tau + result.d2g_dnxdny_integral * beta;
600            block[[i, j]] = coeff;
601        }
602    }
603
604    block
605}
606
607/// Build T-matrices (element to cluster multipole expansion) - parallelized
608fn build_t_matrices(
609    system: &mut SlfmmSystem,
610    elements: &[Element],
611    clusters: &[Cluster],
612    physics: &PhysicsParams,
613    sphere_coords: &[[f64; 3]],
614    sphere_weights: &[f64],
615) {
616    let k = physics.wave_number;
617    let num_sphere_points = sphere_coords.len();
618
619    // Build all T-matrices in parallel
620    let t_matrices: Vec<Array2<Complex64>> = clusters
621        .par_iter()
622        .map(|cluster| {
623            let num_elem = cluster.element_indices.len();
624            let mut t_matrix = Array2::zeros((num_sphere_points, num_elem));
625
626            for (j, &elem_idx) in cluster.element_indices.iter().enumerate() {
627                let elem = &elements[elem_idx];
628                if elem.property.is_evaluation() {
629                    continue;
630                }
631
632                // Precompute difference vector
633                let diff: [f64; 3] = [
634                    elem.center[0] - cluster.center[0],
635                    elem.center[1] - cluster.center[1],
636                    elem.center[2] - cluster.center[2],
637                ];
638
639                // For each integration direction on the unit sphere
640                for (p, coord) in sphere_coords.iter().enumerate() {
641                    let s_dot_diff = coord[0] * diff[0] + coord[1] * diff[1] + coord[2] * diff[2];
642                    let exp_factor =
643                        Complex64::new((k * s_dot_diff).cos(), -(k * s_dot_diff).sin());
644                    t_matrix[[p, j]] = exp_factor * sphere_weights[p];
645                }
646            }
647
648            t_matrix
649        })
650        .collect();
651
652    system.t_matrices = t_matrices;
653}
654
655/// Build D-matrices (cluster to cluster translation) - parallelized
656fn build_d_matrices(
657    system: &mut SlfmmSystem,
658    clusters: &[Cluster],
659    physics: &PhysicsParams,
660    sphere_coords: &[[f64; 3]],
661    n_terms: usize,
662) {
663    let k = physics.wave_number;
664    let num_sphere_points = sphere_coords.len();
665
666    // Collect all far cluster pairs
667    let mut far_pairs: Vec<(usize, usize)> = Vec::new();
668    for (i, cluster_i) in clusters.iter().enumerate() {
669        for &j in &cluster_i.far_clusters {
670            far_pairs.push((i, j));
671        }
672    }
673
674    // Compute all D-matrices in parallel
675    let d_matrices: Vec<DMatrixEntry> = far_pairs
676        .par_iter()
677        .map(|&(i, j)| {
678            let cluster_i = &clusters[i];
679            let cluster_j = &clusters[j];
680
681            // Distance vector between cluster centers
682            let diff = [
683                cluster_i.center[0] - cluster_j.center[0],
684                cluster_i.center[1] - cluster_j.center[1],
685                cluster_i.center[2] - cluster_j.center[2],
686            ];
687            let r = (diff[0] * diff[0] + diff[1] * diff[1] + diff[2] * diff[2]).sqrt();
688            let kr = k * r;
689
690            // Compute translation using spherical Hankel functions
691            let mut d_matrix = Array2::zeros((num_sphere_points, num_sphere_points));
692
693            let h_funcs = spherical_hankel_first_kind(n_terms.max(2), kr, 1.0);
694
695            for p in 0..num_sphere_points {
696                d_matrix[[p, p]] = h_funcs[0] * Complex64::new(0.0, k);
697            }
698
699            DMatrixEntry {
700                source_cluster: i,
701                field_cluster: j,
702                coefficients: d_matrix,
703            }
704        })
705        .collect();
706
707    system.d_matrices = d_matrices;
708}
709
710/// Build S-matrices (cluster multipole to element evaluation) - parallelized
711fn build_s_matrices(
712    system: &mut SlfmmSystem,
713    elements: &[Element],
714    clusters: &[Cluster],
715    physics: &PhysicsParams,
716    sphere_coords: &[[f64; 3]],
717    sphere_weights: &[f64],
718) {
719    let k = physics.wave_number;
720    let num_sphere_points = sphere_coords.len();
721
722    // Build all S-matrices in parallel
723    let s_matrices: Vec<Array2<Complex64>> = clusters
724        .par_iter()
725        .map(|cluster| {
726            let num_elem = cluster.element_indices.len();
727            let mut s_matrix = Array2::zeros((num_elem, num_sphere_points));
728
729            for (j, &elem_idx) in cluster.element_indices.iter().enumerate() {
730                let elem = &elements[elem_idx];
731                if elem.property.is_evaluation() {
732                    continue;
733                }
734
735                // Precompute difference vector
736                let diff: [f64; 3] = [
737                    elem.center[0] - cluster.center[0],
738                    elem.center[1] - cluster.center[1],
739                    elem.center[2] - cluster.center[2],
740                ];
741
742                // For each integration direction on the unit sphere
743                for (p, coord) in sphere_coords.iter().enumerate() {
744                    let s_dot_diff = coord[0] * diff[0] + coord[1] * diff[1] + coord[2] * diff[2];
745                    let exp_factor =
746                        Complex64::new((k * s_dot_diff).cos(), (k * s_dot_diff).sin());
747                    s_matrix[[j, p]] = exp_factor * sphere_weights[p];
748                }
749            }
750
751            s_matrix
752        })
753        .collect();
754
755    system.s_matrices = s_matrices;
756}
757
758/// Get element node coordinates as Array2
759fn get_element_coords(element: &Element, nodes: &Array2<f64>) -> Array2<f64> {
760    let num_nodes = element.connectivity.len();
761    let mut coords = Array2::zeros((num_nodes, 3));
762
763    for (i, &node_idx) in element.connectivity.iter().enumerate() {
764        for j in 0..3 {
765            coords[[i, j]] = nodes[[node_idx, j]];
766        }
767    }
768
769    coords
770}
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775    use crate::core::types::{BoundaryCondition, ElementProperty, ElementType};
776    use ndarray::array;
777
778    fn make_test_cluster() -> Cluster {
779        let mut cluster = Cluster::new(array![0.5, 0.5, 0.0]);
780        cluster.element_indices = vec![0, 1];
781        cluster.near_clusters = vec![];
782        cluster.far_clusters = vec![];
783        cluster
784    }
785
786    fn make_test_elements() -> (Vec<Element>, Array2<f64>) {
787        let nodes = Array2::from_shape_vec(
788            (4, 3),
789            vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.5, 1.0, 0.0, 1.5, 1.0, 0.0],
790        )
791        .unwrap();
792
793        let elem0 = Element {
794            connectivity: vec![0, 1, 2],
795            element_type: ElementType::Tri3,
796            property: ElementProperty::Surface,
797            normal: array![0.0, 0.0, 1.0],
798            node_normals: Array2::zeros((3, 3)),
799            center: array![0.5, 1.0 / 3.0, 0.0],
800            area: 0.5,
801            boundary_condition: BoundaryCondition::Velocity(vec![Complex64::new(1.0, 0.0)]),
802            group: 0,
803            dof_addresses: vec![0],
804        };
805
806        let elem1 = Element {
807            connectivity: vec![1, 3, 2],
808            element_type: ElementType::Tri3,
809            property: ElementProperty::Surface,
810            normal: array![0.0, 0.0, 1.0],
811            node_normals: Array2::zeros((3, 3)),
812            center: array![1.0, 2.0 / 3.0, 0.0],
813            area: 0.5,
814            boundary_condition: BoundaryCondition::Velocity(vec![Complex64::new(0.0, 0.0)]),
815            group: 0,
816            dof_addresses: vec![1],
817        };
818
819        (vec![elem0, elem1], nodes)
820    }
821
822    #[test]
823    fn test_slfmm_system_creation() {
824        let system = SlfmmSystem::new(10, 2, 32, 5);
825        assert_eq!(system.num_dofs, 10);
826        assert_eq!(system.num_sphere_points, 32);
827        assert_eq!(system.num_expansion_terms, 5);
828    }
829
830    #[test]
831    fn test_build_slfmm_system() {
832        let (elements, nodes) = make_test_elements();
833        let cluster = make_test_cluster();
834        let clusters = vec![cluster];
835        let physics = PhysicsParams::new(100.0, 343.0, 1.21, false);
836
837        let system = build_slfmm_system(&elements, &nodes, &clusters, &physics, 4, 8, 5);
838
839        assert_eq!(system.num_dofs, 2);
840        assert_eq!(system.t_matrices.len(), 1);
841        assert_eq!(system.s_matrices.len(), 1);
842    }
843
844    #[test]
845    fn test_near_field_block() {
846        let (elements, nodes) = make_test_elements();
847        let physics = PhysicsParams::new(100.0, 343.0, 1.21, false);
848        let gamma = Complex64::new(physics.gamma(), 0.0);
849        let tau = Complex64::new(physics.tau, 0.0);
850        let beta = physics.burton_miller_beta();
851
852        let block = compute_near_block(
853            &elements,
854            &nodes,
855            &[0, 1],
856            &[0, 1],
857            &physics,
858            gamma,
859            tau,
860            beta,
861            true,
862        );
863
864        assert_eq!(block.shape(), &[2, 2]);
865        // Diagonal entries should be non-zero
866        assert!(block[[0, 0]].norm() > 0.0);
867        assert!(block[[1, 1]].norm() > 0.0);
868    }
869}