use crate::element::GAS_CONSTANT;
use crate::error::{KimiyaError, Result};
use serde::Serialize;
pub const FARADAY: f64 = 96_485.332_12;
pub const ELEMENTARY_CHARGE: f64 = 1.602176634e-19;
#[derive(Debug, Clone, Serialize)]
pub struct HalfReaction {
pub equation: &'static str,
pub couple: &'static str,
pub electrons: u8,
pub standard_potential: f64,
}
pub static STANDARD_POTENTIALS: &[HalfReaction] = &[
HalfReaction {
equation: "Li⁺ + e⁻ → Li",
couple: "Li+/Li",
electrons: 1,
standard_potential: -3.04,
},
HalfReaction {
equation: "K⁺ + e⁻ → K",
couple: "K+/K",
electrons: 1,
standard_potential: -2.924,
},
HalfReaction {
equation: "Ca²⁺ + 2e⁻ → Ca",
couple: "Ca2+/Ca",
electrons: 2,
standard_potential: -2.868,
},
HalfReaction {
equation: "Na⁺ + e⁻ → Na",
couple: "Na+/Na",
electrons: 1,
standard_potential: -2.71,
},
HalfReaction {
equation: "Mg²⁺ + 2e⁻ → Mg",
couple: "Mg2+/Mg",
electrons: 2,
standard_potential: -2.372,
},
HalfReaction {
equation: "Al³⁺ + 3e⁻ → Al",
couple: "Al3+/Al",
electrons: 3,
standard_potential: -1.662,
},
HalfReaction {
equation: "Zn²⁺ + 2e⁻ → Zn",
couple: "Zn2+/Zn",
electrons: 2,
standard_potential: -0.762,
},
HalfReaction {
equation: "Fe²⁺ + 2e⁻ → Fe",
couple: "Fe2+/Fe",
electrons: 2,
standard_potential: -0.447,
},
HalfReaction {
equation: "Ni²⁺ + 2e⁻ → Ni",
couple: "Ni2+/Ni",
electrons: 2,
standard_potential: -0.257,
},
HalfReaction {
equation: "Sn²⁺ + 2e⁻ → Sn",
couple: "Sn2+/Sn",
electrons: 2,
standard_potential: -0.136,
},
HalfReaction {
equation: "Pb²⁺ + 2e⁻ → Pb",
couple: "Pb2+/Pb",
electrons: 2,
standard_potential: -0.126,
},
HalfReaction {
equation: "2H⁺ + 2e⁻ → H₂",
couple: "H+/H2",
electrons: 2,
standard_potential: 0.000,
},
HalfReaction {
equation: "Cu²⁺ + 2e⁻ → Cu",
couple: "Cu2+/Cu",
electrons: 2,
standard_potential: 0.342,
},
HalfReaction {
equation: "I₂ + 2e⁻ → 2I⁻",
couple: "I2/I-",
electrons: 2,
standard_potential: 0.536,
},
HalfReaction {
equation: "Ag⁺ + e⁻ → Ag",
couple: "Ag+/Ag",
electrons: 1,
standard_potential: 0.7996,
},
HalfReaction {
equation: "Br₂ + 2e⁻ → 2Br⁻",
couple: "Br2/Br-",
electrons: 2,
standard_potential: 1.066,
},
HalfReaction {
equation: "Cl₂ + 2e⁻ → 2Cl⁻",
couple: "Cl2/Cl-",
electrons: 2,
standard_potential: 1.358,
},
HalfReaction {
equation: "Au³⁺ + 3e⁻ → Au",
couple: "Au3+/Au",
electrons: 3,
standard_potential: 1.498,
},
HalfReaction {
equation: "Ba²⁺ + 2e⁻ → Ba",
couple: "Ba2+/Ba",
electrons: 2,
standard_potential: -2.912,
},
HalfReaction {
equation: "Sc³⁺ + 3e⁻ → Sc",
couple: "Sc3+/Sc",
electrons: 3,
standard_potential: -2.077,
},
HalfReaction {
equation: "Be²⁺ + 2e⁻ → Be",
couple: "Be2+/Be",
electrons: 2,
standard_potential: -1.847,
},
HalfReaction {
equation: "Ti²⁺ + 2e⁻ → Ti",
couple: "Ti2+/Ti",
electrons: 2,
standard_potential: -1.630,
},
HalfReaction {
equation: "Ti³⁺ + 3e⁻ → Ti",
couple: "Ti3+/Ti",
electrons: 3,
standard_potential: -1.370,
},
HalfReaction {
equation: "Mn²⁺ + 2e⁻ → Mn",
couple: "Mn2+/Mn",
electrons: 2,
standard_potential: -1.185,
},
HalfReaction {
equation: "V²⁺ + 2e⁻ → V",
couple: "V2+/V",
electrons: 2,
standard_potential: -1.130,
},
HalfReaction {
equation: "Cr³⁺ + 3e⁻ → Cr",
couple: "Cr3+/Cr",
electrons: 3,
standard_potential: -0.744,
},
HalfReaction {
equation: "Cd²⁺ + 2e⁻ → Cd",
couple: "Cd2+/Cd",
electrons: 2,
standard_potential: -0.403,
},
HalfReaction {
equation: "Co²⁺ + 2e⁻ → Co",
couple: "Co2+/Co",
electrons: 2,
standard_potential: -0.280,
},
HalfReaction {
equation: "V³⁺ + e⁻ → V²⁺",
couple: "V3+/V2+",
electrons: 1,
standard_potential: -0.255,
},
HalfReaction {
equation: "Fe³⁺ + 3e⁻ → Fe",
couple: "Fe3+/Fe",
electrons: 3,
standard_potential: -0.037,
},
HalfReaction {
equation: "Cu²⁺ + e⁻ → Cu⁺",
couple: "Cu2+/Cu+",
electrons: 1,
standard_potential: 0.153,
},
HalfReaction {
equation: "Sn⁴⁺ + 2e⁻ → Sn²⁺",
couple: "Sn4+/Sn2+",
electrons: 2,
standard_potential: 0.154,
},
HalfReaction {
equation: "VO²⁺ + 2H⁺ + e⁻ → V³⁺ + H₂O",
couple: "VO2+/V3+",
electrons: 1,
standard_potential: 0.337,
},
HalfReaction {
equation: "O₂ + 2H₂O + 4e⁻ → 4OH⁻",
couple: "O2/OH-",
electrons: 4,
standard_potential: 0.401,
},
HalfReaction {
equation: "Cu⁺ + e⁻ → Cu",
couple: "Cu+/Cu",
electrons: 1,
standard_potential: 0.521,
},
HalfReaction {
equation: "Fe³⁺ + e⁻ → Fe²⁺",
couple: "Fe3+/Fe2+",
electrons: 1,
standard_potential: 0.771,
},
HalfReaction {
equation: "Hg₂²⁺ + 2e⁻ → 2Hg",
couple: "Hg22+/Hg",
electrons: 2,
standard_potential: 0.797,
},
HalfReaction {
equation: "Hg²⁺ + 2e⁻ → Hg",
couple: "Hg2+/Hg",
electrons: 2,
standard_potential: 0.851,
},
HalfReaction {
equation: "NO₃⁻ + 10H⁺ + 8e⁻ → NH₄⁺ + 3H₂O",
couple: "NO3-/NH4+",
electrons: 8,
standard_potential: 0.875,
},
HalfReaction {
equation: "ClO⁻ + H₂O + 2e⁻ → Cl⁻ + 2OH⁻",
couple: "ClO-/Cl-",
electrons: 2,
standard_potential: 0.890,
},
HalfReaction {
equation: "NO₃⁻ + 4H⁺ + 3e⁻ → NO + 2H₂O",
couple: "NO3-/NO",
electrons: 3,
standard_potential: 0.957,
},
HalfReaction {
equation: "O₂ + 4H⁺ + 4e⁻ → 2H₂O",
couple: "O2/H2O",
electrons: 4,
standard_potential: 1.229,
},
HalfReaction {
equation: "Cr₂O₇²⁻ + 14H⁺ + 6e⁻ → 2Cr³⁺ + 7H₂O",
couple: "Cr2O72-/Cr3+",
electrons: 6,
standard_potential: 1.330,
},
HalfReaction {
equation: "PbO₂ + 4H⁺ + 2e⁻ → Pb²⁺ + 2H₂O",
couple: "PbO2/Pb2+",
electrons: 2,
standard_potential: 1.455,
},
HalfReaction {
equation: "MnO₄⁻ + 8H⁺ + 5e⁻ → Mn²⁺ + 4H₂O",
couple: "MnO4-/Mn2+",
electrons: 5,
standard_potential: 1.507,
},
HalfReaction {
equation: "Ce⁴⁺ + e⁻ → Ce³⁺",
couple: "Ce4+/Ce3+",
electrons: 1,
standard_potential: 1.720,
},
HalfReaction {
equation: "H₂O₂ + 2H⁺ + 2e⁻ → 2H₂O",
couple: "H2O2/H2O",
electrons: 2,
standard_potential: 1.776,
},
HalfReaction {
equation: "S₂O₈²⁻ + 2e⁻ → 2SO₄²⁻",
couple: "S2O82-/SO42-",
electrons: 2,
standard_potential: 2.010,
},
HalfReaction {
equation: "F₂ + 2e⁻ → 2F⁻",
couple: "F2/F-",
electrons: 2,
standard_potential: 2.866,
},
];
#[must_use]
#[inline]
pub fn lookup_half_reaction(couple: &str) -> Option<&'static HalfReaction> {
STANDARD_POTENTIALS.iter().find(|hr| hr.couple == couple)
}
#[inline]
pub fn nernst_potential(
standard_potential: f64,
n_electrons: u8,
temperature_k: f64,
reaction_quotient: f64,
) -> Result<f64> {
if temperature_k <= 0.0 {
return Err(KimiyaError::InvalidTemperature(
"temperature must be positive".into(),
));
}
if n_electrons == 0 {
return Err(KimiyaError::InvalidInput(
"electron count must be at least 1".into(),
));
}
if reaction_quotient <= 0.0 {
return Err(KimiyaError::InvalidInput(
"reaction quotient must be positive".into(),
));
}
Ok(standard_potential
- (GAS_CONSTANT * temperature_k / (n_electrons as f64 * FARADAY)) * reaction_quotient.ln())
}
#[inline]
pub fn nernst_potential_25c(
standard_potential: f64,
n_electrons: u8,
reaction_quotient: f64,
) -> Result<f64> {
nernst_potential(standard_potential, n_electrons, 298.15, reaction_quotient)
}
#[must_use]
#[inline]
pub fn standard_cell_potential(e_cathode: f64, e_anode: f64) -> f64 {
e_cathode - e_anode
}
pub fn cell_potential_from_couples(cathode_couple: &str, anode_couple: &str) -> Result<f64> {
let cathode = lookup_half_reaction(cathode_couple).ok_or_else(|| {
KimiyaError::InvalidReaction(format!("unknown cathode couple: {cathode_couple}"))
})?;
let anode = lookup_half_reaction(anode_couple).ok_or_else(|| {
KimiyaError::InvalidReaction(format!("unknown anode couple: {anode_couple}"))
})?;
Ok(standard_cell_potential(
cathode.standard_potential,
anode.standard_potential,
))
}
#[must_use]
#[inline]
pub fn is_spontaneous_cell(e_cell: f64) -> bool {
e_cell > 0.0
}
#[inline]
pub fn cell_gibbs_energy(n_electrons: u8, cell_potential: f64) -> Result<f64> {
if n_electrons == 0 {
return Err(KimiyaError::InvalidInput(
"electron count must be at least 1".into(),
));
}
Ok(-(n_electrons as f64) * FARADAY * cell_potential)
}
#[inline]
pub fn faraday_mass_deposited(charge_c: f64, molar_mass_g: f64, n_electrons: u8) -> Result<f64> {
if n_electrons == 0 {
return Err(KimiyaError::InvalidInput(
"electron count must be at least 1".into(),
));
}
Ok(charge_c * molar_mass_g / (n_electrons as f64 * FARADAY))
}
#[inline]
pub fn faraday_charge_required(mass_g: f64, molar_mass_g: f64, n_electrons: u8) -> Result<f64> {
if n_electrons == 0 {
return Err(KimiyaError::InvalidInput(
"electron count must be at least 1".into(),
));
}
if molar_mass_g <= 0.0 {
return Err(KimiyaError::InvalidInput(
"molar mass must be positive".into(),
));
}
Ok(mass_g * n_electrons as f64 * FARADAY / molar_mass_g)
}
#[inline]
pub fn faraday_mass_ratio(
mass_a_g: f64,
molar_mass_a: f64,
n_electrons_a: u8,
molar_mass_b: f64,
n_electrons_b: u8,
) -> Result<f64> {
if n_electrons_a == 0 || n_electrons_b == 0 {
return Err(KimiyaError::InvalidInput(
"electron counts must be at least 1".into(),
));
}
if molar_mass_a <= 0.0 {
return Err(KimiyaError::InvalidInput(
"molar mass A must be positive".into(),
));
}
if molar_mass_b <= 0.0 {
return Err(KimiyaError::InvalidInput(
"molar mass B must be positive".into(),
));
}
let equiv_a = molar_mass_a / n_electrons_a as f64;
let equiv_b = molar_mass_b / n_electrons_b as f64;
Ok(mass_a_g * equiv_b / equiv_a)
}
#[must_use]
#[inline]
pub fn charge_to_moles_electrons(charge_c: f64) -> f64 {
charge_c / FARADAY
}
#[must_use]
#[inline]
pub fn moles_electrons_to_charge(moles: f64) -> f64 {
moles * FARADAY
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn faraday_constant_value() {
let computed = crate::element::AVOGADRO * ELEMENTARY_CHARGE;
assert!(
(computed - FARADAY).abs() / FARADAY < 1e-6,
"F should equal N_A × e, got {computed}"
);
}
#[test]
fn lookup_copper() {
let hr = lookup_half_reaction("Cu2+/Cu").unwrap();
assert_eq!(hr.electrons, 2);
assert!((hr.standard_potential - 0.342).abs() < 0.001);
}
#[test]
fn lookup_hydrogen() {
let hr = lookup_half_reaction("H+/H2").unwrap();
assert!((hr.standard_potential).abs() < f64::EPSILON);
}
#[test]
fn lookup_nonexistent() {
assert!(lookup_half_reaction("Xx/Xx").is_none());
}
#[test]
fn potentials_count() {
assert!(
STANDARD_POTENTIALS.len() >= 49,
"should have 49+ half-reactions, got {}",
STANDARD_POTENTIALS.len()
);
}
#[test]
fn nernst_at_standard_conditions() {
let e = nernst_potential(0.342, 2, 298.15, 1.0).unwrap();
assert!((e - 0.342).abs() < 1e-10, "Q=1 should give E=E°, got {e}");
}
#[test]
fn nernst_q_greater_than_1() {
let e = nernst_potential(0.342, 2, 298.15, 100.0).unwrap();
assert!(e < 0.342, "Q>1 should reduce E below E°, got {e}");
}
#[test]
fn nernst_q_less_than_1() {
let e = nernst_potential(0.342, 2, 298.15, 0.01).unwrap();
assert!(e > 0.342, "Q<1 should increase E above E°, got {e}");
}
#[test]
fn nernst_25c_shorthand() {
let e_full = nernst_potential(0.342, 2, 298.15, 0.1).unwrap();
let e_short = nernst_potential_25c(0.342, 2, 0.1).unwrap();
assert!((e_full - e_short).abs() < 1e-10);
}
#[test]
fn nernst_zero_electrons_is_error() {
assert!(nernst_potential(0.342, 0, 298.15, 1.0).is_err());
}
#[test]
fn nernst_zero_q_is_error() {
assert!(nernst_potential(0.342, 2, 298.15, 0.0).is_err());
}
#[test]
fn nernst_zero_temp_is_error() {
assert!(nernst_potential(0.342, 2, 0.0, 1.0).is_err());
}
#[test]
fn daniell_cell() {
let e = standard_cell_potential(0.342, -0.762);
assert!(
(e - 1.104).abs() < 0.001,
"Daniell cell should be ~1.10V, got {e}"
);
}
#[test]
fn daniell_cell_from_couples() {
let e = cell_potential_from_couples("Cu2+/Cu", "Zn2+/Zn").unwrap();
assert!((e - 1.104).abs() < 0.001);
}
#[test]
fn unknown_couple_is_error() {
assert!(cell_potential_from_couples("Xx/Xx", "Zn2+/Zn").is_err());
}
#[test]
fn spontaneous_cell_positive() {
assert!(is_spontaneous_cell(1.1));
assert!(!is_spontaneous_cell(-0.5));
assert!(!is_spontaneous_cell(0.0));
}
#[test]
fn cell_gibbs_daniell() {
let dg = cell_gibbs_energy(2, 1.104).unwrap();
assert!(dg < 0.0, "spontaneous cell should have negative ΔG");
assert!(
(dg - (-213_038.0)).abs() < 100.0,
"ΔG should be ~-213 kJ/mol, got {dg}"
);
}
#[test]
fn cell_gibbs_zero_electrons_is_error() {
assert!(cell_gibbs_energy(0, 1.0).is_err());
}
#[test]
fn faraday_copper_deposition() {
let mass = faraday_mass_deposited(FARADAY, 63.546, 2).unwrap();
assert!(
(mass - 31.773).abs() < 0.01,
"1F should deposit ~31.77g Cu, got {mass}"
);
}
#[test]
fn faraday_charge_roundtrip() {
let mass = 10.0; let q = faraday_charge_required(mass, 63.546, 2).unwrap();
let back = faraday_mass_deposited(q, 63.546, 2).unwrap();
assert!(
(back - mass).abs() < 0.001,
"roundtrip failed: {mass} → {q}C → {back}g"
);
}
#[test]
fn faraday_zero_electrons_is_error() {
assert!(faraday_mass_deposited(1000.0, 63.546, 0).is_err());
assert!(faraday_charge_required(10.0, 63.546, 0).is_err());
}
#[test]
fn faraday_second_law() {
let mass_ag = faraday_mass_ratio(31.773, 63.546, 2, 107.868, 1).unwrap();
assert!(
(mass_ag - 107.868).abs() < 0.1,
"should deposit ~107.87g Ag, got {mass_ag}"
);
}
#[test]
fn charge_moles_roundtrip() {
let moles = 2.5;
let q = moles_electrons_to_charge(moles);
let back = charge_to_moles_electrons(q);
assert!((back - moles).abs() < 1e-10);
}
#[test]
fn one_faraday_is_one_mole() {
let moles = charge_to_moles_electrons(FARADAY);
assert!((moles - 1.0).abs() < 1e-10);
}
#[test]
fn nernst_25c_quantitative() {
let e = nernst_potential_25c(0.342, 2, 0.01).unwrap();
assert!(
(e - 0.40116).abs() < 0.001,
"Cu²⁺/Cu at Q=0.01 should be ~0.401V, got {e}"
);
}
#[test]
fn nernst_negative_q_is_error() {
assert!(nernst_potential(0.342, 2, 298.15, -1.0).is_err());
}
#[test]
fn faraday_negative_charge_gives_negative_mass() {
let mass = faraday_mass_deposited(-FARADAY, 63.546, 2).unwrap();
assert!(mass < 0.0);
}
#[test]
fn faraday_mass_ratio_zero_molar_mass_b_is_error() {
assert!(faraday_mass_ratio(10.0, 63.546, 2, 0.0, 1).is_err());
}
#[test]
fn all_half_reactions_have_nonzero_electrons() {
for hr in STANDARD_POTENTIALS.iter() {
assert!(hr.electrons > 0, "{} has zero electrons", hr.couple);
}
}
#[test]
fn cell_gibbs_sign_matches_spontaneity() {
let dg_pos = cell_gibbs_energy(2, 1.1).unwrap();
assert!(dg_pos < 0.0);
let dg_neg = cell_gibbs_energy(2, -0.5).unwrap();
assert!(dg_neg > 0.0);
}
}