use serde::{Deserialize, Serialize};
use crate::error::{Result, UshmaError};
use crate::state::GAS_CONSTANT;
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct StatePoint {
pub temperature: f64,
pub pressure: f64,
pub volume: f64,
pub entropy: f64,
pub enthalpy: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ProcessKind {
Isentropic,
Isochoric,
Isobaric,
Isothermal,
Isenthalpic,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum CycleKind {
Otto,
Diesel,
Brayton,
Rankine,
Refrigeration,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CycleResult {
pub kind: CycleKind,
pub state_points: Vec<StatePoint>,
pub processes: Vec<ProcessKind>,
pub heat_in: f64,
pub heat_out: f64,
pub net_work: f64,
pub efficiency: f64,
pub back_work_ratio: f64,
pub gamma: f64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct DiagramPoint {
pub x: f64,
pub y: f64,
}
#[inline]
#[must_use]
pub fn isochoric_heat(moles: f64, cv: f64, t1: f64, t2: f64) -> f64 {
moles * cv * (t2 - t1)
}
#[inline]
#[must_use]
pub fn isobaric_heat(moles: f64, cp: f64, t1: f64, t2: f64) -> f64 {
moles * cp * (t2 - t1)
}
#[inline]
#[must_use]
pub fn adiabatic_work(moles: f64, cv: f64, t1: f64, t2: f64) -> f64 {
moles * cv * (t1 - t2)
}
pub fn adiabatic_temp_from_pressure(t1: f64, p1: f64, p2: f64, gamma: f64) -> Result<f64> {
if t1 <= 0.0 {
return Err(UshmaError::InvalidTemperature { kelvin: t1 });
}
if p1 <= 0.0 {
return Err(UshmaError::InvalidPressure { pascals: p1 });
}
if p2 <= 0.0 {
return Err(UshmaError::InvalidPressure { pascals: p2 });
}
if gamma <= 1.0 {
return Err(UshmaError::InvalidParameter {
reason: format!("gamma {gamma} must be > 1"),
});
}
Ok(t1 * (p2 / p1).powf((gamma - 1.0) / gamma))
}
#[tracing::instrument(level = "debug")]
pub fn otto_cycle(
t1: f64,
p1: f64,
compression_ratio: f64,
heat_in: f64,
gamma: f64,
moles: f64,
) -> Result<CycleResult> {
if t1 <= 0.0 {
return Err(UshmaError::InvalidTemperature { kelvin: t1 });
}
if p1 <= 0.0 {
return Err(UshmaError::InvalidPressure { pascals: p1 });
}
if compression_ratio <= 1.0 {
return Err(UshmaError::InvalidCompressionRatio {
ratio: compression_ratio,
});
}
if gamma <= 1.0 {
return Err(UshmaError::InvalidParameter {
reason: format!("gamma {gamma} must be > 1"),
});
}
if moles <= 0.0 {
return Err(UshmaError::InvalidParameter {
reason: format!("moles {moles} must be positive"),
});
}
if heat_in <= 0.0 {
return Err(UshmaError::InvalidParameter {
reason: format!("heat_in {heat_in} J/mol must be positive"),
});
}
let r = compression_ratio;
let cv = GAS_CONSTANT / (gamma - 1.0);
let cp = gamma * cv;
let n = moles;
let v1 = n * GAS_CONSTANT * t1 / p1;
let v2 = v1 / r;
let t2 = t1 * r.powf(gamma - 1.0);
let p2 = n * GAS_CONSTANT * t2 / v2;
let v3 = v2;
let q_in = heat_in * n;
let t3 = t2 + q_in / (n * cv);
let p3 = n * GAS_CONSTANT * t3 / v3;
let v4 = v1;
let t4 = t3 * (1.0 / r).powf(gamma - 1.0);
let p4 = n * GAS_CONSTANT * t4 / v4;
let q_out = n * cv * (t4 - t1);
let w_net = q_in - q_out;
let efficiency = w_net / q_in;
let s1 = 0.0;
let s2 = s1; let s3 = s2 + n * cv * (t3 / t2).ln(); let s4 = s3;
Ok(CycleResult {
kind: CycleKind::Otto,
state_points: vec![
StatePoint {
temperature: t1,
pressure: p1,
volume: v1,
entropy: s1,
enthalpy: n * cp * t1,
},
StatePoint {
temperature: t2,
pressure: p2,
volume: v2,
entropy: s2,
enthalpy: n * cp * t2,
},
StatePoint {
temperature: t3,
pressure: p3,
volume: v3,
entropy: s3,
enthalpy: n * cp * t3,
},
StatePoint {
temperature: t4,
pressure: p4,
volume: v4,
entropy: s4,
enthalpy: n * cp * t4,
},
],
processes: vec![
ProcessKind::Isentropic,
ProcessKind::Isochoric,
ProcessKind::Isentropic,
ProcessKind::Isochoric,
],
heat_in: q_in,
heat_out: q_out,
net_work: w_net,
efficiency,
back_work_ratio: 0.0, gamma,
})
}
#[tracing::instrument(level = "debug")]
pub fn diesel_cycle(
t1: f64,
p1: f64,
compression_ratio: f64,
cutoff_ratio: f64,
gamma: f64,
moles: f64,
) -> Result<CycleResult> {
if t1 <= 0.0 {
return Err(UshmaError::InvalidTemperature { kelvin: t1 });
}
if p1 <= 0.0 {
return Err(UshmaError::InvalidPressure { pascals: p1 });
}
if compression_ratio <= 1.0 {
return Err(UshmaError::InvalidCompressionRatio {
ratio: compression_ratio,
});
}
if cutoff_ratio <= 1.0 {
return Err(UshmaError::InvalidCutoffRatio {
ratio: cutoff_ratio,
});
}
if cutoff_ratio >= compression_ratio {
return Err(UshmaError::InvalidCycleParameter {
reason: format!(
"cutoff ratio {cutoff_ratio} must be less than compression ratio {compression_ratio}"
),
});
}
if gamma <= 1.0 {
return Err(UshmaError::InvalidParameter {
reason: format!("gamma {gamma} must be > 1"),
});
}
if moles <= 0.0 {
return Err(UshmaError::InvalidParameter {
reason: format!("moles {moles} must be positive"),
});
}
let r = compression_ratio;
let rc = cutoff_ratio;
let cv = GAS_CONSTANT / (gamma - 1.0);
let cp = gamma * cv;
let n = moles;
let v1 = n * GAS_CONSTANT * t1 / p1;
let v2 = v1 / r;
let t2 = t1 * r.powf(gamma - 1.0);
let p2 = n * GAS_CONSTANT * t2 / v2;
let p3 = p2;
let v3 = v2 * rc;
let t3 = t2 * rc;
let v4 = v1;
let t4 = t3 * (v3 / v4).powf(gamma - 1.0);
let p4 = n * GAS_CONSTANT * t4 / v4;
let q_in = n * cp * (t3 - t2);
let q_out = n * cv * (t4 - t1);
let w_net = q_in - q_out;
let efficiency = w_net / q_in;
let s1 = 0.0;
let s2 = s1;
let s3 = s2 + n * cp * (t3 / t2).ln(); let s4 = s3;
Ok(CycleResult {
kind: CycleKind::Diesel,
state_points: vec![
StatePoint {
temperature: t1,
pressure: p1,
volume: v1,
entropy: s1,
enthalpy: n * cp * t1,
},
StatePoint {
temperature: t2,
pressure: p2,
volume: v2,
entropy: s2,
enthalpy: n * cp * t2,
},
StatePoint {
temperature: t3,
pressure: p3,
volume: v3,
entropy: s3,
enthalpy: n * cp * t3,
},
StatePoint {
temperature: t4,
pressure: p4,
volume: v4,
entropy: s4,
enthalpy: n * cp * t4,
},
],
processes: vec![
ProcessKind::Isentropic,
ProcessKind::Isobaric,
ProcessKind::Isentropic,
ProcessKind::Isochoric,
],
heat_in: q_in,
heat_out: q_out,
net_work: w_net,
efficiency,
back_work_ratio: 0.0,
gamma,
})
}
#[tracing::instrument(level = "debug")]
pub fn brayton_cycle(
t1: f64,
p1: f64,
pressure_ratio: f64,
t3: f64,
gamma: f64,
moles: f64,
) -> Result<CycleResult> {
if t1 <= 0.0 {
return Err(UshmaError::InvalidTemperature { kelvin: t1 });
}
if p1 <= 0.0 {
return Err(UshmaError::InvalidPressure { pascals: p1 });
}
if t3 <= 0.0 {
return Err(UshmaError::InvalidTemperature { kelvin: t3 });
}
if pressure_ratio <= 1.0 {
return Err(UshmaError::InvalidPressureRatio {
ratio: pressure_ratio,
});
}
if gamma <= 1.0 {
return Err(UshmaError::InvalidParameter {
reason: format!("gamma {gamma} must be > 1"),
});
}
if moles <= 0.0 {
return Err(UshmaError::InvalidParameter {
reason: format!("moles {moles} must be positive"),
});
}
let rp = pressure_ratio;
let cv = GAS_CONSTANT / (gamma - 1.0);
let cp = gamma * cv;
let n = moles;
let v1 = n * GAS_CONSTANT * t1 / p1;
let p2 = p1 * rp;
let t2 = t1 * rp.powf((gamma - 1.0) / gamma);
let v2 = n * GAS_CONSTANT * t2 / p2;
if t3 <= t2 {
return Err(UshmaError::InvalidCycleParameter {
reason: format!("turbine inlet T3={t3} K must exceed compressor outlet T2={t2:.1} K"),
});
}
let p3 = p2;
let v3 = n * GAS_CONSTANT * t3 / p3;
let p4 = p1;
let t4 = t3 * (1.0 / rp).powf((gamma - 1.0) / gamma);
let v4 = n * GAS_CONSTANT * t4 / p4;
let q_in = n * cp * (t3 - t2);
let q_out = n * cp * (t4 - t1);
let w_net = q_in - q_out;
let efficiency = w_net / q_in;
let w_compressor = n * cp * (t2 - t1);
let w_turbine = n * cp * (t3 - t4);
let back_work_ratio = w_compressor / w_turbine;
let s1 = 0.0;
let s2 = s1;
let s3 = s2 + n * cp * (t3 / t2).ln();
let s4 = s3;
Ok(CycleResult {
kind: CycleKind::Brayton,
state_points: vec![
StatePoint {
temperature: t1,
pressure: p1,
volume: v1,
entropy: s1,
enthalpy: n * cp * t1,
},
StatePoint {
temperature: t2,
pressure: p2,
volume: v2,
entropy: s2,
enthalpy: n * cp * t2,
},
StatePoint {
temperature: t3,
pressure: p3,
volume: v3,
entropy: s3,
enthalpy: n * cp * t3,
},
StatePoint {
temperature: t4,
pressure: p4,
volume: v4,
entropy: s4,
enthalpy: n * cp * t4,
},
],
processes: vec![
ProcessKind::Isentropic,
ProcessKind::Isobaric,
ProcessKind::Isentropic,
ProcessKind::Isobaric,
],
heat_in: q_in,
heat_out: q_out,
net_work: w_net,
efficiency,
back_work_ratio,
gamma,
})
}
#[cfg(feature = "steam")]
#[tracing::instrument(level = "debug")]
pub fn rankine_cycle(
p_condenser: f64,
p_boiler: f64,
t_superheat: Option<f64>,
) -> Result<CycleResult> {
use crate::steam;
if p_condenser <= 0.0 {
return Err(UshmaError::InvalidPressure {
pascals: p_condenser,
});
}
if p_boiler <= 0.0 {
return Err(UshmaError::InvalidPressure { pascals: p_boiler });
}
if p_boiler <= p_condenser {
return Err(UshmaError::InvalidCycleParameter {
reason: format!(
"boiler pressure {p_boiler} Pa must exceed condenser pressure {p_condenser} Pa"
),
});
}
let sat_cond = steam::saturated_by_pressure(p_condenser)?;
let h1 = sat_cond.h_f;
let s1 = sat_cond.s_f;
let v1 = sat_cond.v_f;
let t1 = sat_cond.temperature;
let w_pump = v1 * (p_boiler - p_condenser);
let h2 = h1 + w_pump;
let s2 = s1; let t2 = t1; let v2 = v1;
let (h3, s3, t3, v3) = if let Some(t_sh) = t_superheat {
let sh = steam::superheated_lookup(t_sh, p_boiler)?;
(
sh.specific_enthalpy,
sh.specific_entropy,
sh.temperature,
sh.specific_volume,
)
} else {
let sat_boil = steam::saturated_by_pressure(p_boiler)?;
(
sat_boil.h_g,
sat_boil.s_g,
sat_boil.temperature,
sat_boil.v_g,
)
};
let s4 = s3;
let (h4, t4, v4) = if s4 <= sat_cond.s_g {
let x4 = steam::quality_from_entropy(s4, &sat_cond)?;
let props = steam::wet_steam_properties(x4, &sat_cond)?;
(
props.specific_enthalpy,
sat_cond.temperature,
props.specific_volume,
)
} else {
(sat_cond.h_g, sat_cond.temperature, sat_cond.v_g)
};
let q_in = h3 - h2;
let w_turbine = h3 - h4;
let q_out = h4 - h1;
let w_net = w_turbine - w_pump;
let efficiency = w_net / q_in;
let back_work_ratio = w_pump / w_turbine;
Ok(CycleResult {
kind: CycleKind::Rankine,
state_points: vec![
StatePoint {
temperature: t1,
pressure: p_condenser,
volume: v1,
entropy: s1,
enthalpy: h1,
},
StatePoint {
temperature: t2,
pressure: p_boiler,
volume: v2,
entropy: s2,
enthalpy: h2,
},
StatePoint {
temperature: t3,
pressure: p_boiler,
volume: v3,
entropy: s3,
enthalpy: h3,
},
StatePoint {
temperature: t4,
pressure: p_condenser,
volume: v4,
entropy: s4,
enthalpy: h4,
},
],
processes: vec![
ProcessKind::Isentropic,
ProcessKind::Isobaric,
ProcessKind::Isentropic,
ProcessKind::Isobaric,
],
heat_in: q_in,
heat_out: q_out,
net_work: w_net,
efficiency,
back_work_ratio,
gamma: 0.0,
})
}
#[cfg(feature = "steam")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RefrigerationResult {
pub cycle: CycleResult,
pub cop_refrigeration: f64,
pub cop_heat_pump: f64,
}
#[cfg(feature = "steam")]
#[tracing::instrument(level = "debug")]
pub fn refrigeration_cycle(p_evaporator: f64, p_condenser: f64) -> Result<RefrigerationResult> {
use crate::steam;
if p_evaporator <= 0.0 {
return Err(UshmaError::InvalidPressure {
pascals: p_evaporator,
});
}
if p_condenser <= 0.0 {
return Err(UshmaError::InvalidPressure {
pascals: p_condenser,
});
}
if p_condenser <= p_evaporator {
return Err(UshmaError::InvalidCycleParameter {
reason: format!(
"condenser pressure {p_condenser} Pa must exceed evaporator pressure {p_evaporator} Pa"
),
});
}
let sat_evap = steam::saturated_by_pressure(p_evaporator)?;
let sat_cond = steam::saturated_by_pressure(p_condenser)?;
let h1 = sat_evap.h_g;
let s1 = sat_evap.s_g;
let t1 = sat_evap.temperature;
let v1 = sat_evap.v_g;
let s_target = s1;
let t_lo_search = sat_cond.temperature + 1.0;
let mut t_hi_search = sat_cond.temperature + 200.0;
if steam::superheated_lookup(t_hi_search, p_condenser).is_err() {
t_hi_search = sat_cond.temperature + 100.0;
}
let p_cond = p_condenser;
let t2 = hisab::num::bisection(
|t| {
steam::superheated_lookup(t, p_cond)
.map(|sh| sh.specific_entropy - s_target)
.unwrap_or(-1.0) },
t_lo_search,
t_hi_search,
0.01,
50,
)
.unwrap_or(0.5 * (t_lo_search + t_hi_search));
let sh2 = steam::superheated_lookup(t2, p_condenser)?;
let h2 = sh2.specific_enthalpy;
let s2 = s1;
let v2 = sh2.specific_volume;
let h3 = sat_cond.h_f;
let s3 = sat_cond.s_f;
let t3 = sat_cond.temperature;
let v3 = sat_cond.v_f;
let h4 = h3;
let x4 = steam::quality_from_enthalpy(h4, &sat_evap)?;
let props4 = steam::wet_steam_properties(x4, &sat_evap)?;
let t4 = sat_evap.temperature;
let v4 = props4.specific_volume;
let s4 = props4.specific_entropy;
let q_evap = h1 - h4;
let q_cond = h2 - h3;
let w_comp = h2 - h1;
let cop_ref = q_evap / w_comp;
let cop_hp = q_cond / w_comp;
Ok(RefrigerationResult {
cycle: CycleResult {
kind: CycleKind::Refrigeration,
state_points: vec![
StatePoint {
temperature: t1,
pressure: p_evaporator,
volume: v1,
entropy: s1,
enthalpy: h1,
},
StatePoint {
temperature: t2,
pressure: p_condenser,
volume: v2,
entropy: s2,
enthalpy: h2,
},
StatePoint {
temperature: t3,
pressure: p_condenser,
volume: v3,
entropy: s3,
enthalpy: h3,
},
StatePoint {
temperature: t4,
pressure: p_evaporator,
volume: v4,
entropy: s4,
enthalpy: h4,
},
],
processes: vec![
ProcessKind::Isentropic,
ProcessKind::Isobaric,
ProcessKind::Isenthalpic,
ProcessKind::Isobaric,
],
heat_in: q_evap,
heat_out: q_cond,
net_work: w_comp,
efficiency: cop_ref,
back_work_ratio: 1.0,
gamma: 0.0,
},
cop_refrigeration: cop_ref,
cop_heat_pump: cop_hp,
})
}
#[inline]
#[must_use]
pub fn heat_pump_cop(cop_refrigeration: f64) -> f64 {
cop_refrigeration + 1.0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CycleComparisonEntry {
pub kind: CycleKind,
pub efficiency: f64,
pub net_work: f64,
pub back_work_ratio: f64,
pub carnot_efficiency: f64,
pub second_law_efficiency: f64,
}
pub fn compare_cycles(
entries: &[(CycleKind, &CycleResult, f64, f64)],
) -> Vec<CycleComparisonEntry> {
entries
.iter()
.map(|(kind, result, t_high, t_low)| {
let eta_carnot = if *t_high > *t_low && *t_high > 0.0 {
1.0 - t_low / t_high
} else {
0.0
};
let eta_second = if eta_carnot > 0.0 {
result.efficiency / eta_carnot
} else {
0.0
};
CycleComparisonEntry {
kind: *kind,
efficiency: result.efficiency,
net_work: result.net_work,
back_work_ratio: result.back_work_ratio,
carnot_efficiency: eta_carnot,
second_law_efficiency: eta_second,
}
})
.collect()
}
pub fn cycle_ts_diagram(result: &CycleResult, points_per_process: usize) -> Vec<DiagramPoint> {
let n = result.state_points.len();
let pts = points_per_process.max(2);
let mut out = Vec::with_capacity(n * pts);
for i in 0..n {
let a = &result.state_points[i];
let b = &result.state_points[(i + 1) % n];
for j in 0..pts {
let frac = j as f64 / (pts - 1) as f64;
let t = a.temperature + frac * (b.temperature - a.temperature);
let s = a.entropy + frac * (b.entropy - a.entropy);
out.push(DiagramPoint { x: s, y: t });
}
}
out
}
pub fn cycle_pv_diagram(result: &CycleResult, points_per_process: usize) -> Vec<DiagramPoint> {
let n = result.state_points.len();
let pts = points_per_process.max(2);
let gamma = result.gamma;
let mut out = Vec::with_capacity(n * pts);
for i in 0..n {
let a = &result.state_points[i];
let b = &result.state_points[(i + 1) % n];
let process = result.processes[i];
for j in 0..pts {
let frac = j as f64 / (pts - 1) as f64;
let (v, p) = match process {
ProcessKind::Isentropic => {
let v = a.volume + frac * (b.volume - a.volume);
let p = a.pressure * (a.volume / v).powf(gamma);
(v, p)
}
ProcessKind::Isochoric => {
let v = a.volume;
let p = a.pressure + frac * (b.pressure - a.pressure);
(v, p)
}
ProcessKind::Isobaric => {
let v = a.volume + frac * (b.volume - a.volume);
let p = a.pressure;
(v, p)
}
_ => {
let v = a.volume + frac * (b.volume - a.volume);
let p = a.pressure + frac * (b.pressure - a.pressure);
(v, p)
}
};
out.push(DiagramPoint { x: v, y: p });
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_state_point_construction() {
let sp = StatePoint {
temperature: 300.0,
pressure: 101_325.0,
volume: 0.02241,
entropy: 150.0,
enthalpy: 6000.0,
};
assert!((sp.temperature - 300.0).abs() < 1e-10);
}
#[test]
fn test_process_kind_traits() {
let p = ProcessKind::Isentropic;
assert_eq!(p, ProcessKind::Isentropic);
assert_ne!(p, ProcessKind::Isochoric);
let debug = format!("{p:?}");
assert!(debug.contains("Isentropic"));
}
#[test]
fn test_cycle_kind_traits() {
assert_eq!(CycleKind::Otto, CycleKind::Otto);
assert_ne!(CycleKind::Otto, CycleKind::Diesel);
}
#[test]
fn test_state_point_serde_roundtrip() {
let sp = StatePoint {
temperature: 500.0,
pressure: 200_000.0,
volume: 0.05,
entropy: 200.0,
enthalpy: 10_000.0,
};
let json = serde_json::to_string(&sp).unwrap();
let back: StatePoint = serde_json::from_str(&json).unwrap();
assert!((back.temperature - 500.0).abs() < 1e-10);
}
#[test]
fn test_cycle_kind_serde_roundtrip() {
let json = serde_json::to_string(&CycleKind::Brayton).unwrap();
let back: CycleKind = serde_json::from_str(&json).unwrap();
assert_eq!(back, CycleKind::Brayton);
}
#[test]
fn test_process_kind_serde_roundtrip() {
let json = serde_json::to_string(&ProcessKind::Isenthalpic).unwrap();
let back: ProcessKind = serde_json::from_str(&json).unwrap();
assert_eq!(back, ProcessKind::Isenthalpic);
}
#[test]
fn test_isochoric_heat() {
let q = isochoric_heat(1.0, 20.8, 300.0, 400.0);
assert!((q - 2080.0).abs() < 1e-10);
}
#[test]
fn test_isobaric_heat() {
let q = isobaric_heat(1.0, 29.1, 300.0, 400.0);
assert!((q - 2910.0).abs() < 1e-10);
}
#[test]
fn test_adiabatic_work_expansion() {
let w = adiabatic_work(1.0, 20.8, 500.0, 300.0);
assert!(w > 0.0);
assert!((w - 20.8 * 200.0).abs() < 1e-10);
}
#[test]
fn test_adiabatic_work_compression() {
let w = adiabatic_work(1.0, 20.8, 300.0, 500.0);
assert!(w < 0.0);
}
#[test]
fn test_adiabatic_temp_from_pressure_roundtrip() {
let t2 = adiabatic_temp_from_pressure(300.0, 100_000.0, 1_000_000.0, 1.4).unwrap();
let t_back = adiabatic_temp_from_pressure(t2, 1_000_000.0, 100_000.0, 1.4).unwrap();
assert!((t_back - 300.0).abs() < 1e-10);
}
#[test]
fn test_adiabatic_temp_from_pressure_invalid() {
assert!(adiabatic_temp_from_pressure(0.0, 100_000.0, 200_000.0, 1.4).is_err());
assert!(adiabatic_temp_from_pressure(300.0, 0.0, 200_000.0, 1.4).is_err());
assert!(adiabatic_temp_from_pressure(300.0, 100_000.0, 0.0, 1.4).is_err());
assert!(adiabatic_temp_from_pressure(300.0, 100_000.0, 200_000.0, 1.0).is_err());
}
#[test]
fn test_otto_efficiency_analytical() {
let result = otto_cycle(300.0, 101_325.0, 8.0, 50_000.0, 1.4, 1.0).unwrap();
let expected_eta = 1.0 - 1.0 / 8.0_f64.powf(0.4);
assert!(
(result.efficiency - expected_eta).abs() < 1e-6,
"Otto η={}, expected {}",
result.efficiency,
expected_eta
);
}
#[test]
fn test_otto_energy_conservation() {
let result = otto_cycle(300.0, 101_325.0, 10.0, 40_000.0, 1.4, 1.0).unwrap();
let balance = (result.heat_in - result.net_work - result.heat_out).abs();
assert!(balance < 1e-6, "Energy balance error: {balance} J");
}
#[test]
fn test_otto_four_state_points() {
let result = otto_cycle(300.0, 101_325.0, 8.0, 50_000.0, 1.4, 1.0).unwrap();
assert_eq!(result.state_points.len(), 4);
assert_eq!(result.processes.len(), 4);
}
#[test]
fn test_otto_temperature_ordering() {
let r = otto_cycle(300.0, 101_325.0, 8.0, 50_000.0, 1.4, 1.0).unwrap();
let sp = &r.state_points;
assert!(sp[1].temperature > sp[0].temperature);
assert!(sp[2].temperature > sp[1].temperature);
assert!(sp[3].temperature < sp[2].temperature);
}
#[test]
fn test_otto_isochoric_volumes() {
let r = otto_cycle(300.0, 101_325.0, 8.0, 50_000.0, 1.4, 1.0).unwrap();
let sp = &r.state_points;
assert!((sp[1].volume - sp[2].volume).abs() < 1e-10);
assert!((sp[3].volume - sp[0].volume).abs() < 1e-10);
}
#[test]
fn test_otto_isentropic_entropy() {
let r = otto_cycle(300.0, 101_325.0, 8.0, 50_000.0, 1.4, 1.0).unwrap();
let sp = &r.state_points;
assert!((sp[0].entropy - sp[1].entropy).abs() < 1e-10);
assert!((sp[2].entropy - sp[3].entropy).abs() < 1e-10);
}
#[test]
fn test_otto_higher_compression_higher_efficiency() {
let r8 = otto_cycle(300.0, 101_325.0, 8.0, 50_000.0, 1.4, 1.0).unwrap();
let r10 = otto_cycle(300.0, 101_325.0, 10.0, 50_000.0, 1.4, 1.0).unwrap();
assert!(r10.efficiency > r8.efficiency);
}
#[test]
fn test_diesel_efficiency_analytical() {
let r = 20.0;
let rc = 2.0;
let gamma = 1.4;
let result = diesel_cycle(300.0, 101_325.0, r, rc, gamma, 1.0).unwrap();
let expected = 1.0 - (rc.powf(gamma) - 1.0) / (gamma * (rc - 1.0) * r.powf(gamma - 1.0));
assert!(
(result.efficiency - expected).abs() < 1e-6,
"Diesel η={}, expected {}",
result.efficiency,
expected
);
}
#[test]
fn test_diesel_energy_conservation() {
let result = diesel_cycle(300.0, 101_325.0, 18.0, 2.5, 1.4, 1.0).unwrap();
let balance = (result.heat_in - result.net_work - result.heat_out).abs();
assert!(balance < 1e-6, "Diesel energy balance error: {balance} J");
}
#[test]
fn test_diesel_isobaric_pressures() {
let r = diesel_cycle(300.0, 101_325.0, 20.0, 2.0, 1.4, 1.0).unwrap();
let sp = &r.state_points;
assert!((sp[1].pressure - sp[2].pressure).abs() / sp[1].pressure < 1e-10);
}
#[test]
fn test_diesel_invalid_inputs() {
assert!(diesel_cycle(300.0, 101_325.0, 1.0, 2.0, 1.4, 1.0).is_err());
assert!(diesel_cycle(300.0, 101_325.0, 20.0, 1.0, 1.4, 1.0).is_err());
assert!(diesel_cycle(300.0, 101_325.0, 20.0, 25.0, 1.4, 1.0).is_err()); }
#[test]
fn test_brayton_efficiency_analytical() {
let rp = 10.0;
let gamma = 1.4;
let result = brayton_cycle(300.0, 101_325.0, rp, 1400.0, gamma, 1.0).unwrap();
let expected = 1.0 - 1.0 / rp.powf((gamma - 1.0) / gamma);
assert!(
(result.efficiency - expected).abs() < 1e-6,
"Brayton η={}, expected {}",
result.efficiency,
expected
);
}
#[test]
fn test_brayton_energy_conservation() {
let result = brayton_cycle(300.0, 101_325.0, 10.0, 1400.0, 1.4, 1.0).unwrap();
let balance = (result.heat_in - result.net_work - result.heat_out).abs();
assert!(balance < 1e-6, "Brayton energy balance error: {balance} J");
}
#[test]
fn test_brayton_back_work_ratio() {
let result = brayton_cycle(300.0, 101_325.0, 10.0, 1400.0, 1.4, 1.0).unwrap();
assert!(result.back_work_ratio > 0.3 && result.back_work_ratio < 0.9);
}
#[test]
fn test_brayton_t3_must_exceed_t2() {
assert!(brayton_cycle(300.0, 101_325.0, 10.0, 500.0, 1.4, 1.0).is_err());
}
#[test]
fn test_brayton_invalid_inputs() {
assert!(brayton_cycle(300.0, 101_325.0, 1.0, 1400.0, 1.4, 1.0).is_err());
assert!(brayton_cycle(300.0, 101_325.0, 0.5, 1400.0, 1.4, 1.0).is_err());
assert!(brayton_cycle(300.0, 101_325.0, 10.0, -100.0, 1.4, 1.0).is_err());
}
#[test]
fn test_otto_invalid_inputs() {
assert!(otto_cycle(0.0, 101_325.0, 8.0, 50_000.0, 1.4, 1.0).is_err());
assert!(otto_cycle(300.0, 0.0, 8.0, 50_000.0, 1.4, 1.0).is_err());
assert!(otto_cycle(300.0, 101_325.0, 1.0, 50_000.0, 1.4, 1.0).is_err());
assert!(otto_cycle(300.0, 101_325.0, 0.5, 50_000.0, 1.4, 1.0).is_err());
assert!(otto_cycle(300.0, 101_325.0, 8.0, 50_000.0, 1.0, 1.0).is_err());
assert!(otto_cycle(300.0, 101_325.0, 8.0, 50_000.0, 1.4, 0.0).is_err());
assert!(otto_cycle(300.0, 101_325.0, 8.0, 0.0, 1.4, 1.0).is_err());
assert!(otto_cycle(300.0, 101_325.0, 8.0, -100.0, 1.4, 1.0).is_err());
}
#[test]
fn test_ts_diagram_point_count() {
let r = otto_cycle(300.0, 101_325.0, 8.0, 50_000.0, 1.4, 1.0).unwrap();
let pts = cycle_ts_diagram(&r, 10);
assert_eq!(pts.len(), 40);
}
#[test]
fn test_pv_diagram_point_count() {
let r = brayton_cycle(300.0, 101_325.0, 10.0, 1400.0, 1.4, 1.0).unwrap();
let pts = cycle_pv_diagram(&r, 20);
assert_eq!(pts.len(), 80);
}
#[test]
fn test_ts_diagram_isentropic_vertical() {
let r = otto_cycle(300.0, 101_325.0, 8.0, 50_000.0, 1.4, 1.0).unwrap();
let pts = cycle_ts_diagram(&r, 10);
let s_ref = pts[0].x;
for p in &pts[0..10] {
assert!(
(p.x - s_ref).abs() < 1e-10,
"Isentropic segment not vertical: s={}, expected {}",
p.x,
s_ref
);
}
}
#[test]
fn test_pv_diagram_isochoric_vertical() {
let r = otto_cycle(300.0, 101_325.0, 8.0, 50_000.0, 1.4, 1.0).unwrap();
let pts = cycle_pv_diagram(&r, 10);
let v_ref = pts[10].x;
for p in &pts[10..20] {
assert!(
(p.x - v_ref).abs() < 1e-10,
"Isochoric segment not vertical: v={}, expected {}",
p.x,
v_ref
);
}
}
#[test]
fn test_pv_diagram_isobaric_horizontal() {
let r = brayton_cycle(300.0, 101_325.0, 10.0, 1400.0, 1.4, 1.0).unwrap();
let pts = cycle_pv_diagram(&r, 10);
let p_ref = pts[10].y;
for p in &pts[10..20] {
assert!(
(p.y - p_ref).abs() / p_ref < 1e-10,
"Isobaric segment not horizontal"
);
}
}
#[test]
fn test_pv_diagram_closed_loop() {
let r = otto_cycle(300.0, 101_325.0, 8.0, 50_000.0, 1.4, 1.0).unwrap();
let pts = cycle_pv_diagram(&r, 10);
let first = &pts[0];
let last = &pts[pts.len() - 1];
assert!(
(first.x - last.x).abs() / first.x < 1e-10,
"P-v loop not closed (V)"
);
assert!(
(first.y - last.y).abs() / first.y < 1e-10,
"P-v loop not closed (P)"
);
}
#[test]
fn test_diagram_point() {
let dp = DiagramPoint { x: 1.0, y: 2.0 };
assert!((dp.x - 1.0).abs() < 1e-10);
assert!((dp.y - 2.0).abs() < 1e-10);
}
#[cfg(feature = "steam")]
#[test]
fn test_rankine_basic() {
let r = rankine_cycle(10_000.0, 2_000_000.0, Some(573.15)).unwrap();
assert!(r.efficiency > 0.15 && r.efficiency < 0.40);
assert!(r.net_work > 0.0);
}
#[cfg(feature = "steam")]
#[test]
fn test_rankine_energy_conservation() {
let r = rankine_cycle(10_000.0, 1_000_000.0, Some(573.15)).unwrap();
let balance = (r.heat_in - r.net_work - r.heat_out).abs();
assert!(
balance / r.heat_in < 0.01,
"Rankine energy balance: {balance}"
);
}
#[cfg(feature = "steam")]
#[test]
fn test_rankine_superheated_better_than_saturated() {
let sat = rankine_cycle(10_000.0, 1_000_000.0, None).unwrap();
let sup = rankine_cycle(10_000.0, 1_000_000.0, Some(573.15)).unwrap();
assert!(sup.efficiency > sat.efficiency);
}
#[cfg(feature = "steam")]
#[test]
fn test_rankine_low_back_work_ratio() {
let r = rankine_cycle(10_000.0, 2_000_000.0, Some(573.15)).unwrap();
assert!(r.back_work_ratio < 0.05);
}
#[cfg(feature = "steam")]
#[test]
fn test_rankine_invalid_pressures() {
assert!(rankine_cycle(0.0, 1_000_000.0, None).is_err());
assert!(rankine_cycle(10_000.0, 0.0, None).is_err());
assert!(rankine_cycle(1_000_000.0, 10_000.0, None).is_err()); }
#[cfg(feature = "steam")]
#[test]
fn test_refrigeration_basic() {
let r = refrigeration_cycle(7_384.0, 47_390.0).unwrap();
assert!(r.cop_refrigeration > 0.0);
assert!(r.cop_heat_pump > r.cop_refrigeration);
}
#[cfg(feature = "steam")]
#[test]
fn test_refrigeration_cop_hp_identity() {
let r = refrigeration_cycle(7_384.0, 47_390.0).unwrap();
assert!((r.cop_heat_pump - (r.cop_refrigeration + 1.0)).abs() < 0.1);
}
#[cfg(feature = "steam")]
#[test]
fn test_refrigeration_energy_balance() {
let r = refrigeration_cycle(7_384.0, 47_390.0).unwrap();
let c = &r.cycle;
let balance = (c.heat_out - c.heat_in - c.net_work).abs();
assert!(
balance / c.heat_out < 0.05,
"Refrigeration energy balance: {balance}"
);
}
#[cfg(feature = "steam")]
#[test]
fn test_refrigeration_invalid() {
assert!(refrigeration_cycle(0.0, 47_390.0).is_err());
assert!(refrigeration_cycle(47_390.0, 7_384.0).is_err()); }
#[test]
fn test_heat_pump_cop_identity() {
assert!((heat_pump_cop(3.0) - 4.0).abs() < 1e-10);
assert!((heat_pump_cop(0.0) - 1.0).abs() < 1e-10);
}
#[test]
fn test_compare_cycles_second_law() {
let otto = otto_cycle(300.0, 101_325.0, 8.0, 50_000.0, 1.4, 1.0).unwrap();
let brayton = brayton_cycle(300.0, 101_325.0, 10.0, 1400.0, 1.4, 1.0).unwrap();
let sp_otto = &otto.state_points;
let t_high_otto = sp_otto[2].temperature;
let t_low_otto = sp_otto[0].temperature;
let sp_bray = &brayton.state_points;
let t_high_bray = sp_bray[2].temperature;
let t_low_bray = sp_bray[0].temperature;
let comparison = compare_cycles(&[
(CycleKind::Otto, &otto, t_high_otto, t_low_otto),
(CycleKind::Brayton, &brayton, t_high_bray, t_low_bray),
]);
assert_eq!(comparison.len(), 2);
for entry in &comparison {
assert!(
entry.second_law_efficiency <= 1.0,
"η_II > 1: {}",
entry.second_law_efficiency
);
assert!(entry.second_law_efficiency > 0.0);
assert!(entry.carnot_efficiency > entry.efficiency);
}
}
}