use crate::error::{KimiyaError, Result};
pub fn balance_equation(composition: &[Vec<f64>]) -> Result<Vec<u32>> {
if composition.is_empty() {
return Err(KimiyaError::InvalidInput("empty composition matrix".into()));
}
let n_species = composition[0].len();
if n_species < 2 {
return Err(KimiyaError::InvalidInput("need at least 2 species".into()));
}
for row in composition {
if row.len() != n_species {
return Err(KimiyaError::InvalidInput(
"all rows must have equal length".into(),
));
}
}
let n_elements = composition.len();
let n_unknowns = n_species - 1;
let mut augmented: Vec<Vec<f64>> = Vec::with_capacity(n_elements);
for row in composition {
let mut aug_row = Vec::with_capacity(n_unknowns + 1);
aug_row.extend_from_slice(&row[..n_unknowns]);
aug_row.push(-row[n_species - 1]); augmented.push(aug_row);
}
while augmented.len() < n_unknowns {
let mut zero_row = vec![0.0; n_unknowns + 1];
zero_row[augmented.len()] = 1.0; augmented.push(zero_row);
}
augmented.truncate(n_unknowns);
let solution = hisab::num::gaussian_elimination(&mut augmented)
.map_err(|e| KimiyaError::ComputationError(format!("equation balancing failed: {e}")))?;
let mut coeffs: Vec<f64> = solution;
coeffs.push(1.0);
let all_negative = coeffs.iter().all(|&c| c <= 0.0);
if all_negative {
for c in &mut coeffs {
*c = -(*c);
}
}
let min_abs = coeffs
.iter()
.map(|c| c.abs())
.filter(|&c| c > 1e-10)
.fold(f64::INFINITY, f64::min);
if min_abs < 1e-10 {
return Err(KimiyaError::ComputationError(
"degenerate solution — equation may not be balanceable".into(),
));
}
let scaled: Vec<f64> = coeffs.iter().map(|c| c / min_abs).collect();
let mut best_mult = 1u32;
let mut best_err = f64::INFINITY;
for mult in 1..=12 {
let err: f64 = scaled
.iter()
.map(|&r| {
let v = r * mult as f64;
(v - v.round()).abs()
})
.sum();
if err < best_err {
best_err = err;
best_mult = mult;
}
if best_err < 0.01 {
break;
}
}
let result: Vec<u32> = scaled
.iter()
.map(|&r| (r * best_mult as f64).round().abs() as u32)
.collect();
if result.contains(&0) {
return Err(KimiyaError::ComputationError(
"balancing produced zero coefficient".into(),
));
}
Ok(result)
}
pub fn assign_oxidation_states(atoms: &[(u8, u32)], overall_charge: i32) -> Result<Vec<(u8, i32)>> {
if atoms.is_empty() {
return Err(KimiyaError::InvalidInput(
"need at least one element".into(),
));
}
let mut result: Vec<(u8, Option<i32>)> = atoms.iter().map(|&(z, _)| (z, None)).collect();
let mut remaining_charge = overall_charge;
for (i, &(z, count)) in atoms.iter().enumerate() {
let os = match z {
9 => Some(-1), 3 | 11 | 19 | 37 | 55 | 87 => Some(1), 4 | 12 | 20 | 38 | 56 | 88 => Some(2), _ => None,
};
if let Some(state) = os {
result[i].1 = Some(state);
remaining_charge -= state * count as i32;
}
}
for (i, &(z, count)) in atoms.iter().enumerate() {
if result[i].1.is_some() {
continue;
}
let os = match z {
8 => Some(-2), 1 => Some(1), _ => None,
};
if let Some(state) = os {
result[i].1 = Some(state);
remaining_charge -= state * count as i32;
}
}
let unknowns: Vec<usize> = result
.iter()
.enumerate()
.filter(|(_, (_, os))| os.is_none())
.map(|(i, _)| i)
.collect();
match unknowns.len() {
0 => {} 1 => {
let idx = unknowns[0];
let count = atoms[idx].1 as i32;
if count == 0 {
return Err(KimiyaError::InvalidInput("zero atom count".into()));
}
result[idx].1 = Some(remaining_charge / count);
}
_ => {
return Err(KimiyaError::ComputationError(
"multiple unknown oxidation states — system underdetermined".into(),
));
}
}
Ok(result
.into_iter()
.map(|(z, os)| (z, os.unwrap_or(0)))
.collect())
}
pub fn limiting_reagent(moles_available: &[f64], stoich_coefficients: &[f64]) -> Result<usize> {
if moles_available.len() != stoich_coefficients.len() {
return Err(KimiyaError::InvalidInput(
"moles and coefficients must have equal length".into(),
));
}
if moles_available.is_empty() {
return Err(KimiyaError::InvalidInput(
"need at least one reactant".into(),
));
}
let mut min_ratio = f64::INFINITY;
let mut min_idx = 0;
for (i, (&moles, &coeff)) in moles_available
.iter()
.zip(stoich_coefficients.iter())
.enumerate()
{
if moles < 0.0 {
return Err(KimiyaError::InvalidInput(
"moles available must be non-negative".into(),
));
}
if coeff <= 0.0 {
return Err(KimiyaError::InvalidInput(
"stoichiometric coefficients must be positive".into(),
));
}
let ratio = moles / coeff;
if ratio < min_ratio {
min_ratio = ratio;
min_idx = i;
}
}
Ok(min_idx)
}
pub fn theoretical_yield_moles(
moles_available: &[f64],
stoich_reactants: &[f64],
stoich_product: f64,
) -> Result<f64> {
if stoich_product <= 0.0 {
return Err(KimiyaError::InvalidInput(
"product coefficient must be positive".into(),
));
}
let idx = limiting_reagent(moles_available, stoich_reactants)?;
let limiting_ratio = moles_available[idx] / stoich_reactants[idx];
Ok(limiting_ratio * stoich_product)
}
#[inline]
pub fn percent_yield(actual: f64, theoretical: f64) -> Result<f64> {
if theoretical <= 0.0 {
return Err(KimiyaError::InvalidInput(
"theoretical yield must be positive".into(),
));
}
Ok(actual / theoretical * 100.0)
}
pub fn percent_composition(molecule: &crate::molecule::Molecule) -> Result<Vec<(u8, f64)>> {
let mw = molecule.molecular_weight()?;
if mw <= 0.0 {
return Err(KimiyaError::ComputationError(
"molecular weight must be positive".into(),
));
}
let mut result = Vec::with_capacity(molecule.atoms.len());
for atom in &molecule.atoms {
let element = crate::element::lookup_by_number(atom.element_number).ok_or_else(|| {
KimiyaError::InvalidElement(format!("unknown atomic number {}", atom.element_number))
})?;
let mass_fraction = element.atomic_mass * atom.count as f64 / mw * 100.0;
result.push((atom.element_number, mass_fraction));
}
Ok(result)
}
pub fn empirical_formula(elements: &[(u8, f64)]) -> Result<Vec<(u8, u32)>> {
if elements.is_empty() {
return Err(KimiyaError::InvalidInput(
"need at least one element".into(),
));
}
let mut moles = Vec::with_capacity(elements.len());
for &(z, mass_pct) in elements {
let element = crate::element::lookup_by_number(z)
.ok_or_else(|| KimiyaError::InvalidElement(format!("unknown atomic number {z}")))?;
if mass_pct <= 0.0 {
return Err(KimiyaError::InvalidInput(
"mass percentages must be positive".into(),
));
}
moles.push((z, mass_pct / element.atomic_mass));
}
let min_moles = moles.iter().map(|(_, m)| *m).fold(f64::INFINITY, f64::min);
if min_moles <= 0.0 {
return Err(KimiyaError::ComputationError(
"mole calculation produced non-positive value".into(),
));
}
let ratios: Vec<(u8, f64)> = moles.iter().map(|&(z, m)| (z, m / min_moles)).collect();
let mut best_mult = 1u32;
let mut best_err = f64::INFINITY;
for mult in 1..=6 {
let err: f64 = ratios
.iter()
.map(|&(_, r)| {
let scaled = r * mult as f64;
(scaled - scaled.round()).abs()
})
.sum();
if err < best_err {
best_err = err;
best_mult = mult;
}
}
Ok(ratios
.iter()
.map(|&(z, r)| (z, (r * best_mult as f64).round() as u32))
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::molecule::Molecule;
#[test]
fn limiting_reagent_basic() {
let idx = limiting_reagent(&[3.0, 2.0], &[2.0, 1.0]).unwrap();
assert_eq!(idx, 0);
}
#[test]
fn limiting_reagent_second() {
let idx = limiting_reagent(&[10.0, 1.0], &[2.0, 1.0]).unwrap();
assert_eq!(idx, 1);
}
#[test]
fn limiting_reagent_empty_is_error() {
assert!(limiting_reagent(&[], &[]).is_err());
}
#[test]
fn limiting_reagent_mismatched_is_error() {
assert!(limiting_reagent(&[1.0], &[1.0, 2.0]).is_err());
}
#[test]
fn theoretical_yield_basic() {
let y = theoretical_yield_moles(&[3.0, 2.0], &[2.0, 1.0], 2.0).unwrap();
assert!((y - 3.0).abs() < f64::EPSILON);
}
#[test]
fn percent_yield_basic() {
let pct = percent_yield(8.0, 10.0).unwrap();
assert!((pct - 80.0).abs() < f64::EPSILON);
}
#[test]
fn percent_yield_over_100() {
let pct = percent_yield(11.0, 10.0).unwrap();
assert!((pct - 110.0).abs() < 1e-10);
}
#[test]
fn percent_yield_zero_theoretical_is_error() {
assert!(percent_yield(5.0, 0.0).is_err());
}
#[test]
fn percent_composition_water() {
let water = Molecule::water();
let comp = percent_composition(&water).unwrap();
let h_pct = comp.iter().find(|&&(z, _)| z == 1).unwrap().1;
let o_pct = comp.iter().find(|&&(z, _)| z == 8).unwrap().1;
assert!((h_pct - 11.19).abs() < 0.1);
assert!((o_pct - 88.81).abs() < 0.1);
assert!((h_pct + o_pct - 100.0).abs() < 0.01);
}
#[test]
fn empirical_formula_water() {
let formula = empirical_formula(&[(1, 11.19), (8, 88.81)]).unwrap();
assert_eq!(formula.iter().find(|&&(z, _)| z == 1).unwrap().1, 2);
assert_eq!(formula.iter().find(|&&(z, _)| z == 8).unwrap().1, 1);
}
#[test]
fn empirical_formula_glucose() {
let formula = empirical_formula(&[(6, 40.0), (1, 6.7), (8, 53.3)]).unwrap();
let c = formula.iter().find(|&&(z, _)| z == 6).unwrap().1;
let h = formula.iter().find(|&&(z, _)| z == 1).unwrap().1;
let o = formula.iter().find(|&&(z, _)| z == 8).unwrap().1;
assert_eq!(c, 1);
assert_eq!(h, 2);
assert_eq!(o, 1);
}
#[test]
fn empirical_formula_empty_is_error() {
assert!(empirical_formula(&[]).is_err());
}
#[test]
fn balance_h2_o2_water() {
let comp = vec![vec![2.0, 0.0, -2.0], vec![0.0, 2.0, -1.0]];
let coeffs = balance_equation(&comp).unwrap();
assert_eq!(coeffs.len(), 3);
for row in &comp {
let sum: f64 = row
.iter()
.zip(coeffs.iter())
.map(|(&c, &k)| c * k as f64)
.sum();
assert!(sum.abs() < 0.1, "element not balanced: sum = {sum}");
}
}
#[test]
fn balance_ch4_combustion() {
let comp = vec![
vec![1.0, 0.0, -1.0, 0.0],
vec![4.0, 0.0, 0.0, -2.0],
vec![0.0, 2.0, -2.0, -1.0],
];
let coeffs = balance_equation(&comp).unwrap();
assert_eq!(coeffs.len(), 4);
for row in &comp {
let sum: f64 = row
.iter()
.zip(coeffs.iter())
.map(|(&c, &k)| c * k as f64)
.sum();
assert!(sum.abs() < 0.1, "element not balanced: sum = {sum}");
}
}
#[test]
fn balance_empty_is_error() {
assert!(balance_equation(&[]).is_err());
}
#[test]
fn balance_single_species_is_error() {
assert!(balance_equation(&[vec![1.0]]).is_err());
}
#[test]
fn oxidation_state_water() {
let os = assign_oxidation_states(&[(1, 2), (8, 1)], 0).unwrap();
assert_eq!(os.iter().find(|&&(z, _)| z == 1).unwrap().1, 1);
assert_eq!(os.iter().find(|&&(z, _)| z == 8).unwrap().1, -2);
}
#[test]
fn oxidation_state_nacl() {
let os = assign_oxidation_states(&[(11, 1), (17, 1)], 0).unwrap();
assert_eq!(os.iter().find(|&&(z, _)| z == 11).unwrap().1, 1);
assert_eq!(os.iter().find(|&&(z, _)| z == 17).unwrap().1, -1);
}
#[test]
fn oxidation_state_sulfate_ion() {
let os = assign_oxidation_states(&[(16, 1), (8, 4)], -2).unwrap();
assert_eq!(os.iter().find(|&&(z, _)| z == 16).unwrap().1, 6);
assert_eq!(os.iter().find(|&&(z, _)| z == 8).unwrap().1, -2);
}
#[test]
fn oxidation_state_permanganate() {
let os = assign_oxidation_states(&[(25, 1), (8, 4)], -1).unwrap();
assert_eq!(os.iter().find(|&&(z, _)| z == 25).unwrap().1, 7);
}
#[test]
fn oxidation_state_co2() {
let os = assign_oxidation_states(&[(6, 1), (8, 2)], 0).unwrap();
assert_eq!(os.iter().find(|&&(z, _)| z == 6).unwrap().1, 4);
}
#[test]
fn oxidation_state_empty_is_error() {
assert!(assign_oxidation_states(&[], 0).is_err());
}
}