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.
210    pub hkl: [i32; 3],
211    /// Powder multiplicity under the configured Friedel policy.
212    pub multiplicity: usize,
213    /// D-spacing in ångströms.
214    pub d_spacing_angstrom: f64,
215    /// Reciprocal length `1 / d` in inverse ångströms, without `2 pi`.
216    pub reciprocal_length_inverse_angstrom: f64,
217    /// Analytical d-spacing derivatives in direct-cell parameter order.
218    pub d_spacing_derivatives: [f64; CELL_PARAMETER_COUNT],
219}
220
221/// A prepared generator that caches group topology and recomputes cell metrics.
222#[derive(Clone, Debug)]
223pub struct PreparedReflectionGenerator {
224    space_group: SpaceGroup,
225    merge_friedel: bool,
226    max_candidates: usize,
227    metric_tolerance: f64,
228}
229
230impl PreparedReflectionGenerator {
231    /// Create a generator with an explicit brute-force candidate safety limit.
232    ///
233    /// A candidate is an integer triplet in the safe reciprocal-metric box;
234    /// the default metric compatibility tolerance is `1e-10` relative.
235    ///
236    /// # Errors
237    ///
238    /// Returns an error when `max_candidates` is zero.
239    pub fn new(
240        space_group: SpaceGroup,
241        merge_friedel: bool,
242        max_candidates: usize,
243    ) -> Result<Self, ReflectionGenerationError> {
244        if max_candidates == 0 {
245            return Err(ReflectionGenerationError::InvalidCandidateLimit);
246        }
247        Ok(Self {
248            space_group,
249            merge_friedel,
250            max_candidates,
251            metric_tolerance: DEFAULT_METRIC_TOLERANCE,
252        })
253    }
254
255    /// Borrow the validated symmetry group.
256    #[must_use]
257    pub const fn space_group(&self) -> &SpaceGroup {
258        &self.space_group
259    }
260
261    /// Whether Friedel mates are merged into one powder family.
262    #[must_use]
263    pub const fn merge_friedel(&self) -> bool {
264        self.merge_friedel
265    }
266
267    /// Generate unique, allowed families sorted by increasing reciprocal length.
268    ///
269    /// The index box uses a reciprocal-eigenvalue lower bound plus exact
270    /// ellipsoid projections, so skewed triclinic cells cannot omit valid
271    /// indices. Endpoints are inclusive within 64 floating-point epsilons.
272    /// Accidental equal-d families remain separate records.
273    ///
274    /// # Errors
275    ///
276    /// Returns an error for an invalid cell/range, a cell incompatible with the
277    /// point group, exact symmetry arithmetic failure, or a candidate cube over
278    /// the configured safety limit.
279    pub fn generate(
280        &self,
281        cell: UnitCell,
282        range: ReflectionRange,
283    ) -> Result<Vec<GeneratedReflection>, ReflectionGenerationError> {
284        let geometry = cell.geometry()?;
285        validate_metric_compatibility(
286            &geometry,
287            self.space_group.metric_constraints().equations.as_slice(),
288            self.metric_tolerance,
289        )?;
290        let (min_reciprocal, max_reciprocal) = range.reciprocal_bounds()?;
291        let bounds = safe_index_bounds(&geometry, max_reciprocal)?;
292        let mut sides = [0_usize; 3];
293        for (index, side) in sides.iter_mut().enumerate() {
294            *side = usize::try_from(2_i64 * i64::from(bounds[index]) + 1)
295                .map_err(|_| ReflectionGenerationError::CandidateLimitExceeded)?;
296        }
297        let candidate_count = sides[0]
298            .checked_mul(sides[1])
299            .and_then(|value| value.checked_mul(sides[2]))
300            .ok_or(ReflectionGenerationError::CandidateLimitExceeded)?;
301        if candidate_count > self.max_candidates {
302            return Err(ReflectionGenerationError::CandidateLimitExceeded);
303        }
304
305        let min_squared = min_reciprocal * min_reciprocal;
306        let max_squared = max_reciprocal * max_reciprocal;
307        let boundary_tolerance = 64.0 * f64::EPSILON * max_squared.max(1.0);
308        let mut reflections = Vec::new();
309        for h in -bounds[0]..=bounds[0] {
310            for k in -bounds[1]..=bounds[1] {
311                for l in -bounds[2]..=bounds[2] {
312                    let hkl = [h, k, l];
313                    if hkl == [0, 0, 0] {
314                        continue;
315                    }
316                    let reciprocal_squared = geometry.q_squared(hkl);
317                    if reciprocal_squared + boundary_tolerance < min_squared
318                        || reciprocal_squared - boundary_tolerance > max_squared
319                    {
320                        continue;
321                    }
322                    let family = self
323                        .space_group
324                        .reflection_family(hkl, self.merge_friedel)?;
325                    if family.canonical_hkl != hkl {
326                        continue;
327                    }
328                    if self.space_group.is_systematically_absent(hkl)? {
329                        continue;
330                    }
331                    let (d_spacing, derivatives) = geometry.d_spacing_and_derivatives(hkl)?;
332                    let reciprocal_length = reciprocal_squared.sqrt();
333                    if !range.contains(reciprocal_length, d_spacing) {
334                        continue;
335                    }
336                    reflections.push(GeneratedReflection {
337                        reflection_id: family.reflection_id,
338                        hkl,
339                        multiplicity: family.multiplicity,
340                        d_spacing_angstrom: d_spacing,
341                        reciprocal_length_inverse_angstrom: reciprocal_length,
342                        d_spacing_derivatives: derivatives,
343                    });
344                }
345            }
346        }
347        reflections.sort_by(|left, right| {
348            left.reciprocal_length_inverse_angstrom
349                .total_cmp(&right.reciprocal_length_inverse_angstrom)
350                .then_with(|| left.hkl.cmp(&right.hkl))
351        });
352        Ok(reflections)
353    }
354}
355
356/// Reflection generation validation or numerical error.
357#[derive(Clone, Copy, Debug, PartialEq, Eq)]
358pub enum ReflectionGenerationError {
359    /// Unit-cell geometry was invalid.
360    Cell(CellError),
361    /// Exact group arithmetic failed.
362    Symmetry(SymmetryError),
363    /// A physical range was non-finite, reversed, or outside its domain.
364    InvalidRange,
365    /// Candidate limit was zero.
366    InvalidCandidateLimit,
367    /// The safe index box exceeded the configured candidate limit.
368    CandidateLimitExceeded,
369    /// The cell metric violates exact rotational constraints.
370    CellSymmetryMismatch,
371    /// A positive reciprocal-metric eigenvalue could not be obtained.
372    DegenerateReciprocalMetric,
373}
374
375impl Display for ReflectionGenerationError {
376    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
377        match self {
378            Self::Cell(error) => Display::fmt(error, formatter),
379            Self::Symmetry(error) => Display::fmt(error, formatter),
380            Self::InvalidRange => formatter.write_str("reflection range is invalid"),
381            Self::InvalidCandidateLimit => {
382                formatter.write_str("reflection candidate limit must be positive")
383            }
384            Self::CandidateLimitExceeded => {
385                formatter.write_str("safe reflection candidate box exceeds the configured limit")
386            }
387            Self::CellSymmetryMismatch => {
388                formatter.write_str("unit-cell metric is incompatible with the symmetry rotations")
389            }
390            Self::DegenerateReciprocalMetric => {
391                formatter.write_str("reciprocal metric must be finite and positive definite")
392            }
393        }
394    }
395}
396
397impl Error for ReflectionGenerationError {}
398
399impl From<CellError> for ReflectionGenerationError {
400    fn from(value: CellError) -> Self {
401        Self::Cell(value)
402    }
403}
404
405impl From<SymmetryError> for ReflectionGenerationError {
406    fn from(value: SymmetryError) -> Self {
407        Self::Symmetry(value)
408    }
409}
410
411#[allow(clippy::cast_precision_loss)]
412fn validate_metric_compatibility(
413    geometry: &CellGeometry,
414    equations: &[[i64; 6]],
415    tolerance: f64,
416) -> Result<(), ReflectionGenerationError> {
417    let metric = geometry.direct_metric;
418    let components = [
419        metric[0][0],
420        metric[1][1],
421        metric[2][2],
422        metric[1][2],
423        metric[0][2],
424        metric[0][1],
425    ];
426    let scale = components
427        .iter()
428        .copied()
429        .map(f64::abs)
430        .fold(1.0_f64, f64::max);
431    for equation in equations {
432        let residual = equation
433            .iter()
434            .zip(components)
435            .map(|(coefficient, value)| *coefficient as f64 * value)
436            .sum::<f64>();
437        let coefficient_scale = equation.iter().copied().map(i64::unsigned_abs).sum::<u64>() as f64;
438        if residual.abs() > tolerance * scale * coefficient_scale.max(1.0) {
439            return Err(ReflectionGenerationError::CellSymmetryMismatch);
440        }
441    }
442    Ok(())
443}
444
445fn inclusive_contains(value: f64, minimum: f64, maximum: f64) -> bool {
446    let tolerance =
447        64.0 * f64::EPSILON * value.abs().max(minimum.abs()).max(maximum.abs()).max(1.0);
448    value + tolerance >= minimum && value - tolerance <= maximum
449}
450
451fn safe_index_bounds(
452    geometry: &CellGeometry,
453    max_reciprocal: f64,
454) -> Result<[i32; 3], ReflectionGenerationError> {
455    let direct_diagonal = [
456        geometry.direct_metric[0][0],
457        geometry.direct_metric[1][1],
458        geometry.direct_metric[2][2],
459    ];
460    let reciprocal_eigenvalue_lower_bound = direct_diagonal.iter().sum::<f64>().recip();
461    if !reciprocal_eigenvalue_lower_bound.is_finite() || reciprocal_eigenvalue_lower_bound <= 0.0 {
462        return Err(ReflectionGenerationError::DegenerateReciprocalMetric);
463    }
464    let common_bound = max_reciprocal / reciprocal_eigenvalue_lower_bound.sqrt();
465    let safety_factor = 1.0 + 64.0 * f64::EPSILON;
466    let mut bounds = [0; 3];
467    for (index, bound) in bounds.iter_mut().enumerate() {
468        // Ellipsoid projection gives |h_i| <= q_max sqrt((G*)^-1_ii).
469        // The reciprocal-eigenvalue bound above is a conservative cross-check.
470        let projected = max_reciprocal * direct_diagonal[index].sqrt();
471        let value = (projected.min(common_bound) * safety_factor).ceil() + 1.0;
472        if !value.is_finite() || value > f64::from(i32::MAX - 1) {
473            return Err(ReflectionGenerationError::CandidateLimitExceeded);
474        }
475        #[allow(clippy::cast_possible_truncation)]
476        {
477            *bound = value as i32;
478        }
479    }
480    Ok(bounds)
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use crate::symmetry::{Rational, SymmetryOperation};
487
488    fn cubic_cell() -> UnitCell {
489        UnitCell {
490            a_angstrom: 1.0,
491            b_angstrom: 1.0,
492            c_angstrom: 1.0,
493            alpha_deg: 90.0,
494            beta_deg: 90.0,
495            gamma_deg: 90.0,
496        }
497    }
498
499    fn identity_group() -> SpaceGroup {
500        SpaceGroup::new(vec![SymmetryOperation::identity()]).expect("P1")
501    }
502
503    fn body_centred_group() -> SpaceGroup {
504        let half = Rational::new(1, 2).expect("half");
505        SpaceGroup::new(vec![
506            SymmetryOperation::identity(),
507            SymmetryOperation::new(SymmetryOperation::identity().rotation(), [half, half, half])
508                .expect("centring operation"),
509        ])
510        .expect("I centring group")
511    }
512
513    #[test]
514    fn p1_cubic_generation_has_expected_families_and_multiplicity() {
515        let generator =
516            PreparedReflectionGenerator::new(identity_group(), true, 1_000_000).expect("generator");
517        let reflections = generator
518            .generate(
519                cubic_cell(),
520                ReflectionRange::DSpacing {
521                    min_angstrom: 0.7,
522                    max_angstrom: 1.0,
523                },
524            )
525            .expect("reflections");
526        assert_eq!(reflections.len(), 9);
527        assert!(
528            reflections
529                .iter()
530                .all(|reflection| reflection.multiplicity == 2)
531        );
532        assert!(reflections.windows(2).all(|pair| {
533            pair[0].reciprocal_length_inverse_angstrom <= pair[1].reciprocal_length_inverse_angstrom
534        }));
535    }
536
537    #[test]
538    fn body_centring_removes_odd_index_sum() {
539        let generator = PreparedReflectionGenerator::new(body_centred_group(), true, 1_000_000)
540            .expect("generator");
541        let reflections = generator
542            .generate(
543                cubic_cell(),
544                ReflectionRange::DSpacing {
545                    min_angstrom: 0.7,
546                    max_angstrom: 1.0,
547                },
548            )
549            .expect("reflections");
550        assert_eq!(reflections.len(), 6);
551        assert!(
552            reflections
553                .iter()
554                .all(|reflection| reflection.hkl.into_iter().sum::<i32>() % 2 == 0)
555        );
556    }
557
558    #[test]
559    fn physical_range_forms_select_the_same_cubic_shell() {
560        let generator =
561            PreparedReflectionGenerator::new(identity_group(), true, 1_000_000).expect("generator");
562        let d_range = generator
563            .generate(
564                cubic_cell(),
565                ReflectionRange::DSpacing {
566                    min_angstrom: 0.7,
567                    max_angstrom: 1.0,
568                },
569            )
570            .expect("d range");
571        let q_range = generator
572            .generate(
573                cubic_cell(),
574                ReflectionRange::ScatteringVector {
575                    min_inverse_angstrom: TWO_PI,
576                    max_inverse_angstrom: TWO_PI * 2.0_f64.sqrt(),
577                },
578            )
579            .expect("Q range");
580        let cw_range = generator
581            .generate(
582                cubic_cell(),
583                ReflectionRange::CwTwoTheta {
584                    min_deg: 2.0 * 0.5_f64.asin().to_degrees(),
585                    max_deg: 2.0 * (0.5 * 2.0_f64.sqrt()).asin().to_degrees(),
586                    wavelength_angstrom: 1.0,
587                },
588            )
589            .expect("CW range");
590        let expected = d_range.iter().map(|item| item.hkl).collect::<Vec<_>>();
591        assert_eq!(
592            q_range.iter().map(|item| item.hkl).collect::<Vec<_>>(),
593            expected
594        );
595        assert_eq!(
596            cw_range.iter().map(|item| item.hkl).collect::<Vec<_>>(),
597            expected
598        );
599    }
600
601    #[test]
602    fn tof_filter_and_candidate_limit_are_explicit() {
603        let generator =
604            PreparedReflectionGenerator::new(identity_group(), true, 1_000_000).expect("generator");
605        let reflections = generator
606            .generate(
607                cubic_cell(),
608                ReflectionRange::Tof {
609                    min_us: 900.0,
610                    max_us: 1100.0,
611                    search_min_d_angstrom: 0.5,
612                    search_max_d_angstrom: 1.5,
613                    zero_us: 0.0,
614                    difc_us_per_angstrom: 1000.0,
615                    difa_us_per_angstrom2: 0.0,
616                    difb_us_angstrom: 0.0,
617                },
618            )
619            .expect("TOF range");
620        assert!(
621            reflections
622                .iter()
623                .all(|reflection| (reflection.d_spacing_angstrom - 1.0).abs() < 1e-14)
624        );
625
626        let limited = PreparedReflectionGenerator::new(identity_group(), true, 10)
627            .expect("limited generator");
628        assert_eq!(
629            limited.generate(
630                cubic_cell(),
631                ReflectionRange::DSpacing {
632                    min_angstrom: 0.1,
633                    max_angstrom: 1.0,
634                }
635            ),
636            Err(ReflectionGenerationError::CandidateLimitExceeded)
637        );
638    }
639
640    #[test]
641    fn incompatible_cell_and_point_group_is_rejected() {
642        let quarter_turn =
643            SymmetryOperation::new([[0, -1, 0], [1, 0, 0], [0, 0, 1]], [Rational::zero(); 3])
644                .expect("quarter turn");
645        let half_turn = quarter_turn.compose(quarter_turn).expect("half turn");
646        let three_quarters = quarter_turn.compose(half_turn).expect("three-quarter turn");
647        let tetragonal = SpaceGroup::new(vec![
648            SymmetryOperation::identity(),
649            quarter_turn,
650            half_turn,
651            three_quarters,
652        ])
653        .expect("four-fold group");
654        let generator =
655            PreparedReflectionGenerator::new(tetragonal, true, 1_000_000).expect("generator");
656        let incompatible = UnitCell {
657            b_angstrom: 1.1,
658            ..cubic_cell()
659        };
660        assert_eq!(
661            generator.generate(
662                incompatible,
663                ReflectionRange::DSpacing {
664                    min_angstrom: 0.5,
665                    max_angstrom: 2.0,
666                }
667            ),
668            Err(ReflectionGenerationError::CellSymmetryMismatch)
669        );
670    }
671}