use std::{error::Error as StdError, marker::PhantomData};
use thiserror::Error;
use twine_core::Model;
use uom::si::f64::{TemperatureInterval, ThermalConductance};
use crate::{
models::thermal::hx::discretized::core::{
DiscretizedHx, DiscretizedHxThermoModel, GivenUaConfig, GivenUaError, GivenUaResults,
HeatTransferRate, Inlets, Known, MassFlows, MinDeltaT, PressureDrops,
},
support::{hx::arrangement::CounterFlow, thermo::State},
};
#[derive(Debug, Clone)]
pub struct RecuperatorGivenUa<Fluid, Thermo> {
thermo: Thermo,
segments: usize,
config: RecuperatorGivenUaConfig,
_fluid: PhantomData<Fluid>,
}
#[derive(Debug, Clone, Copy)]
pub struct RecuperatorGivenUaConfig {
pub ua_rel_tol: f64,
pub temp_abs_tol: TemperatureInterval,
pub max_iters: usize,
}
impl Default for RecuperatorGivenUaConfig {
fn default() -> Self {
Self {
ua_rel_tol: 1e-6,
temp_abs_tol: TemperatureInterval::new::<uom::si::temperature_interval::kelvin>(1e-6),
max_iters: 100,
}
}
}
#[derive(Debug, Clone)]
pub struct RecuperatorGivenUaInput<Fluid> {
pub inlets: Inlets<Fluid, Fluid>,
pub mass_flows: MassFlows,
pub pressure_drops: PressureDrops,
pub ua: ThermalConductance,
}
#[derive(Debug, Clone)]
pub struct RecuperatorGivenUaOutput<Fluid> {
pub top_outlet: State<Fluid>,
pub bottom_outlet: State<Fluid>,
pub q_dot: HeatTransferRate,
pub ua: ThermalConductance,
pub min_delta_t: MinDeltaT,
pub iterations: usize,
}
#[derive(Debug, Error)]
pub enum RecuperatorGivenUaError {
#[error("unsupported segment count {0}; supported values are 1, 5, 10, 20, 50")]
UnsupportedSegments(usize),
#[error("recuperator solver failed to converge: {message}")]
Convergence {
message: String,
iterations: Option<usize>,
},
#[error("equal inlet temperatures: solver cannot form a search bracket")]
EqualInletTemperatures,
#[error("target UA must be non-negative, got {0:?}")]
NegativeUa(ThermalConductance),
#[error("thermodynamic model failed: {context}")]
ThermoModelFailed {
context: String,
#[source]
source: Box<dyn StdError + Send + Sync>,
},
}
impl<Fluid, Thermo> RecuperatorGivenUa<Fluid, Thermo> {
pub fn new(
thermo: Thermo,
segments: usize,
config: RecuperatorGivenUaConfig,
) -> Result<Self, RecuperatorGivenUaError> {
if !matches!(segments, 1 | 5 | 10 | 20 | 50) {
return Err(RecuperatorGivenUaError::UnsupportedSegments(segments));
}
Ok(Self {
thermo,
segments,
config,
_fluid: PhantomData,
})
}
fn solve<const N: usize>(
&self,
input: &RecuperatorGivenUaInput<Fluid>,
) -> Result<RecuperatorGivenUaOutput<Fluid>, RecuperatorGivenUaError>
where
Fluid: Clone,
Thermo: DiscretizedHxThermoModel<Fluid>,
{
let known = Known {
inlets: input.inlets.clone(),
m_dot: input.mass_flows,
dp: input.pressure_drops,
};
let ua_abs_tol = input.ua * self.config.ua_rel_tol.abs();
let given_ua_config = GivenUaConfig {
max_iters: self.config.max_iters,
temp_tol: self.config.temp_abs_tol,
ua_tol: ua_abs_tol,
};
let given_ua_results = DiscretizedHx::<CounterFlow, N>::given_ua_same(
&known,
input.ua,
given_ua_config,
&self.thermo,
)
.map_err(RecuperatorGivenUaError::from)?;
Ok(Self::to_output(given_ua_results))
}
fn to_output<const N: usize>(
given_ua_results: GivenUaResults<Fluid, Fluid, N>,
) -> RecuperatorGivenUaOutput<Fluid>
where
Fluid: Clone,
{
let results = given_ua_results.results;
RecuperatorGivenUaOutput {
top_outlet: results.top[N - 1].clone(),
bottom_outlet: results.bottom[0].clone(),
q_dot: results.q_dot,
ua: results.ua,
min_delta_t: results.min_delta_t,
iterations: given_ua_results.iterations,
}
}
}
impl<Fluid, Thermo> Model for RecuperatorGivenUa<Fluid, Thermo>
where
Fluid: Clone,
Thermo: DiscretizedHxThermoModel<Fluid>,
{
type Input = RecuperatorGivenUaInput<Fluid>;
type Output = RecuperatorGivenUaOutput<Fluid>;
type Error = RecuperatorGivenUaError;
fn call(&self, input: &Self::Input) -> Result<Self::Output, Self::Error> {
match self.segments {
1 => self.solve::<2>(input),
5 => self.solve::<6>(input),
10 => self.solve::<11>(input),
20 => self.solve::<21>(input),
50 => self.solve::<51>(input),
_ => unreachable!("validated at construction"),
}
}
}
impl From<GivenUaError> for RecuperatorGivenUaError {
fn from(value: GivenUaError) -> Self {
match value {
GivenUaError::NegativeUa(ua) => Self::NegativeUa(ua),
GivenUaError::Solve(error) => Self::ThermoModelFailed {
context: "discretized heat exchanger solve".to_owned(),
source: Box::new(error),
},
GivenUaError::Bisection(error) => Self::Convergence {
message: error.to_string(),
iterations: None,
},
GivenUaError::EqualInletTemperatures => Self::EqualInletTemperatures,
GivenUaError::MaxIters { iters, .. } => Self::Convergence {
message: "iteration limit reached".to_owned(),
iterations: Some(iters),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
use twine_core::Model;
use uom::si::{
f64::MassRate, mass_rate::kilogram_per_second, thermal_conductance::watt_per_kelvin,
thermodynamic_temperature::kelvin,
};
use crate::models::thermal::hx::discretized::core::{
Inlets, MassFlows, PressureDrops,
test_support::{TestFluid, TestThermoModel, state},
};
fn thermo() -> TestThermoModel {
TestThermoModel::new()
}
fn mass_flows() -> MassFlows {
MassFlows::new_unchecked(
MassRate::new::<kilogram_per_second>(1.0),
MassRate::new::<kilogram_per_second>(1.0),
)
}
fn input(top: f64, bottom: f64, ua_wpk: f64) -> RecuperatorGivenUaInput<TestFluid> {
RecuperatorGivenUaInput {
inlets: Inlets {
top: state(top),
bottom: state(bottom),
},
mass_flows: mass_flows(),
pressure_drops: PressureDrops::default(),
ua: ThermalConductance::new::<watt_per_kelvin>(ua_wpk),
}
}
#[test]
fn new_accepts_supported_segment_counts() {
for n in [1, 5, 10, 20, 50] {
assert!(
RecuperatorGivenUa::<TestFluid, _>::new(
thermo(),
n,
RecuperatorGivenUaConfig::default()
)
.is_ok(),
"segment count {n} should be accepted",
);
}
}
#[test]
fn new_rejects_unsupported_segment_counts() {
for n in [0, 2, 3, 100] {
assert!(
matches!(
RecuperatorGivenUa::<TestFluid, _>::new(
thermo(),
n,
RecuperatorGivenUaConfig::default()
),
Err(RecuperatorGivenUaError::UnsupportedSegments(_))
),
"segment count {n} should be rejected",
);
}
}
#[test]
fn call_hot_cools_and_cold_heats() {
let inp = input(400.0, 600.0, 500.0);
let cold_inlet_temp = inp.inlets.top.temperature;
let hot_inlet_temp = inp.inlets.bottom.temperature;
let recuperator =
RecuperatorGivenUa::new(thermo(), 10, RecuperatorGivenUaConfig::default()).unwrap();
let out = recuperator.call(&inp).unwrap();
assert!(
out.top_outlet.temperature > cold_inlet_temp,
"cold side should be heated"
);
assert!(
out.bottom_outlet.temperature < hot_inlet_temp,
"hot side should be cooled"
);
}
#[test]
fn zero_ua_returns_inlets_unchanged() {
let inp = input(400.0, 600.0, 0.0);
let recuperator =
RecuperatorGivenUa::new(thermo(), 10, RecuperatorGivenUaConfig::default()).unwrap();
let out = recuperator.call(&inp).unwrap();
assert_relative_eq!(out.top_outlet.temperature.get::<kelvin>(), 400.0);
assert_relative_eq!(out.bottom_outlet.temperature.get::<kelvin>(), 600.0);
}
#[test]
fn negative_ua_returns_error() {
let recuperator =
RecuperatorGivenUa::new(thermo(), 10, RecuperatorGivenUaConfig::default()).unwrap();
let result = recuperator.call(&input(400.0, 600.0, -1.0));
assert!(
matches!(result, Err(RecuperatorGivenUaError::NegativeUa(_))),
"expected NegativeUa error",
);
}
#[cfg(any(feature = "coolprop-static", feature = "coolprop-dylib"))]
mod coolprop_tests {
use super::*;
use crate::support::thermo::{
capability::StateFrom, fluid::CarbonDioxide, model::CoolProp,
};
use uom::si::{
f64::{Pressure, ThermodynamicTemperature},
pressure::{megapascal, pascal},
thermal_conductance::kilowatt_per_kelvin,
thermodynamic_temperature::degree_celsius,
};
#[test]
fn co2_five_segments_at_atmospheric_pressure() {
let thermo = CoolProp::<CarbonDioxide>::new().unwrap();
let atm = Pressure::new::<pascal>(101_325.0);
let cold_inlet = thermo
.state_from((
CarbonDioxide,
ThermodynamicTemperature::new::<degree_celsius>(25.0),
atm,
))
.unwrap();
let hot_inlet = thermo
.state_from((
CarbonDioxide,
ThermodynamicTemperature::new::<degree_celsius>(300.0),
atm,
))
.unwrap();
let recuperator =
RecuperatorGivenUa::new(&thermo, 5, RecuperatorGivenUaConfig::default()).unwrap();
let result = recuperator
.call(&RecuperatorGivenUaInput {
inlets: Inlets {
top: cold_inlet,
bottom: hot_inlet,
},
mass_flows: MassFlows::new_unchecked(
MassRate::new::<kilogram_per_second>(1.0),
MassRate::new::<kilogram_per_second>(1.0),
),
pressure_drops: PressureDrops::zero(),
ua: ThermalConductance::new::<watt_per_kelvin>(500.0),
})
.unwrap();
assert!(
result.top_outlet.temperature > cold_inlet.temperature,
"cold side should be heated",
);
assert!(
result.bottom_outlet.temperature < hot_inlet.temperature,
"hot side should be cooled",
);
}
#[test]
fn co2_five_segments_at_sco2_power_cycle_conditions() {
let thermo = CoolProp::<CarbonDioxide>::new().unwrap();
let cold_inlet = thermo
.state_from((
CarbonDioxide,
ThermodynamicTemperature::new::<degree_celsius>(180.0),
Pressure::new::<megapascal>(20.0),
))
.unwrap();
let hot_inlet = thermo
.state_from((
CarbonDioxide,
ThermodynamicTemperature::new::<degree_celsius>(380.0),
Pressure::new::<megapascal>(8.0),
))
.unwrap();
let recuperator =
RecuperatorGivenUa::new(&thermo, 5, RecuperatorGivenUaConfig::default()).unwrap();
let result = recuperator
.call(&RecuperatorGivenUaInput {
inlets: Inlets {
top: cold_inlet,
bottom: hot_inlet,
},
mass_flows: MassFlows::new_unchecked(
MassRate::new::<kilogram_per_second>(1.0),
MassRate::new::<kilogram_per_second>(1.0),
),
pressure_drops: PressureDrops::zero(),
ua: ThermalConductance::new::<watt_per_kelvin>(2000.0),
})
.unwrap();
assert!(
result.top_outlet.temperature > cold_inlet.temperature,
"cold side should be heated",
);
assert!(
result.bottom_outlet.temperature < hot_inlet.temperature,
"hot side should be cooled",
);
}
#[test]
fn co2_five_segments_near_critical_point() {
let thermo = CoolProp::<CarbonDioxide>::new().unwrap();
let cold_inlet = thermo
.state_from((
CarbonDioxide,
ThermodynamicTemperature::new::<degree_celsius>(80.0),
Pressure::new::<megapascal>(20.0),
))
.unwrap();
let hot_inlet = thermo
.state_from((
CarbonDioxide,
ThermodynamicTemperature::new::<degree_celsius>(400.0),
Pressure::new::<megapascal>(8.0),
))
.unwrap();
let recuperator =
RecuperatorGivenUa::new(&thermo, 5, RecuperatorGivenUaConfig::default()).unwrap();
let result = recuperator
.call(&RecuperatorGivenUaInput {
inlets: Inlets {
top: cold_inlet,
bottom: hot_inlet,
},
mass_flows: MassFlows::new_unchecked(
MassRate::new::<kilogram_per_second>(1.0),
MassRate::new::<kilogram_per_second>(1.0),
),
pressure_drops: PressureDrops::zero(),
ua: ThermalConductance::new::<kilowatt_per_kelvin>(2000.0),
})
.unwrap();
assert!(
result.top_outlet.temperature > cold_inlet.temperature,
"cold side should be heated",
);
assert!(
result.bottom_outlet.temperature < hot_inlet.temperature,
"hot side should be cooled",
);
}
}
}