1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
//! Structure model: species, sites, space-group info and the `Structure`
//! container (the supplied expanded unit cell plus its asymmetric-unit
//! provenance). A supplied cell need not be conventional: this module does not
//! standardize the lattice or transform its coordinate setting.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::element::Element;
use super::lattice::Lattice;
use super::symmetry::SymOp;
/// One chemical species occupying (part of) a site.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Species {
/// Element symbol or source label (`Fe`); [`Species::new`] does not validate it.
pub symbol: String,
/// Site occupancy, conventionally 0…1; direct construction does not enforce bounds.
pub occupancy: f64,
/// Oxidation state when the source gave one (`Fe2+` → 2).
pub oxidation: Option<f64>,
}
impl Species {
/// Store a symbol and occupancy without validation; oxidation is initially unknown.
pub fn new(symbol: &str, occupancy: f64) -> Self {
Self {
symbol: symbol.to_string(),
occupancy,
oxidation: None,
}
}
/// The element behind this species' symbol, or `None` when the symbol
/// (or label such as `Ru1`, `Fe2+`) does not name a known element.
pub fn element(&self) -> Option<&'static Element> {
Element::from_symbol(&self.symbol).or_else(|| Element::from_label(&self.symbol))
}
}
/// A crystallographic site of the (expanded) cell.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Site {
/// Label from the source (`Ru1`, `O2`), or a generated one.
pub label: String,
/// Listed site species; physically their occupancies should sum to at most one.
/// Direct construction does not enforce that constraint.
pub species: Vec<Species>,
/// Dimensionless fractional coordinates. Import/expansion can wrap them into
/// [0, 1); [`Site::new`] stores the supplied values unchanged.
pub frac: [f64; 3],
/// Site multiplicity when known (number of equivalent positions).
pub multiplicity: Option<u32>,
/// Wyckoff-position label when supplied by the source.
pub wyckoff: Option<String>,
/// Index of the asymmetric-unit site this one was generated from.
pub asym_index: Option<usize>,
}
impl Site {
/// Create a fully occupied single-species site without wrapping or validation.
/// Coordinates are fractional, not Cartesian Å values.
pub fn new(label: &str, symbol: &str, frac: [f64; 3]) -> Self {
Self {
label: label.to_string(),
species: vec![Species::new(symbol, 1.0)],
frac,
multiplicity: None,
wyckoff: None,
asym_index: None,
}
}
/// Majority species (highest occupancy).
pub fn majority(&self) -> Option<&Species> {
self.species
.iter()
.max_by(|a, b| a.occupancy.total_cmp(&b.occupancy))
}
/// Majority element.
pub fn element(&self) -> Option<&'static Element> {
self.majority().and_then(Species::element)
}
/// `Fe` for a pure site, `(Fe0.7Ni0.3)` for a mixed one.
pub fn species_string(&self) -> String {
if self.species.len() == 1 && (self.species[0].occupancy - 1.0).abs() < 1e-6 {
return self.species[0].symbol.clone();
}
let parts: Vec<String> = self
.species
.iter()
.map(|s| format!("{}{}", s.symbol, trim_float(s.occupancy)))
.collect();
format!("({})", parts.join(""))
}
/// Whether any listed species matches the symbol, ignoring ASCII letter case.
pub fn contains_element(&self, symbol: &str) -> bool {
self.species
.iter()
.any(|s| s.symbol.eq_ignore_ascii_case(symbol))
}
/// Sum of listed occupancies, without clamping or vacancy correction.
pub fn total_occupancy(&self) -> f64 {
self.species.iter().map(|s| s.occupancy).sum()
}
}
/// Space-group metadata carried with a structure.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct SpaceGroupInfo {
/// International Tables number (1…230).
pub number: Option<u16>,
/// Hermann–Mauguin symbol as given by the source.
pub hm_symbol: Option<String>,
/// Hall symbol when present in the source.
pub hall: Option<String>,
/// The operations used to expand the asymmetric unit.
pub operations: Vec<SymOp>,
}
/// A crystal structure: lattice + fully expanded sites, with provenance.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Structure {
/// Display name (mineral name, formula, or file stem).
pub title: String,
/// Where it came from (`cif:/path`, `amcsd:1234`, `mp:mp-33`).
pub source: String,
/// Unit-cell geometry, with lengths in Å and angles in degrees.
pub lattice: Lattice,
/// All explicitly stored sites of the supplied unit cell.
pub sites: Vec<Site>,
/// The asymmetric unit as read from the source (may equal `sites`).
pub asymmetric_sites: Vec<Site>,
/// Space-group identity and expansion operations retained for provenance.
pub space_group: SpaceGroupInfo,
/// Formula supplied by the source, if known.
pub formula_sum: Option<String>,
/// Mineral name supplied by the source, if known.
pub mineral: Option<String>,
/// Free-form notes and parser warnings.
pub warnings: Vec<String>,
}
impl Structure {
/// Store an already-expanded cell, cloning its sites as the asymmetric list.
/// No symmetry expansion, species validation, or coordinate wrapping occurs.
pub fn new(title: &str, lattice: Lattice, sites: Vec<Site>) -> Self {
Self {
title: title.to_string(),
source: String::new(),
lattice,
asymmetric_sites: sites.clone(),
sites,
space_group: SpaceGroupInfo::default(),
formula_sum: None,
mineral: None,
warnings: Vec::new(),
}
}
/// Number of explicitly stored sites in the cell, not the cluster atom count.
pub fn num_sites(&self) -> usize {
self.sites.len()
}
/// Cartesian coordinates of site `i` in Å, without periodic wrapping.
/// Panics if `i` is outside the stored site list.
pub fn cart(&self, i: usize) -> [f64; 3] {
self.lattice.to_cart(self.sites[i].frac)
}
/// Element symbols present, in order of first appearance.
pub fn elements(&self) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for site in &self.sites {
for sp in &site.species {
if !out.iter().any(|s| s == &sp.symbol) {
out.push(sp.symbol.clone());
}
}
}
out
}
/// Occupancy-weighted composition of the cell.
pub fn composition(&self) -> BTreeMap<String, f64> {
let mut comp = BTreeMap::new();
for site in &self.sites {
for sp in &site.species {
*comp.entry(sp.symbol.clone()).or_insert(0.0) += sp.occupancy;
}
}
comp
}
/// Heuristic integer display formula (`RuO2`, `FeS2`), with elements in
/// order of first appearance. Does not use [`Structure::formula_sum`].
///
/// Occupancy-weighted counts are divided by the smallest count (floored at
/// `1e-9`). The first multiplier from 1 through 12 that puts every scaled
/// count within `0.02` of an integer is used; if none qualifies, the
/// multiplier is one. Counts are then rounded. This project-specific display
/// heuristic can approximate fractional stoichiometry and is not a
/// lossless composition representation; use [`Structure::composition`] for
/// the unrounded counts. An empty structure returns an empty string.
pub fn formula(&self) -> String {
let comp = self.composition();
if comp.is_empty() {
return String::new();
}
let counts: Vec<f64> = comp.values().copied().collect();
// Scale so the smallest count is 1, then find the smallest integer
// multiplier (≤ 12) that makes every count integral.
let min = counts
.iter()
.cloned()
.fold(f64::INFINITY, f64::min)
.max(1e-9);
let scaled: Vec<f64> = counts.iter().map(|c| c / min).collect();
let mult = (1..=12)
.find(|m| {
scaled
.iter()
.all(|c| ((c * *m as f64).round() - c * *m as f64).abs() < 0.02)
})
.unwrap_or(1) as f64;
let mut out = String::new();
for symbol in self.elements() {
let n = (comp[&symbol] / min * mult).round() as i64;
out.push_str(&symbol);
if n != 1 {
out.push_str(&n.to_string());
}
}
out
}
/// Indices of sites containing any listed species matching `symbol`,
/// ignoring ASCII letter case. Minority and zero-occupancy entries also
/// match; this does not apply the cluster's occupancy-selection policy.
pub fn sites_of(&self, symbol: &str) -> Vec<usize> {
self.sites
.iter()
.enumerate()
.filter(|(_, s)| s.contains_element(symbol))
.map(|(i, _)| i)
.collect()
}
/// Stored site indices sharing the asymmetric-unit index of site `i`.
///
/// This groups recorded provenance; it does not discover crystallographic
/// equivalence from coordinates. If `i` has no asymmetric-unit index, or is
/// outside the site list, returns `[i]`; pass a valid site index when using
/// the returned indices to access sites.
pub fn equivalent_sites(&self, i: usize) -> Vec<usize> {
match self.sites.get(i).and_then(|s| s.asym_index) {
Some(asym) => self
.sites
.iter()
.enumerate()
.filter(|(_, s)| s.asym_index == Some(asym))
.map(|(j, _)| j)
.collect(),
None => vec![i],
}
}
}
pub(super) fn trim_float(v: f64) -> String {
let s = format!("{v:.3}");
let s = s.trim_end_matches('0').trim_end_matches('.');
if s.is_empty() {
"0".into()
} else {
s.to_string()
}
}