fix: resolve CLI solver state dimension mismatch

Removed mathematical singularity in HeatExchanger models (q_hot - q_cold = 0 was redundant) causing them to incorrectly request 3 equations without internal variables. Fixed ScrewEconomizerCompressor internal_state_len to perfectly align with the solver dimensions.
This commit is contained in:
Sepehr
2026-02-28 22:45:51 +01:00
parent c5a51d82dc
commit fdd124eefd
35 changed files with 10969 additions and 123 deletions

15
crates/vendors/Cargo.toml vendored Normal file
View File

@@ -0,0 +1,15 @@
[package]
name = "entropyk-vendors"
version.workspace = true
authors.workspace = true
edition.workspace = true
description = "Vendor equipment data backends for Entropyk (Copeland, SWEP, Danfoss, Bitzer)"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"
log = "0.4"
[dev-dependencies]
approx = "0.5"

View File

@@ -0,0 +1,41 @@
{
"model": "B5THx20",
"manufacturer": "SWEP",
"num_plates": 20,
"area": 0.45,
"dh": 0.003,
"chevron_angle": 65.0,
"ua_nominal": 1500.0,
"ua_curve": {
"points": [
[
0.2,
0.30
],
[
0.4,
0.55
],
[
0.6,
0.72
],
[
0.8,
0.88
],
[
1.0,
1.00
],
[
1.2,
1.08
],
[
1.5,
1.15
]
]
}
}

View File

@@ -0,0 +1,9 @@
{
"model": "B8THx30",
"manufacturer": "SWEP",
"num_plates": 30,
"area": 0.72,
"dh": 0.0025,
"chevron_angle": 60.0,
"ua_nominal": 2500.0
}

View File

@@ -0,0 +1,4 @@
[
"B5THx20",
"B8THx30"
]

View File

