Skip to main content

phasesmith_workflows/
lattice.rs

1//! Symmetry-aware lattice variables and guarded CW reflection domains.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::error::Error;
5use std::fmt::{Display, Formatter};
6
7use nalgebra::{Matrix3, SymmetricEigen};
8use phasesmith_core::{TofError, TofInstrument};
9use phasesmith_crystallography::{
10    CellError, CrystalSystem, PreparedReflectionGenerator, ReflectionGenerationError,
11    ReflectionRange, SpaceGroup, UnitCell,
12};
13
14const CELL_NAMES: [&str; 6] = [
15    "a_angstrom",
16    "b_angstrom",
17    "c_angstrom",
18    "alpha_deg",
19    "beta_deg",
20    "gamma_deg",
21];
22
23#[derive(Clone, Debug, PartialEq, Eq)]
24enum LatticeKind {
25    Triclinic,
26    Monoclinic { angle: usize },
27    Orthorhombic,
28    PlaneUnique { plane: [usize; 2], unique: usize },
29    Rhombohedral,
30    Cubic,
31}
32
33/// Independent physical lattice variables for one exact crystallographic setting.
34#[derive(Clone, Debug, PartialEq)]
35pub struct LatticeParameterization {
36    space_group: SpaceGroup,
37    reference_cell: UnitCell,
38    kind: LatticeKind,
39    parameter_names: Vec<String>,
40}
41
42impl LatticeParameterization {
43    /// Derive a setting-aware independent-variable mapping.
44    ///
45    /// # Errors
46    ///
47    /// Returns [`LatticeError`] when the cell is incompatible with the group or
48    /// the exact metric basis does not describe a supported conventional setting.
49    pub fn new(space_group: SpaceGroup, cell: UnitCell) -> Result<Self, LatticeError> {
50        validate_metric_compatibility(&space_group, cell)?;
51        let basis = &space_group.metric_constraints().parameterization_basis;
52        let kind = match space_group.crystal_system() {
53            CrystalSystem::Triclinic => LatticeKind::Triclinic,
54            CrystalSystem::Monoclinic => {
55                let free = (0..3)
56                    .filter(|angle| basis.iter().any(|row| row[3 + angle] != 0))
57                    .collect::<Vec<_>>();
58                if free.len() != 1 {
59                    return Err(LatticeError::UnsupportedSetting);
60                }
61                LatticeKind::Monoclinic { angle: free[0] }
62            }
63            CrystalSystem::Orthorhombic => LatticeKind::Orthorhombic,
64            CrystalSystem::Tetragonal | CrystalSystem::Hexagonal => plane_unique_kind(basis)?,
65            CrystalSystem::Trigonal => {
66                let diagonal_equal = (1..3).all(|right| metric_columns_equal(basis, 0, right));
67                let off_diagonal_equal = (4..6).all(|right| metric_columns_equal(basis, 3, right));
68                if diagonal_equal && off_diagonal_equal && basis.iter().any(|row| row[3] != 0) {
69                    LatticeKind::Rhombohedral
70                } else {
71                    plane_unique_kind(basis)?
72                }
73            }
74            CrystalSystem::Cubic => LatticeKind::Cubic,
75        };
76        let parameter_names = names_for_kind(&kind);
77        let result = Self {
78            space_group,
79            reference_cell: cell,
80            kind,
81            parameter_names,
82        };
83        let values = result.values_from_cell(cell)?;
84        let rebuilt = result.to_cell(&values)?;
85        if !cells_close(rebuilt, cell, 2.0e-10) {
86            return Err(LatticeError::UnsupportedSetting);
87        }
88        Ok(result)
89    }
90
91    /// Borrow the exact symmetry group.
92    #[must_use]
93    pub const fn space_group(&self) -> &SpaceGroup {
94        &self.space_group
95    }
96
97    /// Borrow independent parameter names in stable order.
98    #[must_use]
99    pub fn parameter_names(&self) -> &[String] {
100        &self.parameter_names
101    }
102
103    /// Return the reference cell used for fixed components.
104    #[must_use]
105    pub const fn reference_cell(&self) -> UnitCell {
106        self.reference_cell
107    }
108
109    /// Extract independent values after checking setting compatibility.
110    ///
111    /// # Errors
112    ///
113    /// Returns [`LatticeError`] if the supplied cell is incompatible.
114    pub fn values_from_cell(&self, cell: UnitCell) -> Result<Vec<f64>, LatticeError> {
115        cell.geometry().map_err(LatticeError::Cell)?;
116        let full = cell_values(cell);
117        let values = match self.kind {
118            LatticeKind::Triclinic => full.to_vec(),
119            LatticeKind::Monoclinic { angle } => {
120                vec![full[0], full[1], full[2], full[3 + angle]]
121            }
122            LatticeKind::Orthorhombic => full[..3].to_vec(),
123            LatticeKind::PlaneUnique { plane, unique } => {
124                let first = plane[0].min(unique);
125                let second = plane[0].max(unique);
126                vec![full[first], full[second]]
127            }
128            LatticeKind::Rhombohedral => vec![full[0], full[3]],
129            LatticeKind::Cubic => vec![full[0]],
130        };
131        if !cells_close(self.to_cell(&values)?, cell, 2.0e-9) {
132            return Err(LatticeError::IncompatibleCell);
133        }
134        Ok(values)
135    }
136
137    /// Expand independent values into all six direct-cell parameters.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`LatticeError`] for shape, finite, or physical-cell failures.
142    pub fn to_cell(&self, values: &[f64]) -> Result<UnitCell, LatticeError> {
143        if values.len() != self.parameter_names.len() || values.iter().any(|v| !v.is_finite()) {
144            return Err(LatticeError::ValueShape);
145        }
146        let mut full = cell_values(self.reference_cell);
147        match self.kind {
148            LatticeKind::Triclinic => full.copy_from_slice(values),
149            LatticeKind::Monoclinic { angle } => {
150                full[..3].copy_from_slice(&values[..3]);
151                full[3 + angle] = values[3];
152            }
153            LatticeKind::Orthorhombic => full[..3].copy_from_slice(values),
154            LatticeKind::PlaneUnique { plane, unique } => {
155                let representatives = [plane[0].min(unique), plane[0].max(unique)];
156                let plane_value = values[usize::from(representatives[1] == plane[0])];
157                let unique_value = values[usize::from(representatives[1] == unique)];
158                full[plane[0]] = plane_value;
159                full[plane[1]] = plane_value;
160                full[unique] = unique_value;
161            }
162            LatticeKind::Rhombohedral => {
163                full[..3].fill(values[0]);
164                full[3..].fill(values[1]);
165            }
166            LatticeKind::Cubic => full[..3].fill(values[0]),
167        }
168        let cell = UnitCell {
169            a_angstrom: full[0],
170            b_angstrom: full[1],
171            c_angstrom: full[2],
172            alpha_deg: full[3],
173            beta_deg: full[4],
174            gamma_deg: full[5],
175        };
176        cell.geometry().map_err(LatticeError::Cell)?;
177        Ok(cell)
178    }
179
180    /// Return row-major `d(a,b,c,alpha,beta,gamma)/d(independent)`.
181    ///
182    /// # Errors
183    ///
184    /// Returns [`LatticeError`] if values do not describe a physical cell.
185    pub fn cell_jacobian(&self, values: &[f64]) -> Result<Vec<f64>, LatticeError> {
186        self.to_cell(values)?;
187        let columns = values.len();
188        let mut matrix = vec![0.0; 6 * columns];
189        let mut set = |row: usize, column: usize| matrix[row * columns + column] = 1.0;
190        match self.kind {
191            LatticeKind::Triclinic => (0..6).for_each(|index| set(index, index)),
192            LatticeKind::Monoclinic { angle } => {
193                (0..3).for_each(|index| set(index, index));
194                set(3 + angle, 3);
195            }
196            LatticeKind::Orthorhombic => (0..3).for_each(|index| set(index, index)),
197            LatticeKind::PlaneUnique { plane, unique } => {
198                let representatives = [plane[0].min(unique), plane[0].max(unique)];
199                let plane_column = usize::from(representatives[1] == plane[0]);
200                let unique_column = usize::from(representatives[1] == unique);
201                set(plane[0], plane_column);
202                set(plane[1], plane_column);
203                set(unique, unique_column);
204            }
205            LatticeKind::Rhombohedral => {
206                (0..3).for_each(|row| set(row, 0));
207                (3..6).for_each(|row| set(row, 1));
208            }
209            LatticeKind::Cubic => (0..3).for_each(|row| set(row, 0)),
210        }
211        Ok(matrix)
212    }
213}
214
215/// Finite box bounds in one lattice parameterization.
216#[derive(Clone, Debug, PartialEq)]
217pub struct LatticeBounds {
218    parameter_names: Vec<String>,
219    lower: Vec<f64>,
220    upper: Vec<f64>,
221}
222
223impl LatticeBounds {
224    /// Validate finite ordered bounds containing the reference lattice.
225    ///
226    /// # Errors
227    ///
228    /// Returns [`LatticeError`] for shape/order/physical-corner failures.
229    pub fn new(
230        parameterization: &LatticeParameterization,
231        lower: Vec<f64>,
232        upper: Vec<f64>,
233    ) -> Result<Self, LatticeError> {
234        let count = parameterization.parameter_names.len();
235        if lower.len() != count
236            || upper.len() != count
237            || lower.iter().chain(&upper).any(|value| !value.is_finite())
238            || lower.iter().zip(&upper).any(|(low, high)| low >= high)
239        {
240            return Err(LatticeError::InvalidBounds);
241        }
242        let bounds = Self {
243            parameter_names: parameterization.parameter_names.clone(),
244            lower,
245            upper,
246        };
247        bounds.validate_for(parameterization)?;
248        Ok(bounds)
249    }
250
251    /// Construct explicit relative-length and absolute-angle bounds.
252    ///
253    /// # Errors
254    ///
255    /// Returns [`LatticeError`] for invalid controls or physical corners.
256    pub fn around(
257        parameterization: &LatticeParameterization,
258        relative_length: f64,
259        angle_delta_deg: f64,
260    ) -> Result<Self, LatticeError> {
261        if !relative_length.is_finite()
262            || !(0.0..1.0).contains(&relative_length)
263            || relative_length == 0.0
264            || !angle_delta_deg.is_finite()
265            || angle_delta_deg <= 0.0
266        {
267            return Err(LatticeError::InvalidBounds);
268        }
269        let values = parameterization.values_from_cell(parameterization.reference_cell)?;
270        let mut lower = Vec::with_capacity(values.len());
271        let mut upper = Vec::with_capacity(values.len());
272        for (name, value) in parameterization.parameter_names.iter().zip(values) {
273            if name.ends_with("_angstrom") {
274                lower.push(value * (1.0 - relative_length));
275                upper.push(value * (1.0 + relative_length));
276            } else {
277                lower.push((value - angle_delta_deg).max(f64::from_bits(1)));
278                upper.push((value + angle_delta_deg).min(f64::from_bits(180_f64.to_bits() - 1)));
279            }
280        }
281        Self::new(parameterization, lower, upper)
282    }
283
284    /// Borrow lower bounds.
285    #[must_use]
286    pub fn lower(&self) -> &[f64] {
287        &self.lower
288    }
289
290    /// Borrow upper bounds.
291    #[must_use]
292    pub fn upper(&self) -> &[f64] {
293        &self.upper
294    }
295
296    /// Borrow the parameter names that define bound order and meaning.
297    #[must_use]
298    pub fn parameter_names(&self) -> &[String] {
299        &self.parameter_names
300    }
301
302    /// Return deterministic corners of the independent-parameter box.
303    #[must_use]
304    pub fn corner_values(&self) -> Vec<Vec<f64>> {
305        let count = 1_usize << self.lower.len();
306        (0..count)
307            .map(|mask| {
308                (0..self.lower.len())
309                    .map(|index| {
310                        if mask & (1 << index) == 0 {
311                            self.lower[index]
312                        } else {
313                            self.upper[index]
314                        }
315                    })
316                    .collect()
317            })
318            .collect()
319    }
320
321    fn contains(&self, values: &[f64]) -> bool {
322        values.len() == self.lower.len()
323            && values
324                .iter()
325                .zip(self.lower.iter().zip(&self.upper))
326                .all(|(value, (low, high))| low <= value && value <= high)
327    }
328
329    fn validate_for(&self, parameterization: &LatticeParameterization) -> Result<(), LatticeError> {
330        if self.parameter_names != parameterization.parameter_names
331            || !self.contains(&parameterization.values_from_cell(parameterization.reference_cell)?)
332        {
333            return Err(LatticeError::InvalidBounds);
334        }
335        for corner in self.corner_values() {
336            parameterization.to_cell(&corner)?;
337        }
338        Ok(())
339    }
340}
341
342/// CW reflection geometry and independent lattice derivatives.
343#[derive(Clone, Debug, PartialEq)]
344pub struct CwLatticeGeometry {
345    /// D-spacings in reflection order.
346    pub d_spacing_angstrom: Vec<f64>,
347    /// Two-theta positions in degrees.
348    pub two_theta_deg: Vec<f64>,
349    /// Row-major reflection-by-parameter d-spacing derivatives.
350    pub d_d_spacing_d_parameters: Vec<f64>,
351    /// Row-major reflection-by-parameter two-theta derivatives.
352    pub d_two_theta_d_parameters: Vec<f64>,
353    /// Stable independent parameter names.
354    pub parameter_names: Vec<String>,
355}
356
357/// TOF reflection geometry and independent lattice derivatives.
358#[derive(Clone, Debug, PartialEq)]
359pub struct TofLatticeGeometry {
360    /// D-spacings in reflection order.
361    pub d_spacing_angstrom: Vec<f64>,
362    /// Calibrated reflection positions in microseconds.
363    pub tof_us: Vec<f64>,
364    /// Row-major reflection-by-parameter d-spacing derivatives.
365    pub d_d_spacing_d_parameters: Vec<f64>,
366    /// Row-major reflection-by-parameter TOF-position derivatives.
367    pub d_tof_d_parameters: Vec<f64>,
368    /// Stable independent parameter names.
369    pub parameter_names: Vec<String>,
370}
371
372/// Calculate d-spacings, CW positions, and analytical independent-variable chains.
373///
374/// # Errors
375///
376/// Returns [`LatticeError`] for invalid cells/reflections or inaccessible peaks.
377pub fn cw_lattice_geometry(
378    parameterization: &LatticeParameterization,
379    cell: UnitCell,
380    hkl: &[[i32; 3]],
381    wavelength_angstrom: f64,
382) -> Result<CwLatticeGeometry, LatticeError> {
383    if !wavelength_angstrom.is_finite() || wavelength_angstrom <= 0.0 {
384        return Err(LatticeError::InvalidWavelength);
385    }
386    let values = parameterization.values_from_cell(cell)?;
387    let chain = parameterization.cell_jacobian(&values)?;
388    let columns = values.len();
389    let geometry = cell.geometry().map_err(LatticeError::Cell)?;
390    let mut spacing = Vec::with_capacity(hkl.len());
391    let mut positions = Vec::with_capacity(hkl.len());
392    let mut derivatives = vec![0.0; hkl.len() * columns];
393    let mut spacing_derivatives = vec![0.0; hkl.len() * columns];
394    for (reflection, hkl) in hkl.iter().copied().enumerate() {
395        let (d, d_cell) = geometry
396            .d_spacing_and_derivatives(hkl)
397            .map_err(LatticeError::Cell)?;
398        let argument = wavelength_angstrom / (2.0 * d);
399        if argument >= 1.0 {
400            return Err(LatticeError::InaccessibleReflection);
401        }
402        spacing.push(d);
403        positions.push(2.0 * argument.asin().to_degrees());
404        let per_d = -180.0 / std::f64::consts::PI * wavelength_angstrom
405            / (d * d * (1.0 - argument * argument).sqrt());
406        for parameter in 0..columns {
407            let d_parameter = (0..6)
408                .map(|cell_parameter| {
409                    d_cell[cell_parameter] * chain[cell_parameter * columns + parameter]
410                })
411                .sum::<f64>();
412            spacing_derivatives[reflection * columns + parameter] = d_parameter;
413            derivatives[reflection * columns + parameter] = per_d * d_parameter;
414        }
415    }
416    Ok(CwLatticeGeometry {
417        d_spacing_angstrom: spacing,
418        two_theta_deg: positions,
419        d_d_spacing_d_parameters: spacing_derivatives,
420        d_two_theta_d_parameters: derivatives,
421        parameter_names: parameterization.parameter_names.clone(),
422    })
423}
424
425/// Calculate d-spacings, TOF positions, and analytical independent-variable chains.
426///
427/// The position convention is
428/// `tof = zero + difC d + difA d^2 + difB / d`. The d-spacing chain is
429/// independent of the detector bank; only the final position chain uses the
430/// bank-local calibration coefficients.
431///
432/// # Errors
433///
434/// Returns [`LatticeError`] for invalid cells/reflections, an invalid TOF
435/// instrument, or a non-finite calibrated position.
436pub fn tof_lattice_geometry(
437    parameterization: &LatticeParameterization,
438    cell: UnitCell,
439    hkl: &[[i32; 3]],
440    instrument: TofInstrument,
441) -> Result<TofLatticeGeometry, LatticeError> {
442    instrument.validate().map_err(LatticeError::Tof)?;
443    let values = parameterization.values_from_cell(cell)?;
444    let chain = parameterization.cell_jacobian(&values)?;
445    let columns = values.len();
446    let geometry = cell.geometry().map_err(LatticeError::Cell)?;
447    let mut spacing = Vec::with_capacity(hkl.len());
448    let mut positions = Vec::with_capacity(hkl.len());
449    let mut spacing_derivatives = vec![0.0; hkl.len() * columns];
450    let mut position_derivatives = vec![0.0; hkl.len() * columns];
451    for (reflection, hkl) in hkl.iter().copied().enumerate() {
452        let (d, d_cell) = geometry
453            .d_spacing_and_derivatives(hkl)
454            .map_err(LatticeError::Cell)?;
455        let inverse_d = d.recip();
456        let position = instrument.zero_us
457            + instrument.difc_us_per_angstrom * d
458            + instrument.difa_us_per_angstrom2 * d * d
459            + instrument.difb_us_angstrom * inverse_d;
460        if !position.is_finite() {
461            return Err(LatticeError::Tof(TofError::InvalidPosition));
462        }
463        spacing.push(d);
464        positions.push(position);
465        let per_d = instrument.difc_us_per_angstrom + 2.0 * instrument.difa_us_per_angstrom2 * d
466            - instrument.difb_us_angstrom * inverse_d * inverse_d;
467        for parameter in 0..columns {
468            let d_parameter = (0..6)
469                .map(|cell_parameter| {
470                    d_cell[cell_parameter] * chain[cell_parameter * columns + parameter]
471                })
472                .sum::<f64>();
473            spacing_derivatives[reflection * columns + parameter] = d_parameter;
474            position_derivatives[reflection * columns + parameter] = per_d * d_parameter;
475        }
476    }
477    Ok(TofLatticeGeometry {
478        d_spacing_angstrom: spacing,
479        tof_us: positions,
480        d_d_spacing_d_parameters: spacing_derivatives,
481        d_tof_d_parameters: position_derivatives,
482        parameter_names: parameterization.parameter_names.clone(),
483    })
484}
485
486/// Bounded monochromatic reflection topology with stable intensity transfer.
487#[derive(Clone, Debug, PartialEq)]
488pub struct LatticeReflectionDomain {
489    parameterization: LatticeParameterization,
490    bounds: LatticeBounds,
491    wavelength_angstrom: f64,
492    visible_two_theta_deg: [f64; 2],
493    initial_intensity: f64,
494    merge_friedel: bool,
495    max_candidates: usize,
496    guard_scale: f64,
497}
498
499impl LatticeReflectionDomain {
500    /// Validate and own one bounded reflection-domain contract.
501    ///
502    /// # Errors
503    ///
504    /// Returns [`LatticeError`] for invalid scalar or bound state.
505    #[allow(clippy::too_many_arguments)]
506    pub fn new(
507        parameterization: LatticeParameterization,
508        bounds: LatticeBounds,
509        wavelength_angstrom: f64,
510        visible_two_theta_deg: [f64; 2],
511        initial_intensity: f64,
512        merge_friedel: bool,
513        max_candidates: usize,
514        guard_scale: f64,
515    ) -> Result<Self, LatticeError> {
516        if !wavelength_angstrom.is_finite()
517            || wavelength_angstrom <= 0.0
518            || !initial_intensity.is_finite()
519            || initial_intensity < 0.0
520            || !visible_two_theta_deg.iter().all(|value| value.is_finite())
521            || !(0.0 < visible_two_theta_deg[0]
522                && visible_two_theta_deg[0] < visible_two_theta_deg[1]
523                && visible_two_theta_deg[1] < 180.0)
524            || max_candidates == 0
525            || !guard_scale.is_finite()
526            || guard_scale < 1.0
527        {
528            return Err(LatticeError::InvalidDomain);
529        }
530        bounds.validate_for(&parameterization)?;
531        let domain = Self {
532            parameterization,
533            bounds,
534            wavelength_angstrom,
535            visible_two_theta_deg,
536            initial_intensity,
537            merge_friedel,
538            max_candidates,
539            guard_scale,
540        };
541        domain.guarded_d_range(domain.parameterization.reference_cell)?;
542        Ok(domain)
543    }
544
545    /// Borrow the parameterization.
546    #[must_use]
547    pub const fn parameterization(&self) -> &LatticeParameterization {
548        &self.parameterization
549    }
550
551    /// Borrow finite lattice bounds.
552    #[must_use]
553    pub const fn bounds(&self) -> &LatticeBounds {
554        &self.bounds
555    }
556
557    /// Return the monochromatic wavelength.
558    #[must_use]
559    pub const fn wavelength_angstrom(&self) -> f64 {
560        self.wavelength_angstrom
561    }
562
563    /// Return the visible two-theta interval in degrees.
564    #[must_use]
565    pub const fn visible_two_theta_deg(&self) -> [f64; 2] {
566        self.visible_two_theta_deg
567    }
568
569    /// Return the initial value assigned to newly generated families.
570    #[must_use]
571    pub const fn initial_intensity(&self) -> f64 {
572        self.initial_intensity
573    }
574
575    /// Return whether Friedel pairs are merged.
576    #[must_use]
577    pub const fn merge_friedel(&self) -> bool {
578        self.merge_friedel
579    }
580
581    /// Return the reflection-candidate resource ceiling.
582    #[must_use]
583    pub const fn max_candidates(&self) -> usize {
584        self.max_candidates
585    }
586
587    /// Return the conservative reflection-guard scale.
588    #[must_use]
589    pub const fn guard_scale(&self) -> f64 {
590        self.guard_scale
591    }
592
593    /// Clone this guarded contract for another monochromatic wavelength.
594    ///
595    /// # Errors
596    ///
597    /// Returns [`LatticeError`] when the wavelength is invalid or the updated
598    /// guard cannot cover the declared lattice box.
599    pub fn with_wavelength(&self, wavelength_angstrom: f64) -> Result<Self, LatticeError> {
600        Self::new(
601            self.parameterization.clone(),
602            self.bounds.clone(),
603            wavelength_angstrom,
604            self.visible_two_theta_deg,
605            self.initial_intensity,
606            self.merge_friedel,
607            self.max_candidates,
608            self.guard_scale,
609        )
610    }
611
612    /// Validate a compatible cell inside the finite guard box.
613    ///
614    /// # Errors
615    ///
616    /// Returns [`LatticeError`] for an incompatible or out-of-bounds cell.
617    pub fn validate_cell(&self, cell: UnitCell) -> Result<Vec<f64>, LatticeError> {
618        let values = self.parameterization.values_from_cell(cell)?;
619        if !self.bounds.contains(&values) {
620            return Err(LatticeError::OutsideBounds);
621        }
622        Ok(values)
623    }
624
625    /// Generate guarded topology and transfer intensities by stable ID.
626    ///
627    /// # Errors
628    ///
629    /// Returns [`LatticeError`] for an incompatible cell or generation failure.
630    pub fn generate(
631        &self,
632        cell: UnitCell,
633        previous: Option<&BTreeMap<String, f64>>,
634    ) -> Result<GeneratedLatticeDomain, LatticeError> {
635        self.validate_cell(cell)?;
636        if previous.is_some_and(|items| {
637            items
638                .values()
639                .any(|value| !value.is_finite() || *value < 0.0)
640        }) {
641            return Err(LatticeError::InvalidIntensity);
642        }
643        let (min_d, max_d) = self.guarded_d_range(cell)?;
644        let generator = PreparedReflectionGenerator::new(
645            self.parameterization.space_group.clone(),
646            self.merge_friedel,
647            self.max_candidates,
648        )
649        .map_err(LatticeError::Generation)?;
650        let generated = generator
651            .generate(
652                cell,
653                ReflectionRange::DSpacing {
654                    min_angstrom: min_d,
655                    max_angstrom: max_d,
656                },
657            )
658            .map_err(LatticeError::Generation)?;
659        let physical = generated
660            .into_iter()
661            .filter(|item| self.wavelength_angstrom < 2.0 * item.d_spacing_angstrom)
662            .collect::<Vec<_>>();
663        if physical.is_empty() {
664            return Err(LatticeError::NoPhysicalReflections);
665        }
666        let hkl = physical.iter().map(|item| item.hkl).collect::<Vec<_>>();
667        let geometry =
668            cw_lattice_geometry(&self.parameterization, cell, &hkl, self.wavelength_angstrom)?;
669        let reflection_ids = physical
670            .iter()
671            .map(|item| item.reflection_id.clone())
672            .collect::<Vec<_>>();
673        let previous_ids = previous
674            .map(|values| values.keys().cloned().collect::<BTreeSet<_>>())
675            .unwrap_or_default();
676        let current_ids = reflection_ids.iter().cloned().collect::<BTreeSet<_>>();
677        let preserved_reflection_count = current_ids.intersection(&previous_ids).count();
678        let intensities = reflection_ids
679            .iter()
680            .map(|id| {
681                previous
682                    .and_then(|values| values.get(id))
683                    .copied()
684                    .unwrap_or(self.initial_intensity)
685            })
686            .collect::<Vec<_>>();
687        let visible = geometry
688            .two_theta_deg
689            .iter()
690            .map(|value| {
691                self.visible_two_theta_deg[0] <= *value && *value <= self.visible_two_theta_deg[1]
692            })
693            .collect();
694        let added_reflection_ids = reflection_ids
695            .iter()
696            .filter(|id| !previous_ids.contains(*id))
697            .cloned()
698            .collect();
699        let removed_reflection_ids = previous
700            .map(|items| {
701                items
702                    .keys()
703                    .filter(|id| !current_ids.contains(*id))
704                    .cloned()
705                    .collect()
706            })
707            .unwrap_or_default();
708        Ok(GeneratedLatticeDomain {
709            reflection_ids,
710            hkl,
711            multiplicity: physical.iter().map(|item| item.multiplicity).collect(),
712            d_spacing_angstrom: geometry.d_spacing_angstrom,
713            two_theta_deg: geometry.two_theta_deg,
714            integrated_intensity: intensities,
715            visible,
716            guarded_d_min_angstrom: min_d,
717            guarded_d_max_angstrom: max_d,
718            added_reflection_ids,
719            removed_reflection_ids,
720            preserved_reflection_count,
721        })
722    }
723
724    fn guarded_d_range(&self, reference_cell: UnitCell) -> Result<(f64, f64), LatticeError> {
725        let cells = self
726            .bounds
727            .corner_values()
728            .into_iter()
729            .map(|values| self.parameterization.to_cell(&values).map(cell_values))
730            .collect::<Result<Vec<_>, _>>()?;
731        let mut physical_lower = [f64::INFINITY; 6];
732        let mut physical_upper = [f64::NEG_INFINITY; 6];
733        for cell in cells {
734            for index in 0..6 {
735                physical_lower[index] = physical_lower[index].min(cell[index]);
736                physical_upper[index] = physical_upper[index].max(cell[index]);
737            }
738        }
739        let cos_lower = [physical_upper[3], physical_upper[4], physical_upper[5]]
740            .map(|value| value.to_radians().cos());
741        let cos_upper = [physical_lower[3], physical_lower[4], physical_lower[5]]
742            .map(|value| value.to_radians().cos());
743        let mut product_lower = f64::INFINITY;
744        for mask in 0..8 {
745            let product = (0..3)
746                .map(|index| {
747                    if mask & (1 << index) == 0 {
748                        cos_lower[index]
749                    } else {
750                        cos_upper[index]
751                    }
752                })
753                .product::<f64>();
754            product_lower = product_lower.min(product);
755        }
756        let maximum_squares = (0..3)
757            .map(|index| cos_lower[index].powi(2).max(cos_upper[index].powi(2)))
758            .sum::<f64>();
759        let angular_lower = 1.0 + 2.0 * product_lower - maximum_squares;
760        if angular_lower <= 0.0 {
761            return Err(LatticeError::UnboundedGuard);
762        }
763        let length_product = physical_lower[..3].iter().product::<f64>();
764        let determinant_lower = length_product * length_product * angular_lower;
765        let trace_upper = physical_upper[..3]
766            .iter()
767            .map(|value| value * value)
768            .sum::<f64>();
769        let direct_eigenvalue_lower = 4.0 * determinant_lower / trace_upper.powi(2);
770        let reciprocal_lower = trace_upper.recip();
771        let reciprocal_upper = direct_eigenvalue_lower.recip();
772        let reciprocal = reference_cell
773            .geometry()
774            .map_err(LatticeError::Cell)?
775            .reciprocal_metric;
776        let matrix = Matrix3::from_row_slice(&[
777            reciprocal[0][0],
778            reciprocal[0][1],
779            reciprocal[0][2],
780            reciprocal[1][0],
781            reciprocal[1][1],
782            reciprocal[1][2],
783            reciprocal[2][0],
784            reciprocal[2][1],
785            reciprocal[2][2],
786        ]);
787        let eigenvalues = SymmetricEigen::new(matrix).eigenvalues;
788        let minimum_ratio = (eigenvalues.min() / reciprocal_upper).sqrt() / self.guard_scale;
789        let maximum_ratio = (eigenvalues.max() / reciprocal_lower).sqrt() * self.guard_scale;
790        let theta_min = 0.5 * self.visible_two_theta_deg[0].to_radians();
791        let theta_max = 0.5 * self.visible_two_theta_deg[1].to_radians();
792        let visible_max = self.wavelength_angstrom / (2.0 * theta_min.sin());
793        let visible_min = self.wavelength_angstrom / (2.0 * theta_max.sin());
794        Ok((visible_min / maximum_ratio, visible_max / minimum_ratio))
795    }
796}
797
798/// Generated guarded reflection arrays and topology-transfer diagnostics.
799#[derive(Clone, Debug, PartialEq)]
800pub struct GeneratedLatticeDomain {
801    /// Stable reflection IDs.
802    pub reflection_ids: Vec<String>,
803    /// Canonical Miller indices.
804    pub hkl: Vec<[i32; 3]>,
805    /// Powder-family multiplicities under the configured Friedel policy.
806    pub multiplicity: Vec<usize>,
807    /// D-spacings.
808    pub d_spacing_angstrom: Vec<f64>,
809    /// Two-theta positions.
810    pub two_theta_deg: Vec<f64>,
811    /// Stable-ID-transferred integrated intensities.
812    pub integrated_intensity: Vec<f64>,
813    /// Visible-range mask inside guarded topology.
814    pub visible: Vec<bool>,
815    /// Lower guarded d-spacing limit used for generation.
816    pub guarded_d_min_angstrom: f64,
817    /// Upper guarded d-spacing limit used for generation.
818    pub guarded_d_max_angstrom: f64,
819    /// Newly added stable IDs.
820    pub added_reflection_ids: Vec<String>,
821    /// Removed stable IDs.
822    pub removed_reflection_ids: Vec<String>,
823    /// Stable IDs preserved from the previous intensity map.
824    pub preserved_reflection_count: usize,
825}
826
827fn plane_unique_kind(basis: &[[i64; 6]]) -> Result<LatticeKind, LatticeError> {
828    let pairs = (0..3)
829        .flat_map(|left| (left + 1..3).map(move |right| [left, right]))
830        .filter(|pair| metric_columns_equal(basis, pair[0], pair[1]))
831        .collect::<Vec<_>>();
832    if pairs.len() != 1 {
833        return Err(LatticeError::UnsupportedSetting);
834    }
835    let unique = (0..3)
836        .find(|axis| !pairs[0].contains(axis))
837        .ok_or(LatticeError::UnsupportedSetting)?;
838    Ok(LatticeKind::PlaneUnique {
839        plane: pairs[0],
840        unique,
841    })
842}
843
844fn metric_columns_equal(basis: &[[i64; 6]], left: usize, right: usize) -> bool {
845    basis.iter().all(|row| row[left] == row[right])
846}
847
848fn names_for_kind(kind: &LatticeKind) -> Vec<String> {
849    match kind {
850        LatticeKind::Triclinic => CELL_NAMES.iter().map(ToString::to_string).collect(),
851        LatticeKind::Monoclinic { angle } => [
852            CELL_NAMES[0],
853            CELL_NAMES[1],
854            CELL_NAMES[2],
855            CELL_NAMES[3 + angle],
856        ]
857        .into_iter()
858        .map(ToString::to_string)
859        .collect(),
860        LatticeKind::Orthorhombic => CELL_NAMES[..3].iter().map(ToString::to_string).collect(),
861        LatticeKind::PlaneUnique { plane, unique } => {
862            let mut axes = [plane[0], *unique];
863            axes.sort_unstable();
864            axes.into_iter()
865                .map(|axis| CELL_NAMES[axis].to_owned())
866                .collect()
867        }
868        LatticeKind::Rhombohedral => vec![CELL_NAMES[0].to_owned(), CELL_NAMES[3].to_owned()],
869        LatticeKind::Cubic => vec![CELL_NAMES[0].to_owned()],
870    }
871}
872
873fn cell_values(cell: UnitCell) -> [f64; 6] {
874    [
875        cell.a_angstrom,
876        cell.b_angstrom,
877        cell.c_angstrom,
878        cell.alpha_deg,
879        cell.beta_deg,
880        cell.gamma_deg,
881    ]
882}
883
884fn cells_close(left: UnitCell, right: UnitCell, tolerance: f64) -> bool {
885    cell_values(left)
886        .iter()
887        .zip(cell_values(right))
888        .all(|(left, right)| (left - right).abs() <= tolerance)
889}
890
891#[allow(clippy::cast_precision_loss)]
892fn validate_metric_compatibility(
893    space_group: &SpaceGroup,
894    cell: UnitCell,
895) -> Result<(), LatticeError> {
896    let metric = cell.geometry().map_err(LatticeError::Cell)?.direct_metric;
897    let components = [
898        metric[0][0],
899        metric[1][1],
900        metric[2][2],
901        metric[1][2],
902        metric[0][2],
903        metric[0][1],
904    ];
905    let scale = components
906        .iter()
907        .copied()
908        .map(f64::abs)
909        .fold(1.0_f64, f64::max);
910    for equation in &space_group.metric_constraints().equations {
911        let residual = equation
912            .iter()
913            .zip(components)
914            .map(|(coefficient, value)| *coefficient as f64 * value)
915            .sum::<f64>();
916        let coefficient_scale = equation.iter().copied().map(i64::unsigned_abs).sum::<u64>() as f64;
917        if residual.abs() > 1.0e-10 * scale * coefficient_scale.max(1.0) {
918            return Err(LatticeError::IncompatibleCell);
919        }
920    }
921    Ok(())
922}
923
924/// Invalid lattice parameterization, bounds, geometry, or domain generation.
925#[derive(Debug)]
926pub enum LatticeError {
927    /// Unit-cell evaluation failed.
928    Cell(CellError),
929    /// Reflection generation failed.
930    Generation(ReflectionGenerationError),
931    /// TOF calibration validation or position evaluation failed.
932    Tof(TofError),
933    /// Exact setting is not represented by the supported conventional mapping.
934    UnsupportedSetting,
935    /// Supplied cell is incompatible with the parameterization.
936    IncompatibleCell,
937    /// Independent value vector has the wrong shape or non-finite entries.
938    ValueShape,
939    /// Bounds are invalid or do not contain the reference.
940    InvalidBounds,
941    /// A requested cell lies outside the domain's declared finite bounds.
942    OutsideBounds,
943    /// Domain scalar state is invalid.
944    InvalidDomain,
945    /// Wavelength is invalid.
946    InvalidWavelength,
947    /// A reflection is outside the monochromatic Bragg domain.
948    InaccessibleReflection,
949    /// No physical monochromatic reflection remains after guarded generation.
950    NoPhysicalReflections,
951    /// A transferred reflection intensity is negative or non-finite.
952    InvalidIntensity,
953    /// Angle bounds cannot produce a finite guarded search domain.
954    UnboundedGuard,
955}
956
957impl Display for LatticeError {
958    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
959        match self {
960            Self::Cell(error) => Display::fmt(error, formatter),
961            Self::Generation(error) => Display::fmt(error, formatter),
962            Self::Tof(error) => Display::fmt(error, formatter),
963            Self::UnsupportedSetting => formatter.write_str("unsupported lattice setting"),
964            Self::IncompatibleCell => {
965                formatter.write_str("cell is incompatible with lattice setting")
966            }
967            Self::ValueShape => formatter
968                .write_str("lattice values must match the independent variables and be finite"),
969            Self::InvalidBounds => formatter.write_str(
970                "lattice bounds must be finite, ordered, physical, and contain the reference",
971            ),
972            Self::OutsideBounds => {
973                formatter.write_str("lattice cell lies outside the reflection-domain bounds")
974            }
975            Self::InvalidDomain => formatter.write_str("lattice reflection domain is invalid"),
976            Self::InvalidWavelength => {
977                formatter.write_str("wavelength must be positive and finite")
978            }
979            Self::InaccessibleReflection => {
980                formatter.write_str("reflection lies outside the monochromatic Bragg domain")
981            }
982            Self::NoPhysicalReflections => {
983                formatter.write_str("no physical reflections lie in the guarded CW domain")
984            }
985            Self::InvalidIntensity => formatter
986                .write_str("transferred reflection intensities must be finite and non-negative"),
987            Self::UnboundedGuard => formatter
988                .write_str("lattice angle bounds are too broad for a finite guarded domain"),
989        }
990    }
991}
992
993impl Error for LatticeError {
994    fn source(&self) -> Option<&(dyn Error + 'static)> {
995        match self {
996            Self::Cell(error) => Some(error),
997            Self::Generation(error) => Some(error),
998            Self::Tof(error) => Some(error),
999            _ => None,
1000        }
1001    }
1002}