treams_core/cluster/
spheres.rs

1//! Dense clusters of homogeneous nonmagnetic spheres in vacuum, with gradients of the
2//! radii, permittivities and positions.
3//!
4//! Upstream: `treams.TMatrix.cluster` of `TMatrix.sphere` T-matrices, then
5//! `interaction.solve()`. The gradients and the reusable factor of
6//! [`sphere_cluster_factor`] are treams-rs extensions.
7#![allow(clippy::indexing_slicing)] // Validated spheres, mode blocks and Cartesian axes.
8
9use nalgebra::DMatrix;
10
11use rayon::prelude::*;
12
13use super::{InteractionGradient, InteractionResidual, interaction_blocks};
14use crate::coeffs::Material;
15use crate::special::Radial;
16use crate::sw::{self, TranslationPlan};
17use crate::tmatrix::{SphereResidual, sphere};
18use crate::{
19    Complex, Error, Result,
20    numerics::{self, finite},
21    saved::{Reader, SavedState, Writer},
22};
23
24/// What [`sphere_cluster`] saves for its pullback: the Mie residual of each sphere,
25/// the translation plan between two spheres, the positions and the interaction solve.
26#[derive(Clone, Debug)]
27pub struct SphereClusterResidual {
28    k0: f64,
29    positions: Vec<[f64; 3]>,
30    plan: TranslationPlan,
31    spheres: Vec<SphereResidual>,
32    interaction: InteractionResidual,
33}
34
35/// Gradients of the inputs of [`sphere_cluster`], in the order of its arguments.
36#[derive(Clone, Debug)]
37pub struct SphereClusterGradient {
38    /// Gradient of the vacuum wavenumber.
39    pub k0: f64,
40    /// Gradients of the sphere radii.
41    pub radii: Vec<f64>,
42    /// Gradients of the sphere permittivities.
43    pub epsilon: Vec<Complex>,
44    /// Gradients of the sphere positions, through every pair coupling.
45    pub positions: Vec<[f64; 3]>,
46}
47
48/// Solve a finite cluster of homogeneous, nonmagnetic spheres in vacuum.
49///
50/// Returns the interacting T-matrix in the local bases of the spheres, each in
51/// [`sw::modes`] order up to `lmax`. [`particle_cluster`](super::particle_cluster)
52/// couples any particle T-matrices, without the radius and permittivity gradients.
53///
54/// Upstream: `treams.TMatrix.cluster` of `TMatrix.sphere` T-matrices, followed by
55/// `.interaction.solve()`.
56pub fn sphere_cluster(
57    lmax: u32,
58    k0: f64,
59    radii: &[f64],
60    epsilon: &[Complex],
61    positions: &[[f64; 3]],
62) -> Result<SphereClusterResidual> {
63    let ClusterParts {
64        blocks,
65        spheres,
66        plan,
67        coupling,
68    } = cluster_parts(lmax, k0, radii, epsilon, positions)?;
69    Ok(SphereClusterResidual {
70        k0,
71        positions: positions.to_vec(),
72        spheres,
73        plan,
74        interaction: interaction_blocks(blocks, coupling)?,
75    })
76}
77
78/// Assemble and factor a sphere cluster for repeated requested illuminations.
79///
80/// The returned factor differentiates the local T blocks, the coupling and the incident
81/// fields. Use [`IterativeSphereCluster`](super::IterativeSphereCluster) for the
82/// gradients of the radii, permittivities and positions.
83///
84/// treams-rs extension.
85pub fn sphere_cluster_factor(
86    lmax: u32,
87    k0: f64,
88    radii: &[f64],
89    epsilon: &[Complex],
90    positions: &[[f64; 3]],
91) -> Result<super::InteractionFactor> {
92    let parts = cluster_parts(lmax, k0, radii, epsilon, positions)?;
93    super::InteractionFactor::from_blocks(parts.blocks, parts.coupling)
94}
95
96/// Check a cluster of homogeneous spheres in vacuum: a positive finite vacuum
97/// wavenumber and radii, one finite permittivity and position per sphere, and no two
98/// spheres touching or overlapping.
99pub(crate) fn validate_spheres(
100    k0: f64,
101    radii: &[f64],
102    epsilon: &[Complex],
103    positions: &[[f64; 3]],
104) -> Result<()> {
105    if !k0.is_finite()
106        || k0 <= 0.0
107        || radii.is_empty()
108        || radii.len() != epsilon.len()
109        || radii.len() != positions.len()
110        || radii.iter().any(|r| !r.is_finite() || *r <= 0.0)
111        || epsilon.iter().any(|&z| !finite(z))
112        || positions.iter().flatten().any(|x| !x.is_finite())
113    {
114        return Err(Error::InvalidInput(
115            "require positive finite k0 and radii, and matching finite permittivities and positions".into(),
116        ));
117    }
118    for i in 0..radii.len() {
119        for j in 0..i {
120            let distance = positions[i]
121                .iter()
122                .zip(positions[j])
123                .fold(0.0_f64, |norm, (a, b)| norm.hypot(a - b));
124            if distance <= radii[i] + radii[j] || !distance.is_finite() {
125                return Err(Error::InvalidInput(
126                    "spherical particles must have finite separation and must not touch or overlap"
127                        .into(),
128                ));
129            }
130        }
131    }
132    Ok(())
133}
134
135/// The media of a homogeneous nonmagnetic sphere in vacuum.
136pub(crate) fn vacuum_sphere(epsilon: Complex) -> [Material; 2] {
137    [
138        Material {
139            epsilon,
140            ..Material::default()
141        },
142        Material::default(),
143    ]
144}
145
146struct ClusterParts {
147    /// The T-matrix of each sphere.
148    blocks: Vec<DMatrix<Complex>>,
149    spheres: Vec<SphereResidual>,
150    plan: TranslationPlan,
151    coupling: DMatrix<Complex>,
152}
153
154fn cluster_parts(
155    lmax: u32,
156    k0: f64,
157    radii: &[f64],
158    epsilon: &[Complex],
159    positions: &[[f64; 3]],
160) -> Result<ClusterParts> {
161    validate_spheres(k0, radii, epsilon, positions)?;
162    let modes = sw::modes(lmax)?;
163    let modes_per_particle = modes.len();
164    let (blocks, spheres): (Vec<_>, Vec<_>) = crate::threads::install(|| {
165        radii
166            .par_iter()
167            .zip(epsilon)
168            .map(|(&radius, &epsilon)| sphere(lmax, k0, &[radius], &vacuum_sphere(epsilon)))
169            .collect::<Result<Vec<_>>>()
170    })?
171    .into_iter()
172    .unzip();
173    let plan = TranslationPlan::between(&modes, &modes, true)?;
174    let coupling = coupling_matrix(spheres.len(), modes_per_particle, |i, j| {
175        let displacement = std::array::from_fn(|axis| positions[i][axis] - positions[j][axis]);
176        plan.evaluate(Complex::new(k0, 0.0), displacement, Radial::Singular)
177    })?;
178    Ok(ClusterParts {
179        blocks,
180        spheres,
181        plan,
182        coupling,
183    })
184}
185
186/// Assemble complete particle columns in parallel, for either coupling values or
187/// their directional derivative. Self-couplings are exactly zero in both cases.
188fn coupling_matrix(
189    particles: usize,
190    modes_per_particle: usize,
191    pair: impl Fn(usize, usize) -> Result<Vec<Complex>> + Sync,
192) -> Result<DMatrix<Complex>> {
193    let dimension = modes_per_particle
194        .checked_mul(particles)
195        .ok_or_else(|| Error::InvalidInput("cluster is too large".into()))?;
196    let mut coupling = numerics::zeros(dimension, dimension)?;
197    // Each worker owns complete source-particle columns; no locks or dense pair copies.
198    crate::threads::install(|| {
199        coupling
200            .as_mut_slice()
201            .par_chunks_mut(dimension * modes_per_particle)
202            .enumerate()
203            .try_for_each(|(j, columns)| -> Result<()> {
204                for i in 0..particles {
205                    if i == j {
206                        continue;
207                    }
208                    let values = pair(i, j)?;
209                    for col in 0..modes_per_particle {
210                        let start = col * dimension + i * modes_per_particle;
211                        columns[start..start + modes_per_particle].copy_from_slice(
212                            &values[col * modes_per_particle..(col + 1) * modes_per_particle],
213                        );
214                    }
215                }
216                Ok(())
217            })
218    })?;
219    Ok(coupling)
220}
221
222impl SphereClusterResidual {
223    /// Byte count for a cluster's saved numerical state, from its static layout.
224    pub fn state_size(lmax: usize, particles: usize) -> Result<usize> {
225        if particles == 0 {
226            return Err(invalid_state());
227        }
228        let sphere_size = SphereResidual::state_size(lmax, 1)?;
229        let modes = lmax
230            .checked_add(2)
231            .and_then(|value| value.checked_mul(lmax))
232            .and_then(|value| value.checked_mul(2))
233            .ok_or_else(invalid_state)?;
234        let particle_size = sphere_size.checked_add(24).ok_or_else(invalid_state)?;
235        let interaction_size = InteractionResidual::state_size_uniform(modes, particles)?;
236        particles
237            .checked_mul(particle_size)
238            .and_then(|size| size.checked_add(24))
239            .and_then(|size| size.checked_add(interaction_size))
240            .ok_or_else(invalid_state)
241    }
242
243    /// Number of spheres in the recorded cluster.
244    #[must_use]
245    pub fn particles(&self) -> usize {
246        self.spheres.len()
247    }
248
249    /// Interacting T-matrix in the local multipole bases.
250    #[must_use]
251    pub const fn value(&self) -> &DMatrix<Complex> {
252        self.interaction.value()
253    }
254
255    /// The shape of the interacting T-matrix.
256    #[must_use]
257    pub fn shape(&self) -> (usize, usize) {
258        self.interaction.shape()
259    }
260
261    /// Directional derivative for vacuum wavenumber, radii, permittivities and
262    /// positions. Particle and translation tangents feed one solve with the saved LU.
263    pub fn pushforward(
264        &self,
265        k0: f64,
266        radii: &[f64],
267        epsilon: &[Complex],
268        positions: &[[f64; 3]],
269    ) -> Result<DMatrix<Complex>> {
270        let n = self.spheres.len();
271        if !k0.is_finite()
272            || radii.len() != n
273            || epsilon.len() != n
274            || positions.len() != n
275            || radii.iter().any(|r| !r.is_finite())
276            || epsilon.iter().any(|&z| !finite(z))
277            || positions.iter().flatten().any(|x| !x.is_finite())
278        {
279            return Err(Error::InvalidInput(
280                "sphere tangents must be finite and match the recorded cluster".into(),
281            ));
282        }
283        let blocks = crate::threads::install(|| {
284            self.spheres
285                .par_iter()
286                .zip(radii)
287                .zip(epsilon)
288                .map(|((sphere, &radius), &epsilon)| {
289                    let zero = Material {
290                        epsilon: Complex::default(),
291                        mu: Complex::default(),
292                        kappa: Complex::default(),
293                    };
294                    sphere.pushforward(k0, &[radius], &[Material { epsilon, ..zero }, zero])
295                })
296                .collect::<Result<Vec<_>>>()
297        })?;
298        let modes_per_particle = self.shape().0 / n;
299        let coupling = coupling_matrix(n, modes_per_particle, |i, j| {
300            let displacement =
301                std::array::from_fn(|axis| self.positions[i][axis] - self.positions[j][axis]);
302            let tangent = std::array::from_fn(|axis| positions[i][axis] - positions[j][axis]);
303            self.plan.pushforward(
304                Complex::new(self.k0, 0.0),
305                displacement,
306                Radial::Singular,
307                tangent,
308                Complex::new(k0, 0.0),
309            )
310        })?;
311        self.interaction.pushforward_blocks(&blocks, &coupling)
312    }
313
314    /// Gradients of `k0`, the radii, the permittivities and the positions from
315    /// `cotangent`, the gradient of a real loss with respect to the interacting T-matrix.
316    ///
317    /// The spheres run in parallel; their gradients add in sphere order, so the Rayon
318    /// pool does not change them. The LU solve and the matrix products of
319    /// [`InteractionResidual::pullback_blocks`] run in faer with the worker count of
320    /// [`linalg`](crate::linalg).
321    pub fn pullback(&self, cotangent: &DMatrix<Complex>) -> Result<SphereClusterGradient> {
322        let InteractionGradient { local, coupling } =
323            self.interaction.pullback_blocks(cotangent)?;
324        let n = self.spheres.len();
325        let modes_per_particle = coupling.nrows() / n;
326        let (k0, plan, positions) = (self.k0, &self.plan, &self.positions);
327        // Each sphere's Mie pullback and the couplings into it run in parallel; the
328        // gradients are summed in sphere order.
329        let parts = crate::threads::install(|| {
330            self.spheres
331                .par_iter()
332                .zip(local)
333                .enumerate()
334                .map(|(i, (sphere, local))| {
335                    let gradient = sphere.pullback(&local)?;
336                    let mut block = DMatrix::zeros(modes_per_particle, modes_per_particle);
337                    let pairs = (0..n)
338                        .filter(|&j| j != i)
339                        .map(|j| {
340                            block.copy_from(&coupling.view(
341                                (i * modes_per_particle, j * modes_per_particle),
342                                (modes_per_particle, modes_per_particle),
343                            ));
344                            let displacement =
345                                std::array::from_fn(|axis| positions[i][axis] - positions[j][axis]);
346                            plan.pullback(
347                                Complex::new(k0, 0.0),
348                                displacement,
349                                Radial::Singular,
350                                block.as_slice(),
351                            )
352                            .map(|(position, k)| (position, k.re))
353                        })
354                        .collect::<Result<Vec<_>>>()?;
355                    Ok((gradient, pairs))
356                })
357                .collect::<Result<Vec<_>>>()
358        })?;
359        let mut result = SphereClusterGradient {
360            k0: 0.0,
361            radii: vec![0.0; n],
362            epsilon: vec![Complex::default(); n],
363            positions: vec![[0.0; 3]; n],
364        };
365        for (i, (sphere, pairs)) in parts.into_iter().enumerate() {
366            result.radii[i] = sphere.layers.radii[0];
367            result.epsilon[i] = sphere.layers.epsilon[0];
368            result.k0 += sphere.k0;
369            for (j, (gradient, gk)) in (0..n).filter(|&j| j != i).zip(pairs) {
370                result.k0 += gk;
371                for (axis, g) in gradient.into_iter().enumerate() {
372                    result.positions[i][axis] += g;
373                    result.positions[j][axis] -= g;
374                }
375            }
376        }
377        Ok(result)
378    }
379}
380
381fn invalid_state() -> Error {
382    Error::InvalidInput("invalid native sphere-cluster state".into())
383}
384
385impl SavedState for SphereClusterResidual {
386    fn save_state(&self) -> Result<Vec<u8>> {
387        let lmax = self.spheres.first().ok_or_else(invalid_state)?.lmax();
388        let particles = self.particles();
389        let mut writer = Writer::new(Self::state_size(lmax, particles)?);
390        writer.usize(lmax);
391        writer.usize(particles);
392        writer.f64(self.k0);
393        for position in &self.positions {
394            for &coordinate in position {
395                writer.f64(coordinate);
396            }
397        }
398        for sphere in &self.spheres {
399            sphere.write_state(&mut writer);
400        }
401        self.interaction.write_state(&mut writer);
402        Ok(writer.finish())
403    }
404
405    fn from_state(bytes: &[u8]) -> Result<Self> {
406        let mut reader = Reader::new(bytes);
407        let lmax = reader.usize()?;
408        let particles = reader.usize()?;
409        // Prove the complete layout fits before allocating particle or matrix data.
410        if bytes.len() != Self::state_size(lmax, particles)? {
411            return Err(invalid_state());
412        }
413        let k0 = reader.f64()?;
414        if !k0.is_finite() || k0 <= 0.0 {
415            return Err(invalid_state());
416        }
417        let positions = (0..particles)
418            .map(|_| {
419                let position = [reader.f64()?, reader.f64()?, reader.f64()?];
420                if position.iter().any(|value| !value.is_finite()) {
421                    return Err(invalid_state());
422                }
423                Ok(position)
424            })
425            .collect::<Result<Vec<_>>>()?;
426        let spheres = (0..particles)
427            .map(|_| {
428                let sphere = SphereResidual::read_state(&mut reader)?;
429                if sphere.lmax() != lmax || sphere.boundaries() != 1 {
430                    return Err(invalid_state());
431                }
432                Ok(sphere)
433            })
434            .collect::<Result<Vec<_>>>()?;
435        let interaction = InteractionResidual::read_state(&mut reader)?;
436        let modes_per_particle = 2 * lmax * (lmax + 2);
437        if interaction.local_shapes().len() != particles
438            || interaction
439                .local_shapes()
440                .any(|shape| shape != (modes_per_particle, modes_per_particle))
441        {
442            return Err(invalid_state());
443        }
444        reader.finish()?;
445        // The plan depends only on mode labels; all numerical forward state,
446        // including the interaction LU, comes directly from the saved bytes.
447        let modes = sw::modes(u32::try_from(lmax).map_err(|_| invalid_state())?)?;
448        let plan = TranslationPlan::between(&modes, &modes, true)?;
449        Ok(Self {
450            k0,
451            positions,
452            plan,
453            spheres,
454            interaction,
455        })
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    #![allow(clippy::float_cmp)] // Saved state must preserve the exact numerical results.
462
463    use super::*;
464
465    #[test]
466    fn saved_state_preserves_value_and_both_derivatives() -> Result<()> {
467        let cluster = sphere_cluster(
468            1,
469            0.9,
470            &[0.25, 0.3],
471            &[Complex::new(2.1, 0.04), Complex::new(1.7, 0.02)],
472            &[[0.0, 0.0, 0.0], [1.3, 0.2, -0.1]],
473        )?;
474        let bytes = cluster.save_state()?;
475        assert_eq!(bytes.len(), SphereClusterResidual::state_size(1, 2)?);
476        let restored = SphereClusterResidual::from_state(&bytes)?;
477        assert_eq!(cluster.value(), restored.value());
478        assert_eq!(restored.save_state()?, bytes);
479
480        let tangent = |residual: &SphereClusterResidual| {
481            residual.pushforward(
482                0.12,
483                &[0.04, -0.02],
484                &[Complex::new(0.13, 0.01), Complex::new(-0.04, 0.02)],
485                &[[0.01, -0.03, 0.02], [-0.02, 0.04, 0.01]],
486            )
487        };
488        assert_eq!(tangent(&cluster)?, tangent(&restored)?);
489        let cotangent = cluster
490            .value()
491            .map(|value| value + Complex::new(0.03, 0.07));
492        let original = cluster.pullback(&cotangent)?;
493        let roundtrip = restored.pullback(&cotangent)?;
494        assert_eq!(original.k0, roundtrip.k0);
495        assert_eq!(original.radii, roundtrip.radii);
496        assert_eq!(original.epsilon, roundtrip.epsilon);
497        assert_eq!(original.positions, roundtrip.positions);
498
499        assert!(SphereClusterResidual::from_state(&bytes[..bytes.len() - 1]).is_err());
500        let mut extra = bytes;
501        extra.push(0);
502        assert!(SphereClusterResidual::from_state(&extra).is_err());
503        Ok(())
504    }
505}