use super::PetroleumError;
use super::gravity::{f_to_k, k_to_f};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "python", pyo3::pyclass(eq, eq_int))]
pub enum DistillationBasis {
D86,
Tbp,
D2887,
Efv,
}
impl DistillationBasis {
pub fn name(&self) -> &'static str {
match self {
DistillationBasis::D86 => "ASTM D86",
DistillationBasis::Tbp => "TBP",
DistillationBasis::D2887 => "ASTM D2887 (SimDist)",
DistillationBasis::Efv => "EFV",
}
}
pub fn is_weight_basis(&self) -> bool {
matches!(self, DistillationBasis::D2887)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct DistillationCurve {
pub basis: DistillationBasis,
pub fractions: Vec<f64>,
pub temperatures: Vec<f64>,
}
pub const STANDARD_GRID: [f64; 7] = [0.0, 0.10, 0.30, 0.50, 0.70, 0.90, 0.95];
impl DistillationCurve {
pub fn new(
basis: DistillationBasis,
fractions: Vec<f64>,
temperatures: Vec<f64>,
) -> Result<Self, PetroleumError> {
if fractions.len() != temperatures.len() {
return Err(PetroleumError::Curve(format!(
"{} fractions but {} temperatures",
fractions.len(),
temperatures.len()
)));
}
if fractions.len() < 2 {
return Err(PetroleumError::Curve(format!(
"a distillation curve needs at least 2 points, got {}",
fractions.len()
)));
}
for (i, &x) in fractions.iter().enumerate() {
if !(0.0..=1.0).contains(&x) || !x.is_finite() {
return Err(PetroleumError::CutPoints(format!(
"fraction[{i}] = {x} is outside [0, 1]"
)));
}
if i > 0 && x <= fractions[i - 1] {
return Err(PetroleumError::CutPoints(format!(
"fractions must strictly increase: fraction[{}] = {} is not above fraction[{}] = {}",
i,
x,
i - 1,
fractions[i - 1]
)));
}
}
for (i, &t) in temperatures.iter().enumerate() {
if t <= 0.0 || !t.is_finite() {
return Err(PetroleumError::InvalidInput(format!(
"temperature[{i}] = {t} K is not a positive finite temperature"
)));
}
if i > 0 && t < temperatures[i - 1] {
return Err(PetroleumError::InvalidInput(format!(
"a distillation curve cannot decrease: temperature[{}] = {} K is below temperature[{}] = {} K",
i,
t,
i - 1,
temperatures[i - 1]
)));
}
}
Ok(Self {
basis,
fractions,
temperatures,
})
}
pub fn len(&self) -> usize {
self.fractions.len()
}
pub fn is_empty(&self) -> bool {
false
}
pub fn temperature_at(&self, fraction: f64) -> f64 {
let n = self.len();
let xs = &self.fractions;
let ts = &self.temperatures;
if fraction <= xs[0] {
let slope = (ts[1] - ts[0]) / (xs[1] - xs[0]);
return ts[0] + slope * (fraction - xs[0]);
}
if fraction >= xs[n - 1] {
let slope = (ts[n - 1] - ts[n - 2]) / (xs[n - 1] - xs[n - 2]);
return ts[n - 1] + slope * (fraction - xs[n - 1]);
}
let hi = xs.partition_point(|&x| x < fraction).max(1);
let lo = hi - 1;
let w = (fraction - xs[lo]) / (xs[hi] - xs[lo]);
ts[lo] + w * (ts[hi] - ts[lo])
}
pub fn fraction_at(&self, temperature: f64) -> f64 {
let n = self.len();
let xs = &self.fractions;
let ts = &self.temperatures;
let invert = |lo: usize, hi: usize| -> f64 {
let dt = ts[hi] - ts[lo];
if dt.abs() < f64::EPSILON {
return xs[lo];
}
xs[lo] + (temperature - ts[lo]) / dt * (xs[hi] - xs[lo])
};
if temperature <= ts[0] {
return invert(0, 1);
}
if temperature >= ts[n - 1] {
return invert(n - 2, n - 1);
}
let hi = ts.partition_point(|&t| t < temperature).max(1);
invert(hi - 1, hi)
}
pub fn resample(&self, fractions: &[f64]) -> Result<Self, PetroleumError> {
let temperatures = fractions.iter().map(|&x| self.temperature_at(x)).collect();
Self::new(self.basis, fractions.to_vec(), temperatures)
}
fn index_of_half(&self) -> Result<usize, PetroleumError> {
self.fractions
.iter()
.position(|&x| (x - 0.5).abs() < 1e-9)
.ok_or_else(|| {
PetroleumError::CutPoints(
"the API difference procedures need the 50% point on the curve; \
call `resample(&STANDARD_GRID)` first"
.into(),
)
})
}
}
fn row_for<const N: usize>(bounds: &[f64; N], x: f64) -> usize {
for i in (0..N).rev() {
if x >= bounds[i] {
return i;
}
}
0
}
const RIAZI_BOUNDS: [f64; 7] = [0.0, 0.10, 0.30, 0.50, 0.70, 0.90, 0.95];
const RIAZI_A: [f64; 7] = [0.9177, 0.5564, 0.765_17, 0.9013, 0.8821, 0.9552, 0.8177];
const RIAZI_B: [f64; 7] = [1.0019, 1.09, 1.0425, 1.0176, 1.0226, 1.011, 1.0355];
pub fn d86_to_tbp_riazi(curve: &DistillationCurve) -> Result<DistillationCurve, PetroleumError> {
expect_basis(curve, DistillationBasis::D86)?;
let t = curve
.fractions
.iter()
.zip(&curve.temperatures)
.map(|(&x, &t)| {
let i = row_for(&RIAZI_BOUNDS, x);
RIAZI_A[i] * t.powf(RIAZI_B[i])
})
.collect();
DistillationCurve::new(DistillationBasis::Tbp, curve.fractions.clone(), t)
}
pub fn tbp_to_d86_riazi(curve: &DistillationCurve) -> Result<DistillationCurve, PetroleumError> {
expect_basis(curve, DistillationBasis::Tbp)?;
let t = curve
.fractions
.iter()
.zip(&curve.temperatures)
.map(|(&x, &t)| {
let i = row_for(&RIAZI_BOUNDS, x);
(t / RIAZI_A[i]).powf(1.0 / RIAZI_B[i])
})
.collect();
DistillationCurve::new(DistillationBasis::D86, curve.fractions.clone(), t)
}
const DELTA_BOUNDS: [f64; 6] = [0.0, 0.10, 0.30, 0.50, 0.70, 0.90];
const D86_TBP_A: [f64; 6] = [7.4012, 4.9004, 3.0305, 2.5282, 3.0419, 0.117_98];
const D86_TBP_B: [f64; 6] = [0.602_44, 0.716_44, 0.800_76, 0.820_02, 0.754_97, 1.6606];
fn accumulate_from_half(anchor: f64, deltas: &[f64], half: usize) -> Vec<f64> {
let n = deltas.len() + 1;
let mut out = vec![anchor; n];
for i in (0..half).rev() {
out[i] = out[i + 1] - deltas[i];
}
for i in half + 1..n {
out[i] = out[i - 1] + deltas[i - 1];
}
out
}
pub fn d86_to_tbp_daubert(curve: &DistillationCurve) -> Result<DistillationCurve, PetroleumError> {
expect_basis(curve, DistillationBasis::D86)?;
let half = curve.index_of_half()?;
let f: Vec<f64> = curve.temperatures.iter().map(|&t| k_to_f(t)).collect();
let anchor = 0.871_80 * f[half].powf(1.0258);
let deltas: Vec<f64> = (0..f.len() - 1)
.map(|j| {
let i = row_for(&DELTA_BOUNDS, curve.fractions[j]);
D86_TBP_A[i] * (f[j + 1] - f[j]).max(0.0).powf(D86_TBP_B[i])
})
.collect();
let tbp = accumulate_from_half(anchor, &deltas, half);
DistillationCurve::new(
DistillationBasis::Tbp,
curve.fractions.clone(),
tbp.into_iter().map(f_to_k).collect(),
)
}
pub fn tbp_to_d86_daubert(curve: &DistillationCurve) -> Result<DistillationCurve, PetroleumError> {
expect_basis(curve, DistillationBasis::Tbp)?;
let half = curve.index_of_half()?;
let f: Vec<f64> = curve.temperatures.iter().map(|&t| k_to_f(t)).collect();
let anchor = (f[half] / 0.871_80).powf(1.0 / 1.0258);
let deltas: Vec<f64> = (0..f.len() - 1)
.map(|j| {
let i = row_for(&DELTA_BOUNDS, curve.fractions[j]);
((f[j + 1] - f[j]).max(0.0) / D86_TBP_A[i]).powf(1.0 / D86_TBP_B[i])
})
.collect();
let d86 = accumulate_from_half(anchor, &deltas, half);
DistillationCurve::new(
DistillationBasis::D86,
curve.fractions.clone(),
d86.into_iter().map(f_to_k).collect(),
)
}
const SD_BOUNDS: [f64; 7] = [0.05, 0.10, 0.30, 0.50, 0.70, 0.90, 0.95];
const SD_TBP_C: [f64; 7] = [
0.157_79, 0.011_903, 0.053_42, 0.198_61, 0.315_31, 0.974_76, 0.021_72,
];
const SD_TBP_D: [f64; 7] = [1.4296, 2.0253, 1.6988, 1.3975, 1.2938, 0.8723, 1.9733];
pub fn d2887_to_tbp(curve: &DistillationCurve) -> Result<DistillationCurve, PetroleumError> {
expect_basis(curve, DistillationBasis::D2887)?;
let half = curve.index_of_half()?;
let f: Vec<f64> = curve.temperatures.iter().map(|&t| k_to_f(t)).collect();
let deltas: Vec<f64> = (0..f.len() - 1)
.map(|j| {
let i = row_for(&SD_BOUNDS, curve.fractions[j]);
SD_TBP_C[i] * (f[j + 1] - f[j]).max(0.0).powf(SD_TBP_D[i])
})
.collect();
let tbp = accumulate_from_half(f[half], &deltas, half);
DistillationCurve::new(
DistillationBasis::Tbp,
curve.fractions.clone(),
tbp.into_iter().map(f_to_k).collect(),
)
}
pub fn tbp_to_d2887(curve: &DistillationCurve) -> Result<DistillationCurve, PetroleumError> {
expect_basis(curve, DistillationBasis::Tbp)?;
let half = curve.index_of_half()?;
let f: Vec<f64> = curve.temperatures.iter().map(|&t| k_to_f(t)).collect();
let deltas: Vec<f64> = (0..f.len() - 1)
.map(|j| {
let i = row_for(&SD_BOUNDS, curve.fractions[j]);
((f[j + 1] - f[j]).max(0.0) / SD_TBP_C[i]).powf(1.0 / SD_TBP_D[i])
})
.collect();
let sd = accumulate_from_half(f[half], &deltas, half);
DistillationCurve::new(
DistillationBasis::D2887,
curve.fractions.clone(),
sd.into_iter().map(f_to_k).collect(),
)
}
const SD_D86_E: [f64; 6] = [0.3047, 0.060_69, 0.079_78, 0.148_62, 0.307_85, 2.6029];
const SD_D86_F: [f64; 6] = [1.1259, 1.5176, 1.5386, 1.4287, 1.2341, 0.659_62];
pub fn d2887_to_d86(curve: &DistillationCurve) -> Result<DistillationCurve, PetroleumError> {
expect_basis(curve, DistillationBasis::D2887)?;
let half = curve.index_of_half()?;
let f: Vec<f64> = curve.temperatures.iter().map(|&t| k_to_f(t)).collect();
let anchor = 0.776_01 * f[half].powf(1.0395);
let deltas: Vec<f64> = (0..f.len() - 1)
.map(|j| {
let i = row_for(&DELTA_BOUNDS, curve.fractions[j]);
SD_D86_E[i] * (f[j + 1] - f[j]).max(0.0).powf(SD_D86_F[i])
})
.collect();
let d86 = accumulate_from_half(anchor, &deltas, half);
DistillationCurve::new(
DistillationBasis::D86,
curve.fractions.clone(),
d86.into_iter().map(f_to_k).collect(),
)
}
const EFV_BOUNDS: [f64; 7] = [0.0, 0.10, 0.30, 0.50, 0.70, 0.90, 1.0];
const EFV_A: [f64; 7] = [2.9747, 1.4459, 0.8506, 3.268, 8.2873, 10.6266, 7.9952];
const EFV_B: [f64; 7] = [0.8466, 0.9511, 1.0315, 0.8274, 0.6874, 0.6529, 0.6949];
const EFV_C: [f64; 7] = [0.4209, 0.1287, 0.0817, 0.6214, 0.934, 1.1025, 1.0737];
pub fn d86_to_efv(curve: &DistillationCurve, sg: f64) -> Result<DistillationCurve, PetroleumError> {
expect_basis(curve, DistillationBasis::D86)?;
check_sg(sg)?;
let t = curve
.fractions
.iter()
.zip(&curve.temperatures)
.map(|(&x, &t)| {
let i = row_for(&EFV_BOUNDS, x);
EFV_A[i] * t.powf(EFV_B[i]) * sg.powf(EFV_C[i])
})
.collect();
DistillationCurve::new(DistillationBasis::Efv, curve.fractions.clone(), t)
}
pub fn efv_to_d86(curve: &DistillationCurve, sg: f64) -> Result<DistillationCurve, PetroleumError> {
expect_basis(curve, DistillationBasis::Efv)?;
check_sg(sg)?;
let t = curve
.fractions
.iter()
.zip(&curve.temperatures)
.map(|(&x, &t)| {
let i = row_for(&EFV_BOUNDS, x);
(t / (EFV_A[i] * sg.powf(EFV_C[i]))).powf(1.0 / EFV_B[i])
})
.collect();
DistillationCurve::new(DistillationBasis::D86, curve.fractions.clone(), t)
}
pub fn convert_curve(
curve: &DistillationCurve,
target: DistillationBasis,
sg: Option<f64>,
) -> Result<DistillationCurve, PetroleumError> {
if curve.basis == target {
return Ok(curve.clone());
}
match (curve.basis, target) {
(DistillationBasis::D86, DistillationBasis::Efv) => {
return d86_to_efv(curve, need_sg(sg)?);
}
(DistillationBasis::Efv, DistillationBasis::D86) => {
return efv_to_d86(curve, need_sg(sg)?);
}
(DistillationBasis::D2887, DistillationBasis::D86) => return d2887_to_d86(curve),
_ => {}
}
let tbp = match curve.basis {
DistillationBasis::Tbp => curve.clone(),
DistillationBasis::D86 => d86_to_tbp_daubert(curve)?,
DistillationBasis::D2887 => d2887_to_tbp(curve)?,
DistillationBasis::Efv => d86_to_tbp_daubert(&efv_to_d86(curve, need_sg(sg)?)?)?,
};
match target {
DistillationBasis::Tbp => Ok(tbp),
DistillationBasis::D86 => tbp_to_d86_daubert(&tbp),
DistillationBasis::D2887 => tbp_to_d2887(&tbp),
DistillationBasis::Efv => d86_to_efv(&tbp_to_d86_daubert(&tbp)?, need_sg(sg)?),
}
}
fn need_sg(sg: Option<f64>) -> Result<f64, PetroleumError> {
let sg = sg.ok_or_else(|| {
PetroleumError::InvalidInput(
"an EFV conversion needs the fraction's specific gravity".into(),
)
})?;
check_sg(sg)?;
Ok(sg)
}
fn check_sg(sg: f64) -> Result<(), PetroleumError> {
if sg <= 0.0 || !sg.is_finite() {
return Err(PetroleumError::InvalidInput(format!(
"specific gravity must be positive and finite, got {sg}"
)));
}
Ok(())
}
fn expect_basis(curve: &DistillationCurve, want: DistillationBasis) -> Result<(), PetroleumError> {
if curve.basis != want {
return Err(PetroleumError::InvalidInput(format!(
"expected a {} curve, got {}",
want.name(),
curve.basis.name()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn from_celsius(basis: DistillationBasis, x: &[f64], t_c: &[f64]) -> DistillationCurve {
DistillationCurve::new(basis, x.to_vec(), t_c.iter().map(|t| t + 273.15).collect()).unwrap()
}
fn to_celsius(c: &DistillationCurve) -> Vec<f64> {
c.temperatures.iter().map(|t| t - 273.15).collect()
}
fn from_fahrenheit(basis: DistillationBasis, x: &[f64], t_f: &[f64]) -> DistillationCurve {
DistillationCurve::new(basis, x.to_vec(), t_f.iter().map(|&t| f_to_k(t)).collect()).unwrap()
}
#[test]
fn riazi_example_3_3_d86_to_tbp_power_law() {
let x = [0.0, 0.1, 0.3, 0.5, 0.7, 0.9];
let d86 = from_celsius(
DistillationBasis::D86,
&x,
&[165.6, 173.7, 193.3, 206.7, 222.8, 242.8],
);
let tbp = to_celsius(&d86_to_tbp_riazi(&d86).unwrap());
let want = [134.2, 157.4, 190.3, 209.0, 230.2, 254.7];
for (got, want) in tbp.iter().zip(want) {
assert!(
(got - want).abs() < 0.15,
"TBP {got:.2} °C vs published {want:.1} °C (curve {tbp:?})"
);
}
}
#[test]
fn riazi_example_3_3_d86_to_tbp_api_difference_method() {
let x = [0.0, 0.1, 0.3, 0.5, 0.7, 0.9];
let d86 = from_celsius(
DistillationBasis::D86,
&x,
&[165.6, 173.7, 193.3, 206.7, 222.8, 242.8],
);
let tbp = to_celsius(&d86_to_tbp_daubert(&d86).unwrap());
let want = [133.5, 154.2, 189.2, 210.7, 232.9, 258.2];
for (got, want) in tbp.iter().zip(want) {
assert!(
(got - want).abs() < 0.15,
"TBP {got:.2} °C vs published {want:.1} °C (curve {tbp:?})"
);
}
}
#[test]
fn api_data_book_example_d86_to_tbp_in_fahrenheit() {
let x = [0.1, 0.3, 0.5, 0.7, 0.9];
let d86 = from_fahrenheit(
DistillationBasis::D86,
&x,
&[350.0, 380.0, 404.0, 433.0, 469.0],
);
let tbp = d86_to_tbp_daubert(&d86).unwrap();
let got: Vec<f64> = tbp.temperatures.iter().map(|&t| k_to_f(t)).collect();
let want = [316.5, 372.6, 411.2, 451.2, 496.7];
for (g, w) in got.iter().zip(want) {
assert!((g - w).abs() < 0.15, "TBP {g:.2} °F vs published {w:.1} °F");
}
}
#[test]
fn riazi_example_3_4_simdist_to_tbp() {
let x = [0.1, 0.3, 0.5, 0.7, 0.9];
let sd = from_celsius(
DistillationBasis::D2887,
&x,
&[151.7, 162.2, 168.9, 173.3, 181.7],
);
let tbp = to_celsius(&d2887_to_tbp(&sd).unwrap());
let want = [164.3, 166.9, 168.9, 170.9, 176.8];
for (got, want) in tbp.iter().zip(want) {
assert!(
(got - want).abs() < 0.15,
"TBP {got:.2} °C vs published {want:.1} °C (curve {tbp:?})"
);
}
}
#[test]
fn riazi_example_3_5_simdist_to_d86() {
let x = [0.1, 0.3, 0.5, 0.7, 0.9];
let sd = from_celsius(
DistillationBasis::D2887,
&x,
&[33.9, 64.4, 101.7, 140.6, 182.2],
);
let d86 = to_celsius(&d2887_to_d86(&sd).unwrap());
let want = [53.5, 68.2, 96.9, 132.6, 167.8];
for (got, want) in d86.iter().zip(want) {
assert!(
(got - want).abs() < 0.15,
"D86 {got:.2} °C vs published {want:.1} °C (curve {d86:?})"
);
}
}
#[test]
fn api_data_book_example_simdist_to_tbp_in_fahrenheit() {
let x = [0.05, 0.1, 0.3, 0.5, 0.7, 0.9, 0.95];
let sd = from_fahrenheit(
DistillationBasis::D2887,
&x,
&[293.0, 305.0, 324.0, 336.0, 344.0, 359.0, 369.0],
);
let got: Vec<f64> = d2887_to_tbp(&sd)
.unwrap()
.temperatures
.iter()
.map(|&t| k_to_f(t))
.collect();
let want = [322.2, 327.7, 332.4, 336.0, 339.6, 350.1, 357.4];
for (g, w) in got.iter().zip(want) {
assert!((g - w).abs() < 0.15, "TBP {g:.2} °F vs published {w:.1} °F");
}
}
#[test]
fn riazi_example_3_2_tbp_to_d86_and_on_to_efv() {
let x = [0.0, 0.1, 0.3, 0.5, 0.7, 0.9];
let tbp = from_celsius(
DistillationBasis::Tbp,
&x,
&[10.0, 71.1, 143.3, 204.4, 250.6, 291.7],
);
let d86 = tbp_to_d86_riazi(&tbp).unwrap();
let d86_c = to_celsius(&d86);
assert!(
(d86_c[0] - 32.0).abs() < 0.5,
"D86 initial point {:.1} °C vs published 32 °C",
d86_c[0]
);
let efv_c = to_celsius(&d86_to_efv(&d86, 0.7862).unwrap());
assert!(
(efv_c[0] - 68.0).abs() < 0.5,
"EFV initial point {:.1} °C vs published 68 °C",
efv_c[0]
);
}
fn standard_d86() -> DistillationCurve {
from_celsius(
DistillationBasis::D86,
&STANDARD_GRID,
&[150.0, 170.0, 200.0, 230.0, 262.0, 300.0, 320.0],
)
}
#[test]
fn riazi_power_law_round_trips() {
let d86 = standard_d86();
let back = tbp_to_d86_riazi(&d86_to_tbp_riazi(&d86).unwrap()).unwrap();
for (a, b) in d86.temperatures.iter().zip(&back.temperatures) {
assert!((a - b).abs() < 1e-9, "{a} K -> {b} K");
}
}
#[test]
fn api_difference_method_round_trips() {
let d86 = standard_d86();
let back = tbp_to_d86_daubert(&d86_to_tbp_daubert(&d86).unwrap()).unwrap();
for (a, b) in d86.temperatures.iter().zip(&back.temperatures) {
assert!((a - b).abs() < 1e-8, "{a} K -> {b} K");
}
}
#[test]
fn simdist_round_trips_through_tbp() {
let sd = from_celsius(
DistillationBasis::D2887,
&STANDARD_GRID,
&[140.0, 165.0, 198.0, 228.0, 258.0, 296.0, 318.0],
);
let back = tbp_to_d2887(&d2887_to_tbp(&sd).unwrap()).unwrap();
for (a, b) in sd.temperatures.iter().zip(&back.temperatures) {
assert!((a - b).abs() < 1e-8, "{a} K -> {b} K");
}
}
#[test]
fn efv_round_trips() {
let d86 = standard_d86();
let back = efv_to_d86(&d86_to_efv(&d86, 0.82).unwrap(), 0.82).unwrap();
for (a, b) in d86.temperatures.iter().zip(&back.temperatures) {
assert!((a - b).abs() < 1e-9, "{a} K -> {b} K");
}
}
#[test]
fn router_reaches_every_basis_from_every_basis() {
let bases = [
DistillationBasis::D86,
DistillationBasis::Tbp,
DistillationBasis::D2887,
DistillationBasis::Efv,
];
let source = standard_d86();
for &from in &bases {
let start = convert_curve(&source, from, Some(0.82)).unwrap();
for &to in &bases {
let out = convert_curve(&start, to, Some(0.82)).unwrap();
assert_eq!(out.basis, to, "routing {from:?} -> {to:?} kept the basis");
assert_eq!(out.fractions, start.fractions);
for &t in &out.temperatures {
assert!(t.is_finite() && t > 0.0, "{from:?} -> {to:?} gave {t} K");
}
}
}
}
#[test]
fn router_is_identity_on_the_same_basis() {
let d86 = standard_d86();
let same = convert_curve(&d86, DistillationBasis::D86, None).unwrap();
assert_eq!(d86, same);
}
#[test]
fn router_demands_gravity_only_for_efv_legs() {
let d86 = standard_d86();
assert!(convert_curve(&d86, DistillationBasis::Tbp, None).is_ok());
let err = convert_curve(&d86, DistillationBasis::Efv, None).unwrap_err();
assert!(
matches!(err, PetroleumError::InvalidInput(ref m) if m.contains("specific gravity")),
"got {err:?}"
);
}
#[test]
fn tbp_is_wider_boiling_than_d86() {
let d86 = standard_d86();
let tbp = d86_to_tbp_daubert(&d86).unwrap();
let n = d86.len() - 1;
assert!(
tbp.temperatures[0] < d86.temperatures[0],
"TBP initial {} K should be below D86 initial {} K",
tbp.temperatures[0],
d86.temperatures[0]
);
assert!(
tbp.temperatures[n] > d86.temperatures[n],
"TBP final {} K should be above D86 final {} K",
tbp.temperatures[n],
d86.temperatures[n]
);
}
#[test]
fn efv_initial_point_row_crosses_its_neighbour_on_narrow_feeds() {
let x = vec![0.0, 0.10, 0.30, 0.50, 0.70, 0.90];
let narrow = DistillationCurve::new(
DistillationBasis::D86,
x.clone(),
vec![400.0, 410.0, 430.0, 450.0, 470.0, 490.0], )
.unwrap();
let err = d86_to_efv(&narrow, 0.85).unwrap_err();
assert!(
matches!(err, PetroleumError::InvalidInput(ref m) if m.contains("cannot decrease")),
"expected a monotonicity rejection, got {err:?}"
);
let from_ten = DistillationCurve::new(
DistillationBasis::D86,
vec![0.10, 0.30, 0.50, 0.70, 0.90],
vec![410.0, 430.0, 450.0, 470.0, 490.0],
)
.unwrap();
let efv = d86_to_efv(&from_ten, 0.85).unwrap();
for w in efv.temperatures.windows(2) {
assert!(w[1] >= w[0], "10-90 % conversion is not monotone: {w:?}");
}
let wide = DistillationCurve::new(
DistillationBasis::D86,
x,
vec![330.0, 370.0, 450.0, 530.0, 610.0, 700.0], )
.unwrap();
assert!(d86_to_efv(&wide, 0.85).is_ok());
}
#[test]
fn efv_is_flatter_than_d86() {
let d86 = standard_d86();
let efv = d86_to_efv(&d86, 0.82).unwrap();
let n = d86.len() - 1;
let span_d86 = d86.temperatures[n] - d86.temperatures[0];
let span_efv = efv.temperatures[n] - efv.temperatures[0];
assert!(
span_efv < span_d86,
"EFV span {span_efv:.1} K should be under the D86 span {span_d86:.1} K"
);
}
#[test]
fn every_conversion_preserves_monotonicity() {
let d86 = standard_d86();
for target in [
DistillationBasis::Tbp,
DistillationBasis::D2887,
DistillationBasis::Efv,
] {
let out = convert_curve(&d86, target, Some(0.82)).unwrap();
for w in out.temperatures.windows(2) {
assert!(w[1] >= w[0], "{target:?} produced {w:?}");
}
}
}
#[test]
fn interpolation_hits_the_grid_points_exactly() {
let c = standard_d86();
for (&x, &t) in c.fractions.iter().zip(&c.temperatures) {
assert!((c.temperature_at(x) - t).abs() < 1e-9, "at x = {x}");
}
}
#[test]
fn interpolation_is_monotone_between_grid_points() {
let c = standard_d86();
let mut prev = f64::NEG_INFINITY;
for i in 0..=100 {
let t = c.temperature_at(i as f64 / 100.0);
assert!(
t >= prev,
"interpolant decreased at x = {}",
i as f64 / 100.0
);
prev = t;
}
}
#[test]
fn extrapolation_past_the_last_point_keeps_rising() {
let c = standard_d86();
let last = *c.temperatures.last().unwrap();
assert!(c.temperature_at(1.0) > last);
assert!(c.temperature_at(0.0) < *c.temperatures.first().unwrap() + 1e-9);
}
#[test]
fn resampling_onto_the_standard_grid_preserves_shared_points() {
let sparse = from_celsius(
DistillationBasis::D86,
&[0.1, 0.5, 0.9],
&[170.0, 230.0, 300.0],
);
let dense = sparse.resample(&STANDARD_GRID).unwrap();
assert_eq!(dense.len(), STANDARD_GRID.len());
for (x, t) in [(0.1, 170.0), (0.5, 230.0), (0.9, 300.0)] {
let i = dense.fractions.iter().position(|&f| f == x).unwrap();
assert!((dense.temperatures[i] - (t + 273.15)).abs() < 1e-9);
}
}
#[test]
fn difference_methods_explain_themselves_without_a_fifty_percent_point() {
let odd = from_celsius(
DistillationBasis::D86,
&[0.1, 0.3, 0.7, 0.9],
&[170.0, 200.0, 262.0, 300.0],
);
let err = d86_to_tbp_daubert(&odd).unwrap_err();
assert!(
matches!(err, PetroleumError::CutPoints(ref m) if m.contains("resample")),
"the error should point at the fix, got {err:?}"
);
}
#[test]
fn curve_construction_rejects_malformed_input() {
use DistillationBasis::D86;
assert!(DistillationCurve::new(D86, vec![0.1, 0.5], vec![400.0]).is_err());
assert!(DistillationCurve::new(D86, vec![0.5], vec![400.0]).is_err());
assert!(DistillationCurve::new(D86, vec![0.1, 1.5], vec![400.0, 450.0]).is_err());
assert!(DistillationCurve::new(D86, vec![0.5, 0.1], vec![400.0, 450.0]).is_err());
assert!(DistillationCurve::new(D86, vec![0.5, 0.5], vec![400.0, 450.0]).is_err());
assert!(DistillationCurve::new(D86, vec![0.1, 0.5], vec![450.0, 400.0]).is_err());
assert!(DistillationCurve::new(D86, vec![0.1, 0.5], vec![-10.0, 400.0]).is_err());
}
#[test]
fn conversions_reject_a_curve_on_the_wrong_basis() {
let tbp = from_celsius(DistillationBasis::Tbp, &[0.1, 0.5], &[170.0, 230.0]);
let err = d86_to_tbp_riazi(&tbp).unwrap_err();
assert!(
matches!(err, PetroleumError::InvalidInput(ref m) if m.contains("TBP")),
"the error should name the basis it got, got {err:?}"
);
}
#[test]
fn interval_lookup_covers_the_whole_unit_range() {
for i in 0..=1000 {
let x = i as f64 / 1000.0;
assert!(row_for(&RIAZI_BOUNDS, x) < RIAZI_BOUNDS.len());
assert!(row_for(&DELTA_BOUNDS, x) < DELTA_BOUNDS.len());
assert!(row_for(&EFV_BOUNDS, x) < EFV_BOUNDS.len());
}
assert_eq!(row_for(&RIAZI_BOUNDS, 0.0), 0);
assert_eq!(row_for(&RIAZI_BOUNDS, 0.099), 0);
assert_eq!(row_for(&RIAZI_BOUNDS, 0.10), 1);
assert_eq!(row_for(&RIAZI_BOUNDS, 0.95), 6);
assert_eq!(row_for(&RIAZI_BOUNDS, 1.0), 6);
}
}