use crate::composition::Composition;
use crate::error::ProviderError;
use crate::provider::ThermodynamicProvider;
use crate::reaction::{BalancedReaction, CompetingPhase, ReactionEnergy, ThermodynamicConditions};
use std::collections::BTreeSet;
pub struct MaterialsProjectSnapshotProvider {
entries: Vec<CompetingPhase>,
}
impl MaterialsProjectSnapshotProvider {
pub fn from_entries(entries: Vec<CompetingPhase>) -> Self {
Self { entries }
}
fn energy_for(&self, composition: &Composition) -> Option<f64> {
self.entries
.iter()
.filter(|entry| &entry.composition == composition)
.map(CompetingPhase::formation_energy_ev_per_atom)
.fold(None::<f64>, |acc, v| Some(acc.map_or(v, |a| a.min(v))))
}
}
fn atoms_in_formula(composition: &Composition) -> f64 {
composition.iter().map(|(_, amount)| amount).sum()
}
impl ThermodynamicProvider for MaterialsProjectSnapshotProvider {
fn reaction_energy(
&self,
reaction: &BalancedReaction,
_conditions: &ThermodynamicConditions,
) -> std::result::Result<Option<ReactionEnergy>, ProviderError> {
let mut product_total = 0.0;
for species in &reaction.products {
let Some(energy) = self.energy_for(&species.composition) else {
return Ok(None);
};
product_total +=
species.coefficient as f64 * atoms_in_formula(&species.composition) * energy;
}
let mut reactant_total = 0.0;
let mut reactant_atoms = 0.0;
for species in &reaction.reactants {
let Some(energy) = self.energy_for(&species.composition) else {
return Ok(None);
};
let atoms = atoms_in_formula(&species.composition);
reactant_total += species.coefficient as f64 * atoms * energy;
reactant_atoms += species.coefficient as f64 * atoms;
}
let delta_per_atom = (product_total - reactant_total) / reactant_atoms;
ReactionEnergy::new(delta_per_atom)
.map(Some)
.map_err(|e| ProviderError::MalformedRecord(e.to_string()))
}
fn competing_phases(
&self,
target: &Composition,
) -> std::result::Result<Vec<CompetingPhase>, ProviderError> {
let target_elements: BTreeSet<_> = target.elements().collect();
Ok(self
.entries
.iter()
.filter(|entry| &entry.composition != target)
.filter(|entry| {
entry
.composition
.elements()
.any(|element| target_elements.contains(&element))
})
.cloned()
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::composition::Element;
use crate::reaction::ReactionSpecies;
fn element(symbol: &str) -> Element {
Element::new(symbol).unwrap()
}
fn composition(pairs: &[(&str, f64)]) -> Composition {
Composition::new(pairs.iter().map(|&(sym, amt)| (element(sym), amt))).unwrap()
}
fn species(pairs: &[(&str, f64)], coefficient: u64) -> ReactionSpecies {
ReactionSpecies {
composition: composition(pairs),
coefficient,
}
}
#[test]
fn reaction_energy_computes_the_hand_checked_delta_for_a_two_species_reaction() {
let feo = composition(&[("Fe", 1.0), ("O", 1.0)]);
let fe2o2 = composition(&[("Fe", 2.0), ("O", 2.0)]);
let provider = MaterialsProjectSnapshotProvider::from_entries(vec![
CompetingPhase::new(feo, -2.0).unwrap(),
CompetingPhase::new(fe2o2, -3.0).unwrap(),
]);
let reaction = BalancedReaction::new(
vec![species(&[("Fe", 1.0), ("O", 1.0)], 2)],
vec![species(&[("Fe", 2.0), ("O", 2.0)], 1)],
)
.unwrap();
let energy = provider
.reaction_energy(&reaction, &ThermodynamicConditions::default())
.unwrap()
.expect("both species are in the snapshot");
assert_eq!(energy.value_ev_per_atom(), -1.0);
}
#[test]
fn reaction_energy_returns_none_not_a_partial_sum_when_a_species_is_missing() {
let feo = composition(&[("Fe", 1.0), ("O", 1.0)]);
let provider = MaterialsProjectSnapshotProvider::from_entries(vec![
CompetingPhase::new(feo, -2.0).unwrap(),
]);
let reaction = BalancedReaction::new(
vec![species(&[("Fe", 1.0), ("O", 1.0)], 2)],
vec![species(&[("Fe", 2.0), ("O", 2.0)], 1)],
)
.unwrap();
let result = provider
.reaction_energy(&reaction, &ThermodynamicConditions::default())
.unwrap();
assert_eq!(
result, None,
"a missing product must abstain entirely, not sum only the reactant side"
);
}
#[test]
fn reaction_energy_uses_the_lowest_energy_among_duplicate_compositions_regardless_of_order() {
let feo = composition(&[("Fe", 1.0), ("O", 1.0)]);
let fe2o2 = composition(&[("Fe", 2.0), ("O", 2.0)]);
let reaction = BalancedReaction::new(
vec![species(&[("Fe", 1.0), ("O", 1.0)], 2)],
vec![species(&[("Fe", 2.0), ("O", 2.0)], 1)],
)
.unwrap();
let ascending = MaterialsProjectSnapshotProvider::from_entries(vec![
CompetingPhase::new(feo.clone(), -2.0).unwrap(),
CompetingPhase::new(feo.clone(), -5.0).unwrap(),
CompetingPhase::new(fe2o2.clone(), -3.0).unwrap(),
]);
let descending = MaterialsProjectSnapshotProvider::from_entries(vec![
CompetingPhase::new(feo.clone(), -5.0).unwrap(),
CompetingPhase::new(feo, -2.0).unwrap(),
CompetingPhase::new(fe2o2, -3.0).unwrap(),
]);
let a = ascending
.reaction_energy(&reaction, &ThermodynamicConditions::default())
.unwrap()
.unwrap();
let b = descending
.reaction_energy(&reaction, &ThermodynamicConditions::default())
.unwrap()
.unwrap();
assert_eq!(a.value_ev_per_atom(), b.value_ev_per_atom());
assert_eq!(a.value_ev_per_atom(), 2.0);
}
#[test]
fn competing_phases_excludes_the_target_itself_and_unrelated_elements() {
let target = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let shares_an_element = composition(&[("Ba", 1.0), ("O", 1.0)]);
let unrelated = composition(&[("Na", 1.0), ("Cl", 1.0)]);
let provider = MaterialsProjectSnapshotProvider::from_entries(vec![
CompetingPhase::new(target.clone(), -1.0).unwrap(),
CompetingPhase::new(shares_an_element.clone(), -2.0).unwrap(),
CompetingPhase::new(unrelated, -3.0).unwrap(),
]);
let phases = provider.competing_phases(&target).unwrap();
assert_eq!(phases.len(), 1);
assert_eq!(phases[0].composition, shares_an_element);
}
}