feat(components): add ThermoState generators and Eurovent backend demo
This commit is contained in:
@@ -13,3 +13,4 @@ serde.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
approx = "0.5"
|
||||
serde_json = "1.0"
|
||||
|
||||
175
crates/core/src/calib.rs
Normal file
175
crates/core/src/calib.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! Calibration factors (Calib) for matching simulation to real machine test data.
|
||||
//!
|
||||
//! Short name: Calib. Default 1.0 = no correction. Typical range [0.8, 1.2].
|
||||
//! Refs: Buildings Modelica, EnergyPlus, TRNSYS, TIL Suite, alphaXiv.
|
||||
//!
|
||||
//! ## Recommended calibration order
|
||||
//!
|
||||
//! To avoid parameter fighting, calibrate in this order:
|
||||
//! 1. **f_m** — mass flow (compressor power + ṁ measurements)
|
||||
//! 2. **f_dp** — pressure drops (inlet/outlet pressures)
|
||||
//! 3. **f_ua** — heat transfer (superheat, subcooling, capacity)
|
||||
//! 4. **f_power** — compressor power (if f_m insufficient)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn one() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// Calibration factors for matching simulation to real machine test data.
|
||||
///
|
||||
/// Default 1.0 = no correction. Typical range [0.8, 1.2]. All factors are validated to lie in [0.5, 2.0].
|
||||
///
|
||||
/// | Field | Full name | Effect | Components |
|
||||
/// |-----------|------------------------|---------------------------------|-----------------------------|
|
||||
/// | `f_m` | mass flow factor | ṁ_eff = f_m × ṁ_nominal | Compressor, Expansion Valve |
|
||||
/// | `f_dp` | pressure drop factor | ΔP_eff = f_dp × ΔP_nominal | Pipe, Heat Exchanger |
|
||||
/// | `f_ua` | UA factor | UA_eff = f_ua × UA_nominal | Evaporator, Condenser |
|
||||
/// | `f_power` | power factor | Ẇ_eff = f_power × Ẇ_nominal | Compressor |
|
||||
/// | `f_etav` | volumetric efficiency | η_v,eff = f_etav × η_v,nominal | Compressor (displacement) |
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Calib {
|
||||
/// f_m: ṁ_eff = f_m × ṁ_nominal (Compressor, Valve)
|
||||
#[serde(default = "one", alias = "calib_flow")]
|
||||
pub f_m: f64,
|
||||
/// f_dp: ΔP_eff = f_dp × ΔP_nominal (Pipe, HX)
|
||||
#[serde(default = "one", alias = "calib_dpr")]
|
||||
pub f_dp: f64,
|
||||
/// f_ua: UA_eff = f_ua × UA_nominal (Evaporator, Condenser)
|
||||
#[serde(default = "one", alias = "calib_ua")]
|
||||
pub f_ua: f64,
|
||||
/// f_power: Ẇ_eff = f_power × Ẇ_nominal (Compressor)
|
||||
#[serde(default = "one")]
|
||||
pub f_power: f64,
|
||||
/// f_etav: η_v,eff = f_etav × η_v,nominal (Compressor displacement)
|
||||
#[serde(default = "one")]
|
||||
pub f_etav: f64,
|
||||
}
|
||||
|
||||
impl Default for Calib {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
f_m: 1.0,
|
||||
f_dp: 1.0,
|
||||
f_ua: 1.0,
|
||||
f_power: 1.0,
|
||||
f_etav: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error returned when a calibration factor is outside the allowed range [0.5, 2.0].
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CalibValidationError {
|
||||
/// Factor name (e.g. "f_m")
|
||||
pub factor: &'static str,
|
||||
/// Value that failed validation
|
||||
pub value: f64,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CalibValidationError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"calib {} = {} is outside allowed range [0.5, 2.0]",
|
||||
self.factor, self.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CalibValidationError {}
|
||||
|
||||
const MIN_F: f64 = 0.5;
|
||||
const MAX_F: f64 = 2.0;
|
||||
|
||||
impl Calib {
|
||||
/// Validates that all factors lie in [0.5, 2.0]. Returns `Ok(())` or the first invalid factor.
|
||||
pub fn validate(&self) -> Result<(), CalibValidationError> {
|
||||
let check = |name: &'static str, value: f64| {
|
||||
if !(MIN_F..=MAX_F).contains(&value) {
|
||||
Err(CalibValidationError {
|
||||
factor: name,
|
||||
value,
|
||||
})
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
check("f_m", self.f_m)?;
|
||||
check("f_dp", self.f_dp)?;
|
||||
check("f_ua", self.f_ua)?;
|
||||
check("f_power", self.f_power)?;
|
||||
check("f_etav", self.f_etav)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_calib_default_all_one() {
|
||||
let c = Calib::default();
|
||||
assert_eq!(c.f_m, 1.0);
|
||||
assert_eq!(c.f_dp, 1.0);
|
||||
assert_eq!(c.f_ua, 1.0);
|
||||
assert_eq!(c.f_power, 1.0);
|
||||
assert_eq!(c.f_etav, 1.0);
|
||||
assert!(c.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calib_validation_bounds() {
|
||||
let ok = Calib {
|
||||
f_m: 0.5,
|
||||
f_dp: 1.0,
|
||||
f_ua: 2.0,
|
||||
f_power: 1.0,
|
||||
f_etav: 1.0,
|
||||
};
|
||||
assert!(ok.validate().is_ok());
|
||||
|
||||
let bad_m = Calib {
|
||||
f_m: 0.4,
|
||||
..Default::default()
|
||||
};
|
||||
let err = bad_m.validate().unwrap_err();
|
||||
assert_eq!(err.factor, "f_m");
|
||||
assert!((err.value - 0.4).abs() < 1e-9);
|
||||
|
||||
let bad_high = Calib {
|
||||
f_ua: 2.1,
|
||||
..Default::default()
|
||||
};
|
||||
let err2 = bad_high.validate().unwrap_err();
|
||||
assert_eq!(err2.factor, "f_ua");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calib_json_roundtrip() {
|
||||
let c = Calib {
|
||||
f_m: 1.1,
|
||||
f_dp: 0.9,
|
||||
f_ua: 1.0,
|
||||
f_power: 1.05,
|
||||
f_etav: 1.0,
|
||||
};
|
||||
let json = serde_json::to_string(&c).unwrap();
|
||||
let c2: Calib = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(c, c2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calib_aliases_backward_compat() {
|
||||
// calib_flow → f_m
|
||||
let json = r#"{"calib_flow": 1.2}"#;
|
||||
let c: Calib = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(c.f_m, 1.2);
|
||||
assert_eq!(c.f_dp, 1.0);
|
||||
assert_eq!(c.f_ua, 1.0);
|
||||
assert_eq!(c.f_power, 1.0);
|
||||
assert_eq!(c.f_etav, 1.0);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,14 @@
|
||||
#![deny(warnings)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod calib;
|
||||
pub mod types;
|
||||
|
||||
// Re-export all physical types for convenience
|
||||
pub use types::{Enthalpy, MassFlow, Pressure, Temperature};
|
||||
pub use types::{
|
||||
Enthalpy, MassFlow, MIN_MASS_FLOW_REGULARIZATION_KG_S, Power, Pressure, Temperature,
|
||||
ThermalConductance,
|
||||
};
|
||||
|
||||
// Re-export calibration types
|
||||
pub use calib::{Calib, CalibValidationError};
|
||||
|
||||
@@ -303,10 +303,24 @@ impl Div<f64> for Enthalpy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimum mass flow used in denominators to avoid division by zero (zero-flow regularization).
|
||||
///
|
||||
/// When mass flow is zero or below this value, use [`MassFlow::regularized`] in any expression
|
||||
/// that divides by mass flow (e.g. Q/ṁ, ΔP/ṁ²) or by quantities derived from it (e.g. Reynolds,
|
||||
/// capacity rate C = ṁ·Cp). This prevents NaN/Inf while preserving solver convergence.
|
||||
///
|
||||
/// Value: 1e-12 kg/s (small enough to not affect physical results when ṁ >> ε).
|
||||
pub const MIN_MASS_FLOW_REGULARIZATION_KG_S: f64 = 1e-12;
|
||||
|
||||
/// Mass flow rate in kilograms per second (kg/s).
|
||||
///
|
||||
/// Internally stores the value in kilograms per second (SI base unit).
|
||||
///
|
||||
/// # Zero-flow regularization
|
||||
///
|
||||
/// When dividing by mass flow (or using it in denominators), use [`MassFlow::regularized`] so that
|
||||
/// zero-flow branches do not cause division by zero. See [`MIN_MASS_FLOW_REGULARIZATION_KG_S`].
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
@@ -338,6 +352,15 @@ impl MassFlow {
|
||||
pub fn to_grams_per_s(&self) -> f64 {
|
||||
self.0 * 1_000.0
|
||||
}
|
||||
|
||||
/// Returns mass flow clamped to at least [`MIN_MASS_FLOW_REGULARIZATION_KG_S`] for use in denominators.
|
||||
///
|
||||
/// Use this whenever dividing by mass flow (e.g. Q/ṁ) or by a quantity derived from it (e.g. Re ∝ ṁ)
|
||||
/// to avoid division by zero when the branch has zero flow (e.g. component in Off state).
|
||||
#[must_use]
|
||||
pub fn regularized(self) -> Self {
|
||||
MassFlow(self.0.max(MIN_MASS_FLOW_REGULARIZATION_KG_S))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for MassFlow {
|
||||
@@ -392,6 +415,148 @@ impl Div<f64> for MassFlow {
|
||||
}
|
||||
}
|
||||
|
||||
/// Power in Watts (W).
|
||||
///
|
||||
/// Internally stores the value in Watts (SI base unit).
|
||||
/// Provides conversions to/from common units like kilowatts.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use entropyk_core::Power;
|
||||
///
|
||||
/// let p = Power::from_kilowatts(1.0);
|
||||
/// assert_eq!(p.to_watts(), 1000.0);
|
||||
/// assert_eq!(p.to_kilowatts(), 1.0);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
|
||||
pub struct Power(pub f64);
|
||||
|
||||
impl Power {
|
||||
/// Creates a Power from a value in Watts.
|
||||
pub fn from_watts(value: f64) -> Self {
|
||||
Power(value)
|
||||
}
|
||||
|
||||
/// Creates a Power from a value in kilowatts.
|
||||
pub fn from_kilowatts(value: f64) -> Self {
|
||||
Power(value * 1_000.0)
|
||||
}
|
||||
|
||||
/// Creates a Power from a value in megawatts.
|
||||
pub fn from_megawatts(value: f64) -> Self {
|
||||
Power(value * 1_000_000.0)
|
||||
}
|
||||
|
||||
/// Returns the power in Watts.
|
||||
pub fn to_watts(&self) -> f64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Returns the power in kilowatts.
|
||||
pub fn to_kilowatts(&self) -> f64 {
|
||||
self.0 / 1_000.0
|
||||
}
|
||||
|
||||
/// Returns the power in megawatts.
|
||||
pub fn to_megawatts(&self) -> f64 {
|
||||
self.0 / 1_000_000.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Power {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{} W", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for Power {
|
||||
fn from(value: f64) -> Self {
|
||||
Power(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Add<Power> for Power {
|
||||
type Output = Power;
|
||||
|
||||
fn add(self, other: Power) -> Power {
|
||||
Power(self.0 + other.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sub<Power> for Power {
|
||||
type Output = Power;
|
||||
|
||||
fn sub(self, other: Power) -> Power {
|
||||
Power(self.0 - other.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<f64> for Power {
|
||||
type Output = Power;
|
||||
|
||||
fn mul(self, scalar: f64) -> Power {
|
||||
Power(self.0 * scalar)
|
||||
}
|
||||
}
|
||||
|
||||
impl Mul<Power> for f64 {
|
||||
type Output = Power;
|
||||
|
||||
fn mul(self, p: Power) -> Power {
|
||||
Power(self * p.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Div<f64> for Power {
|
||||
type Output = Power;
|
||||
|
||||
fn div(self, scalar: f64) -> Power {
|
||||
Power(self.0 / scalar)
|
||||
}
|
||||
}
|
||||
|
||||
/// Thermal conductance in Watts per Kelvin (W/K).
|
||||
///
|
||||
/// Represents the heat transfer coefficient (UA value) for thermal coupling
|
||||
/// between circuits or components.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
|
||||
pub struct ThermalConductance(pub f64);
|
||||
|
||||
impl ThermalConductance {
|
||||
/// Creates a ThermalConductance from a value in Watts per Kelvin (W/K).
|
||||
pub fn from_watts_per_kelvin(value: f64) -> Self {
|
||||
ThermalConductance(value)
|
||||
}
|
||||
|
||||
/// Creates a ThermalConductance from a value in kilowatts per Kelvin (kW/K).
|
||||
pub fn from_kilowatts_per_kelvin(value: f64) -> Self {
|
||||
ThermalConductance(value * 1_000.0)
|
||||
}
|
||||
|
||||
/// Returns the thermal conductance in Watts per Kelvin.
|
||||
pub fn to_watts_per_kelvin(&self) -> f64 {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Returns the thermal conductance in kilowatts per Kelvin.
|
||||
pub fn to_kilowatts_per_kelvin(&self) -> f64 {
|
||||
self.0 / 1_000.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ThermalConductance {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{} W/K", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for ThermalConductance {
|
||||
fn from(value: f64) -> Self {
|
||||
ThermalConductance(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -656,6 +821,20 @@ mod tests {
|
||||
assert_relative_eq!(m1.to_grams_per_s(), 500.0, epsilon = 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mass_flow_regularized() {
|
||||
use super::MIN_MASS_FLOW_REGULARIZATION_KG_S;
|
||||
let zero = MassFlow::from_kg_per_s(0.0);
|
||||
let r = zero.regularized();
|
||||
assert_relative_eq!(r.to_kg_per_s(), MIN_MASS_FLOW_REGULARIZATION_KG_S, epsilon = 1e-15);
|
||||
let small = MassFlow::from_kg_per_s(1e-14);
|
||||
let r2 = small.regularized();
|
||||
assert_relative_eq!(r2.to_kg_per_s(), MIN_MASS_FLOW_REGULARIZATION_KG_S, epsilon = 1e-15);
|
||||
let normal = MassFlow::from_kg_per_s(0.5);
|
||||
let r3 = normal.regularized();
|
||||
assert_relative_eq!(r3.to_kg_per_s(), 0.5, epsilon = 1e-10);
|
||||
}
|
||||
|
||||
// ==================== TYPE SAFETY TESTS ====================
|
||||
|
||||
#[test]
|
||||
@@ -748,4 +927,53 @@ mod tests {
|
||||
let m = MassFlow::from_kg_per_s(1e-12);
|
||||
assert_relative_eq!(m.to_kg_per_s(), 1e-12, epsilon = 1e-17);
|
||||
}
|
||||
|
||||
// ==================== POWER TESTS ====================
|
||||
|
||||
#[test]
|
||||
fn test_power_from_watts() {
|
||||
let p = Power::from_watts(1000.0);
|
||||
assert_relative_eq!(p.0, 1000.0, epsilon = 1e-10);
|
||||
assert_relative_eq!(p.to_watts(), 1000.0, epsilon = 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_power_from_kilowatts() {
|
||||
let p = Power::from_kilowatts(1.0);
|
||||
assert_relative_eq!(p.to_watts(), 1000.0, epsilon = 1e-6);
|
||||
assert_relative_eq!(p.to_kilowatts(), 1.0, epsilon = 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_power_from_megawatts() {
|
||||
let p = Power::from_megawatts(1.0);
|
||||
assert_relative_eq!(p.to_watts(), 1_000_000.0, epsilon = 1e-6);
|
||||
assert_relative_eq!(p.to_megawatts(), 1.0, epsilon = 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_power_display() {
|
||||
let p = Power::from_watts(5000.0);
|
||||
assert_eq!(format!("{}", p), "5000 W");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_power_arithmetic() {
|
||||
let p1 = Power::from_watts(1000.0);
|
||||
let p2 = Power::from_watts(500.0);
|
||||
let p3 = p1 + p2;
|
||||
assert_relative_eq!(p3.to_watts(), 1500.0, epsilon = 1e-10);
|
||||
|
||||
let p4 = p1 - p2;
|
||||
assert_relative_eq!(p4.to_watts(), 500.0, epsilon = 1e-10);
|
||||
|
||||
let p5 = p1 * 2.0;
|
||||
assert_relative_eq!(p5.to_watts(), 2000.0, epsilon = 1e-10);
|
||||
|
||||
let p6 = p1 / 2.0;
|
||||
assert_relative_eq!(p6.to_watts(), 500.0, epsilon = 1e-10);
|
||||
|
||||
let p7 = 2.0 * p1;
|
||||
assert_relative_eq!(p7.to_watts(), 2000.0, epsilon = 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user