treams_core/lattice/
geometry.rs

1//! Lattice geometry: cell volumes, reciprocal and reduced bases, integer cube points,
2//! diffraction orders and reduction to the first Brillouin zone.
3//!
4//! Upstream: `volume`, `area`, `reciprocal`, `cube`, `cubeedge` and
5//! `diffr_orders_circle` of `treams.lattice`, and `firstbrillouin1d`, `firstbrillouin2d`
6//! and `firstbrillouin3d` of `treams.misc`. `reduce_basis` is a treams-rs extension
7//! that the Ewald sums use.
8#![allow(clippy::indexing_slicing)] // Dimensions are validated before fixed-array indexing.
9use crate::{Error, Result, numerics};
10use nalgebra::{Matrix3, Vector3};
11use std::{
12    f64::consts::TAU,
13    ops::{Add, Mul, Sub},
14};
15
16/// Signed determinant for a one-, two- or three-dimensional cell.
17///
18/// Upstream: `treams.lattice.volume` and, in two dimensions, `treams.lattice.area`.
19#[inline]
20pub fn volume<T: Copy + Add<Output = T> + Sub<Output = T> + Mul<Output = T>>(
21    a: [[T; 3]; 3],
22    dim: usize,
23) -> Result<T> {
24    match dim {
25        1 => Ok(a[0][0]),
26        2 => Ok(a[0][0] * a[1][1] - a[0][1] * a[1][0]),
27        3 => Ok(a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1])
28            + a[0][1] * (a[1][2] * a[2][0] - a[1][0] * a[2][2])
29            + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0])),
30        _ => Err(Error::InvalidInput(
31            "cell dimension must be 1, 2 or 3".into(),
32        )),
33    }
34}
35
36/// Reciprocal row vectors, padded by unused identity axes for dimensions below three.
37///
38/// Upstream: `treams.lattice.reciprocal`.
39pub fn reciprocal(a: [[f64; 3]; 3], dim: usize) -> Result<[[f64; 3]; 3]> {
40    if !(1..=3).contains(&dim)
41        || a[..dim]
42            .iter()
43            .any(|r| r[..dim].iter().any(|v| !v.is_finite()))
44    {
45        return Err(Error::InvalidInput(
46            "require finite lattice vectors in dimension 1, 2 or 3".into(),
47        ));
48    }
49    let matrix = Matrix3::from_fn(|i, j| {
50        if i < dim && j < dim {
51            a[i][j]
52        } else {
53            f64::from(i == j)
54        }
55    });
56    let inverse = matrix
57        .try_inverse()
58        .ok_or_else(|| Error::InvalidInput("lattice vectors must be independent".into()))?;
59    let result = inverse.transpose() * TAU;
60    if result.iter().any(|v| !v.is_finite()) {
61        return Err(Error::InvalidInput(
62            "lattice is numerically singular".into(),
63        ));
64    }
65    Ok(std::array::from_fn(|i| {
66        std::array::from_fn(|j| result[(i, j)])
67    }))
68}
69
70/// Unimodular integer transforms `[U, W]` with `W = U^{-T}`: the rows `U a` of the
71/// leading `dim` rows `a` are a reduced basis of the same lattice, and `W b` are their
72/// reciprocal rows when `b` are those of `a`.
73///
74/// A row is reduced when neither adding a multiple of another row nor, in three
75/// dimensions, adding the sum or difference of the two others shortens it by more than
76/// a relative `1e-8`. Only such strict decreases are applied and rows are never
77/// permuted, so a reduced basis (for example a hexagonal one, whose ties do not
78/// count) is kept. Returns `None` for a reduced basis, or when the reduction would
79/// need coefficients beyond `2^20`, which leaves the given basis in use.
80#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // Coefficients are bounded by 2^20.
81pub(crate) fn reduce_basis(rows: [[f64; 3]; 3], dim: usize) -> Option<[[[i64; 3]; 3]; 2]> {
82    const LIMIT: i64 = 1 << 20;
83    /// Rows `v = U a` with the transforms `U` and `W = U^{-T}`.
84    struct Basis {
85        v: [[f64; 3]; 3],
86        u: [[i64; 3]; 3],
87        w: [[i64; 3]; 3],
88    }
89    fn norm(v: [f64; 3]) -> f64 {
90        v.iter().map(|x| x * x).sum()
91    }
92    impl Basis {
93        /// Add `c` times row `i` to row `k` for each `(i, c)` when that shortens row `k`.
94        fn shorten(&mut self, k: usize, terms: &[(usize, i64)]) -> Option<bool> {
95            let mut candidate = self.v[k];
96            for &(i, c) in terms {
97                if c.abs() > LIMIT {
98                    return None;
99                }
100                for (x, y) in candidate.iter_mut().zip(self.v[i]) {
101                    *x += c as f64 * y;
102                }
103            }
104            if norm(candidate) >= (1.0 - 1e-8) * norm(self.v[k]) {
105                return Some(false);
106            }
107            // Row k of U gains c row i; row i of W = U^{-T} loses c row k.
108            for &(i, c) in terms {
109                for j in 0..3 {
110                    self.u[k][j] += c * self.u[i][j];
111                    self.w[i][j] -= c * self.w[k][j];
112                }
113            }
114            if self
115                .u
116                .iter()
117                .chain(&self.w)
118                .flatten()
119                .any(|x| x.abs() > LIMIT)
120            {
121                return None;
122            }
123            self.v[k] = candidate;
124            Some(true)
125        }
126    }
127    let identity: [[i64; 3]; 3] =
128        std::array::from_fn(|i| std::array::from_fn(|j| i64::from(i == j)));
129    let mut basis = Basis {
130        v: std::array::from_fn(|i| {
131            std::array::from_fn(|j| if i < dim && j < dim { rows[i][j] } else { 0.0 })
132        }),
133        u: identity,
134        w: identity,
135    };
136    for _ in 0..64 {
137        let mut changed = false;
138        for k in 0..dim {
139            for i in (0..dim).filter(|&i| i != k) {
140                let (a, b) = (basis.v[k], basis.v[i]);
141                let mu = (a.iter().zip(b).map(|(x, y)| x * y).sum::<f64>() / norm(b)).round();
142                if !mu.is_finite() || mu.abs() > LIMIT as f64 {
143                    return None;
144                }
145                if mu != 0.0 {
146                    changed |= basis.shorten(k, &[(i, -mu as i64)])?;
147                }
148            }
149            if dim == 3 {
150                let (i, j) = ((k + 1) % 3, (k + 2) % 3);
151                for (s, t) in [(1, 1), (1, -1), (-1, 1), (-1, -1)] {
152                    changed |= basis.shorten(k, &[(i, s), (j, t)])?;
153                }
154            }
155        }
156        if !changed {
157            break;
158        }
159    }
160    (basis.u != identity).then_some([basis.u, basis.w])
161}
162
163/// All integer cube points, or just its boundary, in lexicographic row order.
164///
165/// Upstream: `treams.lattice.cube` (`edge = false`) and `treams.lattice.cubeedge`
166/// (`edge = true`), flattened to `dim` columns per point.
167pub fn cube(dim: usize, n: i64, edge: bool) -> Result<Vec<i64>> {
168    if !(1..=3).contains(&dim) || n < 0 {
169        return Err(Error::InvalidInput(
170            "cube requires dimension 1, 2 or 3 and nonnegative size".into(),
171        ));
172    }
173    let count = usize::try_from(n)
174        .ok()
175        .and_then(|n| n.checked_mul(2))
176        .and_then(|n| n.checked_add(1))
177        .and_then(|side| {
178            side.checked_pow(u32::try_from(dim).ok()?).map(|outer| {
179                if edge && side > 1 {
180                    outer - (side - 2).pow(u32::try_from(dim).unwrap_or_default())
181                } else {
182                    outer
183                }
184            })
185        })
186        .and_then(|rows| rows.checked_mul(dim))
187        .ok_or_else(|| Error::InvalidInput("cube output size overflows".into()))?;
188    let mut values = Vec::new();
189    numerics::reserve(&mut values, count)?;
190    visit_cube(dim, n, edge, |point| {
191        values.extend_from_slice(&point[..dim]);
192        Ok(())
193    })?;
194    Ok(values)
195}
196
197/// Visit cube points without allocating an integer point table.
198pub(crate) fn visit_cube(
199    dim: usize,
200    n: i64,
201    edge: bool,
202    mut visitor: impl FnMut([i64; 3]) -> Result<()>,
203) -> Result<()> {
204    if !(1..=3).contains(&dim) || n < 0 {
205        return Err(Error::InvalidInput(
206            "cube requires dimension 1, 2 or 3 and nonnegative size".into(),
207        ));
208    }
209    fn append(
210        point: &mut [i64; 3],
211        dim: usize,
212        axis: usize,
213        n: i64,
214        edge: bool,
215        boundary: bool,
216        visitor: &mut impl FnMut([i64; 3]) -> Result<()>,
217    ) -> Result<()> {
218        if axis == dim {
219            return visitor(*point);
220        }
221        if edge && !boundary && axis + 1 == dim && n > 0 {
222            for value in [-n, n] {
223                point[axis] = value;
224                append(point, dim, axis + 1, n, edge, true, visitor)?;
225            }
226        } else {
227            for value in -n..=n {
228                point[axis] = value;
229                append(
230                    point,
231                    dim,
232                    axis + 1,
233                    n,
234                    edge,
235                    boundary || value.abs() == n,
236                    visitor,
237                )?;
238            }
239        }
240        Ok(())
241    }
242    append(&mut [0; 3], dim, 0, n, edge, false, &mut visitor)
243}
244
245/// Diffraction orders inside a reciprocal-space circle, with adjacent opposite pairs.
246///
247/// Upstream: `treams.lattice.diffr_orders_circle`.
248#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // Integer bounds are checked before conversion.
249pub fn diffraction_orders(b: [[f64; 2]; 2], radius: f64) -> Result<Vec<i64>> {
250    if !radius.is_finite() || b.iter().flatten().any(|v| !v.is_finite()) {
251        return Err(Error::InvalidInput(
252            "diffraction geometry must be finite".into(),
253        ));
254    }
255    let det = b[0][0] * b[1][1] - b[0][1] * b[1][0];
256    let length = b[1][0].hypot(b[1][1]);
257    if det == 0.0 || !det.is_finite() || !length.is_finite() {
258        return Err(Error::InvalidInput(
259            "lattice vectors must be independent".into(),
260        ));
261    }
262    if radius < 0.0 {
263        return Ok(Vec::new());
264    }
265    let bound = (radius * length / det.abs()).ceil();
266    let nbound = radius * b[0][0].hypot(b[0][1]) / det.abs();
267    if !bound.is_finite()
268        || !nbound.is_finite()
269        || bound >= i64::MAX as f64 - 4.0
270        || nbound >= i64::MAX as f64 - 4.0
271    {
272        return Err(Error::InvalidInput(
273            "diffraction order bounds overflow".into(),
274        ));
275    }
276    let mut orders = vec![0, 0];
277    for m in 0..=bound as i64 {
278        // Rounding can place a row holding a boundary order just beyond the radius;
279        // the padded window keeps it and the hypot test below decides.
280        let distance = (m as f64 * det / length).abs();
281        let center =
282            -(m as f64) * (b[0][0] * (b[1][0] / length) + b[0][1] * (b[1][1] / length)) / length;
283        let half = ((radius - distance) * (radius + distance)).max(0.0).sqrt() / length;
284        let lower = (center - half).floor() as i64 - 1;
285        let upper = (center + half).ceil() as i64 + 1;
286        let mut emit = |n: i64| -> Result<()> {
287            if m == 0 && n <= 0 {
288                return Ok(());
289            }
290            let x = m as f64 * b[0][0] + n as f64 * b[1][0];
291            let y = m as f64 * b[0][1] + n as f64 * b[1][1];
292            if x.hypot(y) <= radius {
293                // Amortized and fallible: a huge radius returns OutOfMemory, not an abort.
294                orders
295                    .try_reserve(4)
296                    .map_err(|_| Error::out_of_memory(orders.len() + 4, size_of::<i64>()))?;
297                orders.extend([m, n, -m, -n]);
298            }
299            Ok(())
300        };
301        for n in lower.max(0)..=upper {
302            emit(n)?;
303        }
304        for n in (lower..=upper.min(-1)).rev() {
305            emit(n)?;
306        }
307    }
308    Ok(orders)
309}
310
311/// Reduce a scalar wavevector to `(-b/2, b/2]`, for positive reciprocal pitch `b`.
312///
313/// Upstream: `treams.misc.firstbrillouin1d`.
314pub fn first_brillouin_1d(k: f64, b: f64) -> Result<f64> {
315    if !k.is_finite() || !b.is_finite() || b <= 0.0 {
316        return Err(Error::InvalidInput(
317            "require finite wavevector and positive reciprocal pitch".into(),
318        ));
319    }
320    let mut result = k - b * (k / b).round_ties_even();
321    if result > 0.5 * b {
322        result -= b;
323    }
324    if result <= -0.5 * b {
325        result += b;
326    }
327    if !result.is_finite() {
328        return Err(Error::InvalidInput("wavevector reduction overflow".into()));
329    }
330    Ok(result)
331}
332
333/// Iterative nearest-cell reduction of a 2D or 3D wavevector.
334///
335/// Each of up to `iterations + 1` passes rounds `k` against every row and then takes
336/// the shortest of `k` and its translates to the `3^dim - 1` neighbouring cells, as
337/// upstream does. A pass that leaves `k` unchanged ends the reduction.
338///
339/// Upstream: `treams.misc.firstbrillouin2d` and `treams.misc.firstbrillouin3d`.
340///
341/// # Errors
342///
343/// Fails with "lattice vectors are not of minimal length" for a 2D basis whose rows the
344/// sum or difference of the two shortens beyond rounding, and for an invalid basis,
345/// dimension or wavevector.
346#[allow(clippy::float_cmp)] // Exact fixed-point termination of the discrete reduction.
347pub fn first_brillouin(
348    mut k: [f64; 3],
349    b: [[f64; 3]; 3],
350    dim: usize,
351    iterations: usize,
352) -> Result<[f64; 3]> {
353    reciprocal(b, dim)?;
354    if !(2..=3).contains(&dim) || k[..dim].iter().any(|v| !v.is_finite()) {
355        return Err(Error::InvalidInput(
356            "require finite two- or three-dimensional wavevector".into(),
357        ));
358    }
359    k[dim..].fill(0.0);
360    let rows = std::array::from_fn::<_, 3, _>(|i| {
361        Vector3::from_fn(|j, _| if i < dim && j < dim { b[i][j] } else { 0.0 })
362    });
363    let norms = rows.map(|v| v.norm_squared());
364    // Relative to the rows, so rounding of hexagonal ties at any length passes. The
365    // floor 1e-14 is the absolute tolerance of upstream `firstbrillouin2d`, so every
366    // basis that upstream accepts passes too.
367    let tolerance = (1e-13 * norms[0].max(norms[1])).max(1e-14);
368    if dim == 2
369        && [
370            (rows[0] + rows[1]).norm_squared(),
371            (rows[0] - rows[1]).norm_squared(),
372        ]
373        .iter()
374        .any(|&v| v < norms[0] - tolerance || v < norms[1] - tolerance)
375    {
376        return Err(Error::InvalidInput(
377            "lattice vectors are not of minimal length".into(),
378        ));
379    }
380    let neighbours = cube(dim, 1, false)?;
381    for _ in 0..=iterations {
382        let previous = k;
383        let mut point = Vector3::from(k);
384        for j in 0..dim {
385            point -= rows[j] * (point.dot(&rows[j]) / norms[j]).round_ties_even();
386        }
387        let mut best = point;
388        for index in neighbours.chunks_exact(dim) {
389            let mut candidate = point;
390            for j in 0..dim {
391                candidate += rows[j] * f64::from(i32::try_from(index[j]).unwrap_or_default());
392            }
393            if candidate.norm_squared() < best.norm_squared() {
394                best = candidate;
395            }
396        }
397        k = best.into();
398        if k == previous {
399            break;
400        }
401    }
402    Ok(k)
403}
404
405#[cfg(test)]
406mod tests {
407    use std::{f64::consts::TAU, result::Result};
408
409    use proptest::{prelude::*, test_runner::TestCaseError};
410
411    use super::{
412        cube, diffraction_orders, first_brillouin, first_brillouin_1d, reciprocal, reduce_basis,
413        volume,
414    };
415    use crate::test_support::{ALGEBRA_CASES, log_uniform, prop_assert_close, table};
416
417    proptest! {
418        #![proptest_config(ProptestConfig::with_cases(ALGEBRA_CASES))]
419
420        #[test]
421        fn reciprocal_duality(
422            a in 0.3f64..3.0,
423            b in 0.3f64..3.0,
424            c in 0.3f64..3.0,
425            s in -1.0f64..1.0,
426            dim in 1usize..4,
427        ) {
428            check_reciprocal_duality([[a, s, 0.0], [0.0, b, s], [0.0, 0.0, c]], dim)?;
429        }
430
431        #[test]
432        fn boundary_is_cube_difference(dim in 1usize..4, n in 1i64..8) {
433            check_cube_boundary(dim, n)?;
434        }
435
436        #[test]
437        fn diffraction_orders_are_complete_paired_and_unique(
438            basis in reciprocal_basis(-3.0..3.0, 0.5..2.0),
439            radius in 0.0f64..4.0,
440        ) {
441            check_diffraction_orders(basis, radius)?;
442        }
443
444        #[test]
445        fn brillouin_reduction_is_a_covariant_voronoi_projection(
446            basis in reciprocal_basis(-0.5..0.5, 1.0..2.0),
447            hexagonal in any::<bool>(),
448            k in prop::array::uniform2(-5.0f64..5.0),
449            cells in prop::array::uniform2(-6i32..=6),
450        ) {
451            check_brillouin_2d(basis, hexagonal, k, cells)?;
452        }
453
454        #[test]
455        fn brillouin_reduction_in_three_dimensions_is_periodic(
456            skew in prop::array::uniform3(-0.2f64..0.2),
457            k in prop::array::uniform3(-5.0f64..5.0),
458            cells in prop::array::uniform3(-6i32..=6),
459        ) {
460            check_brillouin_3d(skew, k, cells)?;
461        }
462
463        #[test]
464        fn brillouin_reduction_in_one_dimension_is_periodic(
465            k in -100.0f64..100.0,
466            b in log_uniform(-3.0..8.0),
467            cells in -20i32..=20,
468        ) {
469            check_brillouin_1d(k, b, cells)?;
470        }
471
472        #[test]
473        fn basis_reduction_finds_the_successive_minima(
474            dim in 2usize..4,
475            pitch in prop::array::uniform3(1.0f64..2.0),
476            skew in prop::array::uniform3(-1.0f64..1.0),
477            shear in prop::array::uniform3(-9i64..=9),
478        ) {
479            check_basis_reduction(dim, pitch, skew, shear)?;
480        }
481    }
482
483    /// The boundary of a cube of half-width `n` is the set of its points with a
484    /// coordinate of modulus `n`, in the same order.
485    fn check_cube_boundary(dim: usize, n: i64) -> Result<(), TestCaseError> {
486        let full = cube(dim, n, false)?;
487        let boundary = cube(dim, n, true)?;
488        let expected: Vec<_> = full
489            .chunks_exact(dim)
490            .filter(|point| point.iter().any(|x| x.abs() == n))
491            .flatten()
492            .copied()
493            .collect();
494        prop_assert_eq!(boundary, expected);
495        Ok(())
496    }
497
498    /// Two-dimensional reciprocal rows `s R(theta) [[1, 0], [shear, aspect]]`, optionally
499    /// reflected, at scales `s` over eleven decades, with the scale.
500    fn reciprocal_basis(
501        shear: std::ops::Range<f64>,
502        aspect: std::ops::Range<f64>,
503    ) -> impl Strategy<Value = ([[f64; 2]; 2], f64)> {
504        (
505            shear,
506            aspect,
507            0.0..TAU,
508            any::<bool>(),
509            log_uniform(-3.0..8.0),
510        )
511            .prop_map(|(shear, aspect, theta, reflect, scale)| {
512                let (sin, cos) = theta.sin_cos();
513                let flip = if reflect { -1.0 } else { 1.0 };
514                let row = |x: f64, y: f64| {
515                    [
516                        scale * (cos * x - sin * y),
517                        scale * flip * (sin * x + cos * y),
518                    ]
519                };
520                ([row(1.0, 0.0), row(shear, aspect)], scale)
521            })
522    }
523
524    /// Reciprocal rows are dual to the direct rows, the reciprocal of the reciprocal
525    /// rows is the direct lattice, and the cell volumes multiply to `(2 pi)^dim`.
526    fn check_reciprocal_duality(direct: [[f64; 3]; 3], dim: usize) -> Result<(), TestCaseError> {
527        let reciprocal = reciprocal(direct, dim)?;
528        for (i, row) in direct.iter().enumerate().take(dim) {
529            for (j, dual) in reciprocal.iter().enumerate().take(dim) {
530                let dot: f64 = row.iter().zip(dual).take(dim).map(|(a, b)| a * b).sum();
531                let expected = if i == j { TAU } else { 0.0 };
532                prop_assert_close!(dot, expected, 3e-14);
533            }
534        }
535        let involution = super::reciprocal(reciprocal, dim)?;
536        for (row, expected) in involution.iter().zip(direct).take(dim) {
537            for (x, e) in row.iter().zip(expected).take(dim) {
538                prop_assert_close!(*x, e, 1e-14, "{involution:?} {direct:?}");
539            }
540        }
541        let product = volume(direct, dim)? * volume(reciprocal, dim)?;
542        let expected = TAU.powi(i32::try_from(dim).unwrap());
543        prop_assert_close!(product, expected, 1e-14 * expected);
544        Ok(())
545    }
546
547    /// Diffraction orders start at `(0, 0)`, list every other order next to its
548    /// opposite, contain no order twice and are exactly the orders inside the circle,
549    /// for oblique, reflected and scaled bases.
550    #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // Small order bounds.
551    fn check_diffraction_orders(
552        (b, scale): ([[f64; 2]; 2], f64),
553        radius: f64,
554    ) -> Result<(), TestCaseError> {
555        let radius = radius * scale;
556        let orders = diffraction_orders(b, radius)?;
557        prop_assert_eq!(&orders[..2], &[0, 0]);
558        let pairs: Vec<(i64, i64)> = orders
559            .as_chunks::<2>()
560            .0
561            .iter()
562            .map(|&p| p.into())
563            .collect();
564        for pair in pairs[1..].as_chunks::<2>().0 {
565            prop_assert_eq!(pair[1], (-pair[0].0, -pair[0].1));
566        }
567        let set: std::collections::BTreeSet<_> = pairs.iter().copied().collect();
568        prop_assert_eq!(set.len(), pairs.len(), "duplicate orders");
569        let det = (b[0][0] * b[1][1] - b[0][1] * b[1][0]).abs();
570        let bound = |row: [f64; 2]| (radius * row[0].hypot(row[1]) / det).ceil() as i64 + 1;
571        let magnitude = |m: i64, n: i64| {
572            let (m, n) = (m as f64, n as f64);
573            (m * b[0][0] + n * b[1][0]).hypot(m * b[0][1] + n * b[1][1])
574        };
575        let (mmax, nmax) = (bound(b[1]), bound(b[0]));
576        let expected: std::collections::BTreeSet<_> = (-mmax..=mmax)
577            .flat_map(|m| (-nmax..=nmax).map(move |n| (m, n)))
578            .filter(|&(m, n)| magnitude(m, n) <= radius)
579            .collect();
580        prop_assert_eq!(set, expected);
581        Ok(())
582    }
583
584    /// Two reductions of the same class `k + G` agree up to ties: they are equal to
585    /// `1e-12 scale`, or differ by a lattice vector while having equal lengths.
586    fn same_reduction(
587        a: [f64; 3],
588        b: [f64; 3],
589        dual: &[[f64; 3]; 3],
590        scale: f64,
591    ) -> Result<(), TestCaseError> {
592        let d: [f64; 3] = std::array::from_fn(|j| a[j] - b[j]);
593        let norm = |v: [f64; 3]| v.iter().map(|x| x * x).sum::<f64>();
594        if norm(d).sqrt() <= 1e-12 * scale {
595            return Ok(());
596        }
597        for row in dual {
598            let cells = row.iter().zip(d).map(|(x, y)| x * y).sum::<f64>() / TAU;
599            prop_assert!(
600                (cells - cells.round()).abs() < 1e-9,
601                "{a:?} and {b:?} differ"
602            );
603        }
604        prop_assert!(
605            (norm(a) - norm(b)).abs() <= 1e-10 * scale * scale,
606            "{a:?} {b:?}"
607        );
608        Ok(())
609    }
610
611    /// Two-dimensional Brillouin reduction accepts reduced bases at every scale,
612    /// including exact hexagonal ties, and returns the shortest vector of `k + G`
613    /// (Voronoi minimality against the eight neighbouring cells), independent of the
614    /// representative `k`, idempotent and covariant under scaling.
615    fn check_brillouin_2d(
616        (b, scale): ([[f64; 2]; 2], f64),
617        hexagonal: bool,
618        k: [f64; 2],
619        cells: [i32; 2],
620    ) -> Result<(), TestCaseError> {
621        let b = if hexagonal {
622            // Rotate the hexagonal basis [[1, 0], [-1/2, sqrt 3 / 2]] like `b`.
623            let [x, y] = [b[0][0], b[0][1]];
624            let s3 = 3.0_f64.sqrt() / 2.0;
625            let flip = (b[0][0] * b[1][1] - b[0][1] * b[1][0]).signum();
626            [[x, y], [-0.5 * x - flip * s3 * y, -0.5 * y + flip * s3 * x]]
627        } else {
628            b
629        };
630        let rows = [[b[0][0], b[0][1], 0.0], [b[1][0], b[1][1], 0.0], [0.0; 3]];
631        let dual = reciprocal(rows, 2)?;
632        let k = [k[0] * scale, k[1] * scale, 0.0];
633        let result = first_brillouin(k, rows, 2, 10)?;
634        let norm = |v: [f64; 3]| v.iter().map(|x| x * x).sum::<f64>();
635        for m in -1..=1_i32 {
636            for n in -1..=1_i32 {
637                let (m, n) = (f64::from(m), f64::from(n));
638                let neighbour: [f64; 3] =
639                    std::array::from_fn(|j| result[j] + m * rows[0][j] + n * rows[1][j]);
640                prop_assert!(
641                    norm(result) <= norm(neighbour) * (1.0 + 1e-12),
642                    "{result:?} is longer than {neighbour:?}"
643                );
644            }
645        }
646        let shifted: [f64; 3] = std::array::from_fn(|j| {
647            k[j] + f64::from(cells[0]) * rows[0][j] + f64::from(cells[1]) * rows[1][j]
648        });
649        let size = scale * (1.0 + norm(k).sqrt() / scale);
650        same_reduction(first_brillouin(shifted, rows, 2, 10)?, result, &dual, size)?;
651        same_reduction(first_brillouin(result, rows, 2, 10)?, result, &dual, size)?;
652        let unit = first_brillouin(
653            k.map(|x| x / scale),
654            rows.map(|r| r.map(|x| x / scale)),
655            2,
656            10,
657        )?;
658        same_reduction(unit.map(|x| x * scale), result, &dual, size)?;
659        Ok(())
660    }
661
662    /// Three-dimensional Brillouin reduction of nearly orthogonal bases is
663    /// independent of the representative `k` and idempotent.
664    fn check_brillouin_3d(
665        skew: [f64; 3],
666        k: [f64; 3],
667        cells: [i32; 3],
668    ) -> Result<(), TestCaseError> {
669        let rows = [
670            [1.0, skew[0], skew[1]],
671            [skew[2], 1.3, skew[0]],
672            [skew[1], skew[2], 1.6],
673        ];
674        let dual = reciprocal(rows, 3)?;
675        let result = first_brillouin(k, rows, 3, 10)?;
676        let shifted: [f64; 3] = std::array::from_fn(|j| {
677            k[j] + (0..3)
678                .map(|i| f64::from(cells[i]) * rows[i][j])
679                .sum::<f64>()
680        });
681        same_reduction(first_brillouin(shifted, rows, 3, 10)?, result, &dual, 10.0)?;
682        same_reduction(first_brillouin(result, rows, 3, 10)?, result, &dual, 10.0)?;
683        Ok(())
684    }
685
686    /// One-dimensional reduction lands in `(-b/2, b/2]` and is periodic in `k`.
687    fn check_brillouin_1d(k: f64, b: f64, cells: i32) -> Result<(), TestCaseError> {
688        let k = k * b;
689        let result = first_brillouin_1d(k, b)?;
690        prop_assert!(
691            -0.5 * b < result && result <= 0.5 * b,
692            "{result} for b = {b}"
693        );
694        let shifted = first_brillouin_1d(k + f64::from(cells) * b, b)?;
695        // Equivalent representatives may lie on opposite sides of a rounded boundary.
696        let difference = (result - shifted).abs();
697        prop_assert!(
698            difference < 1e-12 * b || (difference - b).abs() < 1e-12 * b,
699            "{result} {shifted}"
700        );
701        Ok(())
702    }
703
704    #[test]
705    fn hexagonal_brillouin_zones_are_accepted_at_every_scale() {
706        // An absolute tolerance on squared lengths, as in upstream `firstbrillouin2d`,
707        // rejects some of these bases.
708        let s = 3.0_f64.sqrt();
709        for pitch in [0.3, 0.5, 0.8, 1.0, 20.0, 1e-7, 1e9] {
710            let rows = [
711                [pitch, 0.0, 0.0],
712                [0.5 * pitch, 0.5 * s * pitch, 0.0],
713                [0.0; 3],
714            ];
715            let b = reciprocal(rows, 2).unwrap();
716            let k = [0.1 / pitch, 0.2 / pitch, 0.0];
717            assert!(first_brillouin(k, b, 2, 2).is_ok(), "pitch {pitch}");
718        }
719        let skewed = [[1.0, 0.0, 0.0], [0.9, 0.3, 0.0], [0.0; 3]];
720        assert!(first_brillouin([0.1, 0.2, 0.0], skewed, 2, 2).is_err());
721    }
722
723    /// A basis and a sheared copy of it reduce, by unimodular transforms with exact
724    /// integer inverses, to bases whose rows no sum or difference of rows shortens.
725    /// Such bases are Minkowski reduced for `dim <= 3`, so their sorted row lengths are
726    /// the successive minima of the lattice and agree; reduced bases are kept.
727    fn check_basis_reduction(
728        dim: usize,
729        pitch: [f64; 3],
730        skew: [f64; 3],
731        shear: [i64; 3],
732    ) -> Result<(), TestCaseError> {
733        let a = [
734            [pitch[0], 0.0, 0.0],
735            [skew[0], pitch[1], 0.0],
736            [skew[1], skew[2], pitch[2]],
737        ];
738        let identity: [[i64; 3]; 3] =
739            std::array::from_fn(|i| std::array::from_fn(|j| i64::from(i == j)));
740        let shear = [[1, 0, 0], [shear[0], 1, 0], [shear[1], shear[2], 1]];
741        let combine = |u: &[[i64; 3]; 3], rows: &[[f64; 3]; 3]| -> [[f64; 3]; 3] {
742            std::array::from_fn(|i| {
743                std::array::from_fn(|j| {
744                    if i < dim && j < dim {
745                        (0..dim)
746                            .map(|k| f64::from(i32::try_from(u[i][k]).unwrap()) * rows[k][j])
747                            .sum()
748                    } else {
749                        0.0
750                    }
751                })
752            })
753        };
754        let norm = |v: &[f64; 3]| v.iter().map(|x| x * x).sum::<f64>();
755        let mut minima = Vec::new();
756        for rows in [combine(&identity, &a), combine(&shear, &a)] {
757            let [u, w] = reduce_basis(rows, dim).unwrap_or([identity; 2]);
758            for (i, row) in u.iter().enumerate() {
759                for (j, dual) in w.iter().enumerate() {
760                    let product: i64 = row.iter().zip(dual).map(|(a, b)| a * b).sum();
761                    prop_assert_eq!(product, i64::from(i == j), "U W^T = I");
762                }
763            }
764            let v = combine(&u, &rows);
765            for k in 0..dim {
766                for c in [
767                    [1, 0],
768                    [-1, 0],
769                    [0, 1],
770                    [0, -1],
771                    [1, 1],
772                    [1, -1],
773                    [-1, 1],
774                    [-1, -1],
775                ] {
776                    let (i, j) = ((k + 1) % dim, (k + 2) % dim);
777                    if dim == 2 && c[1] != 0 {
778                        continue;
779                    }
780                    let other: [f64; 3] = std::array::from_fn(|x| {
781                        v[k][x] + f64::from(c[0]) * v[i][x] + f64::from(c[1]) * v[j][x]
782                    });
783                    prop_assert!(
784                        norm(&v[k]) <= (1.0 + 1e-7) * norm(&other),
785                        "row {k} of {v:?} shortened by {c:?}"
786                    );
787                }
788            }
789            prop_assert!(reduce_basis(v, dim).is_none(), "{v:?} is reduced");
790            let mut lengths: Vec<f64> = v[..dim].iter().map(norm).collect();
791            lengths.sort_by(f64::total_cmp);
792            minima.push(lengths);
793        }
794        for (a, b) in minima[0].iter().zip(&minima[1]) {
795            prop_assert!((a - b).abs() <= 1e-7 * a, "{minima:?}");
796        }
797        Ok(())
798    }
799
800    #[test]
801    fn reduced_bases_are_kept() {
802        let s = 3.0_f64.sqrt();
803        for (rows, dim) in [
804            ([[1.0, 0.0, 0.0], [0.5, 0.5 * s, 0.0], [0.0; 3]], 2),
805            ([[1.0, 0.0, 0.0], [-0.5, 0.5 * s, 0.0], [0.0; 3]], 2),
806            // A shorter second row is not permuted first.
807            ([[1.6, 0.0, 0.0], [0.3, 1.4, 0.0], [0.0; 3]], 2),
808            (
809                [
810                    [1.0, 0.0, 0.0],
811                    [0.5, 0.5 * s, 0.0],
812                    [0.5, 0.5 / s, (2.0_f64 / 3.0).sqrt()],
813                ],
814                3,
815            ),
816            ([[2.0, 0.0, 0.0], [0.0; 3], [0.0; 3]], 1),
817        ] {
818            assert!(reduce_basis(rows, dim).is_none(), "{rows:?}");
819        }
820        let sheared = [[1.6, 0.0, 0.0], [13.1, 1.4, 0.0], [0.0; 3]];
821        assert_eq!(
822            reduce_basis(sheared, 2),
823            Some([
824                [[1, 0, 0], [-8, 1, 0], [0, 0, 1]],
825                [[1, 8, 0], [0, 1, 0], [0, 0, 1]]
826            ])
827        );
828    }
829
830    #[test]
831    fn diffraction_cutoff_on_an_order_keeps_it() -> crate::Result<()> {
832        // A cutoff equal to an order's magnitude must keep that order, although the
833        // row distance of the same order can round above the cutoff.
834        let mut failures = Vec::new();
835        for a in [
836            0.1, 0.2, 0.25, 0.3, 0.4, 0.45, 0.5, 0.6, 0.7, 0.8, 1.0, 1.3, 1.5, 2.0,
837        ] {
838            let s = 3.0_f64.sqrt();
839            for b in [
840                [[TAU / a, 0.0], [0.0, TAU / a]],
841                [[TAU / a, 0.0], [0.0, TAU / (1.7 * a)]],
842                [[TAU / a, -TAU / (a * s)], [0.0, 2.0 * TAU / (a * s)]],
843            ] {
844                let magnitude = |m: i32, n: i32| {
845                    let (m, n) = (f64::from(m), f64::from(n));
846                    (m * b[0][0] + n * b[1][0]).hypot(m * b[0][1] + n * b[1][1])
847                };
848                for (m, n) in [(1, 0), (0, 1), (1, 1), (2, 0), (1, -1), (2, 1)] {
849                    let radius = magnitude(m, n);
850                    let orders = diffraction_orders(b, radius)?;
851                    let set: std::collections::BTreeSet<(i64, i64)> = orders
852                        .as_chunks::<2>()
853                        .0
854                        .iter()
855                        .map(|&p| p.into())
856                        .collect();
857                    let expected: std::collections::BTreeSet<_> = (-8..=8)
858                        .flat_map(|m| (-8..=8).map(move |n| (m, n)))
859                        .filter(|&(m, n)| magnitude(m, n) <= radius)
860                        .map(|(m, n)| (i64::from(m), i64::from(n)))
861                        .collect();
862                    if set != expected {
863                        failures.push((a, b, (m, n)));
864                    }
865                }
866            }
867        }
868        assert!(
869            failures.is_empty(),
870            "orders dropped at the cutoff: {failures:?}"
871        );
872        Ok(())
873    }
874
875    #[test]
876    fn cube_matches_lean_model() {
877        // `just formal` keeps this file equal to `Treams.Shells.cube`.
878        let cases = table::<String, i64>(include_str!(concat!(
879            env!("CARGO_MANIFEST_DIR"),
880            "/../../formal/golden/cube.txt"
881        )));
882        // The cases of `formal/Golden.lean`, in its order: a duplicated, missing or
883        // reordered line fails even when the line count is unchanged.
884        let grid: Vec<_> = (1..=3)
885            .flat_map(|dim| (0..=4).flat_map(move |n| [true, false].map(|edge| (dim, n, edge))))
886            .collect();
887        assert_eq!(cases.len(), grid.len());
888        for ((key, expected), (dim, n, edge)) in cases.iter().zip(grid) {
889            assert_eq!(key, &[dim.to_string(), n.to_string(), edge.to_string()]);
890            assert_eq!(&cube(dim, n, edge).unwrap(), expected, "{key:?}");
891        }
892    }
893}