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