Skip to main content

phasesmith_io/
space_groups.rs

1//! Native lookup of exact conventional space-group operation sets.
2
3use std::error::Error;
4use std::fmt::{Display, Formatter};
5
6use moyo::data::{HallSymbolEntry, Setting, hall_symbol_entry, operations_from_number};
7use phasesmith_crystallography::{Rational, SpaceGroup, SymmetryError, SymmetryOperation};
8
9const HALL_ENTRY_COUNT: i32 = 530;
10const TRANSLATION_DENOMINATOR: i64 = 12;
11const TRANSLATION_DENOMINATOR_F64: f64 = 12.0;
12
13/// Reviewed source and version for native space-group lookup data.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub struct SpaceGroupDatabaseProvenance {
16    /// Database provider crate.
17    pub provider: &'static str,
18    /// Exact pinned crate version.
19    pub version: &'static str,
20    /// Upstream data lineage declared by the provider.
21    pub lineage: &'static str,
22    /// Number of conventional Hall settings.
23    pub hall_setting_count: usize,
24}
25
26/// Provenance for the pure-Rust conventional space-group database.
27pub const SPACE_GROUP_DATABASE_PROVENANCE: SpaceGroupDatabaseProvenance =
28    SpaceGroupDatabaseProvenance {
29        provider: "moyo",
30        version: "0.15.0",
31        lineage: "spglib Hall-symbol database",
32        hall_setting_count: 530,
33    };
34
35/// Human identifiers plus an exact engine-owned conventional operation set.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct SpaceGroupInfo {
38    /// International Tables number in `[1, 230]`.
39    pub number: i32,
40    /// Short Hermann--Mauguin symbol.
41    pub hm_symbol: String,
42    /// Hall symbol defining this exact setting and origin.
43    pub hall_symbol: String,
44    /// Setting qualifier, empty when the group has one reference setting.
45    pub setting: String,
46    /// Validated exact conventional operation set.
47    pub space_group: SpaceGroup,
48}
49
50/// Native space-group lookup or database-conversion failure.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub enum SpaceGroupLookupError {
53    /// International number lies outside `[1, 230]`.
54    InvalidNumber,
55    /// No unique supported Hermann--Mauguin or Hall symbol matched.
56    UnknownSymbol {
57        /// Rejected caller value.
58        symbol: String,
59    },
60    /// A pinned database record could not be resolved consistently.
61    InvalidDatabaseEntry,
62    /// Generated operations failed `PhaseSmith`'s exact group validation.
63    Symmetry(SymmetryError),
64}
65
66impl Display for SpaceGroupLookupError {
67    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
68        match self {
69            Self::InvalidNumber => {
70                formatter.write_str("space-group number must be an integer in [1, 230]")
71            }
72            Self::UnknownSymbol { symbol } => {
73                write!(
74                    formatter,
75                    "unknown or ambiguous space-group symbol {symbol:?}"
76                )
77            }
78            Self::InvalidDatabaseEntry => {
79                formatter.write_str("native space-group database entry is invalid")
80            }
81            Self::Symmetry(error) => Display::fmt(error, formatter),
82        }
83    }
84}
85
86impl Error for SpaceGroupLookupError {
87    fn source(&self) -> Option<&(dyn Error + 'static)> {
88        match self {
89            Self::Symmetry(error) => Some(error),
90            _ => None,
91        }
92    }
93}
94
95/// Resolve an International Tables number in the conventional standard setting.
96///
97/// # Errors
98///
99/// Returns [`SpaceGroupLookupError`] for an out-of-range number or invalid
100/// pinned database record.
101pub fn space_group_by_number(number: i32) -> Result<SpaceGroupInfo, SpaceGroupLookupError> {
102    if !(1..=230).contains(&number) {
103        return Err(SpaceGroupLookupError::InvalidNumber);
104    }
105    let hall_number = Setting::Standard
106        .hall_number(number)
107        .ok_or(SpaceGroupLookupError::InvalidDatabaseEntry)?;
108    info_from_hall_number(hall_number)
109}
110
111/// Resolve an exact Hall symbol, preserving its setting and origin choice.
112///
113/// Both the conventional CIF quote operator (`"`) and Moyo's internal (`=`)
114/// spelling are accepted.
115///
116/// # Errors
117///
118/// Returns [`SpaceGroupLookupError::UnknownSymbol`] if no Hall entry matches.
119pub fn space_group_by_hall_symbol(symbol: &str) -> Result<SpaceGroupInfo, SpaceGroupLookupError> {
120    let requested = symbol.trim();
121    let entry = entries()
122        .find(|entry| symbol_key(entry.hall_symbol) == symbol_key(requested))
123        .ok_or_else(|| unknown_symbol(symbol))?;
124    info_from_hall_number(entry.hall_number)
125}
126
127/// Resolve a Hermann--Mauguin, full-setting, or Hall symbol.
128///
129/// Short Hermann--Mauguin symbols select the conventional standard setting.
130/// A full symbol, explicit `:H`/`:R` qualifier, or Hall symbol preserves the
131/// requested setting.
132///
133/// # Errors
134///
135/// Returns [`SpaceGroupLookupError::UnknownSymbol`] when the value cannot be
136/// resolved uniquely, or a structured database/symmetry error.
137pub fn space_group_by_symbol(symbol: &str) -> Result<SpaceGroupInfo, SpaceGroupLookupError> {
138    let requested = symbol.trim();
139    if requested.is_empty() {
140        return Err(unknown_symbol(symbol));
141    }
142
143    if let Ok(info) = space_group_by_hall_symbol(requested) {
144        return Ok(info);
145    }
146
147    let (hm, qualifier) = hm_and_qualifier(requested);
148    let hm_key = symbol_key(hm);
149    let full_matches = entries()
150        .filter(|entry| symbol_key(entry.hm_full) == hm_key)
151        .filter(|entry| qualifier.is_none_or(|value| entry.setting.eq_ignore_ascii_case(value)))
152        .collect::<Vec<_>>();
153    if full_matches.len() == 1 {
154        return info_from_hall_number(full_matches[0].hall_number);
155    }
156
157    let short_numbers = entries()
158        .filter(|entry| symbol_key(entry.hm_short) == hm_key)
159        .filter(|entry| qualifier.is_none_or(|value| entry.setting.eq_ignore_ascii_case(value)))
160        .map(|entry| entry.number)
161        .collect::<std::collections::BTreeSet<_>>();
162    if short_numbers.len() != 1 {
163        return Err(unknown_symbol(symbol));
164    }
165    let number = *short_numbers
166        .first()
167        .ok_or_else(|| unknown_symbol(symbol))?;
168    if qualifier.is_none() {
169        return space_group_by_number(number);
170    }
171    let matched = entries()
172        .find(|entry| {
173            entry.number == number
174                && symbol_key(entry.hm_short) == hm_key
175                && qualifier.is_some_and(|value| entry.setting.eq_ignore_ascii_case(value))
176        })
177        .ok_or_else(|| unknown_symbol(symbol))?;
178    info_from_hall_number(matched.hall_number)
179}
180
181fn entries() -> impl Iterator<Item = HallSymbolEntry> {
182    (1..=HALL_ENTRY_COUNT).filter_map(hall_symbol_entry)
183}
184
185fn info_from_hall_number(hall_number: i32) -> Result<SpaceGroupInfo, SpaceGroupLookupError> {
186    let entry =
187        hall_symbol_entry(hall_number).ok_or(SpaceGroupLookupError::InvalidDatabaseEntry)?;
188    let operations =
189        operations_from_number(entry.number, Setting::HallNumber(entry.hall_number), false)
190            .map_err(|_| SpaceGroupLookupError::InvalidDatabaseEntry)?;
191    let operations = operations
192        .iter()
193        .map(|operation| {
194            let rotation = operation.rotation_as_array();
195            let translation = operation
196                .translation_as_array()
197                .map(rational_from_database_translation)
198                .into_iter()
199                .collect::<Result<Vec<_>, _>>()?;
200            let translation: [Rational; 3] = translation
201                .try_into()
202                .map_err(|_| SpaceGroupLookupError::InvalidDatabaseEntry)?;
203            SymmetryOperation::new(rotation, translation).map_err(SpaceGroupLookupError::Symmetry)
204        })
205        .collect::<Result<Vec<_>, _>>()?;
206    let space_group = SpaceGroup::new(operations).map_err(SpaceGroupLookupError::Symmetry)?;
207    Ok(SpaceGroupInfo {
208        number: entry.number,
209        hm_symbol: entry.hm_short.replace('_', ""),
210        hall_symbol: entry.hall_symbol.replace('=', "\""),
211        setting: entry.setting.to_owned(),
212        space_group,
213    })
214}
215
216fn rational_from_database_translation(value: f64) -> Result<Rational, SpaceGroupLookupError> {
217    let normalized = value.rem_euclid(1.0);
218    let scaled = normalized * TRANSLATION_DENOMINATOR_F64;
219    let rounded = scaled.round();
220    if !rounded.is_finite() || (scaled - rounded).abs() > 1.0e-8 {
221        return Err(SpaceGroupLookupError::InvalidDatabaseEntry);
222    }
223    #[allow(clippy::cast_possible_truncation)]
224    let numerator = rounded as i64;
225    Rational::new(numerator, TRANSLATION_DENOMINATOR).map_err(SpaceGroupLookupError::Symmetry)
226}
227
228fn symbol_key(value: &str) -> String {
229    value
230        .chars()
231        .filter(|character| !character.is_whitespace() && *character != '_')
232        // Moyo stores the Hall-symbol quote operator as `=` internally while
233        // conventional CIF files use `"`.
234        .map(|character| if character == '"' { '=' } else { character })
235        .flat_map(char::to_lowercase)
236        .collect()
237}
238
239fn hm_and_qualifier(value: &str) -> (&str, Option<&str>) {
240    if let Some((base, qualifier)) = value.rsplit_once(':') {
241        let qualifier = qualifier.trim();
242        if matches!(qualifier.to_ascii_lowercase().as_str(), "h" | "r") {
243            return (base.trim(), Some(qualifier));
244        }
245    }
246    let mut words = value.split_whitespace().collect::<Vec<_>>();
247    if words.len() > 1 {
248        let last = words.last().copied().unwrap_or_default();
249        if matches!(last.to_ascii_lowercase().as_str(), "h" | "r") {
250            words.pop();
251            let split = value.len() - last.len();
252            return (value[..split].trim(), Some(last));
253        }
254    }
255    (value, None)
256}
257
258fn unknown_symbol(symbol: &str) -> SpaceGroupLookupError {
259    SpaceGroupLookupError::UnknownSymbol {
260        symbol: symbol.to_owned(),
261    }
262}