treams_core/lattice/
cell.rs

1//! The lattice and Bloch vector of a sum, and the automatic Ewald split.
2//!
3//! Upstream: `_check_eta` of `lattice/_esum.pyx` for the automatic split. The reduced
4//! bases are a treams-rs extension.
5
6use std::f64::consts::PI;
7
8use super::geometry::{reciprocal, reduce_basis, volume};
9use crate::{Complex, Error, Result};
10
11/// Row lattice vectors, a real Bloch vector `kpar` and their reduced bases in one, two or
12/// three dimensions.
13///
14/// They are bundled because their derivatives share one jet; treams passes the lattice
15/// vectors `a` and the Bloch vector `kpar` separately.
16///
17/// The lattice vectors and the Bloch vector are given in the lattice frame: Cartesian
18/// components along the lattice's axes, which are z for one-dimensional spherical sums,
19/// x for one-dimensional cylindrical sums, the xy plane in two dimensions and xyz in
20/// three. They are not fractional coordinates. Lattice-vector and Bloch derivatives use
21/// the same frame.
22///
23/// The constructor also reduces the direct and the reciprocal rows (`reduce_basis`),
24/// over which the Ewald parts sum. A reduction is stored as `None` where the given rows
25/// are already reduced, and then the parts sum over the given rows.
26#[derive(Clone, Debug)]
27pub struct BlochLattice {
28    pub(super) dim: usize,
29    /// Row vectors in lattice coordinates, zero outside the leading `dim` block.
30    pub(super) direct: [[f64; 3]; 3],
31    /// Reciprocal rows with `a_i · b_j = 2π δ_ij` in the leading block.
32    pub(super) reciprocal: [[f64; 3]; 3],
33    /// Bloch vector in the lattice frame, zero beyond the leading `dim` entries.
34    pub(super) kpar: [f64; 3],
35    pub(super) measure: f64,
36    /// Reduced basis of the direct rows for the real-space sum; `None` where the given
37    /// rows are already reduced (or need coefficients beyond `2^20`), so the sum uses
38    /// them as they are.
39    pub(super) direct_reduction: Option<Reduction>,
40    /// Independently reduced basis of the reciprocal rows for the reciprocal sum; `None`
41    /// as for `direct_reduction`.
42    pub(super) reciprocal_reduction: Option<Reduction>,
43}
44
45/// A reduced basis `U rows` of lattice `rows` from [`geometry::reduce_basis`]: the
46/// integer rows of `U` and of its inverse transpose `W`, stored exactly as `f64`.
47/// Ewald shells of a skewed basis are far from Euclidean shells and can stop before
48/// short vectors with large indices; shells of the reduced basis cannot.
49///
50/// [`geometry::reduce_basis`]: super::geometry::reduce_basis
51#[derive(Clone, Copy, Debug)]
52pub(super) struct Reduction {
53    pub(super) rows: [[f64; 3]; 3],
54    pub(super) dual: [[f64; 3]; 3],
55}
56
57impl Reduction {
58    #[allow(clippy::cast_precision_loss)] // Entries are bounded by 2^20.
59    fn new(rows: [[f64; 3]; 3], dim: usize) -> Option<Self> {
60        let [rows, dual] = reduce_basis(rows, dim)?.map(|m| m.map(|row| row.map(|x| x as f64)));
61        Some(Self { rows, dual })
62    }
63
64    /// Reduced-basis coordinates of the lattice point nearest to the point with
65    /// coordinates `fractional` in the given basis, rounding in reduced coordinates.
66    /// `None` stands for a given basis that is already reduced.
67    pub(super) fn round(reduction: Option<&Self>, fractional: [f64; 3], dim: usize) -> [f64; 3] {
68        match reduction {
69            None => fractional.map(f64::round),
70            Some(reduction) => std::array::from_fn(|i| {
71                (0..dim)
72                    .map(|k| reduction.dual[i][k] * fractional[k])
73                    .sum::<f64>()
74                    .round()
75            }),
76        }
77    }
78
79    /// Given-basis coordinates `U^T c` of a point with reduced coordinates `c`. `None`
80    /// stands for a given basis that is already reduced, where both coincide.
81    pub(super) fn given(reduction: Option<&Self>, reduced: [f64; 3], dim: usize) -> [f64; 3] {
82        reduction.map_or(reduced, |reduction| {
83            std::array::from_fn(|k| (0..dim).map(|i| reduction.rows[i][k] * reduced[i]).sum())
84        })
85    }
86}
87
88impl BlochLattice {
89    /// Number of independent lattice vectors.
90    #[must_use]
91    pub fn dimension(&self) -> usize {
92        self.dim
93    }
94
95    /// Validate and precompute the reciprocal lattice. Rows are primitive vectors.
96    pub fn new(vectors: &[Vec<f64>], kpar: &[f64]) -> Result<Self> {
97        let dim = vectors.len();
98        if !(1..=3).contains(&dim)
99            || kpar.len() != dim
100            || vectors.iter().any(|row| row.len() != dim)
101        {
102            return Err(Error::InvalidInput(
103                "lattice must be a finite square 1D, 2D or 3D matrix, with matching Bloch vector"
104                    .into(),
105            ));
106        }
107        Self::from_array(
108            std::array::from_fn(|i| {
109                std::array::from_fn(|j| {
110                    if i < dim && j < dim {
111                        vectors[i][j]
112                    } else {
113                        0.0
114                    }
115                })
116            }),
117            std::array::from_fn(|i| if i < dim { kpar[i] } else { 0.0 }),
118            dim,
119        )
120    }
121
122    /// Construct from fixed storage without allocating intermediate row vectors.
123    ///
124    /// Only the leading `dim` block of `vectors` and the leading `dim` entries of `kpar`
125    /// count; the rest is stored as zero, so callers need not pad.
126    pub fn from_array(vectors: [[f64; 3]; 3], kpar: [f64; 3], dim: usize) -> Result<Self> {
127        if !(1..=3).contains(&dim) || kpar[..dim].iter().any(|x| !x.is_finite()) {
128            return Err(Error::InvalidInput(
129                "invalid lattice dimension or Bloch vector".into(),
130            ));
131        }
132        let kpar = std::array::from_fn(|i| if i < dim { kpar[i] } else { 0.0 });
133        let direct = std::array::from_fn(|i| {
134            std::array::from_fn(|j| {
135                if i < dim && j < dim {
136                    vectors[i][j]
137                } else {
138                    0.0
139                }
140            })
141        });
142        let reciprocal = reciprocal(direct, dim)?;
143        let measure = volume(direct, dim)?.abs();
144        if !measure.is_finite() {
145            return Err(Error::InvalidInput(
146                "lattice is numerically singular".into(),
147            ));
148        }
149        Ok(Self {
150            dim,
151            direct,
152            reciprocal,
153            kpar,
154            measure,
155            direct_reduction: Reduction::new(direct, dim),
156            reciprocal_reduction: Reduction::new(reciprocal, dim),
157        })
158    }
159}
160
161/// The Ewald split: `eta`, or for zero an automatic choice from `kL` with the cell
162/// length `L = measure^(1/dim)`. The exact sum is independent of the split, so it is
163/// held fixed during differentiation instead of differentiating the heuristic.
164pub(crate) fn resolve_split(k: Complex, lattice: &BlochLattice, eta: Complex) -> Complex {
165    if eta != Complex::default() {
166        return eta;
167    }
168    let length = match lattice.dim {
169        1 => lattice.measure,
170        2 => lattice.measure.sqrt(),
171        _ => lattice.measure.cbrt(),
172    };
173    let e = 1.0 / (k * length);
174    (2.0 * PI).sqrt() * e * (0.125 / e.norm()).max(1.0)
175}