feat(components): add ThermoState generators and Eurovent backend demo

This commit is contained in:
Sepehr
2026-02-20 22:01:38 +01:00
parent 375d288950
commit 4a40fddfe3
271 changed files with 28614 additions and 447 deletions

View File

@@ -13,3 +13,4 @@ serde.workspace = true
[dev-dependencies]
approx = "0.5"
serde_json = "1.0"

175
crates/core/src/calib.rs Normal file
View 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);
}
}

View File

@@ -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};

View File

@@ -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);
}
}