chore: update documentation to reflect recent architectural changes and improve clarity

This commit is contained in:
Sepehr
2026-03-10 22:59:04 +01:00
parent d88914a44f
commit 891c4ba436
530 changed files with 2544 additions and 1513 deletions

View File

@@ -6,6 +6,7 @@ edition.workspace = true
description = "Vendor equipment data backends for Entropyk (Copeland, SWEP, Danfoss, Bitzer)"
[dependencies]
csv = "1.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "1.0"

View File

@@ -0,0 +1,2 @@
model,manufacturer,refrigerant,c0,c1,c2,c3,c4,c5,c6,c7,c8,c9,p0,p1,p2,p3,p4,p5,p6,p7,p8,p9,t_suction_min,t_suction_max,t_discharge_min,t_discharge_max
4HES-5Y,Bitzer,R410A,12000.0,220.0,-65.0,1.8,1.0,-2.2,0.025,0.012,-0.008,0.004,3200.0,75.0,28.0,0.7,0.45,0.7,0.01,0.006,0.004,0.002,-15.0,12.0,30.0,55.0
1 model manufacturer refrigerant c0 c1 c2 c3 c4 c5 c6 c7 c8 c9 p0 p1 p2 p3 p4 p5 p6 p7 p8 p9 t_suction_min t_suction_max t_discharge_min t_discharge_max
2 4HES-5Y Bitzer R410A 12000.0 220.0 -65.0 1.8 1.0 -2.2 0.025 0.012 -0.008 0.004 3200.0 75.0 28.0 0.7 0.45 0.7 0.01 0.006 0.004 0.002 -15.0 12.0 30.0 55.0

View File

@@ -0,0 +1,2 @@
model,manufacturer,refrigerant,c0,c1,c2,c3,c4,c5,c6,c7,c8,c9,p0,p1,p2,p3,p4,p5,p6,p7,p8,p9,t_suction_min,t_suction_max,t_discharge_min,t_discharge_max
4NFC-20Y,Bitzer,R134a,32000.0,580.0,-150.0,4.2,2.5,-5.0,0.06,0.03,-0.02,0.01,8200.0,180.0,70.0,1.8,1.2,1.8,0.025,0.015,0.01,0.006,-10.0,15.0,25.0,60.0
1 model manufacturer refrigerant c0 c1 c2 c3 c4 c5 c6 c7 c8 c9 p0 p1 p2 p3 p4 p5 p6 p7 p8 p9 t_suction_min t_suction_max t_discharge_min t_discharge_max
2 4NFC-20Y Bitzer R134a 32000.0 580.0 -150.0 4.2 2.5 -5.0 0.06 0.03 -0.02 0.01 8200.0 180.0 70.0 1.8 1.2 1.8 0.025 0.015 0.01 0.006 -10.0 15.0 25.0 60.0

418
crates/vendors/src/compressors/bitzer.rs vendored Normal file
View File

