use crate::composition::{Composition, Element};
use crate::error::{GugenError, Result, require_finite};
use crate::reaction::{
BalancedReaction, COMPOSITION_CONSERVATION_TOLERANCE, check_element_conservation,
};
use std::collections::BTreeMap;
pub(crate) const ATOMIC_WEIGHTS: [(&str, f64); 118] = [
("H", 1.008),
("He", 4.002602),
("Li", 6.94),
("Be", 9.0121831),
("B", 10.81),
("C", 12.011),
("N", 14.007),
("O", 15.999),
("F", 18.99840316),
("Ne", 20.1797),
("Na", 22.98976928),
("Mg", 24.305),
("Al", 26.9815384),
("Si", 28.085),
("P", 30.973761998),
("S", 32.06),
("Cl", 35.45),
("Ar", 39.95),
("K", 39.0983),
("Ca", 40.078),
("Sc", 44.955907),
("Ti", 47.867),
("V", 50.9415),
("Cr", 51.9961),
("Mn", 54.938043),
("Fe", 55.845),
("Co", 58.933194),
("Ni", 58.6934),
("Cu", 63.546),
("Zn", 65.38),
("Ga", 69.723),
("Ge", 72.630),
("As", 74.921595),
("Se", 78.971),
("Br", 79.904),
("Kr", 83.798),
("Rb", 85.4678),
("Sr", 87.62),
("Y", 88.905838),
("Zr", 91.224),
("Nb", 92.90637),
("Mo", 95.95),
("Tc", 97.0),
("Ru", 101.07),
("Rh", 102.90549),
("Pd", 106.42),
("Ag", 107.8682),
("Cd", 112.414),
("In", 114.818),
("Sn", 118.710),
("Sb", 121.760),
("Te", 127.60),
("I", 126.90447),
("Xe", 131.293),
("Cs", 132.90545196),
("Ba", 137.327),
("La", 138.90547),
("Ce", 140.116),
("Pr", 140.90766),
("Nd", 144.242),
("Pm", 145.0),
("Sm", 150.36),
("Eu", 151.964),
("Gd", 157.25),
("Tb", 158.925354),
("Dy", 162.500),
("Ho", 164.930329),
("Er", 167.259),
("Tm", 168.934219),
("Yb", 173.045),
("Lu", 174.9668),
("Hf", 178.486),
("Ta", 180.94788),
("W", 183.84),
("Re", 186.207),
("Os", 190.23),
("Ir", 192.217),
("Pt", 195.084),
("Au", 196.966570),
("Hg", 200.592),
("Tl", 204.38),
("Pb", 207.2),
("Bi", 208.98040),
("Po", 209.0),
("At", 210.0),
("Rn", 222.0),
("Fr", 223.0),
("Ra", 226.0),
("Ac", 227.0),
("Th", 232.0377),
("Pa", 231.03588),
("U", 238.02891),
("Np", 237.0),
("Pu", 244.0),
("Am", 243.0),
("Cm", 247.0),
("Bk", 247.0),
("Cf", 251.0),
("Es", 252.0),
("Fm", 257.0),
("Md", 258.0),
("No", 259.0),
("Lr", 262.0),
("Rf", 267.0),
("Db", 270.0),
("Sg", 269.0),
("Bh", 270.0),
("Hs", 270.0),
("Mt", 278.0),
("Ds", 281.0),
("Rg", 281.0),
("Cn", 285.0),
("Nh", 286.0),
("Fl", 289.0),
("Mc", 289.0),
("Lv", 293.0),
("Ts", 293.0),
("Og", 294.0),
];
pub(crate) fn atomic_weight_amu(element: Element) -> f64 {
ATOMIC_WEIGHTS
.iter()
.find(|&&(sym, _)| sym == element.symbol())
.map(|&(_, w)| w)
.expect("ATOMIC_WEIGHTS covers every ELEMENT_SYMBOLS entry -- checked in tests")
}
pub fn reduced_mass_amu(composition: &Composition) -> Option<f64> {
let n_elems = composition.len();
if n_elems < 2 {
return None;
}
let total_atoms: f64 = composition.iter().map(|(_, amount)| amount).sum();
let denominator = (n_elems as f64 - 1.0) * total_atoms;
let entries: Vec<(Element, f64)> = composition.iter().collect();
let mut mass_sum = 0.0;
for i in 0..entries.len() {
for j in (i + 1)..entries.len() {
let (elem_i, alpha_i) = entries[i];
let (elem_j, alpha_j) = entries[j];
let m_i = atomic_weight_amu(elem_i);
let m_j = atomic_weight_amu(elem_j);
mass_sum += (alpha_i + alpha_j) * (m_i * m_j) / (m_i + m_j);
}
}
Some(mass_sum / denominator)
}
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Kelvin(f64);
impl Kelvin {
pub const MIN: f64 = 300.0;
pub const MAX: f64 = 1800.0;
pub fn new(value: f64) -> Result<Self> {
require_finite("Kelvin", value)?;
if !(Self::MIN..=Self::MAX).contains(&value) {
return Err(GugenError::InvalidRange {
min: Self::MIN,
max: Self::MAX,
});
}
Ok(Self(value))
}
pub fn value(&self) -> f64 {
self.0
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for Kelvin {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = f64::deserialize(deserializer)?;
Kelvin::new(value).map_err(serde::de::Error::custom)
}
}
fn g_delta_sisso_ev_per_atom(
volume_angstrom3_per_atom: f64,
reduced_mass_amu: f64,
temperature: Kelvin,
) -> f64 {
let t = temperature.value();
(-2.48e-4 * volume_angstrom3_per_atom.ln()
- 8.94e-5 * reduced_mass_amu / volume_angstrom3_per_atom)
* t
+ 0.181 * t.ln()
- 0.882
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ThermodynamicDatasetIdentity {
pub source: String,
pub release: String,
pub compatibility_scheme: String,
pub snapshot_checksum: String,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct SolidThermodynamicEntry {
pub composition: Composition,
pub phase_id: Option<String>,
formation_enthalpy_ev_per_atom: f64,
volume_angstrom3_per_atom: f64,
pub dataset: ThermodynamicDatasetIdentity,
}
impl SolidThermodynamicEntry {
pub fn new(
composition: Composition,
phase_id: Option<String>,
formation_enthalpy_ev_per_atom: f64,
volume_angstrom3_per_atom: f64,
dataset: ThermodynamicDatasetIdentity,
) -> Result<Self> {
require_finite(
"formation_enthalpy_ev_per_atom",
formation_enthalpy_ev_per_atom,
)?;
require_finite("volume_angstrom3_per_atom", volume_angstrom3_per_atom)?;
if volume_angstrom3_per_atom <= 0.0 {
return Err(GugenError::NonPositiveMagnitude {
field: "volume_angstrom3_per_atom",
value: volume_angstrom3_per_atom,
});
}
Ok(Self {
composition,
phase_id,
formation_enthalpy_ev_per_atom,
volume_angstrom3_per_atom,
dataset,
})
}
pub fn formation_enthalpy_ev_per_atom(&self) -> f64 {
self.formation_enthalpy_ev_per_atom
}
pub fn volume_angstrom3_per_atom(&self) -> f64 {
self.volume_angstrom3_per_atom
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for SolidThermodynamicEntry {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
struct Raw {
composition: Composition,
phase_id: Option<String>,
formation_enthalpy_ev_per_atom: f64,
volume_angstrom3_per_atom: f64,
dataset: ThermodynamicDatasetIdentity,
}
let raw = Raw::deserialize(deserializer)?;
SolidThermodynamicEntry::new(
raw.composition,
raw.phase_id,
raw.formation_enthalpy_ev_per_atom,
raw.volume_angstrom3_per_atom,
raw.dataset,
)
.map_err(serde::de::Error::custom)
}
}
pub fn relative_solid_gibbs_ev_per_atom(
entry: &SolidThermodynamicEntry,
temperature: Kelvin,
) -> f64 {
let Some(reduced_mass) = reduced_mass_amu(&entry.composition) else {
return entry.formation_enthalpy_ev_per_atom;
};
let g_delta =
g_delta_sisso_ev_per_atom(entry.volume_angstrom3_per_atom, reduced_mass, temperature);
entry.formation_enthalpy_ev_per_atom + g_delta
}
fn most_stable_entry_for<'a>(
entries: &'a [SolidThermodynamicEntry],
composition: &Composition,
) -> Option<&'a SolidThermodynamicEntry> {
entries
.iter()
.filter(|e| &e.composition == composition)
.min_by(|a, b| {
a.formation_enthalpy_ev_per_atom
.total_cmp(&b.formation_enthalpy_ev_per_atom)
.then_with(|| {
a.volume_angstrom3_per_atom
.total_cmp(&b.volume_angstrom3_per_atom)
})
})
}
fn distinct_datasets_among<'a>(
entries: &'a [SolidThermodynamicEntry],
compositions: &[&Composition],
) -> Vec<&'a ThermodynamicDatasetIdentity> {
let mut found: Vec<&ThermodynamicDatasetIdentity> = Vec::new();
for entry in entries {
if compositions.iter().any(|c| **c == entry.composition)
&& !found.iter().any(|d| **d == entry.dataset)
{
found.push(&entry.dataset);
}
}
found
}
pub fn balanced_reaction_delta_ev_per_atom(
reaction: &BalancedReaction,
entries: &[SolidThermodynamicEntry],
temperature: Kelvin,
) -> Result<Option<f64>> {
check_element_conservation(reaction.reactants(), reaction.products())?;
let compositions: Vec<&Composition> = reaction
.reactants()
.iter()
.chain(reaction.products())
.map(|s| &s.composition)
.collect();
let datasets = distinct_datasets_among(entries, &compositions);
if datasets.len() > 1 {
return Err(GugenError::InconsistentThermodynamicDataset(format!(
"entries relevant to this reaction span {} distinct dataset identities \
(e.g. {:?} vs {:?}) -- refusing to select or mix across them",
datasets.len(),
datasets[0],
datasets[1]
)));
}
let mut product_total = 0.0;
for species in reaction.products() {
let Some(entry) = most_stable_entry_for(entries, &species.composition) else {
return Ok(None);
};
let atoms: f64 = species.composition.iter().map(|(_, amt)| amt).sum();
product_total += species.coefficient() as f64
* atoms
* relative_solid_gibbs_ev_per_atom(entry, temperature);
}
let mut reactant_total = 0.0;
let mut reactant_atoms = 0.0;
for species in reaction.reactants() {
let Some(entry) = most_stable_entry_for(entries, &species.composition) else {
return Ok(None);
};
let atoms: f64 = species.composition.iter().map(|(_, amt)| amt).sum();
reactant_total += species.coefficient() as f64
* atoms
* relative_solid_gibbs_ev_per_atom(entry, temperature);
reactant_atoms += species.coefficient() as f64 * atoms;
}
let delta = (product_total - reactant_total) / reactant_atoms;
Ok(delta.is_finite().then_some(delta))
}
pub fn decomposition_margin_ev_per_atom(
target: &SolidThermodynamicEntry,
alternative_assemblage: &[(SolidThermodynamicEntry, f64)],
temperature: Kelvin,
) -> Result<Option<f64>> {
for (entry, amount) in alternative_assemblage {
require_finite("amount", *amount)?;
if *amount <= 0.0 {
return Err(GugenError::NonPositiveMagnitude {
field: "amount",
value: *amount,
});
}
if entry.dataset != target.dataset {
return Err(GugenError::InconsistentThermodynamicDataset(format!(
"alternative assemblage entry's dataset {:?} does not match target's dataset {:?}",
entry.dataset, target.dataset
)));
}
}
let mut assemblage_composition: BTreeMap<Element, f64> = BTreeMap::new();
for (entry, amount) in alternative_assemblage {
for (element, elem_amount) in entry.composition.iter() {
*assemblage_composition.entry(element).or_insert(0.0) += amount * elem_amount;
}
}
let target_composition: BTreeMap<Element, f64> = target.composition.iter().collect();
if assemblage_composition.len() != target_composition.len() {
return Ok(None);
}
for (element, target_amount) in &target_composition {
let Some(assemblage_amount) = assemblage_composition.get(element) else {
return Ok(None);
};
if (assemblage_amount - target_amount).abs() > COMPOSITION_CONSERVATION_TOLERANCE {
return Ok(None);
}
}
let target_atoms: f64 = target.composition.iter().map(|(_, amt)| amt).sum();
let target_total = target_atoms * relative_solid_gibbs_ev_per_atom(target, temperature);
let assemblage_total: f64 = alternative_assemblage
.iter()
.map(|(entry, amount)| {
let atoms: f64 = entry.composition.iter().map(|(_, amt)| amt).sum();
amount * atoms * relative_solid_gibbs_ev_per_atom(entry, temperature)
})
.sum();
let margin = (assemblage_total - target_total) / target_atoms;
Ok(margin.is_finite().then_some(margin))
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DecompositionComparison {
pub alternative_description: String,
pub margin_ev_per_atom: Option<f64>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ThermodynamicSelectivityAssessment {
pub temperature: Kelvin,
pub reaction_delta_ev_per_atom: Option<f64>,
pub decomposition_comparisons: Vec<DecompositionComparison>,
pub dataset: ThermodynamicDatasetIdentity,
pub limitations: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
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(composition: Composition, coefficient: u64) -> ReactionSpecies {
ReactionSpecies::new(composition, coefficient).unwrap()
}
fn dataset() -> ThermodynamicDatasetIdentity {
ThermodynamicDatasetIdentity {
source: "test".to_string(),
release: "2026.08".to_string(),
compatibility_scheme: "test-scheme".to_string(),
snapshot_checksum: "deadbeef".to_string(),
}
}
#[test]
fn atomic_weights_table_covers_every_element_symbol_exactly_once() {
use crate::composition::ELEMENT_SYMBOLS;
assert_eq!(ATOMIC_WEIGHTS.len(), ELEMENT_SYMBOLS.len());
for sym in ELEMENT_SYMBOLS {
assert!(
ATOMIC_WEIGHTS.iter().any(|&(s, _)| s == sym),
"missing atomic weight for {sym}"
);
}
let unique: std::collections::BTreeSet<&str> =
ATOMIC_WEIGHTS.iter().map(|&(s, _)| s).collect();
assert_eq!(
unique.len(),
ATOMIC_WEIGHTS.len(),
"duplicate symbol in table"
);
}
#[test]
fn kelvin_rejects_outside_bartel_2018_validated_range() {
assert!(Kelvin::new(299.9).is_err());
assert!(Kelvin::new(1800.1).is_err());
assert!(Kelvin::new(300.0).is_ok());
assert!(Kelvin::new(1800.0).is_ok());
assert!(Kelvin::new(900.0).is_ok());
}
#[test]
fn solid_thermodynamic_entry_rejects_non_positive_volume() {
let result = SolidThermodynamicEntry::new(
composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]),
None,
-1.5,
0.0,
dataset(),
);
assert!(result.is_err());
}
#[test]
fn reduced_mass_is_scale_invariant_matching_pymatgen() {
let batio3 = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let scaled = composition(&[("Ba", 2.0), ("Ti", 2.0), ("O", 6.0)]);
let rm1 = reduced_mass_amu(&batio3).unwrap();
let rm2 = reduced_mass_amu(&scaled).unwrap();
assert!((rm1 - rm2).abs() < 1e-9);
assert!((rm1 - 17.627455183378554).abs() < 1e-3);
}
#[test]
fn reduced_mass_is_none_for_a_pure_element() {
let fe = composition(&[("Fe", 1.0)]);
assert_eq!(reduced_mass_amu(&fe), None);
}
#[test]
fn g_delta_sisso_matches_pymatgen_across_the_validated_temperature_range() {
let cases: [(f64, f64, f64, f64); 10] = [
(10.0, 10.0, 300.0, -0.04774770300598463),
(10.0, 10.0, 1800.0, -0.7141008936694916),
(20.0, 30.0, 300.0, -0.11272785323964452),
(20.0, 30.0, 1800.0, -1.103981795071451),
(20.0, 30.0, 900.0, -0.44010399129555045),
(45.0, 80.0, 500.0, -0.30864874958376987),
(45.0, 80.0, 1300.0, -1.0180896826709018),
(12.0, 17.627455183378554, 900.0, -0.32358979907553453),
(5.0, 5.0, 300.0, 0.0038224472276753296),
(100.0, 150.0, 1800.0, -1.8224348791820337),
];
for (volume, reduced_mass, temp_k, expected) in cases {
let t = Kelvin::new(temp_k).unwrap();
let actual = g_delta_sisso_ev_per_atom(volume, reduced_mass, t);
assert!(
(actual - expected).abs() < 1e-9,
"V={volume} m={reduced_mass} T={temp_k}: expected {expected}, got {actual}"
);
}
}
#[test]
fn balanced_reaction_delta_matches_an_independent_pymatgen_computation() {
let ab = composition(&[("Na", 1.0), ("Cl", 1.0)]);
let cd = composition(&[("K", 1.0), ("Br", 1.0)]);
let ac = composition(&[("Na", 1.0), ("Br", 1.0)]);
let bd = composition(&[("K", 1.0), ("Cl", 1.0)]);
let entries = vec![
SolidThermodynamicEntry::new(ab.clone(), None, -1.5, 4.0_f64.powi(3), dataset())
.unwrap(),
SolidThermodynamicEntry::new(cd.clone(), None, -1.2, 5.0_f64.powi(3), dataset())
.unwrap(),
SolidThermodynamicEntry::new(ac.clone(), None, -1.8, 4.3_f64.powi(3), dataset())
.unwrap(),
SolidThermodynamicEntry::new(bd.clone(), None, -1.1, 4.7_f64.powi(3), dataset())
.unwrap(),
];
let reaction = BalancedReaction::new(
vec![species(ab, 1), species(cd, 1)],
vec![species(ac, 1), species(bd, 1)],
)
.unwrap();
let t = Kelvin::new(900.0).unwrap();
let actual = balanced_reaction_delta_ev_per_atom(&reaction, &entries, t)
.unwrap()
.unwrap();
let expected = -0.10251961226707129;
assert!(
(actual - expected).abs() < 1e-9,
"expected {expected}, got {actual}"
);
}
#[test]
fn balanced_reaction_delta_abstains_when_a_species_has_no_entry() {
let feo = composition(&[("Fe", 1.0), ("O", 1.0)]);
let fe2o2 = composition(&[("Fe", 2.0), ("O", 2.0)]);
let entries = vec![
SolidThermodynamicEntry::new(feo.clone(), None, -2.0, 12.0, dataset()).unwrap(),
];
let reaction =
BalancedReaction::new(vec![species(feo, 2)], vec![species(fe2o2, 1)]).unwrap();
let result =
balanced_reaction_delta_ev_per_atom(&reaction, &entries, Kelvin::new(900.0).unwrap());
assert_eq!(result, Ok(None));
}
#[test]
fn balanced_reaction_delta_picks_the_lowest_0k_energy_among_duplicate_compositions() {
let feo = composition(&[("Fe", 1.0), ("O", 1.0)]);
let fe2o2 = composition(&[("Fe", 2.0), ("O", 2.0)]);
let reaction = BalancedReaction::new(
vec![species(feo.clone(), 2)],
vec![species(fe2o2.clone(), 1)],
)
.unwrap();
let ascending = vec![
SolidThermodynamicEntry::new(feo.clone(), None, -2.0, 12.0, dataset()).unwrap(),
SolidThermodynamicEntry::new(feo.clone(), None, -5.0, 12.0, dataset()).unwrap(),
SolidThermodynamicEntry::new(fe2o2.clone(), None, -3.0, 20.0, dataset()).unwrap(),
];
let descending = vec![
SolidThermodynamicEntry::new(feo.clone(), None, -5.0, 12.0, dataset()).unwrap(),
SolidThermodynamicEntry::new(feo, None, -2.0, 12.0, dataset()).unwrap(),
SolidThermodynamicEntry::new(fe2o2, None, -3.0, 20.0, dataset()).unwrap(),
];
let t = Kelvin::new(900.0).unwrap();
let a = balanced_reaction_delta_ev_per_atom(&reaction, &ascending, t)
.unwrap()
.unwrap();
let b = balanced_reaction_delta_ev_per_atom(&reaction, &descending, t)
.unwrap()
.unwrap();
assert_eq!(a, b, "must be order-independent regardless of entry order");
}
#[test]
fn balanced_reaction_delta_is_order_independent_even_when_duplicate_entries_tie_on_enthalpy() {
let feo = composition(&[("Fe", 1.0), ("O", 1.0)]);
let fe2o2 = composition(&[("Fe", 2.0), ("O", 2.0)]);
let reaction = BalancedReaction::new(
vec![species(feo.clone(), 2)],
vec![species(fe2o2.clone(), 1)],
)
.unwrap();
let forward = vec![
SolidThermodynamicEntry::new(feo.clone(), None, -2.0, 12.0, dataset()).unwrap(),
SolidThermodynamicEntry::new(feo.clone(), None, -2.0, 20.0, dataset()).unwrap(),
SolidThermodynamicEntry::new(fe2o2.clone(), None, -3.0, 20.0, dataset()).unwrap(),
];
let reversed = vec![
SolidThermodynamicEntry::new(feo.clone(), None, -2.0, 20.0, dataset()).unwrap(),
SolidThermodynamicEntry::new(feo, None, -2.0, 12.0, dataset()).unwrap(),
SolidThermodynamicEntry::new(fe2o2, None, -3.0, 20.0, dataset()).unwrap(),
];
let t = Kelvin::new(900.0).unwrap();
let a = balanced_reaction_delta_ev_per_atom(&reaction, &forward, t)
.unwrap()
.unwrap();
let b = balanced_reaction_delta_ev_per_atom(&reaction, &reversed, t)
.unwrap()
.unwrap();
assert_eq!(
a, b,
"an enthalpy tie must not let entry-list order decide the volume used"
);
}
#[test]
fn decomposition_margin_computes_batio3_vs_bao_plus_tio2() {
let batio3 = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let bao = composition(&[("Ba", 1.0), ("O", 1.0)]);
let tio2 = composition(&[("Ti", 1.0), ("O", 2.0)]);
let target = SolidThermodynamicEntry::new(batio3, None, -3.5, 60.0, dataset()).unwrap();
let bao_entry = SolidThermodynamicEntry::new(bao, None, -2.0, 20.0, dataset()).unwrap();
let tio2_entry = SolidThermodynamicEntry::new(tio2, None, -3.0, 30.0, dataset()).unwrap();
let t = Kelvin::new(900.0).unwrap();
let actual = decomposition_margin_ev_per_atom(
&target,
&[(bao_entry.clone(), 1.0), (tio2_entry.clone(), 1.0)],
t,
)
.unwrap()
.unwrap();
let g_target = relative_solid_gibbs_ev_per_atom(&target, t) * 5.0; let g_bao = relative_solid_gibbs_ev_per_atom(&bao_entry, t) * 2.0; let g_tio2 = relative_solid_gibbs_ev_per_atom(&tio2_entry, t) * 3.0; let expected = (g_bao + g_tio2 - g_target) / 5.0;
assert!(
(actual - expected).abs() < 1e-9,
"expected {expected}, got {actual}"
);
}
#[test]
fn decomposition_margin_abstains_when_composition_is_not_conserved() {
let batio3 = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let bao = composition(&[("Ba", 1.0), ("O", 1.0)]);
let target = SolidThermodynamicEntry::new(batio3, None, -3.5, 60.0, dataset()).unwrap();
let bao_entry = SolidThermodynamicEntry::new(bao, None, -2.0, 20.0, dataset()).unwrap();
let t = Kelvin::new(900.0).unwrap();
let result = decomposition_margin_ev_per_atom(&target, &[(bao_entry, 1.0)], t);
assert_eq!(result, Ok(None));
}
#[test]
fn decomposition_margin_is_negative_when_the_alternative_is_more_stable() {
let batio3 = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let bao = composition(&[("Ba", 1.0), ("O", 1.0)]);
let tio2 = composition(&[("Ti", 1.0), ("O", 2.0)]);
let target = SolidThermodynamicEntry::new(batio3, None, -1.0, 60.0, dataset()).unwrap();
let bao_entry = SolidThermodynamicEntry::new(bao, None, -5.0, 20.0, dataset()).unwrap();
let tio2_entry = SolidThermodynamicEntry::new(tio2, None, -5.0, 30.0, dataset()).unwrap();
let t = Kelvin::new(900.0).unwrap();
let margin =
decomposition_margin_ev_per_atom(&target, &[(bao_entry, 1.0), (tio2_entry, 1.0)], t)
.unwrap()
.unwrap();
assert!(
margin < 0.0,
"a much more stable alternative assemblage must give a negative margin, got {margin}"
);
}
#[test]
fn thermodynamic_selectivity_assessment_assembles_from_the_primitives() {
let batio3 = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let bao = composition(&[("Ba", 1.0), ("O", 1.0)]);
let tio2 = composition(&[("Ti", 1.0), ("O", 2.0)]);
let target = SolidThermodynamicEntry::new(batio3, None, -3.5, 60.0, dataset()).unwrap();
let bao_entry = SolidThermodynamicEntry::new(bao, None, -2.0, 20.0, dataset()).unwrap();
let tio2_entry = SolidThermodynamicEntry::new(tio2, None, -3.0, 30.0, dataset()).unwrap();
let t = Kelvin::new(900.0).unwrap();
let margin =
decomposition_margin_ev_per_atom(&target, &[(bao_entry, 1.0), (tio2_entry, 1.0)], t)
.unwrap();
let assessment = ThermodynamicSelectivityAssessment {
temperature: t,
reaction_delta_ev_per_atom: None,
decomposition_comparisons: vec![DecompositionComparison {
alternative_description: "BaO + TiO2".to_string(),
margin_ev_per_atom: margin,
}],
dataset: dataset(),
limitations: vec![
"gas-free closed solid systems only; no thermodynamic_support connection"
.to_string(),
],
};
assert_eq!(assessment.decomposition_comparisons.len(), 1);
assert!(
assessment.decomposition_comparisons[0]
.margin_ev_per_atom
.is_some()
);
}
#[test]
fn balanced_reaction_new_rejects_element_imbalance() {
let feo = composition(&[("Fe", 1.0), ("O", 1.0)]);
let fe2o3 = composition(&[("Fe", 2.0), ("O", 3.0)]);
let result = BalancedReaction::new(vec![species(feo, 1)], vec![species(fe2o3, 1)]);
assert!(
matches!(result, Err(GugenError::UnbalancedReaction { .. })),
"expected UnbalancedReaction, got {result:?}"
);
}
#[test]
fn balanced_reaction_delta_rejects_any_mismatched_dataset_field() {
let feo = composition(&[("Fe", 1.0), ("O", 1.0)]);
let fe2o2 = composition(&[("Fe", 2.0), ("O", 2.0)]);
let reaction = BalancedReaction::new(
vec![species(feo.clone(), 2)],
vec![species(fe2o2.clone(), 1)],
)
.unwrap();
let variants = [
ThermodynamicDatasetIdentity {
source: "other-source".to_string(),
..dataset()
},
ThermodynamicDatasetIdentity {
release: "other-release".to_string(),
..dataset()
},
ThermodynamicDatasetIdentity {
compatibility_scheme: "other-scheme".to_string(),
..dataset()
},
ThermodynamicDatasetIdentity {
snapshot_checksum: "other-checksum".to_string(),
..dataset()
},
];
for other in variants {
let entries = vec![
SolidThermodynamicEntry::new(feo.clone(), None, -2.0, 12.0, dataset()).unwrap(),
SolidThermodynamicEntry::new(fe2o2.clone(), None, -3.0, 20.0, other.clone())
.unwrap(),
];
let result = balanced_reaction_delta_ev_per_atom(
&reaction,
&entries,
Kelvin::new(900.0).unwrap(),
);
assert!(
matches!(result, Err(GugenError::InconsistentThermodynamicDataset(_))),
"expected rejection for differing dataset {other:?}, got {result:?}"
);
}
}
#[test]
fn balanced_reaction_delta_does_not_cross_datasets_to_pick_the_lowest_energy_duplicate() {
let feo = composition(&[("Fe", 1.0), ("O", 1.0)]);
let fe2o2 = composition(&[("Fe", 2.0), ("O", 2.0)]);
let reaction = BalancedReaction::new(
vec![species(feo.clone(), 2)],
vec![species(fe2o2.clone(), 1)],
)
.unwrap();
let other_dataset = ThermodynamicDatasetIdentity {
source: "other-source".to_string(),
..dataset()
};
let entries = vec![
SolidThermodynamicEntry::new(feo.clone(), None, -2.0, 12.0, dataset()).unwrap(),
SolidThermodynamicEntry::new(feo, None, -50.0, 12.0, other_dataset).unwrap(),
SolidThermodynamicEntry::new(fe2o2, None, -3.0, 20.0, dataset()).unwrap(),
];
let result =
balanced_reaction_delta_ev_per_atom(&reaction, &entries, Kelvin::new(900.0).unwrap());
assert!(
matches!(result, Err(GugenError::InconsistentThermodynamicDataset(_))),
"must reject rather than silently pick the lower-enthalpy cross-dataset entry, got {result:?}"
);
}
#[test]
fn decomposition_margin_rejects_mismatched_dataset() {
let batio3 = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let bao = composition(&[("Ba", 1.0), ("O", 1.0)]);
let tio2 = composition(&[("Ti", 1.0), ("O", 2.0)]);
let target = SolidThermodynamicEntry::new(batio3, None, -3.5, 60.0, dataset()).unwrap();
let other_dataset = ThermodynamicDatasetIdentity {
release: "other-release".to_string(),
..dataset()
};
let bao_entry = SolidThermodynamicEntry::new(bao, None, -2.0, 20.0, other_dataset).unwrap();
let tio2_entry = SolidThermodynamicEntry::new(tio2, None, -3.0, 30.0, dataset()).unwrap();
let t = Kelvin::new(900.0).unwrap();
let result =
decomposition_margin_ev_per_atom(&target, &[(bao_entry, 1.0), (tio2_entry, 1.0)], t);
assert!(
matches!(result, Err(GugenError::InconsistentThermodynamicDataset(_))),
"expected rejection, got {result:?}"
);
}
#[test]
fn decomposition_margin_rejects_invalid_amount() {
let batio3 = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let bao = composition(&[("Ba", 1.0), ("O", 1.0)]);
let tio2 = composition(&[("Ti", 1.0), ("O", 2.0)]);
let target = SolidThermodynamicEntry::new(batio3, None, -3.5, 60.0, dataset()).unwrap();
let bao_entry = SolidThermodynamicEntry::new(bao, None, -2.0, 20.0, dataset()).unwrap();
let tio2_entry = SolidThermodynamicEntry::new(tio2, None, -3.0, 30.0, dataset()).unwrap();
let t = Kelvin::new(900.0).unwrap();
for bad_amount in [f64::NAN, f64::INFINITY, 0.0, -1.0] {
let result = decomposition_margin_ev_per_atom(
&target,
&[(bao_entry.clone(), bad_amount), (tio2_entry.clone(), 1.0)],
t,
);
assert!(
result.is_err(),
"amount {bad_amount} must be rejected, got {result:?}"
);
}
}
#[test]
fn decomposition_margin_abstains_on_non_finite_result_instead_of_returning_nan() {
let batio3 = composition(&[("Ba", 1.0), ("Ti", 1.0), ("O", 3.0)]);
let bao = composition(&[("Ba", 1.0), ("O", 1.0)]);
let tio2 = composition(&[("Ti", 1.0), ("O", 2.0)]);
let mut target = SolidThermodynamicEntry::new(batio3, None, -3.5, 60.0, dataset()).unwrap();
target.formation_enthalpy_ev_per_atom = f64::NAN;
let bao_entry = SolidThermodynamicEntry::new(bao, None, -2.0, 20.0, dataset()).unwrap();
let tio2_entry = SolidThermodynamicEntry::new(tio2, None, -3.0, 30.0, dataset()).unwrap();
let t = Kelvin::new(900.0).unwrap();
let result =
decomposition_margin_ev_per_atom(&target, &[(bao_entry, 1.0), (tio2_entry, 1.0)], t);
assert_eq!(
result,
Ok(None),
"must abstain instead of returning Some(NaN)"
);
}
#[test]
fn balanced_reaction_delta_abstains_on_non_finite_result_instead_of_returning_nan() {
let feo = composition(&[("Fe", 1.0), ("O", 1.0)]);
let fe2o2 = composition(&[("Fe", 2.0), ("O", 2.0)]);
let reaction = BalancedReaction::new(
vec![species(feo.clone(), 2)],
vec![species(fe2o2.clone(), 1)],
)
.unwrap();
let mut fe2o2_entry =
SolidThermodynamicEntry::new(fe2o2, None, -3.0, 20.0, dataset()).unwrap();
fe2o2_entry.formation_enthalpy_ev_per_atom = f64::NAN;
let entries = vec![
SolidThermodynamicEntry::new(feo, None, -2.0, 12.0, dataset()).unwrap(),
fe2o2_entry,
];
let result =
balanced_reaction_delta_ev_per_atom(&reaction, &entries, Kelvin::new(900.0).unwrap());
assert_eq!(
result,
Ok(None),
"must abstain instead of returning Some(NaN)"
);
}
}