Skip to main content

xtalforge_core/
hull.rs

1#![allow(clippy::indexing_slicing, clippy::needless_range_loop)] // fixed-size numeric kernels
2
3//! Formation energies and convex-hull distances (Task 9.6.3).
4
5use std::collections::{BTreeMap, BTreeSet};
6
7use serde::{Deserialize, Serialize};
8
9use crate::Error;
10
11/// One phase in a convex-hull calculation.
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct PhaseEntry {
14    /// Phase name.
15    pub name: String,
16    /// Element amounts in the formula unit.
17    pub composition: BTreeMap<String, f64>,
18    /// Total energy in eV for the formula unit.
19    pub energy: f64,
20}
21
22/// One analysed phase.
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct PhasePoint {
25    /// Phase name.
26    pub name: String,
27    /// Element fractions.
28    pub composition: BTreeMap<String, f64>,
29    /// Formation energy per atom in eV.
30    pub formation_energy: f64,
31    /// Energy of the lower convex hull at this composition, per atom.
32    pub hull_energy: f64,
33    /// Distance above the hull in eV/atom; zero for a stable phase.
34    pub distance: f64,
35    /// Whether the phase lies on the hull within `1e-6` eV/atom.
36    pub stable: bool,
37    /// Hull decomposition as `(entry index, weight)` for unstable phases.
38    pub decomposition: Vec<(usize, f64)>,
39}
40
41/// Result of [`convex_hull`].
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct ConvexHull {
44    /// Elements in alphabetical order.
45    pub elements: Vec<String>,
46    /// Reference chemical potentials in eV/atom.
47    pub references: BTreeMap<String, f64>,
48    /// Analysed points in input order.
49    pub points: Vec<PhasePoint>,
50}
51
52/// Computes formation energies and the distance to the lower convex hull.
53///
54/// When `references` is `None`, the energy per atom of each pure-element
55/// entry is used as the chemical potential. The hull is solved with a
56/// two-phase simplex, so any number of elements is supported.
57///
58/// # Errors
59///
60/// Returns [`Error::InvalidInput`] for an empty list, non-finite energies,
61/// empty compositions or a missing elemental reference.
62pub fn convex_hull(
63    entries: &[PhaseEntry],
64    references: Option<&BTreeMap<String, f64>>,
65) -> Result<ConvexHull, Error> {
66    if entries.is_empty() {
67        return Err(Error::InvalidInput(
68            "the convex hull needs at least one phase".to_owned(),
69        ));
70    }
71    let mut elements = BTreeSet::new();
72    for entry in entries {
73        if entry.composition.is_empty() {
74            return Err(Error::InvalidInput(format!(
75                "phase {} has an empty composition",
76                entry.name
77            )));
78        }
79        if !entry.energy.is_finite() {
80            return Err(Error::InvalidInput(format!(
81                "phase {} has a non-finite energy",
82                entry.name
83            )));
84        }
85        for (element, amount) in &entry.composition {
86            if !amount.is_finite() || *amount < 0.0 {
87                return Err(Error::InvalidInput(format!(
88                    "phase {} has an invalid amount of {element}",
89                    entry.name
90                )));
91            }
92            if *amount > 0.0 {
93                elements.insert(element.clone());
94            }
95        }
96    }
97    let elements: Vec<String> = elements.into_iter().collect();
98    let reference_map = match references {
99        Some(map) => {
100            let mut checked = BTreeMap::new();
101            for element in &elements {
102                let Some(value) = map.get(element) else {
103                    return Err(Error::InvalidInput(format!(
104                        "no reference energy for element {element}"
105                    )));
106                };
107                if !value.is_finite() {
108                    return Err(Error::InvalidInput(format!(
109                        "reference energy for {element} is not finite"
110                    )));
111                }
112                checked.insert(element.clone(), *value);
113            }
114            checked
115        }
116        None => {
117            let mut derived = BTreeMap::new();
118            for entry in entries {
119                let total: f64 = entry.composition.values().sum();
120                let non_zero: Vec<&String> = entry
121                    .composition
122                    .iter()
123                    .filter(|(_, amount)| **amount > 0.0)
124                    .map(|(element, _)| element)
125                    .collect();
126                if non_zero.len() == 1
127                    && total > 0.0
128                    && let Some(element) = non_zero.first()
129                {
130                    derived
131                        .entry((*element).clone())
132                        .or_insert(entry.energy / total);
133                }
134            }
135            for element in &elements {
136                if !derived.contains_key(element) {
137                    return Err(Error::InvalidInput(format!(
138                        "no pure-element phase to derive the chemical potential of {element}"
139                    )));
140                }
141            }
142            derived
143        }
144    };
145    let mut fractions: Vec<BTreeMap<String, f64>> = Vec::with_capacity(entries.len());
146    let mut formation = Vec::with_capacity(entries.len());
147    for entry in entries {
148        let total: f64 = entry.composition.values().sum();
149        if total <= 0.0 {
150            return Err(Error::InvalidInput(format!(
151                "phase {} has a zero formula unit",
152                entry.name
153            )));
154        }
155        let mut fraction = BTreeMap::new();
156        for element in &elements {
157            fraction.insert(
158                element.clone(),
159                entry.composition.get(element).copied().unwrap_or(0.0) / total,
160            );
161        }
162        let reference_energy: f64 = entry
163            .composition
164            .iter()
165            .map(|(element, amount)| reference_map.get(element).copied().unwrap_or(0.0) * amount)
166            .sum();
167        formation.push((entry.energy - reference_energy) / total);
168        fractions.push(fraction);
169    }
170    let mut points = Vec::with_capacity(entries.len());
171    for index in 0..entries.len() {
172        let target = &fractions[index];
173        let (hull_energy, decomposition) = solve_hull(&fractions, &formation, target, &elements)?;
174        let distance = formation[index] - hull_energy;
175        points.push(PhasePoint {
176            name: entries[index].name.clone(),
177            composition: target.clone(),
178            formation_energy: formation[index],
179            hull_energy,
180            distance,
181            stable: distance <= 1e-6,
182            decomposition,
183        });
184    }
185    Ok(ConvexHull {
186        elements,
187        references: reference_map,
188        points,
189    })
190}
191
192fn solve_hull(
193    fractions: &[BTreeMap<String, f64>],
194    energies: &[f64],
195    target: &BTreeMap<String, f64>,
196    elements: &[String],
197) -> Result<(f64, Vec<(usize, f64)>), Error> {
198    let rows = elements.len().max(1);
199    let columns = fractions.len();
200    let mut matrix = vec![vec![0.0; columns]; rows];
201    let mut vector = vec![0.0; rows];
202    for (row, element) in elements.iter().enumerate() {
203        for column in 0..columns {
204            matrix[row][column] = fractions[column].get(element).copied().unwrap_or(0.0);
205        }
206        vector[row] = target.get(element).copied().unwrap_or(0.0);
207    }
208    if elements.is_empty() {
209        matrix[0][0] = 1.0;
210        vector[0] = 1.0;
211    }
212    let solution = simplex(&matrix, &vector, energies)?;
213    let mut decomposition = Vec::new();
214    let mut energy = 0.0;
215    for (index, weight) in solution.iter().enumerate() {
216        if *weight > 1e-9 {
217            decomposition.push((index, *weight));
218            energy += weight * energies[index];
219        }
220    }
221    Ok((energy, decomposition))
222}
223
224/// Two-phase simplex for `min c w` subject to `A w = b`, `w >= 0`.
225fn simplex(matrix: &[Vec<f64>], vector: &[f64], costs: &[f64]) -> Result<Vec<f64>, Error> {
226    let rows = matrix.len();
227    let columns = costs.len();
228    if rows == 0 || columns == 0 {
229        return Err(Error::InvalidInput(
230            "the simplex needs a non-empty system".to_owned(),
231        ));
232    }
233    let total = columns + rows;
234    let mut tableau = vec![vec![0.0; total + 1]; rows + 1];
235    let mut basis = vec![0usize; rows];
236    for row in 0..rows {
237        for column in 0..columns {
238            tableau[row][column] = matrix[row][column];
239        }
240        tableau[row][columns + row] = 1.0;
241        tableau[row][total] = vector[row];
242        basis[row] = columns + row;
243    }
244    for column in 0..total {
245        let mut sum = 0.0;
246        for row in 0..rows {
247            sum += tableau[row][column];
248        }
249        tableau[rows][column] = if column < columns { -sum } else { 1.0 - sum };
250    }
251    let mut sum = 0.0;
252    for row in 0..rows {
253        sum += vector[row];
254    }
255    tableau[rows][total] = -sum;
256    pivot_until_done(&mut tableau, &mut basis, rows, total, Some(columns))?;
257    if -tableau[rows][total] > 1e-8 {
258        return Err(Error::InvalidInput(
259            "the convex hull is infeasible".to_owned(),
260        ));
261    }
262    for column in 0..total {
263        let mut sum = 0.0;
264        for (row, &index) in basis.iter().enumerate() {
265            let cost = if index < columns { costs[index] } else { 0.0 };
266            sum += cost * tableau[row][column];
267        }
268        tableau[rows][column] = if column < columns {
269            costs[column] - sum
270        } else {
271            1e9
272        };
273    }
274    let mut sum = 0.0;
275    for (row, &index) in basis.iter().enumerate() {
276        if index < columns {
277            sum += costs[index] * tableau[row][total];
278        }
279    }
280    tableau[rows][total] = -sum;
281    pivot_until_done(&mut tableau, &mut basis, rows, total, Some(columns))?;
282    let mut solution = vec![0.0; columns];
283    for (row, &index) in basis.iter().enumerate() {
284        if index < columns {
285            solution[index] = tableau[row][total];
286        }
287    }
288    Ok(solution)
289}
290
291fn pivot_until_done(
292    tableau: &mut [Vec<f64>],
293    basis: &mut [usize],
294    rows: usize,
295    total: usize,
296    allowed: Option<usize>,
297) -> Result<(), Error> {
298    let limit = allowed.unwrap_or(total);
299    for _ in 0..10_000 {
300        let mut entering = None;
301        let mut best = -1e-10;
302        for column in 0..limit {
303            let value = tableau[rows][column];
304            if value < best {
305                best = value;
306                entering = Some(column);
307            }
308        }
309        let Some(column) = entering else {
310            return Ok(());
311        };
312        let mut leaving = None;
313        let mut ratio = f64::INFINITY;
314        for row in 0..rows {
315            let coefficient = tableau[row][column];
316            if coefficient > 1e-12 {
317                let candidate = tableau[row][total] / coefficient;
318                if candidate < ratio - 1e-12 {
319                    ratio = candidate;
320                    leaving = Some(row);
321                }
322            }
323        }
324        let Some(row) = leaving else {
325            return Err(Error::InvalidInput(
326                "the convex-hull linear program is unbounded".to_owned(),
327            ));
328        };
329        basis[row] = column;
330        let pivot = tableau[row][column];
331        for value in 0..=total {
332            tableau[row][value] /= pivot;
333        }
334        for other in 0..=rows {
335            if other == row {
336                continue;
337            }
338            let factor = tableau[other][column];
339            if factor.abs() < 1e-18 {
340                continue;
341            }
342            for value in 0..=total {
343                tableau[other][value] -= factor * tableau[row][value];
344            }
345        }
346    }
347    Err(Error::InvalidInput(
348        "the convex-hull simplex did not converge".to_owned(),
349    ))
350}