358 lines
11 KiB
Rust
358 lines
11 KiB
Rust
//! Mixture types and utilities for multi-component refrigerants.
|
|
//!
|
|
//! This module provides types for representing refrigerant mixtures
|
|
//! (e.g., R454B = R32/R1234yf) and their thermodynamic properties.
|
|
|
|
use std::fmt;
|
|
use std::hash::{Hash, Hasher};
|
|
|
|
/// A refrigerant mixture composed of multiple components.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```
|
|
/// use entropyk_fluids::mixture::Mixture;
|
|
///
|
|
/// let r454b = Mixture::from_mass_fractions(&[
|
|
/// ("R32", 0.5),
|
|
/// ("R1234yf", 0.5),
|
|
/// ]).unwrap();
|
|
///
|
|
/// let r410a = Mixture::from_mole_fractions(&[
|
|
/// ("R32", 0.5),
|
|
/// ("R125", 0.5),
|
|
/// ]).unwrap();
|
|
/// ```
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Mixture {
|
|
/// Components in the mixture (names as used by CoolProp)
|
|
components: Vec<String>,
|
|
/// Fractions (either mass or mole basis, depending on constructor)
|
|
fractions: Vec<f64>,
|
|
/// Whether fractions are mole-based (true) or mass-based (false)
|
|
mole_fractions: bool,
|
|
}
|
|
|
|
impl Mixture {
|
|
/// Create a mixture from mass fractions.
|
|
///
|
|
/// # Arguments
|
|
/// * `fractions` - Pairs of (fluid name, mass fraction)
|
|
///
|
|
/// # Errors
|
|
/// Returns an error if fractions don't sum to 1.0 or are invalid
|
|
pub fn from_mass_fractions(fractions: &[(&str, f64)]) -> Result<Self, MixtureError> {
|
|
Self::validate_fractions(fractions)?;
|
|
Ok(Mixture {
|
|
components: fractions
|
|
.iter()
|
|
.map(|(name, _)| (*name).to_string())
|
|
.collect(),
|
|
fractions: fractions.iter().map(|(_, frac)| *frac).collect(),
|
|
mole_fractions: false,
|
|
})
|
|
}
|
|
|
|
/// Create a mixture from mole fractions.
|
|
///
|
|
/// # Arguments
|
|
/// * `fractions` - Pairs of (fluid name, mole fraction)
|
|
///
|
|
/// # Errors
|
|
/// Returns an error if fractions don't sum to 1.0 or are invalid
|
|
pub fn from_mole_fractions(fractions: &[(&str, f64)]) -> Result<Self, MixtureError> {
|
|
Self::validate_fractions(fractions)?;
|
|
Ok(Mixture {
|
|
components: fractions
|
|
.iter()
|
|
.map(|(name, _)| (*name).to_string())
|
|
.collect(),
|
|
fractions: fractions.iter().map(|(_, frac)| *frac).collect(),
|
|
mole_fractions: true,
|
|
})
|
|
}
|
|
|
|
/// Validate that fractions are valid (sum to 1.0, all non-negative)
|
|
fn validate_fractions(fractions: &[(&str, f64)]) -> Result<(), MixtureError> {
|
|
if fractions.is_empty() {
|
|
return Err(MixtureError::InvalidComposition(
|
|
"Mixture must have at least one component".to_string(),
|
|
));
|
|
}
|
|
|
|
let sum: f64 = fractions.iter().map(|(_, frac)| frac).sum();
|
|
if (sum - 1.0).abs() > 1e-6 {
|
|
return Err(MixtureError::InvalidComposition(format!(
|
|
"Fractions must sum to 1.0, got {}",
|
|
sum
|
|
)));
|
|
}
|
|
|
|
for (_, frac) in fractions {
|
|
if *frac < 0.0 || *frac > 1.0 {
|
|
return Err(MixtureError::InvalidComposition(format!(
|
|
"Fraction must be between 0 and 1, got {}",
|
|
frac
|
|
)));
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get the components in this mixture.
|
|
pub fn components(&self) -> &[String] {
|
|
&self.components
|
|
}
|
|
|
|
/// Get the fractions (mass or mole basis depending on constructor).
|
|
pub fn fractions(&self) -> &[f64] {
|
|
&self.fractions
|
|
}
|
|
|
|
/// Check if fractions are mole-based.
|
|
pub fn is_mole_fractions(&self) -> bool {
|
|
self.mole_fractions
|
|
}
|
|
|
|
/// Check if fractions are mass-based.
|
|
pub fn is_mass_fractions(&self) -> bool {
|
|
!self.mole_fractions
|
|
}
|
|
|
|
/// Convert to CoolProp mixture string format.
|
|
///
|
|
/// CoolProp format: "R32[0.5]&R125[0.5]" (mole fractions)
|
|
pub fn to_coolprop_string(&self) -> String {
|
|
self.components
|
|
.iter()
|
|
.zip(self.fractions.iter())
|
|
.map(|(name, frac)| format!("{}[{}]", name, frac))
|
|
.collect::<Vec<_>>()
|
|
.join("&")
|
|
}
|
|
|
|
/// Get the number of components in this mixture.
|
|
pub fn len(&self) -> usize {
|
|
self.components.len()
|
|
}
|
|
|
|
/// Check if this mixture has no components.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.components.is_empty()
|
|
}
|
|
|
|
/// Convert mass fractions to mole fractions.
|
|
///
|
|
/// Requires molar masses for each component.
|
|
/// Uses simplified molar masses for common refrigerants.
|
|
pub fn to_mole_fractions(&self) -> Result<Vec<f64>, MixtureError> {
|
|
if self.mole_fractions {
|
|
return Ok(self.fractions.to_vec());
|
|
}
|
|
|
|
let total: f64 = self
|
|
.components
|
|
.iter()
|
|
.zip(self.fractions.iter())
|
|
.map(|(c, frac)| frac / Self::molar_mass(c))
|
|
.sum();
|
|
|
|
Ok(self
|
|
.components
|
|
.iter()
|
|
.zip(self.fractions.iter())
|
|
.map(|(c, frac)| (frac / Self::molar_mass(c)) / total)
|
|
.collect())
|
|
}
|
|
|
|
/// Get molar mass (g/mol) for common refrigerants.
|
|
fn molar_mass(fluid: &str) -> f64 {
|
|
match fluid.to_uppercase().as_str() {
|
|
"R32" => 52.02,
|
|
"R125" => 120.02,
|
|
"R134A" => 102.03,
|
|
"R1234YF" => 114.04,
|
|
"R1234ZE" => 114.04,
|
|
"R410A" => 72.58,
|
|
"R404A" => 97.60,
|
|
"R407C" => 86.20,
|
|
"R290" | "PROPANE" => 44.10,
|
|
"R600" | "BUTANE" => 58.12,
|
|
"R600A" | "ISOBUTANE" => 58.12,
|
|
"CO2" | "R744" => 44.01,
|
|
"WATER" | "H2O" => 18.02,
|
|
"AIR" => 28.97,
|
|
"NITROGEN" | "N2" => 28.01,
|
|
"OXYGEN" | "O2" => 32.00,
|
|
_ => 50.0, // Default fallback
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Hash for Mixture {
|
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
|
// Use CoolProp string as stable hash representation
|
|
self.to_coolprop_string().hash(state);
|
|
}
|
|
}
|
|
|
|
impl Eq for Mixture {}
|
|
|
|
impl fmt::Display for Mixture {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
let fraction_type = if self.mole_fractions { "mole" } else { "mass" };
|
|
write!(f, "Mixture ({} fractions): ", fraction_type)?;
|
|
for (i, (comp, frac)) in self
|
|
.components
|
|
.iter()
|
|
.zip(self.fractions.iter())
|
|
.enumerate()
|
|
{
|
|
if i > 0 {
|
|
write!(f, ", ")?;
|
|
}
|
|
write!(f, "{}={:.2}", comp, frac)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Errors that can occur when working with mixtures.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum MixtureError {
|
|
/// Invalid mixture composition
|
|
InvalidComposition(String),
|
|
/// Mixture not supported by backend
|
|
MixtureNotSupported(String),
|
|
/// Invalid fraction type
|
|
InvalidFractionType(String),
|
|
}
|
|
|
|
impl fmt::Display for MixtureError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
MixtureError::InvalidComposition(msg) => {
|
|
write!(f, "Invalid mixture composition: {}", msg)
|
|
}
|
|
MixtureError::MixtureNotSupported(msg) => write!(f, "Mixture not supported: {}", msg),
|
|
MixtureError::InvalidFractionType(msg) => write!(f, "Invalid fraction type: {}", msg),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for MixtureError {}
|
|
|
|
/// Pre-defined common refrigerant mixtures.
|
|
pub mod predefined {
|
|
use super::*;
|
|
|
|
/// R454B: R32 (50%) / R1234yf (50%) - mass fractions
|
|
pub fn r454b() -> Mixture {
|
|
Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap()
|
|
}
|
|
|
|
/// R410A: R32 (50%) / R125 (50%) - mass fractions
|
|
pub fn r410a() -> Mixture {
|
|
Mixture::from_mass_fractions(&[("R32", 0.5), ("R125", 0.5)]).unwrap()
|
|
}
|
|
|
|
/// R407C: R32 (23%) / R125 (25%) / R134a (52%) - mass fractions
|
|
pub fn r407c() -> Mixture {
|
|
Mixture::from_mass_fractions(&[("R32", 0.23), ("R125", 0.25), ("R134a", 0.52)]).unwrap()
|
|
}
|
|
|
|
/// R404A: R125 (44%) / R143a (52%) / R134a (4%) - mass fractions
|
|
pub fn r404a() -> Mixture {
|
|
Mixture::from_mass_fractions(&[("R125", 0.44), ("R143a", 0.52), ("R134a", 0.04)]).unwrap()
|
|
}
|
|
|
|
/// R32/R125 (50/50) mixture - mass fractions
|
|
pub fn r32_r125_5050() -> Mixture {
|
|
Mixture::from_mass_fractions(&[("R32", 0.5), ("R125", 0.5)]).unwrap()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_mixture_creation_mass() {
|
|
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
|
|
assert_eq!(mixture.components().len(), 2);
|
|
assert!(mixture.is_mass_fractions());
|
|
}
|
|
|
|
#[test]
|
|
fn test_mixture_creation_mole() {
|
|
let mixture = Mixture::from_mole_fractions(&[("R32", 0.5), ("R125", 0.5)]).unwrap();
|
|
assert_eq!(mixture.components().len(), 2);
|
|
assert!(mixture.is_mole_fractions());
|
|
}
|
|
|
|
#[test]
|
|
fn test_coolprop_string() {
|
|
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
|
|
let cp_string = mixture.to_coolprop_string();
|
|
assert!(cp_string.contains("R32[0.5]"));
|
|
assert!(cp_string.contains("R1234yf[0.5]"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_predefined_r454b() {
|
|
let mixture = predefined::r454b();
|
|
assert_eq!(mixture.components().len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_fractions_sum() {
|
|
let result = Mixture::from_mass_fractions(&[("R32", 0.3), ("R125", 0.5)]);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_fraction_negative() {
|
|
let result = Mixture::from_mass_fractions(&[("R32", -0.5), ("R125", 1.5)]);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_mixture_hash() {
|
|
let m1 = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
|
|
let m2 = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
|
|
|
|
use std::collections::hash_map::DefaultHasher;
|
|
let mut h1 = DefaultHasher::new();
|
|
let mut h2 = DefaultHasher::new();
|
|
m1.hash(&mut h1);
|
|
m2.hash(&mut h2);
|
|
assert_eq!(h1.finish(), h2.finish());
|
|
}
|
|
|
|
#[test]
|
|
fn test_mass_to_mole_conversion() {
|
|
// R454B: 50% mass R32, 50% mass R1234yf
|
|
// Molar masses: R32=52.02, R1234yf=114.04
|
|
// Mole fraction R32 = (0.5/52.02) / (0.5/52.02 + 0.5/114.04) ≈ 0.687
|
|
let mixture = Mixture::from_mass_fractions(&[("R32", 0.5), ("R1234yf", 0.5)]).unwrap();
|
|
let mole_fracs = mixture.to_mole_fractions().unwrap();
|
|
|
|
// Verify sum = 1.0
|
|
let sum: f64 = mole_fracs.iter().sum();
|
|
assert!((sum - 1.0).abs() < 1e-6);
|
|
|
|
// R32 should be ~69% mole fraction (higher due to lower molar mass)
|
|
assert!(mole_fracs[0] > 0.6 && mole_fracs[0] < 0.8);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mole_fractions_passthrough() {
|
|
let mixture = Mixture::from_mole_fractions(&[("R32", 0.5), ("R125", 0.5)]).unwrap();
|
|
let mole_fracs = mixture.to_mole_fractions().unwrap();
|
|
|
|
assert!((mole_fracs[0] - 0.5).abs() < 1e-6);
|
|
assert!((mole_fracs[1] - 0.5).abs() < 1e-6);
|
|
}
|
|
}
|