use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use xtalforge_core::elements::element_by_symbol;
use xtalforge_core::{Element, Structure};
use crate::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Info,
Warning,
Error,
}
impl Severity {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Info => "info",
Self::Warning => "warning",
Self::Error => "error",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Diagnostic {
pub code: String,
pub severity: Severity,
pub message: String,
pub atoms: Vec<usize>,
pub suggestion: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DiagnosticReport {
pub n_atoms: usize,
pub diagnostics: Vec<Diagnostic>,
pub counts: BTreeMap<String, usize>,
}
impl DiagnosticReport {
#[must_use]
pub fn has_errors(&self) -> bool {
self.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == Severity::Error)
}
#[must_use]
pub fn find(&self, code: &str) -> Option<&Diagnostic> {
self.diagnostics
.iter()
.find(|diagnostic| diagnostic.code == code)
}
}
fn covalent_radius(element: &str) -> f64 {
element_by_symbol(element).map_or(0.8, |info| info.covalent_radius)
}
fn typical_valence(element: &Element) -> Option<usize> {
match element.symbol.as_str() {
"H" | "D" => Some(1),
"C" => Some(4),
"N" => Some(4),
"O" => Some(2),
"F" | "Cl" | "Br" | "I" => Some(1),
"S" => Some(6),
"P" => Some(5),
"B" => Some(4),
_ => None,
}
}
fn push(
diagnostics: &mut Vec<Diagnostic>,
code: &'static str,
severity: Severity,
message: String,
atoms: Vec<usize>,
suggestion: Option<&'static str>,
) {
diagnostics.push(Diagnostic {
code: code.to_owned(),
severity,
message,
atoms,
suggestion: suggestion.map(str::to_owned),
});
}
pub fn diagnose(structure: &Structure) -> Result<DiagnosticReport, Error> {
let p1 = structure.p1()?;
let graph = structure.bond_graph()?;
let mut diagnostics = Vec::new();
if p1.atoms.is_empty() {
push(
&mut diagnostics,
"empty_structure",
Severity::Error,
"the structure has no atoms".to_owned(),
Vec::new(),
None,
);
return Ok(report(p1.atoms.len(), diagnostics));
}
let mut overlaps = Vec::new();
let mut shortest = f64::INFINITY;
for (i, first) in p1.atoms.iter().enumerate() {
for (j, second) in p1.atoms.iter().enumerate().skip(i + 1) {
let distance = p1.lattice.distance(first.frac, second.frac);
if distance < shortest {
shortest = distance;
}
if distance < 0.4 {
overlaps.push((i, j));
}
}
}
if !overlaps.is_empty() {
let mut atoms: Vec<usize> = overlaps.iter().flat_map(|(i, j)| [*i, *j]).collect();
atoms.sort_unstable();
atoms.dedup();
push(
&mut diagnostics,
"overlapping_atoms",
Severity::Error,
format!(
"{} pair(s) of atoms closer than 0.4 A; shortest contact {:.3} A",
overlaps.len(),
shortest
),
atoms,
None,
);
}
let partial: Vec<usize> = p1
.atoms
.iter()
.enumerate()
.filter(|(_, atom)| atom.occupancy < 0.999)
.map(|(index, _)| index)
.collect();
if !partial.is_empty() {
push(
&mut diagnostics,
"partial_occupancy",
Severity::Info,
format!(
"{} site(s) have occupancy below 1; the formula counts them differently",
partial.len()
),
partial,
None,
);
}
let total_charge = structure.summary()?.total_charge;
if total_charge.abs() > 1e-6 {
push(
&mut diagnostics,
"charge_imbalance",
Severity::Warning,
format!("the stored total charge is {total_charge:+.3} e, not zero"),
Vec::new(),
Some("info"),
);
}
let mut undercoordinated = Vec::new();
let mut dangling = Vec::new();
let mut overcoordinated = Vec::new();
for (index, atom) in p1.atoms.iter().enumerate() {
let Ok(element) = element_by_symbol(&atom.element) else {
continue;
};
let bonds = graph.degree(index);
if bonds == 0 {
if !element.is_metal && !element.is_dummy() {
dangling.push(index);
}
continue;
}
if let Some(valence) = typical_valence(element)
&& bonds < valence
&& matches!(element.symbol.as_str(), "C" | "N" | "O")
{
undercoordinated.push(index);
}
if bonds > 12 {
overcoordinated.push(index);
}
}
if !dangling.is_empty() {
push(
&mut diagnostics,
"dangling_atoms",
Severity::Warning,
format!(
"{} non-metal atom(s) have no bonds; check the bond rules or the site labels",
dangling.len()
),
dangling,
Some("bonds"),
);
}
if !undercoordinated.is_empty() {
push(
&mut diagnostics,
"missing_hydrogens",
Severity::Info,
format!(
"{} C/N/O atom(s) have fewer bonds than their valence; the model probably lacks hydrogens",
undercoordinated.len()
),
undercoordinated,
Some("hydrogens"),
);
}
if !overcoordinated.is_empty() {
push(
&mut diagnostics,
"unusual_coordination",
Severity::Warning,
format!(
"{} atom(s) have more than 12 bonds; the bond rules are probably too generous",
overcoordinated.len()
),
overcoordinated,
Some("bonds"),
);
}
match xtalforge_symmetry::detect_symmetry(structure, Default::default()) {
Ok(dataset) => {
if dataset.number == 1 && p1.atoms.len() > 3 {
push(
&mut diagnostics,
"no_symmetry",
Severity::Info,
"the structure is P1; a CIF that declared a space group may have lost it"
.to_owned(),
Vec::new(),
Some("symmetry"),
);
}
}
Err(error) => push(
&mut diagnostics,
"symmetry_failed",
Severity::Info,
format!("symmetry detection did not finish: {error}"),
Vec::new(),
None,
),
}
diagnostics.sort_by(|left, right| {
right
.severity
.cmp(&left.severity)
.then_with(|| left.code.cmp(&right.code))
});
Ok(report(p1.atoms.len(), diagnostics))
}
fn report(n_atoms: usize, diagnostics: Vec<Diagnostic>) -> DiagnosticReport {
let mut counts = BTreeMap::new();
for diagnostic in &diagnostics {
*counts
.entry(diagnostic.severity.as_str().to_owned())
.or_insert(0) += 1;
}
for severity in ["info", "warning", "error"] {
counts.entry(severity.to_owned()).or_insert(0);
}
DiagnosticReport {
n_atoms,
diagnostics,
counts,
}
}
pub fn shortest_contact(structure: &Structure) -> Result<f64, Error> {
let p1 = structure.p1()?;
let mut shortest = f64::INFINITY;
for (i, first) in p1.atoms.iter().enumerate() {
for second in p1.atoms.iter().skip(i + 1) {
shortest = shortest.min(p1.lattice.distance(first.frac, second.frac));
}
}
if shortest.is_finite() {
Ok(shortest)
} else {
Err(Error::Core(xtalforge_core::Error::InvalidInput(
"a contact needs at least two atoms".to_owned(),
)))
}
}
pub fn bond_threshold(first: &str, second: &str, scale: f64) -> Result<f64, Error> {
Ok((covalent_radius(first) + covalent_radius(second)) * scale)
}