style: fix AI code review findings for vendor backend

This commit is contained in:
Sepehr 2026-02-28 19:37:02 +01:00
parent bd4113f49e
commit 3eb2219454
4 changed files with 654 additions and 11 deletions

View File

@ -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
View 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
View 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.1211.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
View 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(&params).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(&params).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", &params).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", &params);
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());
}
}