//! 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, /// Fractions (either mass or mole basis, depending on constructor) fractions: Vec, /// 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::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::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::>() .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, 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(&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); } }