1#![allow(clippy::indexing_slicing, clippy::needless_range_loop)] use std::collections::{BTreeMap, BTreeSet};
6
7use serde::{Deserialize, Serialize};
8
9use crate::Error;
10
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13pub struct PhaseEntry {
14 pub name: String,
16 pub composition: BTreeMap<String, f64>,
18 pub energy: f64,
20}
21
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct PhasePoint {
25 pub name: String,
27 pub composition: BTreeMap<String, f64>,
29 pub formation_energy: f64,
31 pub hull_energy: f64,
33 pub distance: f64,
35 pub stable: bool,
37 pub decomposition: Vec<(usize, f64)>,
39}
40
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct ConvexHull {
44 pub elements: Vec<String>,
46 pub references: BTreeMap<String, f64>,
48 pub points: Vec<PhasePoint>,
50}
51
52pub 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
224fn 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}