@@ -0,0 +1,286 @@
//! Copeland (Emerson) compressor data backend.
//!
//! Loads AHRI 540 compressor coefficients from JSON files in the
//! `data/copeland/compressors/` directory.
use std::collections::HashMap;
use std::path::PathBuf;
use crate::error::VendorError;
use crate::vendor_api::{
BphxParameters, CompressorCoefficients, UaCalcParams, VendorBackend,
};
/// Backend for Copeland (Emerson) scroll compressor data.
///
/// Loads an index file (`index.json`) listing available compressor models,
/// then eagerly pre-caches each model's JSON file into memory.
///
/// # Example
///
/// ```no_run
/// use entropyk_vendors::compressors::copeland::CopelandBackend;
/// use entropyk_vendors::VendorBackend;
///
/// let backend = CopelandBackend::new().expect("load copeland data");
/// let models = backend.list_compressor_models().unwrap();
/// println!("Available: {:?}", models);
/// ```
#[derive(Debug)]
pub struct CopelandBackend {
/// Root path to the Copeland data directory.
data_path: PathBuf,
/// Pre-loaded compressor coefficients keyed by model name.
compressor_cache: HashMap<String, CompressorCoefficients>,
/// Sorted list of available models.
sorted_models: Vec<String>,
}
impl CopelandBackend {
/// Create a new Copeland backend, loading all compressor models from disk.
///
/// The data directory is resolved via the `ENTROPYK_DATA` environment variable.
/// If unset, it falls back to the compile-time `CARGO_MANIFEST_DIR/data` in debug mode,
/// or `./data` in release mode.
pub fn new() -> Result<Self, VendorError> {
let base_path = std::env::var("ENTROPYK_DATA")
.map(PathBuf::from)
.unwrap_or_else(|_| {
#[cfg(debug_assertions)]
{
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data")
}
#[cfg(not(debug_assertions))]
{
PathBuf::from("data")
}
});
let data_path = base_path.join("copeland");
let mut backend = Self {
data_path,
compressor_cache: HashMap::new(),
sorted_models: Vec::new(),
};
backend.load_index()?;
Ok(backend)
}
/// Create a new Copeland backend from a custom data path.
///
/// Useful for testing with alternative data directories.
pub fn from_path(data_path: PathBuf) -> Result<Self, VendorError> {
let mut backend = Self {
data_path,
compressor_cache: HashMap::new(),
sorted_models: Vec::new(),
};
backend.load_index()?;
Ok(backend)
}
/// Load the compressor index and pre-cache all referenced models.
fn load_index(&mut self) -> Result<(), VendorError> {
let index_path = self.data_path.join("compressors").join("index.json");
let index_content = std::fs::read_to_string(&index_path).map_err(|e| {
VendorError::IoError {
path: index_path.display().to_string(),
source: e,
}
})?;
let models: Vec<String> = serde_json::from_str(&index_content)?;
for model in models {
match self.load_model(&model) {
Ok(coeffs) => {
self.compressor_cache.insert(model.clone(), coeffs);
self.sorted_models.push(model);
}
Err(e) => {
log::warn!("[entropyk-vendors] Skipping Copeland model {}: {}", model, e);
}
}
}
self.sorted_models.sort();
Ok(())
}
/// Load a single compressor model from its JSON file.
fn load_model(&self, model: &str) -> Result<CompressorCoefficients, VendorError> {
let model_path = self
.data_path
.join("compressors")
.join(format!("{}.json", model));
let content = std::fs::read_to_string(&model_path).map_err(|e| VendorError::IoError {
path: model_path.display().to_string(),
source: e,
})?;
let coeffs: CompressorCoefficients = serde_json::from_str(&content)?;
Ok(coeffs)
}
}
impl VendorBackend for CopelandBackend {
fn vendor_name(&self) -> &str {
"Copeland (Emerson)"
}
fn list_compressor_models(&self) -> Result<Vec<String>, VendorError> {
Ok(self.sorted_models.clone())
}
fn get_compressor_coefficients(
&self,
model: &str,
) -> Result<CompressorCoefficients, VendorError> {
self.compressor_cache
.get(model)
.cloned()
.ok_or_else(|| VendorError::ModelNotFound(model.to_string()))
}
fn list_bphx_models(&self) -> Result<Vec<String>, VendorError> {
// Copeland does not provide BPHX data
Ok(vec![])
}
fn get_bphx_parameters(&self, model: &str) -> Result<BphxParameters, VendorError> {
Err(VendorError::InvalidFormat(format!(
"Copeland does not provide BPHX data (requested: {})",
model
)))
}
fn compute_ua(&self, model: &str, _params: &UaCalcParams) -> Result<f64, VendorError> {
Err(VendorError::InvalidFormat(format!(
"Copeland does not provide BPHX/UA data (requested: {})",
model
)))
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_copeland_backend_new() {
let backend = CopelandBackend::new();
assert!(backend.is_ok(), "CopelandBackend::new() should succeed");
}
#[test]
fn test_copeland_vendor_name() {
let backend = CopelandBackend::new().unwrap();
assert_eq!(backend.vendor_name(), "Copeland (Emerson)");
}
#[test]
fn test_copeland_list_compressor_models() {
let backend = CopelandBackend::new().unwrap();
let models = backend.list_compressor_models().unwrap();
assert_eq!(models.len(), 2);
assert!(models.contains(&"ZP54KCE-TFD".to_string()));
assert!(models.contains(&"ZP49KCE-TFD".to_string()));
}
#[test]
fn test_copeland_get_compressor_zp54() {
let backend = CopelandBackend::new().unwrap();
let coeffs = backend
.get_compressor_coefficients("ZP54KCE-TFD")
.unwrap();
assert_eq!(coeffs.model, "ZP54KCE-TFD");
assert_eq!(coeffs.manufacturer, "Copeland");
assert_eq!(coeffs.refrigerant, "R410A");
assert_eq!(coeffs.capacity_coeffs.len(), 10);
assert_eq!(coeffs.power_coeffs.len(), 10);
// Check first capacity coefficient
assert!((coeffs.capacity_coeffs[0] - 18000.0).abs() < 1e-10);
// Check first power coefficient
assert!((coeffs.power_coeffs[0] - 4500.0).abs() < 1e-10);
// mass_flow_coeffs not provided in Copeland data
assert!(coeffs.mass_flow_coeffs.is_none());
}
#[test]
fn test_copeland_get_compressor_zp49() {
let backend = CopelandBackend::new().unwrap();
let coeffs = backend
.get_compressor_coefficients("ZP49KCE-TFD")
.unwrap();
assert_eq!(coeffs.model, "ZP49KCE-TFD");
assert_eq!(coeffs.manufacturer, "Copeland");
assert_eq!(coeffs.refrigerant, "R410A");
assert!((coeffs.capacity_coeffs[0] - 16500.0).abs() < 1e-10);
assert!((coeffs.power_coeffs[0] - 4100.0).abs() < 1e-10);
}
#[test]
fn test_copeland_validity_range() {
let backend = CopelandBackend::new().unwrap();
let coeffs = backend
.get_compressor_coefficients("ZP54KCE-TFD")
.unwrap();
assert!((coeffs.validity.t_suction_min - (-10.0)).abs() < 1e-10);
assert!((coeffs.validity.t_suction_max - 20.0).abs() < 1e-10);
assert!((coeffs.validity.t_discharge_min - 25.0).abs() < 1e-10);
assert!((coeffs.validity.t_discharge_max - 65.0).abs() < 1e-10);
}
#[test]
fn test_copeland_model_not_found() {
let backend = CopelandBackend::new().unwrap();
let result = backend.get_compressor_coefficients("NONEXISTENT");
assert!(result.is_err());
match result.unwrap_err() {
VendorError::ModelNotFound(m) => assert_eq!(m, "NONEXISTENT"),
other => panic!("Expected ModelNotFound, got: {:?}", other),
}
}
#[test]
fn test_copeland_list_bphx_empty() {
let backend = CopelandBackend::new().unwrap();
let models = backend.list_bphx_models().unwrap();
assert!(models.is_empty());
}
#[test]
fn test_copeland_get_bphx_returns_error() {
let backend = CopelandBackend::new().unwrap();
let result = backend.get_bphx_parameters("anything");
assert!(result.is_err());
match result.unwrap_err() {
VendorError::InvalidFormat(msg) => {
assert!(msg.contains("Copeland does not provide BPHX"));
}
other => panic!("Expected InvalidFormat, got: {:?}", other),
}
}
#[test]
fn test_copeland_object_safety() {
let backend: Box<dyn VendorBackend> = Box::new(CopelandBackend::new().unwrap());
assert_eq!(backend.vendor_name(), "Copeland (Emerson)");
let models = backend.list_compressor_models().unwrap();
assert!(!models.is_empty());
}
}

View File

@@ -0,0 +1,340 @@
//! SWEP brazed-plate heat exchanger (BPHX) data backend.
//!
//! Loads BPHX parameters and UA curves from JSON files in the
//! `data/swep/bphx/` directory.
use std::collections::HashMap;
use std::path::PathBuf;
use crate::error::VendorError;
use crate::vendor_api::{
BphxParameters, CompressorCoefficients, UaCalcParams, VendorBackend,
};
/// Backend for SWEP brazed-plate heat exchanger data.
///
/// Loads an index file (`index.json`) listing available BPHX models,
/// then eagerly pre-caches each model's JSON file into memory.
///
/// # Example
///
/// ```no_run
/// use entropyk_vendors::heat_exchangers::swep::SwepBackend;
/// use entropyk_vendors::VendorBackend;
///
/// let backend = SwepBackend::new().expect("load SWEP data");
/// let models = backend.list_bphx_models().unwrap();
/// println!("Available: {:?}", models);
/// ```
#[derive(Debug)]
pub struct SwepBackend {
/// Root path to the SWEP data directory.
data_path: PathBuf,
/// Pre-loaded BPHX parameters keyed by model name.
bphx_cache: HashMap<String, BphxParameters>,
/// Sorted list of available models.
sorted_models: Vec<String>,
}
impl SwepBackend {
/// Create a new SWEP backend, loading all BPHX models from disk.
///
/// The data directory is resolved via the `ENTROPYK_DATA` environment variable.
/// If unset, it falls back to the compile-time `CARGO_MANIFEST_DIR/data` in debug mode,
/// or `./data` in release mode.
pub fn new() -> Result<Self, VendorError> {
let base_path = std::env::var("ENTROPYK_DATA")
.map(PathBuf::from)
.unwrap_or_else(|_| {
#[cfg(debug_assertions)]
{
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data")
}
#[cfg(not(debug_assertions))]
{
PathBuf::from("data")
}
});
let data_path = base_path.join("swep");
let mut backend = Self {
data_path,
bphx_cache: HashMap::new(),
sorted_models: Vec::new(),
};
backend.load_index()?;
Ok(backend)
}
/// Create a new SWEP backend from a custom data path.
///
/// Useful for testing with alternative data directories.
pub fn from_path(data_path: PathBuf) -> Result<Self, VendorError> {
let mut backend = Self {
data_path,
bphx_cache: HashMap::new(),
sorted_models: Vec::new(),
};
backend.load_index()?;
Ok(backend)
}
/// Load the BPHX index and pre-cache all referenced models.
fn load_index(&mut self) -> Result<(), VendorError> {
let index_path = self.data_path.join("bphx").join("index.json");
let index_content = std::fs::read_to_string(&index_path).map_err(|e| {
VendorError::IoError {
path: index_path.display().to_string(),
source: e,
}
})?;
let models: Vec<String> = serde_json::from_str(&index_content)?;
for model in models {
match self.load_model(&model) {
Ok(params) => {
self.bphx_cache.insert(model.clone(), params);
self.sorted_models.push(model);
}
Err(e) => {
log::warn!("[entropyk-vendors] Skipping SWEP model {}: {}", model, e);
}
}
}
self.sorted_models.sort();
Ok(())
}
/// Load a single BPHX model from its JSON file.
fn load_model(&self, model: &str) -> Result<BphxParameters, VendorError> {
let model_path = self
.data_path
.join("bphx")
.join(format!("{}.json", model));
let content = std::fs::read_to_string(&model_path).map_err(|e| VendorError::IoError {
path: model_path.display().to_string(),
source: e,
})?;
let params: BphxParameters = serde_json::from_str(&content)?;
Ok(params)
}
}
impl VendorBackend for SwepBackend {
fn vendor_name(&self) -> &str {
"SWEP"
}
fn list_compressor_models(&self) -> Result<Vec<String>, VendorError> {
// SWEP does not provide compressor data
Ok(vec![])
}
fn get_compressor_coefficients(
&self,
model: &str,
) -> Result<CompressorCoefficients, VendorError> {
Err(VendorError::InvalidFormat(format!(
"SWEP does not provide compressor data (requested: {})",
model
)))
}
fn list_bphx_models(&self) -> Result<Vec<String>, VendorError> {
Ok(self.sorted_models.clone())
}
fn get_bphx_parameters(&self, model: &str) -> Result<BphxParameters, VendorError> {
self.bphx_cache
.get(model)
.cloned()
.ok_or_else(|| VendorError::ModelNotFound(model.to_string()))
}
fn compute_ua(&self, model: &str, params: &UaCalcParams) -> Result<f64, VendorError> {
let bphx = self.get_bphx_parameters(model)?;
match bphx.ua_curve {
Some(ref curve) => {
let ratio = if params.mass_flow_ref > 0.0 {
params.mass_flow / params.mass_flow_ref
} else {
1.0
};
let ua_ratio = curve.interpolate(ratio).unwrap_or(1.0);
Ok(bphx.ua_nominal * ua_ratio)
}
None => Ok(bphx.ua_nominal),
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_swep_backend_new() {
let backend = SwepBackend::new();
assert!(backend.is_ok(), "SwepBackend::new() should succeed");
}
#[test]
fn test_swep_vendor_name() {
let backend = SwepBackend::new().unwrap();
assert_eq!(backend.vendor_name(), "SWEP");
}
#[test]
fn test_swep_list_bphx_models() {
let backend = SwepBackend::new().unwrap();
let models = backend.list_bphx_models().unwrap();
assert_eq!(models.len(), 2);
// Sorted order
assert_eq!(models, vec!["B5THx20".to_string(), "B8THx30".to_string()]);
}
#[test]
fn test_swep_get_bphx_b5thx20() {
let backend = SwepBackend::new().unwrap();
let params = backend.get_bphx_parameters("B5THx20").unwrap();
assert_eq!(params.model, "B5THx20");
assert_eq!(params.manufacturer, "SWEP");
assert_eq!(params.num_plates, 20);
assert!((params.area - 0.45).abs() < 1e-10);
assert!((params.dh - 0.003).abs() < 1e-10);
assert!((params.chevron_angle - 65.0).abs() < 1e-10);
assert!((params.ua_nominal - 1500.0).abs() < 1e-10);
assert!(params.ua_curve.is_some());
}
#[test]
fn test_swep_get_bphx_b8thx30() {
let backend = SwepBackend::new().unwrap();
let params = backend.get_bphx_parameters("B8THx30").unwrap();
assert_eq!(params.model, "B8THx30");
assert_eq!(params.manufacturer, "SWEP");
assert_eq!(params.num_plates, 30);
assert!((params.area - 0.72).abs() < 1e-10);
assert!((params.dh - 0.0025).abs() < 1e-10);
assert!((params.chevron_angle - 60.0).abs() < 1e-10);
assert!((params.ua_nominal - 2500.0).abs() < 1e-10);
assert!(params.ua_curve.is_none());
}
#[test]
fn test_swep_ua_curve_present() {
let backend = SwepBackend::new().unwrap();
let params = backend.get_bphx_parameters("B5THx20").unwrap();
let curve = params.ua_curve.as_ref().unwrap();
// Verify curve has correct number of points
assert_eq!(curve.points.len(), 7);
// Verify interpolation at known points
assert!((curve.interpolate(1.0).unwrap() - 1.0).abs() < 1e-10);
assert!((curve.interpolate(0.2).unwrap() - 0.30).abs() < 1e-10);
assert!((curve.interpolate(1.5).unwrap() - 1.15).abs() < 1e-10);
}
#[test]
fn test_swep_model_not_found() {
let backend = SwepBackend::new().unwrap();
let result = backend.get_bphx_parameters("NONEXISTENT");
assert!(result.is_err());
match result.unwrap_err() {
VendorError::ModelNotFound(m) => assert_eq!(m, "NONEXISTENT"),
other => panic!("Expected ModelNotFound, got: {:?}", other),
}
}
#[test]
fn test_swep_compute_ua_with_curve() {
let backend = SwepBackend::new().unwrap();
let params = UaCalcParams {
mass_flow: 0.5,
mass_flow_ref: 1.0,
temperature_hot_in: 340.0,
temperature_cold_in: 280.0,
refrigerant: "R410A".into(),
};
let ua = backend.compute_ua("B5THx20", &params).unwrap();
// mass_flow_ratio = 0.5 → interpolate between (0.4, 0.55) and (0.6, 0.72)
// t = (0.5 - 0.4) / (0.6 - 0.4) = 0.5
// ua_ratio = 0.55 + 0.5 * (0.72 - 0.55) = 0.55 + 0.085 = 0.635
// ua = 1500.0 * 0.635 = 952.5
assert!((ua - 952.5).abs() < 1e-10);
}
#[test]
fn test_swep_compute_ua_without_curve() {
let backend = SwepBackend::new().unwrap();
let params = UaCalcParams {
mass_flow: 0.3,
mass_flow_ref: 0.5,
temperature_hot_in: 340.0,
temperature_cold_in: 280.0,
refrigerant: "R410A".into(),
};
let ua = backend.compute_ua("B8THx30", &params).unwrap();
// No curve → returns ua_nominal
assert!((ua - 2500.0).abs() < 1e-10);
}
#[test]
fn test_swep_compute_ua_model_not_found() {
let backend = SwepBackend::new().unwrap();
let params = UaCalcParams {
mass_flow: 0.3,
mass_flow_ref: 0.5,
temperature_hot_in: 340.0,
temperature_cold_in: 280.0,
refrigerant: "R410A".into(),
};
let result = backend.compute_ua("NONEXISTENT", &params);
assert!(result.is_err());
}
#[test]
fn test_swep_list_compressor_models_empty() {
let backend = SwepBackend::new().unwrap();
let models = backend.list_compressor_models().unwrap();
assert!(models.is_empty());
}
#[test]
fn test_swep_get_compressor_returns_error() {
let backend = SwepBackend::new().unwrap();
let result = backend.get_compressor_coefficients("anything");
assert!(result.is_err());
match result.unwrap_err() {
VendorError::InvalidFormat(msg) => {
assert!(msg.contains("SWEP does not provide compressor"));
}
other => panic!("Expected InvalidFormat, got: {:?}", other),
}
}
#[test]
fn test_swep_object_safety() {
let backend: Box<dyn VendorBackend> = Box::new(SwepBackend::new().unwrap());
assert_eq!(backend.vendor_name(), "SWEP");
let models = backend.list_bphx_models().unwrap();
assert!(!models.is_empty());
}
}