use super::traits::ThermoModel;
use crate::thermophysics::constants::T_STD;
use crate::thermophysics::eos::EquationOfState;
use crate::thermophysics::imports::*;
#[derive(Debug, Clone)]
pub struct JanafThermo<E: EquationOfState> {
eos: E,
tlow: f64,
thigh: f64,
tcommon: f64,
low: [f64; 7],
high: [f64; 7],
}
impl<E: EquationOfState> JanafThermo<E> {
pub fn new(eos: E, tlow: f64, thigh: f64, tcommon: f64, low: [f64; 7], high: [f64; 7]) -> Self {
Self {
eos,
tlow,
thigh,
tcommon,
low,
high,
}
}
fn coeffs(&self, t: f64) -> &[f64; 7] {
if t < self.tcommon {
&self.low
} else {
&self.high
}
}
}
impl<E: EquationOfState> EquationOfState for JanafThermo<E> {
fn mol_weight(&self) -> MolarMass {
self.eos.mol_weight()
}
fn r(&self) -> SpecificHeatCapacity {
self.eos.r()
}
fn rho(&self, p: Pressure, t: ThermodynamicTemperature) -> MassDensity {
self.eos.rho(p, t)
}
fn psi(&self, p: Pressure, t: ThermodynamicTemperature) -> Compressibility {
self.eos.psi(p, t)
}
fn z(&self, p: Pressure, t: ThermodynamicTemperature) -> Ratio {
self.eos.z(p, t)
}
fn cp_m_cv(&self, p: Pressure, t: ThermodynamicTemperature) -> SpecificHeatCapacity {
self.eos.cp_m_cv(p, t)
}
fn cp_eos(&self, p: Pressure, t: ThermodynamicTemperature) -> SpecificHeatCapacity {
self.eos.cp_eos(p, t)
}
fn h_eos(&self, p: Pressure, t: ThermodynamicTemperature) -> AvailableEnergy {
self.eos.h_eos(p, t)
}
fn e_eos(&self, p: Pressure, t: ThermodynamicTemperature) -> AvailableEnergy {
self.eos.e_eos(p, t)
}
fn s_eos(&self, p: Pressure, t: ThermodynamicTemperature) -> SpecificHeatCapacity {
self.eos.s_eos(p, t)
}
}
impl<E: EquationOfState> ThermoModel for JanafThermo<E> {
fn cp(&self, p: Pressure, t: ThermodynamicTemperature) -> SpecificHeatCapacity {
let tv = t.get::<kelvin>().clamp(self.tlow, self.thigh);
let a = self.coeffs(tv);
let cp_val = (((a[4] * tv + a[3]) * tv + a[2]) * tv + a[1]) * tv + a[0];
SpecificHeatCapacity::new::<joule_per_kilogram_kelvin>(cp_val) + self.eos.cp_eos(p, t)
}
fn ha(&self, p: Pressure, t: ThermodynamicTemperature) -> AvailableEnergy {
let tv = t.get::<kelvin>().clamp(self.tlow, self.thigh);
let a = self.coeffs(tv);
let ha_val = ((((a[4] / 5.0 * tv + a[3] / 4.0) * tv + a[2] / 3.0) * tv + a[1] / 2.0) * tv
+ a[0])
* tv
+ a[5];
AvailableEnergy::new::<joule_per_kilogram>(ha_val) + self.eos.h_eos(p, t)
}
fn hs(&self, p: Pressure, t: ThermodynamicTemperature) -> AvailableEnergy {
self.ha(p, t) - self.hc()
}
fn hc(&self) -> AvailableEnergy {
let t = T_STD;
let a = &self.low;
let hc_val =
((((a[4] / 5.0 * t + a[3] / 4.0) * t + a[2] / 3.0) * t + a[1] / 2.0) * t + a[0]) * t
+ a[5];
AvailableEnergy::new::<joule_per_kilogram>(hc_val)
}
fn s(&self, p: Pressure, t: ThermodynamicTemperature) -> SpecificHeatCapacity {
let tv = t.get::<kelvin>().clamp(self.tlow, self.thigh);
let a = self.coeffs(tv);
let s_val = (((a[4] / 4.0 * tv + a[3] / 3.0) * tv + a[2] / 2.0) * tv + a[1]) * tv
+ a[0] * tv.ln()
+ a[6];
SpecificHeatCapacity::new::<joule_per_kilogram_kelvin>(s_val) + self.eos.s_eos(p, t)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::thermophysics::eos::PerfectGas;
use approx::assert_relative_eq;
use uom::si::molar_mass::gram_per_mole;
use uom::si::pressure::pascal;
use uom::si::specific_heat_capacity::joule_per_kilogram_kelvin;
use uom::si::thermodynamic_temperature::kelvin;
fn air_janaf() -> JanafThermo<PerfectGas> {
let eos = PerfectGas::new(MolarMass::new::<gram_per_mole>(28.97));
let r = eos.r().get::<joule_per_kilogram_kelvin>();
let low_dim = [
3.53100528,
-1.23660988e-4,
-5.02999433e-7,
2.43530612e-9,
-1.40881235e-12,
-1046.976280,
2.96747038,
];
let high_dim = [
2.95257637,
1.39690040e-3,
-4.92631603e-7,
7.86010195e-10,
-4.60755204e-13,
-923.948688,
5.87188762,
];
let scale_cp = |d: [f64; 7]| -> [f64; 7] {
[
d[0] * r,
d[1] * r,
d[2] * r,
d[3] * r,
d[4] * r,
d[5] * r,
d[6] * r,
]
};
JanafThermo::new(
eos,
200.0,
6000.0,
1000.0,
scale_cp(low_dim),
scale_cp(high_dim),
)
}
#[test]
fn cp_at_300k_is_reasonable() {
let a = air_janaf();
let p = Pressure::new::<pascal>(101_325.0);
let t = ThermodynamicTemperature::new::<kelvin>(300.0);
let cp = a.cp(p, t).get::<joule_per_kilogram_kelvin>();
assert!(
cp > 1000.0 && cp < 1100.0,
"Cp = {cp} J/(kg·K) out of expected range"
);
}
#[test]
fn ha_roundtrip() {
let a = air_janaf();
let p = Pressure::new::<pascal>(101_325.0);
let t_in = ThermodynamicTemperature::new::<kelvin>(800.0);
let ha = a.ha(p, t_in);
let t_out = a
.t_from_ha(ha, p, ThermodynamicTemperature::new::<kelvin>(400.0))
.unwrap();
assert_relative_eq!(t_in.get::<kelvin>(), t_out.get::<kelvin>(), epsilon = 0.01);
}
#[test]
fn hs_plus_hc_equals_ha() {
let a = air_janaf();
let p = Pressure::new::<pascal>(101_325.0);
let t = ThermodynamicTemperature::new::<kelvin>(500.0);
use uom::si::available_energy::joule_per_kilogram;
let ha = a.ha(p, t).get::<joule_per_kilogram>();
let hs_hc = (a.hs(p, t) + a.hc()).get::<joule_per_kilogram>();
assert_relative_eq!(ha, hs_hc, epsilon = 1e-3);
}
#[test]
fn cp_both_ranges_are_reasonable() {
let a = air_janaf();
let p = Pressure::new::<pascal>(101_325.0);
let t_low = ThermodynamicTemperature::new::<kelvin>(500.0);
let t_high = ThermodynamicTemperature::new::<kelvin>(1500.0);
let cp_low = a.cp(p, t_low).get::<joule_per_kilogram_kelvin>();
let cp_high = a.cp(p, t_high).get::<joule_per_kilogram_kelvin>();
assert!(cp_low > 1000.0 && cp_low < 1300.0, "low Cp = {cp_low}");
assert!(cp_high > 1000.0 && cp_high < 1300.0, "high Cp = {cp_high}");
}
#[test]
#[ignore = "TODO: Newton stalls at ~1152 K from t0=100 K targeting 3000 K — JANAF discontinuity at Tcommon may be a deeper issue; see docs/porting-roadmap.md"]
fn newton_converges_from_bad_initial_guess() {
let a = air_janaf();
let p = Pressure::new::<pascal>(101_325.0);
let t_target = ThermodynamicTemperature::new::<kelvin>(3000.0);
let ha_target = a.ha(p, t_target);
let t0 = ThermodynamicTemperature::new::<kelvin>(100.0);
let t_out = a
.t_from_ha(ha_target, p, t0)
.expect("Newton should converge from t0=100 K to 3000 K");
assert!(
(t_out.get::<kelvin>() - 3000.0).abs() < 5.0,
"expected ≈3000 K, got {:.1} K",
t_out.get::<kelvin>()
);
}
#[test]
fn newton_t_max_clamp_returns_err() {
use crate::thermophysics::error::ThermoError;
let a = air_janaf();
let p = Pressure::new::<pascal>(101_325.0);
let t_max = ThermodynamicTemperature::new::<kelvin>(6000.0);
let ha_max = a.ha(p, t_max);
let impossible_ha = ha_max
+ uom::si::f64::AvailableEnergy::new::<uom::si::available_energy::joule_per_kilogram>(
1.0e9,
);
let t0 = ThermodynamicTemperature::new::<kelvin>(300.0);
let result = a.t_from_ha(impossible_ha, p, t0);
assert!(
matches!(result, Err(ThermoError::NonConvergent { .. })),
"expected NonConvergent, got {:?}",
result
);
if let Err(ThermoError::NonConvergent { last_t, .. }) = result {
assert!(last_t.is_finite(), "last_t is not finite: {last_t}");
}
}
#[test]
fn newton_t_min_clamp_returns_err() {
use crate::thermophysics::error::ThermoError;
use uom::si::available_energy::joule_per_kilogram;
let a = air_janaf();
let p = Pressure::new::<pascal>(101_325.0);
let t_min = ThermodynamicTemperature::new::<kelvin>(100.0);
let ha_min = a.ha(p, t_min);
let impossible_ha =
ha_min - uom::si::f64::AvailableEnergy::new::<joule_per_kilogram>(1.0e9);
let t0 = ThermodynamicTemperature::new::<kelvin>(300.0);
let result = a.t_from_ha(impossible_ha, p, t0);
assert!(
matches!(result, Err(ThermoError::NonConvergent { .. })),
"expected NonConvergent, got {:?}",
result
);
}
#[test]
fn newton_crosses_tcommon_discontinuity() {
let a = air_janaf();
let p = Pressure::new::<pascal>(101_325.0);
let t_target = ThermodynamicTemperature::new::<kelvin>(1500.0);
let ha_target = a.ha(p, t_target);
let t0 = ThermodynamicTemperature::new::<kelvin>(800.0);
let t_out = a
.t_from_ha(ha_target, p, t0)
.expect("Newton should converge across Tcommon");
assert!(
(t_out.get::<kelvin>() - 1500.0).abs() < 50.0,
"expected ≈1500 K, got {:.1} K",
t_out.get::<kelvin>()
);
}
}