Skip to main content

phasesmith_crystallography/
reflection.rs

1//! Bounded, deterministic reciprocal-family generation.
2
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5
6use crate::cell::{CELL_PARAMETER_COUNT, CellError, CellGeometry, UnitCell};
7use crate::symmetry::{SpaceGroup, SymmetryError};
8
9const TWO_PI: f64 = 2.0 * std::f64::consts::PI;
10const DEFAULT_METRIC_TOLERANCE: f64 = 1.0e-10;
11
12/// Physical range used to select reciprocal families.
13#[derive(Clone, Copy, Debug, PartialEq)]
14pub enum ReflectionRange {
15    /// Inclusive d-spacing interval in ångströms.
16    DSpacing {
17        /// Smallest included d-spacing.
18        min_angstrom: f64,
19        /// Largest included d-spacing.
20        max_angstrom: f64,
21    },
22    /// Inclusive scattering-vector interval `Q = 2 pi / d`.
23    ScatteringVector {
24        /// Smallest included Q in inverse ångströms.
25        min_inverse_angstrom: f64,
26        /// Largest included Q in inverse ångströms.
27        max_inverse_angstrom: f64,
28    },
29    /// Inclusive monochromatic constant-wavelength `2 theta` interval.
30    CwTwoTheta {
31        /// Smallest included `2 theta` in degrees.
32        min_deg: f64,
33        /// Largest included `2 theta` in degrees.
34        max_deg: f64,
35        /// Monochromatic wavelength in ångströms.
36        wavelength_angstrom: f64,
37    },
38    /// Inclusive TOF interval with an explicit safe d-spacing search interval.
39    Tof {
40        /// Smallest included time-of-flight coordinate in microseconds.
41        min_us: f64,
42        /// Largest included time-of-flight coordinate in microseconds.
43        max_us: f64,
44        /// Smallest d-spacing searched, in ångströms.
45        search_min_d_angstrom: f64,
46        /// Largest d-spacing searched, in ångströms.
47        search_max_d_angstrom: f64,
48        /// TOF zero offset in microseconds.
49        zero_us: f64,
50        /// Linear calibration coefficient in microseconds per ångström.
51        difc_us_per_angstrom: f64,
52        /// Quadratic coefficient in microseconds per square ångström.
53        difa_us_per_angstrom2: f64,
54        /// Inverse-d coefficient in microsecond ångströms.
55        difb_us_angstrom: f64,
56    },
57}
58
59impl ReflectionRange {
60    fn reciprocal_bounds(self) -> Result<(f64, f64), ReflectionGenerationError> {
61        self.validate()?;
62        Ok(match self {
63            Self::DSpacing {
64                min_angstrom,
65                max_angstrom,
66            } => (max_angstrom.recip(), min_angstrom.recip()),
67            Self::ScatteringVector {
68                min_inverse_angstrom,
69                max_inverse_angstrom,
70            } => (min_inverse_angstrom / TWO_PI, max_inverse_angstrom / TWO_PI),
71            Self::CwTwoTheta {
72                min_deg,
73                max_deg,
74                wavelength_angstrom,
75            } => {
76                let min_theta = 0.5 * min_deg.to_radians();
77                let max_theta = 0.5 * max_deg.to_radians();
78                (
79                    2.0 * min_theta.sin() / wavelength_angstrom,
80                    2.0 * max_theta.sin() / wavelength_angstrom,
81                )
82            }
83            Self::Tof {
84                search_min_d_angstrom,
85                search_max_d_angstrom,
86                ..
87            } => (search_max_d_angstrom.recip(), search_min_d_angstrom.recip()),
88        })
89    }
90
91    fn validate(self) -> Result<(), ReflectionGenerationError> {
92        let finite = match self {
93            Self::DSpacing {
94                min_angstrom,
95                max_angstrom,
96            } => {
97                min_angstrom.is_finite()
98                    && max_angstrom.is_finite()
99                    && min_angstrom > 0.0
100                    && max_angstrom >= min_angstrom
101            }
102            Self::ScatteringVector {
103                min_inverse_angstrom,
104                max_inverse_angstrom,
105            } => {
106                min_inverse_angstrom.is_finite()
107                    && max_inverse_angstrom.is_finite()
108                    && min_inverse_angstrom >= 0.0
109                    && max_inverse_angstrom > 0.0
110                    && max_inverse_angstrom >= min_inverse_angstrom
111            }
112            Self::CwTwoTheta {
113                min_deg,
114                max_deg,
115                wavelength_angstrom,
116            } => {
117                min_deg.is_finite()
118                    && max_deg.is_finite()
119                    && wavelength_angstrom.is_finite()
120                    && min_deg >= 0.0
121                    && max_deg < 180.0
122                    && max_deg >= min_deg
123                    && wavelength_angstrom > 0.0
124            }
125            Self::Tof {
126                min_us,
127                max_us,
128                search_min_d_angstrom,
129                search_max_d_angstrom,
130                zero_us,
131                difc_us_per_angstrom,
132                difa_us_per_angstrom2,
133                difb_us_angstrom,
134            } => {
135                [
136                    min_us,
137                    max_us,
138                    search_min_d_angstrom,
139                    search_max_d_angstrom,
140                    zero_us,
141                    difc_us_per_angstrom,
142                    difa_us_per_angstrom2,
143                    difb_us_angstrom,
144                ]
145                .into_iter()
146                .all(f64::is_finite)
147                    && max_us >= min_us
148                    && search_min_d_angstrom > 0.0
149                    && search_max_d_angstrom >= search_min_d_angstrom
150            }
151        };
152        if finite {
153            Ok(())
154        } else {
155            Err(ReflectionGenerationError::InvalidRange)
156        }
157    }
158
159    fn contains(self, reciprocal_length: f64, d_spacing: f64) -> bool {
160        match self {
161            Self::DSpacing {
162                min_angstrom,
163                max_angstrom,
164            } => inclusive_contains(d_spacing, min_angstrom, max_angstrom),
165            Self::ScatteringVector {
166                min_inverse_angstrom,
167                max_inverse_angstrom,
168            } => inclusive_contains(
169                TWO_PI * reciprocal_length,
170                min_inverse_angstrom,
171                max_inverse_angstrom,
172            ),
173            Self::CwTwoTheta {
174                min_deg,
175                max_deg,
176                wavelength_angstrom,
177            } => {
178                let argument = 0.5 * wavelength_angstrom * reciprocal_length;
179                if argument > 1.0 {
180                    return false;
181                }
182                let two_theta = 2.0 * argument.asin().to_degrees();
183                inclusive_contains(two_theta, min_deg, max_deg)
184            }
185            Self::Tof {
186                min_us,
187                max_us,
188                zero_us,
189                difc_us_per_angstrom,
190                difa_us_per_angstrom2,
191                difb_us_angstrom,
192                ..
193            } => {
194                let tof = zero_us
195                    + difc_us_per_angstrom * d_spacing
196                    + difa_us_per_angstrom2 * d_spacing * d_spacing
197                    + difb_us_angstrom / d_spacing;
198                inclusive_contains(tof, min_us, max_us)
199            }
200        }
201    }
202}
203
204/// One generated reciprocal family with metric-dependent values.
205#[derive(Clone, Debug, PartialEq)]
206pub struct GeneratedReflection {
207    /// Stable canonical Miller-index ID.
208    pub reflection_id: String,
209    /// Canonical Miller representative used for calculation and stable identity.
210    pub hkl: [i32; 3],
211    /// Human-facing representative selected from the same exact reciprocal orbit.
212    pub conventional_hkl: [i32; 3],
213    /// Powder multiplicity under the configured Friedel policy.
214    pub multiplicity: usize,
215    /// D-spacing in ångströms.
216    pub d_spacing_angstrom: f64,
217    /// Reciprocal length `1 / d` in inverse ångströms, without `2 pi`.
218    pub reciprocal_length_inverse_angstrom: f64,
219    /// Analytical d-spacing derivatives in direct-cell parameter order.
220    pub d_spacing_derivatives: [f64; CELL_PARAMETER_COUNT],
221}
222
223/// A prepared generator that caches group topology and recomputes cell metrics.
224#[derive(Clone, Debug)]
225pub struct PreparedReflectionGenerator {
226    space_group: SpaceGroup,
227    merge_friedel: bool,
228    max_candidates: usize,
229    metric_tolerance: f64,
230}
231
232impl PreparedReflectionGenerator {
233    /// Create a generator with an explicit brute-force candidate safety limit.
234    ///
235    /// A candidate is an integer triplet in the safe reciprocal-metric box;
236    /// the default metric compatibility tolerance is `1e-10` relative.
237    ///
238    /// # Errors
239    ///
240    /// Returns an error when `max_candidates` is zero.
241    pub fn new(
242        space_group: SpaceGroup,
243        merge_friedel: bool,
244        max_candidates: usize,
245    ) -> Result<Self, ReflectionGenerationError> {
246        if max_candidates == 0 {
247            return Err(ReflectionGenerationError::InvalidCandidateLimit);
248        }
249        Ok(Self {
250            space_group,
251            merge_friedel,
252            max_candidates,
253            metric_tolerance: DEFAULT_METRIC_TOLERANCE,
254        })
255    }
256
257    /// Borrow the validated symmetry group.
258    #[must_use]
259    pub const fn space_group(&self) -> &SpaceGroup {
260        &self.space_group
261    }
262
263    /// Whether Friedel mates are merged into one powder family.
264    #[must_use]
265    pub const fn merge_friedel(&self) -> bool {
266        self.merge_friedel
267    }
268
269    /// Generate unique, allowed families sorted by increasing reciprocal length.
270    ///
271    /// The index box uses a reciprocal-eigenvalue lower bound plus exact
272    /// ellipsoid projections, so skewed triclinic cells cannot omit valid
273    /// indices. Endpoints are inclusive within 64 floating-point epsilons.
274    /// Accidental equal-d families remain separate records.
275    ///
276    /// # Errors
277    ///
278    /// Returns an error for an invalid cell/range, a cell incompatible with the
279    /// point group, exact symmetry arithmetic failure, or a candidate cube over
280    /// the configured safety limit.
281    pub fn generate(
282        &self,
283        cell: UnitCell,
284        range: ReflectionRange,
285    ) -> Result<Vec<GeneratedReflection>, ReflectionGenerationError> {
286        let geometry = cell.geometry()?;
287        validate_metric_compatibility(
288            &geometry,
289            self.space_group.metric_constraints().equations.as_slice(),
290            self.metric_tolerance,
291        )?;
292        let (min_reciprocal, max_reciprocal) = range.reciprocal_bounds()?;
293        let bounds = safe_index_bounds(&geometry, max_reciprocal)?;
294        let mut sides = [0_usize; 3];
295        for (index, side) in sides.iter_mut().enumerate() {
296            *side = usize::try_from(2_i64 * i64::from(bounds[index]) + 1)
297                .map_err(|_| ReflectionGenerationError::CandidateLimitExceeded)?;
298        }
299        let candidate_count = sides[0]
300            .checked_mul(sides[1])
301            .and_then(|value| value.checked_mul(sides[2]))
302            .ok_or(ReflectionGenerationError::CandidateLimitExceeded)?;
303        if candidate_count > self.max_candidates {
304            return Err(ReflectionGenerationError::CandidateLimitExceeded);
305        }
306
307        let min_squared = min_reciprocal * min_reciprocal;
308        let max_squared = max_reciprocal * max_reciprocal;
309        let boundary_tolerance = 64.0 * f64::EPSILON * max_squared.max(1.0);
310        let mut reflections = Vec::new();
311        for h in -bounds[0]..=bounds[0] {
312            for k in -bounds[1]..=bounds[1] {
313                for l in -bounds[2]..=bounds[2] {
314                    let hkl = [h, k, l];
315                    if hkl == [0, 0, 0] {
316                        continue;
317                    }
318                    let reciprocal_squared = geometry.q_squared(hkl);
319                    if reciprocal_squared + boundary_tolerance < min_squared
320                        || reciprocal_squared - boundary_tolerance > max_squared
321                    {
322                        continue;
323                    }
324                    let family = self
325                        .space_group
326                        .reflection_family(hkl, self.merge_friedel)?;
327                    if family.canonical_hkl != hkl {
328                        continue;
329                    }
330                    if self.space_group.is_systematically_absent(hkl)? {
331                        continue;
332                    }
333                    let (d_spacing, derivatives) = geometry.d_spacing_and_derivatives(hkl)?;
334                    let reciprocal_length = reciprocal_squared.sqrt();
335                    if !range.contains(reciprocal_length, d_spacing) {
336                        continue;
337                    }
338                    reflections.push(GeneratedReflection {
339                        reflection_id: family.reflection_id,
340                        hkl,
341                        conventional_hkl: family.conventional_hkl,
342                        multiplicity: family.multiplicity,
343                        d_spacing_angstrom: d_spacing,
344                        reciprocal_length_inverse_angstrom: reciprocal_length,
345                        d_spacing_derivatives: derivatives,
346                    });
347                }
348            }
349        }
350        reflections.sort_by(|left, right| {
351            left.reciprocal_length_inverse_angstrom
352                .total_cmp(&right.reciprocal_length_inverse_angstrom)
353                .then_with(|| left.hkl.cmp(&right.hkl))
354        });
355        Ok(reflections)
356    }
357}
358
359/// Reflection generation validation or numerical error.
360#[derive(Clone, Copy, Debug, PartialEq, Eq)]
361pub enum ReflectionGenerationError {
362    /// Unit-cell geometry was invalid.
363    Cell(CellError),
364    /// Exact group arithmetic failed.
365    Symmetry(SymmetryError),
366    /// A physical range was non-finite, reversed, or outside its domain.
367    InvalidRange,
368    /// Candidate limit was zero.
369    InvalidCandidateLimit,
370    /// The safe index box exceeded the configured candidate limit.
371    CandidateLimitExceeded,
372    /// The cell metric violates exact rotational constraints.
373    CellSymmetryMismatch,
374    /// A positive reciprocal-metric eigenvalue could not be obtained.
375    DegenerateReciprocalMetric,
376}
377
378impl Display for ReflectionGenerationError {
379    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
380        match self {
381            Self::Cell(error) => Display::fmt(error, formatter),
382            Self::Symmetry(error) => Display::fmt(error, formatter),
383            Self::InvalidRange => formatter.write_str("reflection range is invalid"),
384            Self::InvalidCandidateLimit => {
385                formatter.write_str("reflection candidate limit must be positive")
386            }
387            Self::CandidateLimitExceeded => {
388                formatter.write_str("safe reflection candidate box exceeds the configured limit")
389            }
390            Self::CellSymmetryMismatch => {
391                formatter.write_str("unit-cell metric is incompatible with the symmetry rotations")
392            }
393            Self::DegenerateReciprocalMetric => {
394                formatter.write_str("reciprocal metric must be finite and positive definite")
395            }
396        }
397    }
398}
399
400impl Error for ReflectionGenerationError {}
401
402impl From<CellError> for ReflectionGenerationError {
403    fn from(value: CellError) -> Self {
404        Self::Cell(value)
405    }
406}
407
408impl From<SymmetryError> for ReflectionGenerationError {
409    fn from(value: SymmetryError) -> Self {
410        Self::Symmetry(value)
411    }
412}
413
414#[allow(clippy::cast_precision_loss)]
415fn validate_metric_compatibility(
416    geometry: &CellGeometry,
417    equations: &[[i64; 6]],
418    tolerance: f64,
419) -> Result<(), ReflectionGenerationError> {
420    let metric = geometry.direct_metric;
421    let components = [
422        metric[0][0],
423        metric[1][1],
424        metric[2][2],
425        metric[1][2],
426        metric[0][2],
427        metric[0][1],
428    ];
429    let scale = components
430        .iter()
431        .copied()
432        .map(f64::abs)
433        .fold(1.0_f64, f64::max);
434    for equation in equations {
435        let residual = equation
436            .iter()
437            .zip(components)
438            .map(|(coefficient, value)| *coefficient as f64 * value)
439            .sum::<f64>();
440        let coefficient_scale = equation.iter().copied().map(i64::unsigned_abs).sum::<u64>() as f64;
441        if residual.abs() > tolerance * scale * coefficient_scale.max(1.0) {
442            return Err(ReflectionGenerationError::CellSymmetryMismatch);
443        }
444    }
445    Ok(())
446}
447
448fn inclusive_contains(value: f64, minimum: f64, maximum: f64) -> bool {
449    let tolerance =
450        64.0 * f64::EPSILON * value.abs().max(minimum.abs()).max(maximum.abs()).max(1.0);
451    value + tolerance >= minimum && value - tolerance <= maximum
452}
453
454fn safe_index_bounds(
455    geometry: &CellGeometry,
456    max_reciprocal: f64,
457) -> Result<[i32; 3], ReflectionGenerationError> {
458    let direct_diagonal = [
459        geometry.direct_metric[0][0],
460        geometry.direct_metric[1][1],
461        geometry.direct_metric[2][2],
462    ];
463    let reciprocal_eigenvalue_lower_bound = direct_diagonal.iter().sum::<f64>().recip();
464    if !reciprocal_eigenvalue_lower_bound.is_finite() || reciprocal_eigenvalue_lower_bound <= 0.0 {
465        return Err(ReflectionGenerationError::DegenerateReciprocalMetric);
466    }
467    let common_bound = max_reciprocal / reciprocal_eigenvalue_lower_bound.sqrt();
468    let safety_factor = 1.0 + 64.0 * f64::EPSILON;
469    let mut bounds = [0; 3];
470    for (index, bound) in bounds.iter_mut().enumerate() {
471        // Ellipsoid projection gives |h_i| <= q_max sqrt((G*)^-1_ii).
472        // The reciprocal-eigenvalue bound above is a conservative cross-check.
473        let projected = max_reciprocal * direct_diagonal[index].sqrt();
474        let value = (projected.min(common_bound) * safety_factor).ceil() + 1.0;
475        if !value.is_finite() || value > f64::from(i32::MAX - 1) {
476            return Err(ReflectionGenerationError::CandidateLimitExceeded);
477        }
478        #[allow(clippy::cast_possible_truncation)]
479        {
480            *bound = value as i32;
481        }
482    }
483    Ok(bounds)
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use crate::symmetry::{Rational, SymmetryOperation};
490
491    fn cubic_cell() -> UnitCell {
492        UnitCell {
493            a_angstrom: 1.0,
494            b_angstrom: 1.0,
495            c_angstrom: 1.0,
496            alpha_deg: 90.0,
497            beta_deg: 90.0,
498            gamma_deg: 90.0,
499        }
500    }
501
502    fn identity_group() -> SpaceGroup {
503        SpaceGroup::new(vec![SymmetryOperation::identity()]).expect("P1")
504    }
505
506    fn body_centred_group() -> SpaceGroup {
507        let half = Rational::new(1, 2).expect("half");
508        SpaceGroup::new(vec![
509            SymmetryOperation::identity(),
510            SymmetryOperation::new(SymmetryOperation::identity().rotation(), [half, half, half])
511                .expect("centring operation"),
512        ])
513        .expect("I centring group")
514    }
515
516    #[test]
517    fn p1_cubic_generation_has_expected_families_and_multiplicity() {
518        let generator =
519            PreparedReflectionGenerator::new(identity_group(), true, 1_000_000).expect("generator");
520        let reflections = generator
521            .generate(
522                cubic_cell(),
523                ReflectionRange::DSpacing {
524                    min_angstrom: 0.7,
525                    max_angstrom: 1.0,
526                },
527            )
528            .expect("reflections");
529        assert_eq!(reflections.len(), 9);
530        assert!(
531            reflections
532                .iter()
533                .all(|reflection| reflection.multiplicity == 2)
534        );
535        assert!(reflections.windows(2).all(|pair| {
536            pair[0].reciprocal_length_inverse_angstrom <= pair[1].reciprocal_length_inverse_angstrom
537        }));
538    }
539
540    #[test]
541    fn body_centring_removes_odd_index_sum() {
542        let generator = PreparedReflectionGenerator::new(body_centred_group(), true, 1_000_000)
543            .expect("generator");
544        let reflections = generator
545            .generate(
546                cubic_cell(),
547                ReflectionRange::DSpacing {
548                    min_angstrom: 0.7,
549                    max_angstrom: 1.0,
550                },
551            )
552            .expect("reflections");
553        assert_eq!(reflections.len(), 6);
554        assert!(
555            reflections
556                .iter()
557                .all(|reflection| reflection.hkl.into_iter().sum::<i32>() % 2 == 0)
558        );
559    }
560
561    #[test]
562    fn physical_range_forms_select_the_same_cubic_shell() {
563        let generator =
564            PreparedReflectionGenerator::new(identity_group(), true, 1_000_000).expect("generator");
565        let d_range = generator
566            .generate(
567                cubic_cell(),
568                ReflectionRange::DSpacing {
569                    min_angstrom: 0.7,
570                    max_angstrom: 1.0,
571                },
572            )
573            .expect("d range");
574        let q_range = generator
575            .generate(
576                cubic_cell(),
577                ReflectionRange::ScatteringVector {
578                    min_inverse_angstrom: TWO_PI,
579                    max_inverse_angstrom: TWO_PI * 2.0_f64.sqrt(),
580                },
581            )
582            .expect("Q range");
583        let cw_range = generator
584            .generate(
585                cubic_cell(),
586                ReflectionRange::CwTwoTheta {
587                    min_deg: 2.0 * 0.5_f64.asin().to_degrees(),
588                    max_deg: 2.0 * (0.5 * 2.0_f64.sqrt()).asin().to_degrees(),
589                    wavelength_angstrom: 1.0,
590                },
591            )
592            .expect("CW range");
593        let expected = d_range.iter().map(|item| item.hkl).collect::<Vec<_>>();
594        assert_eq!(
595            q_range.iter().map(|item| item.hkl).collect::<Vec<_>>(),
596            expected
597        );
598        assert_eq!(
599            cw_range.iter().map(|item| item.hkl).collect::<Vec<_>>(),
600            expected
601        );
602    }
603
604    #[test]
605    fn tof_filter_and_candidate_limit_are_explicit() {
606        let generator =
607            PreparedReflectionGenerator::new(identity_group(), true, 1_000_000).expect("generator");
608        let reflections = generator
609            .generate(
610                cubic_cell(),
611                ReflectionRange::Tof {
612                    min_us: 900.0,
613                    max_us: 1100.0,
614                    search_min_d_angstrom: 0.5,
615                    search_max_d_angstrom: 1.5,
616                    zero_us: 0.0,
617                    difc_us_per_angstrom: 1000.0,
618                    difa_us_per_angstrom2: 0.0,
619                    difb_us_angstrom: 0.0,
620                },
621            )
622            .expect("TOF range");
623        assert!(
624            reflections
625                .iter()
626                .all(|reflection| (reflection.d_spacing_angstrom - 1.0).abs() < 1e-14)
627        );
628
629        let limited = PreparedReflectionGenerator::new(identity_group(), true, 10)
630            .expect("limited generator");
631        assert_eq!(
632            limited.generate(
633                cubic_cell(),
634                ReflectionRange::DSpacing {
635                    min_angstrom: 0.1,
636                    max_angstrom: 1.0,
637                }
638            ),
639            Err(ReflectionGenerationError::CandidateLimitExceeded)
640        );
641    }
642
643    #[test]
644    fn incompatible_cell_and_point_group_is_rejected() {
645        let quarter_turn =
646            SymmetryOperation::new([[0, -1, 0], [1, 0, 0], [0, 0, 1]], [Rational::zero(); 3])
647                .expect("quarter turn");
648        let half_turn = quarter_turn.compose(quarter_turn).expect("half turn");
649        let three_quarters = quarter_turn.compose(half_turn).expect("three-quarter turn");
650        let tetragonal = SpaceGroup::new(vec![
651            SymmetryOperation::identity(),
652            quarter_turn,
653            half_turn,
654            three_quarters,
655        ])
656        .expect("four-fold group");
657        let generator =
658            PreparedReflectionGenerator::new(tetragonal, true, 1_000_000).expect("generator");
659        let incompatible = UnitCell {
660            b_angstrom: 1.1,
661            ..cubic_cell()
662        };
663        assert_eq!(
664            generator.generate(
665                incompatible,
666                ReflectionRange::DSpacing {
667                    min_angstrom: 0.5,
668                    max_angstrom: 2.0,
669                }
670            ),
671            Err(ReflectionGenerationError::CellSymmetryMismatch)
672        );
673    }
674}