use super::PetroleumError;
const WATER_SG_REFERENCE: f64 = 1.0;
#[inline]
pub(super) fn k_to_r(t_k: f64) -> f64 {
t_k * 1.8
}
#[inline]
pub(super) fn r_to_k(t_r: f64) -> f64 {
t_r / 1.8
}
#[inline]
pub(super) fn k_to_f(t_k: f64) -> f64 {
t_k * 1.8 - 459.67
}
#[inline]
pub(super) fn f_to_k(t_f: f64) -> f64 {
(t_f + 459.67) / 1.8
}
pub fn api_from_sg(sg: f64) -> Result<f64, PetroleumError> {
if sg <= 0.0 || !sg.is_finite() {
return Err(PetroleumError::InvalidInput(format!(
"specific gravity must be positive and finite, got {sg}"
)));
}
Ok(141.5 * WATER_SG_REFERENCE / sg - 131.5)
}
pub fn sg_from_api(api: f64) -> Result<f64, PetroleumError> {
let denom = api + 131.5;
if denom <= 0.0 || !api.is_finite() {
return Err(PetroleumError::InvalidInput(format!(
"API gravity must exceed -131.5, got {api}"
)));
}
Ok(141.5 * WATER_SG_REFERENCE / denom)
}
pub fn watson_k(tb: f64, sg: f64) -> Result<f64, PetroleumError> {
if tb <= 0.0 || !tb.is_finite() {
return Err(PetroleumError::InvalidInput(format!(
"boiling point must be positive and finite, got {tb} K"
)));
}
if sg <= 0.0 || !sg.is_finite() {
return Err(PetroleumError::InvalidInput(format!(
"specific gravity must be positive and finite, got {sg}"
)));
}
Ok(k_to_r(tb).cbrt() / sg)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AverageBoilingPoint {
pub vabp: f64,
pub wabp: f64,
pub mabp: f64,
pub cabp: f64,
pub meabp: f64,
}
pub fn average_boiling_points(
d86_10: f64,
d86_30: f64,
d86_50: f64,
d86_70: f64,
d86_90: f64,
) -> Result<AverageBoilingPoint, PetroleumError> {
let (t10, t30, t50, t70, t90) = (
k_to_f(d86_10),
k_to_f(d86_30),
k_to_f(d86_50),
k_to_f(d86_70),
k_to_f(d86_90),
);
if t90 < t10 {
return Err(PetroleumError::InvalidInput(format!(
"D86 90% point ({d86_90} K) is below the 10% point ({d86_10} K)"
)));
}
let vabp = (t10 + t30 + t50 + t70 + t90) / 5.0;
let sl = (t90 - t10) / 80.0;
let v32 = (vabp - 32.0).max(0.0);
let sl = sl.max(0.0);
let d_wabp = (-3.062_123 - 0.018_29 * v32.powf(0.6667) + 4.458_18 * sl.powf(0.25)).exp();
let d_mabp = (-0.563_793 - 0.007_981 * v32.powf(0.6667) + 3.047_29 * sl.powf(0.333)).exp();
let d_cabp = (-0.235_89 - 0.069_06 * v32.powf(0.45) + 1.885_8 * sl.powf(0.45)).exp();
let d_meabp = (-0.944_02 - 0.008_65 * v32.powf(0.6667) + 2.997_91 * sl.powf(0.333)).exp();
Ok(AverageBoilingPoint {
vabp: f_to_k(vabp),
wabp: f_to_k(vabp + d_wabp),
mabp: f_to_k(vabp - d_mabp),
cabp: f_to_k(vabp - d_cabp),
meabp: f_to_k(vabp - d_meabp),
})
}
pub fn weighted_boiling_point(tb: &[f64], fractions: &[f64]) -> Result<f64, PetroleumError> {
if tb.len() != fractions.len() {
return Err(PetroleumError::InvalidInput(format!(
"boiling points ({}) and fractions ({}) differ in length",
tb.len(),
fractions.len()
)));
}
if tb.is_empty() {
return Err(PetroleumError::InvalidInput("no cuts given".into()));
}
let total: f64 = fractions.iter().sum();
if total <= 0.0 {
return Err(PetroleumError::InvalidInput(
"fractions must sum to a positive number".into(),
));
}
Ok(tb.iter().zip(fractions).map(|(t, f)| t * f).sum::<f64>() / total)
}
pub fn cubic_boiling_point(tb: &[f64], volume_fractions: &[f64]) -> Result<f64, PetroleumError> {
if tb.len() != volume_fractions.len() {
return Err(PetroleumError::InvalidInput(format!(
"boiling points ({}) and fractions ({}) differ in length",
tb.len(),
volume_fractions.len()
)));
}
if tb.is_empty() {
return Err(PetroleumError::InvalidInput("no cuts given".into()));
}
let total: f64 = volume_fractions.iter().sum();
if total <= 0.0 {
return Err(PetroleumError::InvalidInput(
"fractions must sum to a positive number".into(),
));
}
let root: f64 = tb
.iter()
.zip(volume_fractions)
.map(|(t, v)| v * t.cbrt())
.sum::<f64>()
/ total;
Ok(root.powi(3))
}
pub fn blend_watson_k(kw: &[f64], weight_fractions: &[f64]) -> Result<f64, PetroleumError> {
weighted_boiling_point(kw, weight_fractions)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn water_is_ten_degrees_api() {
assert!((api_from_sg(1.0).unwrap() - 10.0).abs() < 1e-12);
assert!((sg_from_api(10.0).unwrap() - 1.0).abs() < 1e-12);
}
#[test]
fn api_and_sg_round_trip() {
for sg in [0.6, 0.7342, 0.8829, 1.05] {
let api = api_from_sg(sg).unwrap();
let back = sg_from_api(api).unwrap();
assert!((back - sg).abs() < 1e-12, "sg {sg} -> api {api} -> {back}");
}
}
#[test]
fn lighter_oil_has_higher_api() {
let light = api_from_sg(0.75).unwrap();
let heavy = api_from_sg(0.95).unwrap();
assert!(light > heavy, "light {light} should exceed heavy {heavy}");
}
#[test]
fn rejects_nonphysical_gravity() {
assert!(api_from_sg(0.0).is_err());
assert!(api_from_sg(-0.5).is_err());
assert!(sg_from_api(-131.5).is_err());
assert!(sg_from_api(-200.0).is_err());
}
#[test]
fn watson_k_separates_paraffins_from_aromatics() {
let paraffin = watson_k(371.55, 0.6882).unwrap();
let aromatic = watson_k(353.219, 0.8829).unwrap();
assert!(
(12.5..13.0).contains(¶ffin),
"n-heptane K_W = {paraffin}, expected ~12.7"
);
assert!(
(9.5..10.0).contains(&aromatic),
"benzene K_W = {aromatic}, expected ~9.7"
);
}
#[test]
fn watson_k_rejects_bad_input() {
assert!(watson_k(0.0, 0.8).is_err());
assert!(watson_k(400.0, 0.0).is_err());
assert!(watson_k(f64::NAN, 0.8).is_err());
}
#[test]
fn temperature_conversions_are_exact_at_known_anchors() {
assert!((k_to_r(273.15) - 491.67).abs() < 1e-9);
assert!((k_to_f(273.15) - 32.0).abs() < 1e-9);
assert!((f_to_k(32.0) - 273.15).abs() < 1e-9);
assert!((r_to_k(491.67) - 273.15).abs() < 1e-9);
}
#[test]
fn average_boiling_points_respect_the_physical_ordering() {
let a = average_boiling_points(450.0, 480.0, 505.0, 530.0, 565.0).unwrap();
assert!(
a.wabp >= a.vabp,
"WABP {} should be >= VABP {}",
a.wabp,
a.vabp
);
assert!(
a.vabp >= a.cabp,
"VABP {} should be >= CABP {}",
a.vabp,
a.cabp
);
assert!(
a.cabp >= a.meabp,
"CABP {} should be >= MeABP {}",
a.cabp,
a.meabp
);
assert!(
a.meabp >= a.mabp,
"MeABP {} should be >= MABP {}",
a.meabp,
a.mabp
);
}
#[test]
fn narrow_cut_collapses_every_average_onto_vabp() {
let a = average_boiling_points(500.0, 500.2, 500.4, 500.6, 500.8).unwrap();
for (name, v) in [
("wabp", a.wabp),
("mabp", a.mabp),
("cabp", a.cabp),
("meabp", a.meabp),
] {
assert!(
(v - a.vabp).abs() < 0.5,
"{name} = {v} drifted from VABP {} on a narrow cut",
a.vabp
);
}
}
#[test]
fn average_boiling_points_reject_a_decreasing_curve() {
assert!(average_boiling_points(560.0, 540.0, 520.0, 500.0, 480.0).is_err());
}
#[test]
fn low_boiling_cut_below_freezing_stays_finite() {
let a = average_boiling_points(250.0, 255.0, 260.0, 265.0, 270.0).unwrap();
for v in [a.vabp, a.wabp, a.mabp, a.cabp, a.meabp] {
assert!(v.is_finite(), "got a non-finite average: {v}");
}
}
#[test]
fn cubic_average_never_exceeds_volume_average() {
for spread in [10.0, 50.0, 150.0] {
let tb = [400.0 - spread, 400.0, 400.0 + spread];
let v = [0.3, 0.4, 0.3];
let vabp = weighted_boiling_point(&tb, &v).unwrap();
let cabp = cubic_boiling_point(&tb, &v).unwrap();
assert!(cabp <= vabp, "spread {spread}: CABP {cabp} > VABP {vabp}");
}
}
#[test]
fn averages_agree_for_a_single_cut() {
let tb = [430.0];
let f = [1.0];
assert!((weighted_boiling_point(&tb, &f).unwrap() - 430.0).abs() < 1e-9);
assert!((cubic_boiling_point(&tb, &f).unwrap() - 430.0).abs() < 1e-9);
}
#[test]
fn discrete_averages_normalize_their_weights() {
let tb = [350.0, 450.0];
let a = weighted_boiling_point(&tb, &[0.25, 0.75]).unwrap();
let b = weighted_boiling_point(&tb, &[0.5, 1.5]).unwrap();
assert!((a - b).abs() < 1e-12);
}
#[test]
fn discrete_averages_reject_mismatched_lengths() {
assert!(weighted_boiling_point(&[400.0, 450.0], &[1.0]).is_err());
assert!(cubic_boiling_point(&[400.0], &[0.5, 0.5]).is_err());
assert!(weighted_boiling_point(&[], &[]).is_err());
}
#[test]
fn blended_watson_k_lands_between_its_endpoints() {
let k = blend_watson_k(&[10.0, 13.0], &[0.5, 0.5]).unwrap();
assert!((k - 11.5).abs() < 1e-12, "got {k}");
}
}