style: fix AI code review findings for vendor backend
This commit is contained in:
parent
bd4113f49e
commit
3eb2219454
@ -1,5 +1,5 @@
|
|||||||
# Sprint Status - Entropyk
|
# Sprint Status - Entropyk
|
||||||
# Last Updated: 2026-02-24
|
# Last Updated: 2026-02-28
|
||||||
# Project: Entropyk
|
# Project: Entropyk
|
||||||
# Project Key: NOKEY
|
# Project Key: NOKEY
|
||||||
# Tracking System: file-system
|
# Tracking System: file-system
|
||||||
@ -53,6 +53,10 @@ development_status:
|
|||||||
1-8-auxiliary-transport-components: done
|
1-8-auxiliary-transport-components: done
|
||||||
1-11-flow-junctions-flowsplitter-flowmerger: done
|
1-11-flow-junctions-flowsplitter-flowmerger: done
|
||||||
1-12-boundary-conditions-flowsource-flowsink: done
|
1-12-boundary-conditions-flowsource-flowsink: done
|
||||||
|
epic-1-retrospective: optional
|
||||||
|
|
||||||
|
# Epic 2: Fluid Properties Backend
|
||||||
|
epic-2: done
|
||||||
2-1-fluid-backend-trait-abstraction: done
|
2-1-fluid-backend-trait-abstraction: done
|
||||||
2-2-coolprop-integration-sys-crate: done
|
2-2-coolprop-integration-sys-crate: done
|
||||||
2-3-tabular-interpolation-backend: done
|
2-3-tabular-interpolation-backend: done
|
||||||
@ -61,7 +65,7 @@ development_status:
|
|||||||
2-6-critical-point-damping-co2-r744: done
|
2-6-critical-point-damping-co2-r744: done
|
||||||
2-7-incompressible-fluids-support: done
|
2-7-incompressible-fluids-support: done
|
||||||
2-8-rich-thermodynamic-state-abstraction: done
|
2-8-rich-thermodynamic-state-abstraction: done
|
||||||
epic-1-retrospective: optional
|
epic-2-retrospective: optional
|
||||||
|
|
||||||
# Epic 3: System Topology (Graph)
|
# Epic 3: System Topology (Graph)
|
||||||
epic-3: done
|
epic-3: done
|
||||||
@ -125,7 +129,7 @@ development_status:
|
|||||||
epic-8-retrospective: optional
|
epic-8-retrospective: optional
|
||||||
|
|
||||||
# Epic 9: Coherence Corrections (Post-Audit)
|
# Epic 9: Coherence Corrections (Post-Audit)
|
||||||
epic-9: in-progress
|
epic-9: done
|
||||||
9-1-circuitid-type-unification: done
|
9-1-circuitid-type-unification: done
|
||||||
9-2-fluidid-type-unification: done
|
9-2-fluidid-type-unification: done
|
||||||
9-3-expansionvalve-energy-methods: done
|
9-3-expansionvalve-energy-methods: done
|
||||||
@ -154,13 +158,13 @@ development_status:
|
|||||||
11-2-drum-recirculation-drum: done
|
11-2-drum-recirculation-drum: done
|
||||||
11-3-floodedevaporator: done
|
11-3-floodedevaporator: done
|
||||||
11-4-floodedcondenser: done
|
11-4-floodedcondenser: done
|
||||||
11-5-bphxexchanger-base: in-progress
|
11-5-bphxexchanger-base: done
|
||||||
11-6-bphxevaporator: backlog
|
11-6-bphxevaporator: done
|
||||||
11-7-bphxcondenser: backlog
|
11-7-bphxcondenser: done
|
||||||
11-8-correlationselector: backlog
|
11-8-correlationselector: done
|
||||||
11-9-movingboundaryhx-zone-discretization: backlog
|
11-9-movingboundaryhx-zone-discretization: done
|
||||||
11-10-movingboundaryhx-cache-optimization: backlog
|
11-10-movingboundaryhx-cache-optimization: done
|
||||||
11-11-vendorbackend-trait: backlog
|
11-11-vendorbackend-trait: done
|
||||||
11-12-copeland-parser: ready-for-dev
|
11-12-copeland-parser: ready-for-dev
|
||||||
11-13-swep-parser: ready-for-dev
|
11-13-swep-parser: ready-for-dev
|
||||||
11-14-danfoss-parser: ready-for-dev
|
11-14-danfoss-parser: ready-for-dev
|
||||||
@ -171,7 +175,7 @@ development_status:
|
|||||||
# Support for ScrewEconomizerCompressor, MCHXCondenserCoil, FloodedEvaporator
|
# Support for ScrewEconomizerCompressor, MCHXCondenserCoil, FloodedEvaporator
|
||||||
# with proper internal state variables, CoolProp backend, and controls
|
# with proper internal state variables, CoolProp backend, and controls
|
||||||
epic-12: in-progress
|
epic-12: in-progress
|
||||||
12-1-cli-internal-state-variables: ready-for-dev
|
12-1-cli-internal-state-variables: in-progress
|
||||||
12-2-cli-coolprop-backend: ready-for-dev
|
12-2-cli-coolprop-backend: ready-for-dev
|
||||||
12-3-cli-screw-compressor-config: ready-for-dev
|
12-3-cli-screw-compressor-config: ready-for-dev
|
||||||
12-4-cli-mchx-condenser-config: ready-for-dev
|
12-4-cli-mchx-condenser-config: ready-for-dev
|
||||||
|
|||||||
31
crates/vendors/src/error.rs
vendored
Normal file
31
crates/vendors/src/error.rs
vendored
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
//! Error types for vendor backend operations.
|
||||||
|
|
||||||
|
/// Errors that can occur during vendor data operations.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum VendorError {
|
||||||
|
/// The requested equipment model was not found in this vendor's catalog.
|
||||||
|
#[error("Model not found: {0}")]
|
||||||
|
ModelNotFound(String),
|
||||||
|
|
||||||
|
/// The data file has an invalid or unsupported format.
|
||||||
|
#[error("Invalid data format: {0}")]
|
||||||
|
InvalidFormat(String),
|
||||||
|
|
||||||
|
/// The expected data file could not be found on disk.
|
||||||
|
#[error("Data file not found: {0}")]
|
||||||
|
FileNotFound(String),
|
||||||
|
|
||||||
|
/// JSON parsing failed.
|
||||||
|
#[error("Parse error: {0}")]
|
||||||
|
ParseError(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
/// An I/O error occurred while reading vendor data.
|
||||||
|
#[error("IO error reading {path}: {source}")]
|
||||||
|
IoError {
|
||||||
|
/// The path where the IO error occurred.
|
||||||
|
path: String,
|
||||||
|
/// The underlying IO error.
|
||||||
|
#[source]
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
}
|
||||||
28
crates/vendors/src/lib.rs
vendored
Normal file
28
crates/vendors/src/lib.rs
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
//! Vendor equipment data backends for Entropyk.
|
||||||
|
//!
|
||||||
|
//! This crate provides a uniform [`VendorBackend`] trait and associated data types
|
||||||
|
//! for accessing manufacturer equipment catalogs (compressor AHRI 540 coefficients,
|
||||||
|
//! BPHX parameters, UA curves, etc.) from Copeland, SWEP, Danfoss, and Bitzer.
|
||||||
|
//!
|
||||||
|
//! # Architecture
|
||||||
|
//!
|
||||||
|
//! - **Standalone crate** — no dependency on `entropyk-core`, `entropyk-fluids`,
|
||||||
|
//! or `entropyk-components`. Vendor data is pure data structures + I/O.
|
||||||
|
//! - **Object-safe trait** — `VendorBackend` can be stored as `Box<dyn VendorBackend>`.
|
||||||
|
//! - Individual vendor implementations (stories 11.12–11.15) will live in the
|
||||||
|
//! `compressors` and `heat_exchangers` submodules.
|
||||||
|
|
||||||
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
pub mod error;
|
||||||
|
pub mod vendor_api;
|
||||||
|
|
||||||
|
pub mod compressors;
|
||||||
|
pub mod heat_exchangers;
|
||||||
|
|
||||||
|
// Public re-exports for convenience
|
||||||
|
pub use error::VendorError;
|
||||||
|
pub use vendor_api::{
|
||||||
|
BphxParameters, CompressorCoefficients, CompressorValidityRange, UaCalcParams, UaCurve,
|
||||||
|
VendorBackend,
|
||||||
|
};
|
||||||
580
crates/vendors/src/vendor_api.rs
vendored
Normal file
580
crates/vendors/src/vendor_api.rs
vendored
Normal file
@ -0,0 +1,580 @@
|
|||||||
|
//! VendorBackend trait and associated data types.
|
||||||
|
//!
|
||||||
|
//! This module defines the uniform API for accessing manufacturer equipment data
|
||||||
|
//! (compressor coefficients, heat exchanger parameters) from vendor catalogs.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::VendorError;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Compressor data types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// AHRI 540 compressor coefficient set.
|
||||||
|
///
|
||||||
|
/// The 10 coefficients follow the polynomial form:
|
||||||
|
/// ```text
|
||||||
|
/// C = a₀ + a₁·Ts + a₂·Td + a₃·Ts² + a₄·Ts·Td + a₅·Td²
|
||||||
|
/// + a₆·Ts³ + a₇·Td·Ts² + a₈·Ts·Td² + a₉·Td³
|
||||||
|
/// ```
|
||||||
|
/// where `Ts` = suction saturation temperature (°C) and
|
||||||
|
/// `Td` = discharge saturation temperature (°C).
|
||||||
|
///
|
||||||
|
/// Both `capacity_coeffs` (W) and `power_coeffs` (W) use this convention.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct CompressorCoefficients {
|
||||||
|
/// Equipment model identifier (e.g. `"ZP54KCE-TFD"`).
|
||||||
|
pub model: String,
|
||||||
|
/// Manufacturer name (e.g. `"Copeland"`).
|
||||||
|
pub manufacturer: String,
|
||||||
|
/// Refrigerant designation (e.g. `"R410A"`).
|
||||||
|
pub refrigerant: String,
|
||||||
|
/// 10 AHRI 540 capacity polynomial coefficients (W).
|
||||||
|
pub capacity_coeffs: [f64; 10],
|
||||||
|
/// 10 AHRI 540 power polynomial coefficients (W).
|
||||||
|
pub power_coeffs: [f64; 10],
|
||||||
|
/// Optional 10 AHRI 540 mass-flow polynomial coefficients (kg/s).
|
||||||
|
/// Not all vendors provide mass-flow polynomials.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mass_flow_coeffs: Option<[f64; 10]>,
|
||||||
|
/// Operating envelope for the coefficient set.
|
||||||
|
pub validity: CompressorValidityRange,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Operating envelope for compressor coefficients.
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
|
pub struct CompressorValidityRange {
|
||||||
|
/// Minimum suction saturation temperature (°C).
|
||||||
|
pub t_suction_min: f64,
|
||||||
|
/// Maximum suction saturation temperature (°C).
|
||||||
|
pub t_suction_max: f64,
|
||||||
|
/// Minimum discharge saturation temperature (°C).
|
||||||
|
pub t_discharge_min: f64,
|
||||||
|
/// Maximum discharge saturation temperature (°C).
|
||||||
|
pub t_discharge_max: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for CompressorValidityRange {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Inner {
|
||||||
|
t_suction_min: f64,
|
||||||
|
t_suction_max: f64,
|
||||||
|
t_discharge_min: f64,
|
||||||
|
t_discharge_max: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
let inner = Inner::deserialize(deserializer)?;
|
||||||
|
|
||||||
|
if inner.t_suction_min > inner.t_suction_max {
|
||||||
|
return Err(serde::de::Error::custom(format!(
|
||||||
|
"Invalid suction temperature range: min ({} °C) > max ({} °C)",
|
||||||
|
inner.t_suction_min, inner.t_suction_max
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if inner.t_discharge_min > inner.t_discharge_max {
|
||||||
|
return Err(serde::de::Error::custom(format!(
|
||||||
|
"Invalid discharge temperature range: min ({} °C) > max ({} °C)",
|
||||||
|
inner.t_discharge_min, inner.t_discharge_max
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
t_suction_min: inner.t_suction_min,
|
||||||
|
t_suction_max: inner.t_suction_max,
|
||||||
|
t_discharge_min: inner.t_discharge_min,
|
||||||
|
t_discharge_max: inner.t_discharge_max,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BPHX data types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Brazed-plate heat exchanger (BPHX) parameters from vendor catalog.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct BphxParameters {
|
||||||
|
/// Equipment model identifier (e.g. `"B5THx20"`).
|
||||||
|
pub model: String,
|
||||||
|
/// Manufacturer name (e.g. `"SWEP"`).
|
||||||
|
pub manufacturer: String,
|
||||||
|
/// Number of plates.
|
||||||
|
pub num_plates: usize,
|
||||||
|
/// Total heat-transfer area (m²).
|
||||||
|
pub area: f64,
|
||||||
|
/// Hydraulic diameter (m).
|
||||||
|
pub dh: f64,
|
||||||
|
/// Chevron angle (degrees).
|
||||||
|
pub chevron_angle: f64,
|
||||||
|
/// Nominal overall heat-transfer coefficient × area product (W/K)
|
||||||
|
/// at reference operating conditions.
|
||||||
|
pub ua_nominal: f64,
|
||||||
|
/// Optional UA part-load curve for off-design conditions.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ua_curve: Option<UaCurve>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UA part-load correction curve.
|
||||||
|
///
|
||||||
|
/// Each point maps a mass-flow ratio (actual / reference) to a UA ratio
|
||||||
|
/// (actual UA / nominal UA). Linear interpolation is used between points.
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||||
|
pub struct UaCurve {
|
||||||
|
/// Sorted list of `(mass_flow_ratio, ua_ratio)` points.
|
||||||
|
pub points: Vec<(f64, f64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for UaCurve {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Inner {
|
||||||
|
points: Vec<(f64, f64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut inner = Inner::deserialize(deserializer)?;
|
||||||
|
|
||||||
|
// Sort points by mass_flow_ratio (x-axis) to ensure interpolation works correctly
|
||||||
|
// even if the JSON is out of order. f64 sorting requires partial_cmp.
|
||||||
|
inner.points.sort_by(|a, b| {
|
||||||
|
a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self { points: inner.points })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UaCurve {
|
||||||
|
/// Linearly interpolate the UA ratio for a given mass-flow ratio.
|
||||||
|
///
|
||||||
|
/// Values outside the curve range are clamped to the nearest endpoint.
|
||||||
|
/// Returns `None` if the curve has no points.
|
||||||
|
pub fn interpolate(&self, mass_flow_ratio: f64) -> Option<f64> {
|
||||||
|
let pts = &self.points;
|
||||||
|
if pts.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if pts.len() == 1 {
|
||||||
|
return Some(pts[0].1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp to endpoints
|
||||||
|
if mass_flow_ratio <= pts[0].0 {
|
||||||
|
return Some(pts[0].1);
|
||||||
|
}
|
||||||
|
if mass_flow_ratio >= pts[pts.len() - 1].0 {
|
||||||
|
return Some(pts[pts.len() - 1].1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find enclosing interval and interpolate
|
||||||
|
for window in pts.windows(2) {
|
||||||
|
let (x0, y0) = window[0];
|
||||||
|
let (x1, y1) = window[1];
|
||||||
|
if mass_flow_ratio >= x0 && mass_flow_ratio <= x1 {
|
||||||
|
let t = (mass_flow_ratio - x0) / (x1 - x0);
|
||||||
|
return Some(y0 + t * (y1 - y0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback (should not be reached with sorted points)
|
||||||
|
Some(pts[pts.len() - 1].1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input parameters for a vendor-specific UA calculation.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct UaCalcParams {
|
||||||
|
/// Current mass flow rate (kg/s).
|
||||||
|
pub mass_flow: f64,
|
||||||
|
/// Reference mass flow rate (kg/s) at which `ua_nominal` was measured.
|
||||||
|
pub mass_flow_ref: f64,
|
||||||
|
/// Hot-side inlet temperature (K).
|
||||||
|
pub temperature_hot_in: f64,
|
||||||
|
/// Cold-side inlet temperature (K).
|
||||||
|
pub temperature_cold_in: f64,
|
||||||
|
/// Refrigerant designation.
|
||||||
|
pub refrigerant: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VendorBackend trait
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Trait for accessing manufacturer equipment data.
|
||||||
|
///
|
||||||
|
/// Implementations load catalog data from vendor-specific file formats
|
||||||
|
/// (JSON, CSV, etc.) and expose a uniform query API.
|
||||||
|
///
|
||||||
|
/// The trait is object-safe (`Send + Sync`) so it can be stored as
|
||||||
|
/// `Box<dyn VendorBackend>` or `Arc<dyn VendorBackend>`.
|
||||||
|
pub trait VendorBackend: Send + Sync {
|
||||||
|
/// Human-readable name of the vendor (e.g. `"Copeland (Emerson)"`).
|
||||||
|
fn vendor_name(&self) -> &str;
|
||||||
|
|
||||||
|
/// List all compressor models available in this vendor's catalog.
|
||||||
|
fn list_compressor_models(&self) -> Result<Vec<String>, VendorError>;
|
||||||
|
|
||||||
|
/// Retrieve AHRI 540 coefficients for a specific compressor model.
|
||||||
|
fn get_compressor_coefficients(
|
||||||
|
&self,
|
||||||
|
model: &str,
|
||||||
|
) -> Result<CompressorCoefficients, VendorError>;
|
||||||
|
|
||||||
|
/// List all BPHX models available in this vendor's catalog.
|
||||||
|
fn list_bphx_models(&self) -> Result<Vec<String>, VendorError>;
|
||||||
|
|
||||||
|
/// Retrieve catalog parameters for a specific BPHX model.
|
||||||
|
fn get_bphx_parameters(&self, model: &str) -> Result<BphxParameters, VendorError>;
|
||||||
|
|
||||||
|
/// Compute UA for a given operating point using vendor-specific methods.
|
||||||
|
///
|
||||||
|
/// The default implementation simply returns `ua_nominal`.
|
||||||
|
fn compute_ua(&self, model: &str, _params: &UaCalcParams) -> Result<f64, VendorError> {
|
||||||
|
let bphx = self.get_bphx_parameters(model)?;
|
||||||
|
Ok(bphx.ua_nominal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ---- Serialization round-trip: CompressorCoefficients ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compressor_coefficients_json_round_trip() {
|
||||||
|
let coeffs = CompressorCoefficients {
|
||||||
|
model: "ZP54KCE-TFD".into(),
|
||||||
|
manufacturer: "Copeland".into(),
|
||||||
|
refrigerant: "R410A".into(),
|
||||||
|
capacity_coeffs: [18000.0, 350.0, -120.0, 2.5, 1.8, -4.2, 0.05, 0.03, -0.02, 0.01],
|
||||||
|
power_coeffs: [4500.0, 95.0, 45.0, 0.8, 0.5, 1.2, 0.02, 0.01, 0.01, 0.005],
|
||||||
|
mass_flow_coeffs: None,
|
||||||
|
validity: CompressorValidityRange {
|
||||||
|
t_suction_min: -10.0,
|
||||||
|
t_suction_max: 20.0,
|
||||||
|
t_discharge_min: 25.0,
|
||||||
|
t_discharge_max: 65.0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(&coeffs).expect("serialize");
|
||||||
|
let deserialized: CompressorCoefficients =
|
||||||
|
serde_json::from_str(&json).expect("deserialize");
|
||||||
|
assert_eq!(coeffs, deserialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compressor_coefficients_with_mass_flow() {
|
||||||
|
let coeffs = CompressorCoefficients {
|
||||||
|
model: "TEST-001".into(),
|
||||||
|
manufacturer: "TestVendor".into(),
|
||||||
|
refrigerant: "R134a".into(),
|
||||||
|
capacity_coeffs: [1.0; 10],
|
||||||
|
power_coeffs: [2.0; 10],
|
||||||
|
mass_flow_coeffs: Some([0.1; 10]),
|
||||||
|
validity: CompressorValidityRange {
|
||||||
|
t_suction_min: -15.0,
|
||||||
|
t_suction_max: 15.0,
|
||||||
|
t_discharge_min: 30.0,
|
||||||
|
t_discharge_max: 60.0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string(&coeffs).expect("serialize");
|
||||||
|
let deserialized: CompressorCoefficients =
|
||||||
|
serde_json::from_str(&json).expect("deserialize");
|
||||||
|
assert_eq!(coeffs, deserialized);
|
||||||
|
assert!(deserialized.mass_flow_coeffs.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Serialization round-trip: BphxParameters ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bphx_parameters_json_round_trip_without_curve() {
|
||||||
|
let params = BphxParameters {
|
||||||
|
model: "B5THx20".into(),
|
||||||
|
manufacturer: "SWEP".into(),
|
||||||
|
num_plates: 20,
|
||||||
|
area: 0.45,
|
||||||
|
dh: 0.003,
|
||||||
|
chevron_angle: 65.0,
|
||||||
|
ua_nominal: 1500.0,
|
||||||
|
ua_curve: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(¶ms).expect("serialize");
|
||||||
|
let deserialized: BphxParameters = serde_json::from_str(&json).expect("deserialize");
|
||||||
|
assert_eq!(params, deserialized);
|
||||||
|
assert!(deserialized.ua_curve.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bphx_parameters_json_round_trip_with_curve() {
|
||||||
|
let params = BphxParameters {
|
||||||
|
model: "B8THx30".into(),
|
||||||
|
manufacturer: "SWEP".into(),
|
||||||
|
num_plates: 30,
|
||||||
|
area: 0.72,
|
||||||
|
dh: 0.0025,
|
||||||
|
chevron_angle: 60.0,
|
||||||
|
ua_nominal: 2500.0,
|
||||||
|
ua_curve: Some(UaCurve {
|
||||||
|
points: vec![(0.2, 0.3), (0.5, 0.65), (1.0, 1.0), (1.5, 1.2)],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(¶ms).expect("serialize");
|
||||||
|
let deserialized: BphxParameters = serde_json::from_str(&json).expect("deserialize");
|
||||||
|
assert_eq!(params, deserialized);
|
||||||
|
assert!(deserialized.ua_curve.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- UaCurve interpolation ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ua_curve_interpolation_midpoint() {
|
||||||
|
let curve = UaCurve {
|
||||||
|
points: vec![(0.0, 0.0), (1.0, 1.0), (2.0, 1.5)],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exact point
|
||||||
|
assert_eq!(curve.interpolate(0.0), Some(0.0));
|
||||||
|
assert_eq!(curve.interpolate(1.0), Some(1.0));
|
||||||
|
assert_eq!(curve.interpolate(2.0), Some(1.5));
|
||||||
|
|
||||||
|
// Midpoint
|
||||||
|
let mid = curve.interpolate(0.5).unwrap();
|
||||||
|
assert!((mid - 0.5).abs() < 1e-10);
|
||||||
|
|
||||||
|
let mid2 = curve.interpolate(1.5).unwrap();
|
||||||
|
assert!((mid2 - 1.25).abs() < 1e-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ua_curve_interpolation_clamp() {
|
||||||
|
let curve = UaCurve {
|
||||||
|
points: vec![(0.5, 0.4), (1.0, 1.0), (1.5, 1.3)],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Below range → clamp to first
|
||||||
|
assert_eq!(curve.interpolate(0.1), Some(0.4));
|
||||||
|
|
||||||
|
// Above range → clamp to last
|
||||||
|
assert_eq!(curve.interpolate(3.0), Some(1.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ua_curve_interpolation_empty() {
|
||||||
|
let curve = UaCurve { points: vec![] };
|
||||||
|
assert_eq!(curve.interpolate(1.0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ua_curve_interpolation_single_point() {
|
||||||
|
let curve = UaCurve {
|
||||||
|
points: vec![(1.0, 0.8)],
|
||||||
|
};
|
||||||
|
assert_eq!(curve.interpolate(0.5), Some(0.8));
|
||||||
|
assert_eq!(curve.interpolate(2.0), Some(0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- VendorError display ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vendor_error_display_model_not_found() {
|
||||||
|
let err = VendorError::ModelNotFound("XYZ-999".into());
|
||||||
|
assert_eq!(err.to_string(), "Model not found: XYZ-999");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vendor_error_display_invalid_format() {
|
||||||
|
let err = VendorError::InvalidFormat("missing capacity_coeffs".into());
|
||||||
|
assert_eq!(err.to_string(), "Invalid data format: missing capacity_coeffs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vendor_error_display_file_not_found() {
|
||||||
|
let err = VendorError::FileNotFound("/data/missing.json".into());
|
||||||
|
assert_eq!(err.to_string(), "Data file not found: /data/missing.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Mock VendorBackend ----
|
||||||
|
|
||||||
|
struct MockVendor {
|
||||||
|
compressors: std::collections::HashMap<String, CompressorCoefficients>,
|
||||||
|
bphx: std::collections::HashMap<String, BphxParameters>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockVendor {
|
||||||
|
fn new() -> Self {
|
||||||
|
let mut compressors = std::collections::HashMap::new();
|
||||||
|
compressors.insert(
|
||||||
|
"TEST-COMP".into(),
|
||||||
|
CompressorCoefficients {
|
||||||
|
model: "TEST-COMP".into(),
|
||||||
|
manufacturer: "MockVendor".into(),
|
||||||
|
refrigerant: "R410A".into(),
|
||||||
|
capacity_coeffs: [1.0; 10],
|
||||||
|
power_coeffs: [2.0; 10],
|
||||||
|
mass_flow_coeffs: None,
|
||||||
|
validity: CompressorValidityRange {
|
||||||
|
t_suction_min: -10.0,
|
||||||
|
t_suction_max: 20.0,
|
||||||
|
t_discharge_min: 25.0,
|
||||||
|
t_discharge_max: 65.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut bphx = std::collections::HashMap::new();
|
||||||
|
bphx.insert(
|
||||||
|
"TEST-HX".into(),
|
||||||
|
BphxParameters {
|
||||||
|
model: "TEST-HX".into(),
|
||||||
|
manufacturer: "MockVendor".into(),
|
||||||
|
num_plates: 20,
|
||||||
|
area: 0.5,
|
||||||
|
dh: 0.003,
|
||||||
|
chevron_angle: 65.0,
|
||||||
|
ua_nominal: 2000.0,
|
||||||
|
ua_curve: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Self { compressors, bphx }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VendorBackend for MockVendor {
|
||||||
|
fn vendor_name(&self) -> &str {
|
||||||
|
"MockVendor"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_compressor_models(&self) -> Result<Vec<String>, VendorError> {
|
||||||
|
Ok(self.compressors.keys().cloned().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_compressor_coefficients(
|
||||||
|
&self,
|
||||||
|
model: &str,
|
||||||
|
) -> Result<CompressorCoefficients, VendorError> {
|
||||||
|
self.compressors
|
||||||
|
.get(model)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| VendorError::ModelNotFound(model.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_bphx_models(&self) -> Result<Vec<String>, VendorError> {
|
||||||
|
Ok(self.bphx.keys().cloned().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_bphx_parameters(&self, model: &str) -> Result<BphxParameters, VendorError> {
|
||||||
|
self.bphx
|
||||||
|
.get(model)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| VendorError::ModelNotFound(model.into()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mock_vendor_list_compressors() {
|
||||||
|
let vendor = MockVendor::new();
|
||||||
|
let models = vendor.list_compressor_models().unwrap();
|
||||||
|
assert_eq!(models.len(), 1);
|
||||||
|
assert!(models.contains(&"TEST-COMP".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mock_vendor_get_compressor() {
|
||||||
|
let vendor = MockVendor::new();
|
||||||
|
let coeffs = vendor.get_compressor_coefficients("TEST-COMP").unwrap();
|
||||||
|
assert_eq!(coeffs.model, "TEST-COMP");
|
||||||
|
assert_eq!(coeffs.manufacturer, "MockVendor");
|
||||||
|
assert_eq!(coeffs.refrigerant, "R410A");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mock_vendor_compressor_not_found() {
|
||||||
|
let vendor = MockVendor::new();
|
||||||
|
let result = vendor.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_mock_vendor_list_bphx() {
|
||||||
|
let vendor = MockVendor::new();
|
||||||
|
let models = vendor.list_bphx_models().unwrap();
|
||||||
|
assert_eq!(models.len(), 1);
|
||||||
|
assert!(models.contains(&"TEST-HX".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mock_vendor_get_bphx() {
|
||||||
|
let vendor = MockVendor::new();
|
||||||
|
let params = vendor.get_bphx_parameters("TEST-HX").unwrap();
|
||||||
|
assert_eq!(params.model, "TEST-HX");
|
||||||
|
assert_eq!(params.num_plates, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_mock_vendor_bphx_not_found() {
|
||||||
|
let vendor = MockVendor::new();
|
||||||
|
let result = vendor.get_bphx_parameters("NONEXISTENT");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_compute_ua_returns_nominal() {
|
||||||
|
let vendor = MockVendor::new();
|
||||||
|
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 = vendor.compute_ua("TEST-HX", ¶ms).unwrap();
|
||||||
|
assert!((ua - 2000.0).abs() < 1e-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_compute_ua_model_not_found() {
|
||||||
|
let vendor = MockVendor::new();
|
||||||
|
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 = vendor.compute_ua("NONEXISTENT", ¶ms);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Object safety ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vendor_backend_is_object_safe() {
|
||||||
|
let vendor: Box<dyn VendorBackend> = Box::new(MockVendor::new());
|
||||||
|
assert_eq!(vendor.vendor_name(), "MockVendor");
|
||||||
|
let models = vendor.list_compressor_models().unwrap();
|
||||||
|
assert!(!models.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user