@@ -0,0 +1,418 @@
//! Bitzer compressor data backend.
//!
//! Loads AHRI 540 compressor coefficients from CSV files in the
//! `data/bitzer/compressors/` directory. The CSV columns `c0`..`c9` (capacity)
//! and `p0`..`p9` (power) are in AHRI 540 standard polynomial order:
//! Ts, Td, Ts², Ts·Td, Td², Ts³, Td·Ts², Ts·Td², Td³ (see [`CompressorCoefficients`]).
use std::collections::HashMap;
use std::path::PathBuf;
use crate::error::VendorError;
use crate::vendor_api::{
BphxParameters, CompressorCoefficients, CompressorValidityRange, UaCalcParams, VendorBackend,
};
/// Backend for Bitzer compressor data.
///
/// Discovers compressor models by scanning `*.csv` files in `data/bitzer/compressors/`
/// and uses the file stem (e.g. `4NFC-20Y`) as the model id. Each CSV row is mapped
/// to AHRI 540 `CompressorCoefficients`.
///
/// # CSV format
///
/// Header row with columns: `model`, `manufacturer`, `refrigerant`, `c0`..`c9` (capacity),
/// `p0`..`p9` (power), `t_suction_min`, `t_suction_max`, `t_discharge_min`, `t_discharge_max`.
/// One data row per file (one model per CSV file).
///
/// # Example
///
/// ```no_run
/// use entropyk_vendors::compressors::bitzer::BitzerBackend;
/// use entropyk_vendors::VendorBackend;
///
/// let backend = BitzerBackend::new().expect("load bitzer data");
/// let models = backend.list_compressor_models().unwrap();
/// println!("Available: {:?}", models);
/// ```
#[derive(Debug)]
pub struct BitzerBackend {
/// Root path to the Bitzer 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 BitzerBackend {
/// Create a new Bitzer 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("bitzer");
let mut backend = Self {
data_path,
compressor_cache: HashMap::new(),
sorted_models: Vec::new(),
};
backend.load_compressors()?;
Ok(backend)
}
/// Create a new Bitzer 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_compressors()?;
Ok(backend)
}
/// Discover CSV files in `data/bitzer/compressors/` and pre-cache all models.
fn load_compressors(&mut self) -> Result<(), VendorError> {
let compressors_dir = self.data_path.join("compressors");
let entries = match std::fs::read_dir(&compressors_dir) {
Ok(e) => e,
Err(e) => {
return Err(VendorError::IoError {
path: compressors_dir.display().to_string(),
source: e,
});
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "csv") {
if let Some(stem) = path.file_stem() {
let model = stem.to_string_lossy().into_owned();
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 Bitzer model {}: {}", model, e);
}
}
}
}
}
self.sorted_models.sort();
Ok(())
}
/// Load a single compressor model from its CSV file.
fn load_model(&self, model: &str) -> Result<CompressorCoefficients, VendorError> {
if model.contains('/') || model.contains('\\') || model.contains("..") {
return Err(VendorError::ModelNotFound(model.to_string()));
}
let model_path = self
.data_path
.join("compressors")
.join(format!("{}.csv", model));
let content = std::fs::read_to_string(&model_path).map_err(|e| VendorError::IoError {
path: model_path.display().to_string(),
source: e,
})?;
parse_bitzer_csv(&content, model).map_err(|e| {
VendorError::InvalidFormat(format!("Parse error in {}: {}", model_path.display(), e))
})
}
}
/// Parse a Bitzer CSV string into CompressorCoefficients.
///
/// CSV must have a header row and at least one data row (one model per file).
/// Only the first data row is used; additional rows are ignored.
/// Columns `c0`..`c9` and `p0`..`p9` are in AHRI 540 order: Ts, Td, Ts², Ts·Td, Td², Ts³, Td·Ts², Ts·Td², Td³.
/// The `model_id` argument is used as fallback when the CSV `model` column is missing.
fn parse_bitzer_csv(content: &str, model_id: &str) -> Result<CompressorCoefficients, String> {
let mut rdr = csv::Reader::from_reader(content.as_bytes());
let headers: Vec<String> = rdr
.headers()
.map_err(|e| e.to_string())?
.iter()
.map(String::from)
.collect();
let mut record = csv::StringRecord::new();
if !rdr.read_record(&mut record).map_err(|e| e.to_string())? {
return Err("CSV has no data row".to_string());
}
let get = |name: &str| -> Result<f64, String> {
let i = headers
.iter()
.position(|h| h == name)
.ok_or_else(|| format!("missing column {}", name))?;
record
.get(i)
.ok_or_else(|| format!("missing value for {}", name))?
.trim()
.parse()
.map_err(|e: std::num::ParseFloatError| e.to_string())
};
let get_str = |name: &str| -> Result<String, String> {
let i = headers
.iter()
.position(|h| h == name)
.ok_or_else(|| format!("missing column {}", name))?;
record
.get(i)
.map(|s| s.trim().to_string())
.ok_or_else(|| format!("missing value for {}", name))
};
let model = get_str("model").unwrap_or_else(|_| model_id.to_string());
let manufacturer = get_str("manufacturer").unwrap_or_else(|_| "Bitzer".to_string());
let refrigerant = get_str("refrigerant").unwrap_or_else(|_| "R134a".to_string());
let mut capacity_coeffs = [0.0_f64; 10];
for (i, c) in capacity_coeffs.iter_mut().enumerate() {
*c = get(&format!("c{}", i))?;
}
let mut power_coeffs = [0.0_f64; 10];
for (i, p) in power_coeffs.iter_mut().enumerate() {
*p = get(&format!("p{}", i))?;
}
let validity = CompressorValidityRange {
t_suction_min: get("t_suction_min")?,
t_suction_max: get("t_suction_max")?,
t_discharge_min: get("t_discharge_min")?,
t_discharge_max: get("t_discharge_max")?,
};
if validity.t_suction_min > validity.t_suction_max {
return Err(format!(
"Invalid suction temperature range: min ({}) > max ({})",
validity.t_suction_min, validity.t_suction_max
));
}
if validity.t_discharge_min > validity.t_discharge_max {
return Err(format!(
"Invalid discharge temperature range: min ({}) > max ({})",
validity.t_discharge_min, validity.t_discharge_max
));
}
Ok(CompressorCoefficients {
model,
manufacturer,
refrigerant,
capacity_coeffs,
power_coeffs,
mass_flow_coeffs: None,
validity,
})
}
impl VendorBackend for BitzerBackend {
fn vendor_name(&self) -> &str {
"Bitzer"
}
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> {
Ok(vec![])
}
fn get_bphx_parameters(&self, model: &str) -> Result<BphxParameters, VendorError> {
Err(VendorError::InvalidFormat(format!(
"Bitzer does not provide BPHX data (requested: {})",
model
)))
}
fn compute_ua(&self, model: &str, _params: &UaCalcParams) -> Result<f64, VendorError> {
Err(VendorError::InvalidFormat(format!(
"Bitzer does not provide BPHX/UA data (requested: {})",
model
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bitzer_backend_new() {
let backend = BitzerBackend::new();
assert!(backend.is_ok(), "BitzerBackend::new() should succeed");
}
#[test]
fn test_bitzer_backend_from_path() {
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 backend = BitzerBackend::from_path(base_path.join("bitzer"));
assert!(backend.is_ok(), "BitzerBackend::from_path() should succeed");
}
#[test]
fn test_bitzer_vendor_name() {
let backend = BitzerBackend::new().unwrap();
assert_eq!(backend.vendor_name(), "Bitzer");
}
#[test]
fn test_bitzer_list_compressor_models() {
let backend = BitzerBackend::new().unwrap();
let models = backend.list_compressor_models().unwrap();
assert_eq!(models.len(), 2);
assert!(models.contains(&"4NFC-20Y".to_string()));
assert!(models.contains(&"4HES-5Y".to_string()));
assert_eq!(models, vec!["4HES-5Y".to_string(), "4NFC-20Y".to_string()]);
}
#[test]
fn test_bitzer_get_compressor_4nfc_20y() {
let backend = BitzerBackend::new().unwrap();
let coeffs = backend
.get_compressor_coefficients("4NFC-20Y")
.unwrap();
assert_eq!(coeffs.model, "4NFC-20Y");
assert_eq!(coeffs.manufacturer, "Bitzer");
assert_eq!(coeffs.refrigerant, "R134a");
assert_eq!(coeffs.capacity_coeffs.len(), 10);
assert_eq!(coeffs.power_coeffs.len(), 10);
assert!((coeffs.capacity_coeffs[0] - 32000.0).abs() < 1e-10);
assert!((coeffs.power_coeffs[0] - 8200.0).abs() < 1e-10);
assert!((coeffs.capacity_coeffs[9] - 0.01).abs() < 1e-10);
assert!((coeffs.power_coeffs[9] - 0.006).abs() < 1e-10);
assert!(coeffs.mass_flow_coeffs.is_none());
}
#[test]
fn test_bitzer_get_compressor_4hes_5y() {
let backend = BitzerBackend::new().unwrap();
let coeffs = backend.get_compressor_coefficients("4HES-5Y").unwrap();
assert_eq!(coeffs.model, "4HES-5Y");
assert_eq!(coeffs.manufacturer, "Bitzer");
assert_eq!(coeffs.refrigerant, "R410A");
assert!((coeffs.capacity_coeffs[0] - 12000.0).abs() < 1e-10);
assert!((coeffs.power_coeffs[0] - 3200.0).abs() < 1e-10);
}
#[test]
fn test_bitzer_validity_range() {
let backend = BitzerBackend::new().unwrap();
let coeffs = backend.get_compressor_coefficients("4NFC-20Y").unwrap();
assert!((coeffs.validity.t_suction_min - (-10.0)).abs() < 1e-10);
assert!((coeffs.validity.t_suction_max - 15.0).abs() < 1e-10);
assert!((coeffs.validity.t_discharge_min - 25.0).abs() < 1e-10);
assert!((coeffs.validity.t_discharge_max - 60.0).abs() < 1e-10);
}
#[test]
fn test_bitzer_model_not_found() {
let backend = BitzerBackend::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_bitzer_list_bphx_empty() {
let backend = BitzerBackend::new().unwrap();
let models = backend.list_bphx_models().unwrap();
assert!(models.is_empty());
}
#[test]
fn test_bitzer_get_bphx_returns_error() {
let backend = BitzerBackend::new().unwrap();
let result = backend.get_bphx_parameters("anything");
assert!(result.is_err());
match result.unwrap_err() {
VendorError::InvalidFormat(msg) => {
assert!(msg.contains("Bitzer does not provide BPHX"));
}
other => panic!("Expected InvalidFormat, got: {:?}", other),
}
}
#[test]
fn test_bitzer_object_safety() {
let backend: Box<dyn VendorBackend> = Box::new(BitzerBackend::new().unwrap());
assert_eq!(backend.vendor_name(), "Bitzer");
let models = backend.list_compressor_models().unwrap();
assert!(!models.is_empty());
}
#[test]
fn test_bitzer_csv_multiple_rows_first_used() {
// When CSV has multiple data rows, only the first is used (one model per file).
let csv = "model,manufacturer,refrigerant,c0,c1,c2,c3,c4,c5,c6,c7,c8,c9,p0,p1,p2,p3,p4,p5,p6,p7,p8,p9,t_suction_min,t_suction_max,t_discharge_min,t_discharge_max\n\
4NFC-20Y,Bitzer,R134a,32000.0,580.0,-150.0,4.2,2.5,-5.0,0.06,0.03,-0.02,0.01,8200.0,180.0,70.0,1.8,1.2,1.8,0.025,0.015,0.01,0.006,-10.0,15.0,25.0,60.0\n\
Other,Bitzer,R410A,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,-15.0,15.0,30.0,60.0";
let result = parse_bitzer_csv(csv, "test");
assert!(result.is_ok());
let coeffs = result.unwrap();
assert_eq!(coeffs.model, "4NFC-20Y");
assert!((coeffs.capacity_coeffs[0] - 32000.0).abs() < 1e-10);
}
}

View File

@@ -6,6 +6,6 @@
/// Copeland (Emerson) compressor data backend.
pub mod copeland;
// Future vendor implementations (stories 11.14, 11.15):
/// Danfoss (11.14), Bitzer (11.15) compressor data backends.
pub mod danfoss;
// pub mod bitzer; // Story 11.15
pub mod bitzer;

View File

@@ -21,6 +21,7 @@ pub mod compressors;
pub mod heat_exchangers;
// Public re-exports for convenience
pub use compressors::bitzer::BitzerBackend;
pub use compressors::copeland::CopelandBackend;
pub use compressors::danfoss::DanfossBackend;
pub use heat_exchangers::swep::SwepBackend;