use super::PetroleumError;
use crate::numerics::root_finding::brent;
const MMHG_PER_KPA: f64 = 760.0 / 101.325;
const P_STANDARD_MMHG: f64 = 760.0;
const P_MIN_MMHG: f64 = 1e-4;
const P_MAX_MMHG: f64 = 50_000.0;
fn q_group(p_mmhg: f64) -> f64 {
let lp = p_mmhg.log10();
let (a, b, c, d) = if p_mmhg < 2.0 {
Q_BRANCHES[0]
} else if p_mmhg < P_STANDARD_MMHG {
Q_BRANCHES[1]
} else {
Q_BRANCHES[2]
};
(a - b * lp) / (c - d * lp)
}
fn watson_correction(tb_ref: f64, p_mmhg: f64, watson_k: Option<f64>) -> f64 {
let Some(kw) = watson_k else { return 0.0 };
let f = ((1.8 * tb_ref - 659.67) / 200.0).clamp(0.0, 1.0);
1.3889 * f * (kw - 12.0) * (p_mmhg / P_STANDARD_MMHG).log10()
}
fn check_pressure(p_kpa: f64) -> Result<f64, PetroleumError> {
if p_kpa <= 0.0 || !p_kpa.is_finite() {
return Err(PetroleumError::InvalidInput(format!(
"pressure must be positive and finite, got {p_kpa} kPa"
)));
}
let mmhg = p_kpa * MMHG_PER_KPA;
if !(P_MIN_MMHG..=P_MAX_MMHG).contains(&mmhg) {
return Err(PetroleumError::InvalidInput(format!(
"pressure {p_kpa} kPa ({mmhg:.4} mmHg) is outside the \
{P_MIN_MMHG}–{P_MAX_MMHG} mmHg range Maxwell-Bonnell covers"
)));
}
Ok(mmhg)
}
fn check_temperature(t: f64) -> Result<(), PetroleumError> {
if t <= 0.0 || !t.is_finite() {
return Err(PetroleumError::InvalidInput(format!(
"temperature must be positive and finite, got {t} K"
)));
}
Ok(())
}
pub fn normal_boiling_point(t: f64, p: f64, watson_k: Option<f64>) -> Result<f64, PetroleumError> {
check_temperature(t)?;
let p_mmhg = check_pressure(p)?;
let q = q_group(p_mmhg);
let denom = 1.0 + t * (0.3861 * q - 0.000_516_06);
if denom <= 0.0 {
return Err(PetroleumError::NoConvergence(format!(
"Maxwell-Bonnell is degenerate at T = {t} K, P = {p} kPa"
)));
}
let tb_uncorrected = 748.1 * q * t / denom;
let mut tb = tb_uncorrected;
for _ in 0..100 {
let next = tb_uncorrected + watson_correction(tb, p_mmhg, watson_k);
if (next - tb).abs() < 1e-13 {
return Ok(next);
}
tb = next;
}
Err(PetroleumError::NoConvergence(format!(
"the Maxwell-Bonnell Watson-K correction did not settle at T = {t} K, P = {p} kPa"
)))
}
pub fn boiling_point_at_pressure(
tb: f64,
p: f64,
watson_k: Option<f64>,
) -> Result<f64, PetroleumError> {
check_temperature(tb)?;
let p_mmhg = check_pressure(p)?;
let q = q_group(p_mmhg);
let tb_uncorrected = tb - watson_correction(tb, p_mmhg, watson_k);
let c = 0.3861 * q - 0.000_516_06;
let denom = 748.1 * q - tb_uncorrected * c;
if denom <= 0.0 {
return Err(PetroleumError::NoConvergence(format!(
"Maxwell-Bonnell inversion is degenerate at Tb = {tb} K, P = {p} kPa"
)));
}
Ok(tb_uncorrected / denom)
}
const Q_BRANCHES: [(f64, f64, f64, f64); 3] = [
(6.761_56, 0.987_672, 3000.538, 43.0),
(5.994_296, 0.972_546, 2663.129, 95.76),
(6.412_631, 0.989_679, 2770.085, 36.0),
];
pub fn vapor_pressure(t: f64, tb: f64, watson_k: Option<f64>) -> Result<f64, PetroleumError> {
check_temperature(t)?;
check_temperature(tb)?;
if let Some(p) = vapor_pressure_closed_form(t, tb, watson_k, false) {
return Ok(p);
}
vapor_pressure_brent(t, tb, watson_k)
}
pub fn ln_vapor_pressure(t: f64, tb: f64, watson_k: Option<f64>) -> Result<f64, PetroleumError> {
check_temperature(t)?;
check_temperature(tb)?;
vapor_pressure_closed_form(t, tb, watson_k, true)
.map(f64::ln)
.ok_or_else(|| {
PetroleumError::NoConvergence(format!(
"Maxwell-Bonnell has no vapor pressure for Tb = {tb} K at T = {t} K"
))
})
}
fn boiling_point_unchecked(tb: f64, p_mmhg: f64, watson_k: Option<f64>) -> Option<f64> {
if !(p_mmhg > 0.0 && p_mmhg.is_finite()) {
return None;
}
let q = q_group(p_mmhg);
let tb_uncorrected = tb - watson_correction(tb, p_mmhg, watson_k);
let c = 0.3861 * q - 0.000_516_06;
let denom = 748.1 * q - tb_uncorrected * c;
(denom > 0.0).then(|| tb_uncorrected / denom)
}
fn vapor_pressure_closed_form(
t: f64,
tb: f64,
watson_k: Option<f64>,
extrapolate: bool,
) -> Option<f64> {
let l0 = P_STANDARD_MMHG.log10();
let k = match watson_k {
Some(kw) => 1.3889 * ((1.8 * tb - 659.67) / 200.0).clamp(0.0, 1.0) * (kw - 12.0),
None => 0.0,
};
let m = tb + k * l0;
let a0 = 748.1 - 0.3861 * m;
let s = 1.0 - 0.000_516_06 * t;
let bounds = if extrapolate {
[f64::NEG_INFINITY, 2f64.log10(), l0, f64::INFINITY]
} else {
[P_MIN_MMHG.log10(), 2f64.log10(), l0, P_MAX_MMHG.log10()]
};
let mut best: Option<(f64, f64)> = None; for (i, &(a, b, c, d)) in Q_BRANCHES.iter().enumerate() {
let alpha = -0.3861 * b * k * t - s * d * k;
let beta = t * (0.3861 * a * k - b * a0) + s * (c * k + d * m);
let gamma = t * a * a0 - s * c * m;
let mut roots: [Option<f64>; 2] = [None, None];
if alpha.abs() < 1e-300 {
if beta != 0.0 {
roots[0] = Some(-gamma / beta);
}
} else {
let disc = beta * beta - 4.0 * alpha * gamma;
if disc >= 0.0 {
let sq = disc.sqrt();
let q = -0.5 * (beta + beta.signum() * sq);
roots[0] = Some(q / alpha);
if q != 0.0 {
roots[1] = Some(gamma / q);
}
}
}
let (lo, hi) = (bounds[i], bounds[i + 1]);
for l in roots.into_iter().flatten() {
let inside = if i == 2 {
(lo..=hi).contains(&l)
} else {
(lo..hi).contains(&l)
};
if !inside {
continue;
}
if let Some(t_back) = boiling_point_unchecked(tb, 10f64.powf(l), watson_k) {
let r = (t_back - t).abs();
if r < 1e-6 && best.is_none_or(|(rb, _)| r < rb) {
best = Some((r, l));
}
}
}
}
if let Some((_, l)) = best {
return Some(10f64.powf(l) / MMHG_PER_KPA);
}
for &p_mmhg in &[2.0, P_STANDARD_MMHG] {
let p_kpa = p_mmhg / MMHG_PER_KPA;
let below = boiling_point_at_pressure(tb, p_kpa * (1.0 - 1e-9), watson_k).ok()?;
let above = boiling_point_at_pressure(tb, p_kpa * (1.0 + 1e-9), watson_k).ok()?;
if (below.min(above)..=below.max(above)).contains(&t) {
return Some(p_kpa);
}
}
None
}
fn vapor_pressure_brent(t: f64, tb: f64, watson_k: Option<f64>) -> Result<f64, PetroleumError> {
let residual = |log_p: f64| -> f64 {
let p_kpa = 10f64.powf(log_p) / MMHG_PER_KPA;
match boiling_point_at_pressure(tb, p_kpa, watson_k) {
Ok(t_boil) => t_boil - t,
Err(_) => f64::INFINITY * (log_p - P_STANDARD_MMHG.log10()).signum(),
}
};
let (lo, hi) = (P_MIN_MMHG.log10(), P_MAX_MMHG.log10());
let (f_lo, f_hi) = (residual(lo), residual(hi));
if !(f_lo <= 0.0 && f_hi >= 0.0) {
return Err(PetroleumError::NoConvergence(format!(
"no vapor pressure in {P_MIN_MMHG}–{P_MAX_MMHG} mmHg puts the boiling \
point of a Tb = {tb} K fraction at {t} K"
)));
}
let log_p = brent(residual, lo, hi, 1e-12, 200).map_err(|e| {
PetroleumError::NoConvergence(format!(
"Maxwell-Bonnell vapor-pressure inversion failed at T = {t} K, Tb = {tb} K: {e}"
))
})?;
Ok(10f64.powf(log_p) / MMHG_PER_KPA)
}
#[cfg(test)]
mod tests {
use super::*;
const REFERENCE: [(&str, f64, f64, f64, [f64; 3]); 7] = [
(
"n-hexane",
341.866,
0.664,
3044.1,
[5.800675, 2697.547514, -48.784],
),
(
"n-heptane",
371.55,
0.6882,
2735.73,
[5.966566, 2921.142342, -56.199],
),
(
"n-octane",
398.794,
0.707,
2483.59,
[6.114906, 3123.134317, -63.515],
),
(
"n-nonane",
423.913,
0.7219,
2281.0,
[6.252519, 3311.186441, -70.456],
),
(
"n-decane",
447.27,
0.7342,
2103.0,
[6.322187, 3442.756153, -79.292],
),
(
"benzene",
353.219,
0.8829,
4907.277,
[5.358805, 2771.932525, -53.226],
),
(
"toluene",
383.746,
0.8719,
4126.3,
[5.606494, 3056.958021, -55.525],
),
];
fn antoine_psat(row: &(&str, f64, f64, f64, [f64; 3]), t: f64) -> f64 {
let [a1, a2, a3] = row.4;
row.3 * (a1 - a2 / (a3 + t)).exp()
}
fn kw_of(row: &(&str, f64, f64, f64, [f64; 3])) -> f64 {
super::super::gravity::watson_k(row.1, row.2).unwrap()
}
#[test]
fn recovers_normal_boiling_points_from_antoine_vapor_pressures() {
let mut worst: f64 = 0.0;
let mut who = String::new();
let mut total = 0.0;
let mut n = 0;
for row in &REFERENCE {
let kw = kw_of(row);
for step in 0..=10 {
let t = 320.0 + step as f64 * 20.0;
let p = antoine_psat(row, t);
let Ok(tb) = normal_boiling_point(t, p, Some(kw)) else {
continue;
};
let err = 100.0 * (tb - row.1).abs() / row.1;
total += err;
n += 1;
if err > worst {
worst = err;
who = format!("{} at {t:.0} K", row.0);
}
}
}
let mean = total / n as f64;
assert!(
n > 50,
"only {n} usable points — the oracle sweep is too thin"
);
assert!(
mean < 0.20,
"mean error {mean:.3}%, expected < 0.20% over {n} points"
);
assert!(
worst < 1.15,
"worst error {worst:.3}% ({who}), expected < 1.15%"
);
}
#[test]
fn predicts_vapor_pressure_within_a_few_percent_of_antoine() {
for row in &REFERENCE {
let kw = kw_of(row);
for step in 0..=6 {
let t = 340.0 + step as f64 * 25.0;
let want = antoine_psat(row, t);
if !(0.05..3000.0).contains(&want) {
continue;
}
let got = vapor_pressure(t, row.1, Some(kw)).unwrap();
let err = 100.0 * (got - want).abs() / want;
assert!(
err < 25.0,
"{} at {t:.0} K: Maxwell-Bonnell {got:.3} kPa vs Antoine \
{want:.3} kPa ({err:.1}%)",
row.0
);
}
}
}
#[test]
fn is_nearly_but_not_exactly_an_identity_at_one_atmosphere() {
for row in &REFERENCE {
let t = boiling_point_at_pressure(row.1, 101.325, Some(kw_of(row))).unwrap();
let err = t - row.1;
assert!(
(0.0..0.4).contains(&err),
"{}: boiling point at 1 atm came back {t} K vs Tb = {} K \
(offset {err:+.4} K, expected the documented 0-0.4 K overshoot)",
row.0,
row.1
);
}
let light = boiling_point_at_pressure(350.0, 101.325, None).unwrap() - 350.0;
let heavy = boiling_point_at_pressure(750.0, 101.325, None).unwrap() - 750.0;
assert!(heavy > light, "offset {light:.4} K -> {heavy:.4} K");
}
#[test]
fn the_two_directions_are_exact_inverses() {
for kw in [None, Some(10.5), Some(12.0), Some(13.0)] {
for tb in [350.0, 450.0, 600.0, 750.0] {
for p in [0.5, 5.0, 50.0, 101.325, 500.0] {
let t = boiling_point_at_pressure(tb, p, kw).unwrap();
let back = normal_boiling_point(t, p, kw).unwrap();
assert!(
(back - tb).abs() < 1e-8,
"Tb {tb} K at {p} kPa -> T {t} K -> Tb {back} K (K_W = {kw:?})"
);
}
}
}
}
#[test]
fn vapor_pressure_inverts_the_boiling_point_relation() {
for kw in [None, Some(11.0), Some(12.6)] {
for tb in [400.0, 550.0, 700.0] {
for t in [350.0, 450.0, 550.0] {
if t > tb + 150.0 {
continue;
}
let Ok(p) = vapor_pressure(t, tb, kw) else {
continue;
};
let back = boiling_point_at_pressure(tb, p, kw).unwrap();
assert!(
(back - t).abs() < 0.6,
"T {t} K -> P {p} kPa -> T {back} K (Tb {tb} K, K_W {kw:?})"
);
}
}
}
}
#[test]
fn vacuum_lowers_the_boiling_point() {
let p_10mmhg = 10.0 / MMHG_PER_KPA;
let t = boiling_point_at_pressure(600.0, p_10mmhg, Some(11.8)).unwrap();
assert!(t < 600.0, "boiling point at 10 mmHg came out at {t} K");
assert!(
(450.0..520.0).contains(&t),
"a 600 K cut should boil around 470-490 K at 10 mmHg, got {t} K"
);
}
#[test]
fn boiling_point_rises_monotonically_with_pressure() {
let mut prev = f64::NEG_INFINITY;
for step in 0..40 {
let p = 0.1 * 1.2f64.powi(step);
let Ok(t) = boiling_point_at_pressure(550.0, p, Some(12.2)) else {
continue;
};
assert!(t > prev, "boiling point fell to {t} K at {p} kPa");
prev = t;
}
}
#[test]
fn vapor_pressure_rises_monotonically_with_temperature() {
let mut prev = f64::NEG_INFINITY;
for step in 0..25 {
let t = 350.0 + step as f64 * 10.0;
let Ok(p) = vapor_pressure(t, 500.0, Some(12.0)) else {
continue;
};
assert!(p > prev, "vapor pressure fell to {p} kPa at {t} K");
prev = p;
}
}
#[test]
fn the_watson_correction_vanishes_at_the_reference() {
for p in [1.0, 10.0, 101.325, 1000.0] {
let with = normal_boiling_point(500.0, p, Some(12.0)).unwrap();
let without = normal_boiling_point(500.0, p, None).unwrap();
assert!(
(with - without).abs() < 1e-12,
"at {p} kPa the K_W = 12 correction moved Tb by {}",
with - without
);
}
}
#[test]
fn aromatic_and_paraffinic_corrections_pull_in_opposite_directions() {
let p = 10.0 / MMHG_PER_KPA;
let base = normal_boiling_point(500.0, p, None).unwrap();
let paraffin = normal_boiling_point(500.0, p, Some(12.8)).unwrap();
let aromatic = normal_boiling_point(500.0, p, Some(10.5)).unwrap();
assert!(paraffin < base, "paraffin {paraffin} vs uncorrected {base}");
assert!(aromatic > base, "aromatic {aromatic} vs uncorrected {base}");
}
#[test]
fn the_branch_boundaries_step_by_the_documented_amount() {
for (boundary, lo_k, hi_k) in [(2.0, 0.05, 0.15), (760.0, 0.3, 0.6)] {
for tb in [450.0, 650.0] {
let below =
boiling_point_at_pressure(tb, boundary * (1.0 - 1e-9) / MMHG_PER_KPA, None)
.unwrap();
let above =
boiling_point_at_pressure(tb, boundary * (1.0 + 1e-9) / MMHG_PER_KPA, None)
.unwrap();
let jump = (above - below).abs();
assert!(
(lo_k..=hi_k).contains(&jump),
"boiling point steps {jump:.4} K across {boundary} mmHg at \
Tb = {tb} K; the module docs claim {lo_k}-{hi_k} K"
);
}
}
}
#[test]
fn out_of_range_pressure_is_reported_not_extrapolated() {
assert!(normal_boiling_point(500.0, 0.0, None).is_err());
assert!(normal_boiling_point(500.0, -1.0, None).is_err());
assert!(normal_boiling_point(500.0, 1e-9, None).is_err());
assert!(normal_boiling_point(500.0, 1e7, None).is_err());
assert!(boiling_point_at_pressure(500.0, 1e7, None).is_err());
}
#[test]
fn out_of_range_temperature_is_reported() {
assert!(normal_boiling_point(0.0, 101.325, None).is_err());
assert!(normal_boiling_point(f64::NAN, 101.325, None).is_err());
assert!(vapor_pressure(-5.0, 500.0, None).is_err());
assert!(vapor_pressure(500.0, 0.0, None).is_err());
}
#[test]
fn an_unreachable_vapor_pressure_says_so_rather_than_guessing() {
let err = vapor_pressure(200.0, 900.0, Some(11.5)).unwrap_err();
assert!(
matches!(err, PetroleumError::NoConvergence(_)),
"got {err:?}"
);
}
#[test]
fn closed_form_inversion_matches_the_brent_oracle_everywhere() {
let mut checked = 0;
for tb in [350.0, 450.0, 550.0, 650.0, 750.0] {
for kw in [None, Some(10.5), Some(11.8), Some(12.9)] {
let mut t = 0.55 * tb;
while t < 1.05 * tb {
if let Ok(oracle) = vapor_pressure_brent(t, tb, kw) {
let got =
vapor_pressure_closed_form(t, tb, kw, false).unwrap_or_else(|| {
panic!("no closed form at T={t}, Tb={tb}, K={kw:?}")
});
let rel = (got - oracle).abs() / oracle;
assert!(
rel < 1e-7,
"T={t} Tb={tb} K={kw:?}: closed form {got} vs Brent {oracle} ({rel:.2e})"
);
checked += 1;
}
t += 3.7;
}
}
}
assert!(checked > 500, "only {checked} points compared");
}
#[test]
fn closed_form_reproduces_the_forward_relation_exactly() {
for tb in [400.0, 600.0, 800.0] {
for kw in [None, Some(11.0)] {
for frac in [0.7, 0.8, 0.9, 0.97] {
let t = frac * tb;
let p = vapor_pressure(t, tb, kw).unwrap();
let back = boiling_point_at_pressure(tb, p, kw).unwrap();
assert!((back - t).abs() < 1e-8, "T={t} Tb={tb} K={kw:?}: {back}");
}
}
}
}
#[test]
fn a_temperature_inside_the_branch_step_returns_the_boundary_pressure() {
let tb = 500.0;
let p760 = P_STANDARD_MMHG / MMHG_PER_KPA;
let below = boiling_point_at_pressure(tb, p760 * (1.0 - 1e-9), None).unwrap();
let above = boiling_point_at_pressure(tb, p760 * (1.0 + 1e-9), None).unwrap();
let mid = 0.5 * (below + above);
assert!((below - above).abs() > 0.2, "no step to test at Tb={tb}");
let p = vapor_pressure(mid, tb, None).unwrap();
assert!(
(p - p760).abs() < 1e-9,
"got {p} kPa, expected the 760 mmHg boundary"
);
}
#[test]
fn ln_vapor_pressure_extrapolates_where_vapor_pressure_refuses() {
let p = vapor_pressure(450.0, 500.0, Some(11.5)).unwrap();
assert!((ln_vapor_pressure(450.0, 500.0, Some(11.5)).unwrap() - p.ln()).abs() < 1e-12);
assert!(vapor_pressure(600.0, 343.0, None).is_err());
let ln_p = ln_vapor_pressure(600.0, 343.0, None).unwrap();
assert!(
ln_p > (P_MAX_MMHG / MMHG_PER_KPA).ln(),
"extrapolated ln P = {ln_p}"
);
let a = ln_vapor_pressure(560.0, 343.0, None).unwrap();
let b = ln_vapor_pressure(580.0, 343.0, None).unwrap();
assert!(a < b && b < ln_p);
assert!(vapor_pressure(200.0, 900.0, Some(11.5)).is_err());
assert!(
ln_vapor_pressure(200.0, 900.0, Some(11.5)).unwrap() < (P_MIN_MMHG / MMHG_PER_KPA).ln()
);
}
}