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