Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,15 +1,28 @@
|
||||
//! Calibration factors (Calib) for matching simulation to real machine test data.
|
||||
//! Calibration Z-factors (BOLT / Modelica convention) 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.
|
||||
//!
|
||||
//! ## BOLT naming parity
|
||||
//!
|
||||
//! Entropyk uses the same multiplicative Z-factor semantics as BOLT/Modelica
|
||||
//! product models (`Z_power`, `Z_flow_suc`, `Z_Ucd`, `Z_dpc`, …):
|
||||
//!
|
||||
//! | Entropyk field | Legacy `f_*` | BOLT equivalent | Effect |
|
||||
//! |---|---|---|---|
|
||||
//! | `z_flow` | `f_m` | `Z_flow_suc` | ṁ_suc,eff = z_flow × ṁ_suc,nominal |
|
||||
//! | `z_flow_eco` | — | `Z_flow_eco` | ṁ_eco,eff = z_flow_eco × ṁ_eco,nominal |
|
||||
//! | `z_dp` | `f_dp` | `Z_dpc`, `Z_dp_ref` | ΔP_eff = z_dp × ΔP_nominal |
|
||||
//! | `z_ua` | `f_ua` | `Z_UA`, `Z_Ucd`, `Z_Uev` | UA_eff = z_ua × UA_nominal |
|
||||
//! | `z_power` | `f_power` | `Z_power` | Ẇ_eff = z_power × Ẇ_nominal |
|
||||
//! | `z_etav` | `f_etav` | — (η_v correction) | η_v,eff = z_etav × η_v,nominal |
|
||||
//!
|
||||
//! ## 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)
|
||||
//! 1. **z_flow** — mass flow (compressor power + ṁ measurements)
|
||||
//! 2. **z_dp** — pressure drops (inlet/outlet pressures)
|
||||
//! 3. **z_ua** — heat transfer (superheat, subcooling, capacity)
|
||||
//! 4. **z_power** — compressor power (if z_flow insufficient)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -17,34 +30,122 @@ fn one() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// Calibration factors for matching simulation to real machine test data.
|
||||
/// Canonical name for the mass-flow Z-factor (`z_flow`, BOLT `Z_flow_suc`).
|
||||
pub const Z_FLOW: &str = "z_flow";
|
||||
/// Canonical name for the economizer mass-flow Z-factor (`z_flow_eco`, BOLT `Z_flow_eco`).
|
||||
pub const Z_FLOW_ECO: &str = "z_flow_eco";
|
||||
/// Canonical name for the pressure-drop Z-factor (`z_dp`, BOLT `Z_dpc`).
|
||||
pub const Z_DP: &str = "z_dp";
|
||||
/// Canonical name for the UA Z-factor (`z_ua`, BOLT `Z_UA` / `Z_Ucd` / `Z_Uev`).
|
||||
pub const Z_UA: &str = "z_ua";
|
||||
/// Canonical name for the power Z-factor (`z_power`, BOLT `Z_power`).
|
||||
pub const Z_POWER: &str = "z_power";
|
||||
/// Canonical name for the volumetric-efficiency Z-factor (`z_etav`).
|
||||
pub const Z_ETAV: &str = "z_etav";
|
||||
|
||||
/// Normalizes a user/config factor string to a canonical Z-factor name.
|
||||
///
|
||||
/// Accepts legacy `f_*` names, canonical `z_*` names, and common BOLT spellings
|
||||
/// (`Z_power`, `Z_flow_suc`, `Z_Ucd`, …).
|
||||
pub fn normalize_factor_name(factor: &str) -> Option<&'static str> {
|
||||
match factor.trim().to_ascii_lowercase().replace('_', "").as_str() {
|
||||
"fm" | "zflow" | "zflowsuc" => Some(Z_FLOW),
|
||||
"zfloweco" => Some(Z_FLOW_ECO),
|
||||
"fdp" | "zdp" | "zdpc" | "zdprefer" => Some(Z_DP),
|
||||
"fua" | "zua" | "zucd" | "zuev" => Some(Z_UA),
|
||||
"fpower" | "zpower" => Some(Z_POWER),
|
||||
"fetav" | "zetav" => Some(Z_ETAV),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if `id` ends with a recognized calibration-factor suffix
|
||||
/// (canonical `z_*`, legacy `f_*`, or BOLT-style `Z_*` embedded in bounded-var ids).
|
||||
pub fn id_ends_with_calib_suffix(id: &str) -> Option<&'static str> {
|
||||
let lower = id.to_ascii_lowercase();
|
||||
for suffix in [
|
||||
"z_flow_suc",
|
||||
"z_flow_eco",
|
||||
"z_flow",
|
||||
"z_power",
|
||||
"z_etav",
|
||||
"z_ua",
|
||||
"z_ucd",
|
||||
"z_uev",
|
||||
"z_dpc",
|
||||
"z_dp",
|
||||
"f_m",
|
||||
"f_dp",
|
||||
"f_ua",
|
||||
"f_power",
|
||||
"f_etav",
|
||||
] {
|
||||
if lower.ends_with(suffix) {
|
||||
return normalize_factor_name(suffix);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Calibration Z-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, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Calib {
|
||||
/// f_m: ṁ_eff = f_m × ṁ_nominal (Compressor, Valve)
|
||||
#[serde(default = "one", alias = "f_m", alias = "calib_flow")]
|
||||
pub f_m: f64,
|
||||
/// f_dp: ΔP_eff = f_dp × ΔP_nominal (Pipe, HX)
|
||||
#[serde(default = "one", alias = "f_dp", alias = "calib_dpr")]
|
||||
pub f_dp: f64,
|
||||
/// f_ua: UA_eff = f_ua × UA_nominal (Evaporator, Condenser)
|
||||
#[serde(default = "one", alias = "f_ua", alias = "calib_ua")]
|
||||
pub f_ua: f64,
|
||||
/// f_power: Ẇ_eff = f_power × Ẇ_nominal (Compressor)
|
||||
#[serde(default = "one", alias = "f_power")]
|
||||
pub f_power: f64,
|
||||
/// f_etav: η_v,eff = f_etav × η_v,nominal (Compressor displacement)
|
||||
#[serde(default = "one", alias = "f_etav")]
|
||||
pub f_etav: f64,
|
||||
/// z_flow: ṁ_eff = z_flow × ṁ_nominal (BOLT `Z_flow_suc`)
|
||||
#[serde(
|
||||
default = "one",
|
||||
rename = "z_flow",
|
||||
alias = "zFlow",
|
||||
alias = "Z_flow_suc",
|
||||
alias = "Z_flow",
|
||||
alias = "f_m",
|
||||
alias = "calib_flow"
|
||||
)]
|
||||
pub z_flow: f64,
|
||||
/// z_flow_eco: economizer injection mass-flow multiplier (BOLT `Z_flow_eco`)
|
||||
#[serde(
|
||||
default = "one",
|
||||
rename = "z_flow_eco",
|
||||
alias = "zFlowEco",
|
||||
alias = "Z_flow_eco"
|
||||
)]
|
||||
pub z_flow_eco: f64,
|
||||
/// z_dp: ΔP_eff = z_dp × ΔP_nominal (BOLT `Z_dpc`)
|
||||
#[serde(
|
||||
default = "one",
|
||||
rename = "z_dp",
|
||||
alias = "zDp",
|
||||
alias = "Z_dpc",
|
||||
alias = "Z_dp",
|
||||
alias = "f_dp",
|
||||
alias = "calib_dpr"
|
||||
)]
|
||||
pub z_dp: f64,
|
||||
/// z_ua: UA_eff = z_ua × UA_nominal (BOLT `Z_UA` / `Z_Ucd` / `Z_Uev`)
|
||||
#[serde(
|
||||
default = "one",
|
||||
rename = "z_ua",
|
||||
alias = "zUa",
|
||||
alias = "Z_UA",
|
||||
alias = "Z_Ucd",
|
||||
alias = "Z_Uev",
|
||||
alias = "f_ua",
|
||||
alias = "calib_ua"
|
||||
)]
|
||||
pub z_ua: f64,
|
||||
/// z_power: Ẇ_eff = z_power × Ẇ_nominal (BOLT `Z_power`)
|
||||
#[serde(
|
||||
default = "one",
|
||||
rename = "z_power",
|
||||
alias = "zPower",
|
||||
alias = "Z_power",
|
||||
alias = "f_power"
|
||||
)]
|
||||
pub z_power: f64,
|
||||
/// z_etav: η_v,eff = z_etav × η_v,nominal (compressor displacement correction)
|
||||
#[serde(default = "one", rename = "z_etav", alias = "zEtav", alias = "f_etav")]
|
||||
pub z_etav: f64,
|
||||
/// Traceability: identifier or hash of the test data used to derive these factors.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub calibration_source: Option<String>,
|
||||
@@ -53,38 +154,46 @@ pub struct Calib {
|
||||
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,
|
||||
z_flow: 1.0,
|
||||
z_flow_eco: 1.0,
|
||||
z_dp: 1.0,
|
||||
z_ua: 1.0,
|
||||
z_power: 1.0,
|
||||
z_etav: 1.0,
|
||||
calibration_source: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the state vector indices of calibration factors if they are defined as control variables.
|
||||
/// Stores the state vector indices of calibration Z-factors if they are defined as control variables.
|
||||
///
|
||||
/// Used for Inverse Control (Story 5.5). If an index is `Some(i)`, the component should
|
||||
/// read its calibration factor from `state[i]` instead of using its nominal internal value.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct CalibIndices {
|
||||
/// State index for f_m multiplier
|
||||
pub f_m: Option<usize>,
|
||||
/// State index for f_dp multiplier
|
||||
pub f_dp: Option<usize>,
|
||||
/// State index for f_ua multiplier
|
||||
pub f_ua: Option<usize>,
|
||||
/// State index for f_power multiplier
|
||||
pub f_power: Option<usize>,
|
||||
/// State index for f_etav multiplier
|
||||
pub f_etav: Option<usize>,
|
||||
/// State index for z_flow multiplier (BOLT Z_flow_suc)
|
||||
pub z_flow: Option<usize>,
|
||||
/// State index for z_flow_eco multiplier (BOLT Z_flow_eco)
|
||||
pub z_flow_eco: Option<usize>,
|
||||
/// State index for z_dp multiplier (BOLT Z_dpc)
|
||||
pub z_dp: Option<usize>,
|
||||
/// State index for z_ua multiplier (BOLT Z_UA)
|
||||
pub z_ua: Option<usize>,
|
||||
/// State index for z_power multiplier (BOLT Z_power)
|
||||
pub z_power: Option<usize>,
|
||||
/// State index for z_etav multiplier
|
||||
pub z_etav: Option<usize>,
|
||||
/// State index for a *physical actuator* free variable (dimensioned, not a
|
||||
/// multiplier). Interpreted per component: EXV/orifice opening [0..1], fan
|
||||
/// speed ratio, screw slide position, injection-valve opening, condenser
|
||||
/// liquid level, etc.
|
||||
pub actuator: Option<usize>,
|
||||
}
|
||||
|
||||
/// 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")
|
||||
/// Factor name (e.g. "z_flow")
|
||||
pub factor: &'static str,
|
||||
/// Value that failed validation
|
||||
pub value: f64,
|
||||
@@ -102,26 +211,45 @@ impl std::fmt::Display for CalibValidationError {
|
||||
|
||||
impl std::error::Error for CalibValidationError {}
|
||||
|
||||
const MIN_F: f64 = 0.5;
|
||||
const MAX_F: f64 = 2.0;
|
||||
const MIN_Z: f64 = 0.5;
|
||||
const MAX_Z: f64 = 2.0;
|
||||
|
||||
impl Calib {
|
||||
/// Updates a single factor by name. Returns `true` if the factor was recognized.
|
||||
/// Updates a single factor by name (accepts canonical `z_*`, legacy `f_*`, BOLT `Z_*`).
|
||||
pub fn set_factor(&mut self, factor: &str, value: f64) -> bool {
|
||||
match factor {
|
||||
"f_m" => { self.f_m = value; true }
|
||||
"f_dp" => { self.f_dp = value; true }
|
||||
"f_ua" => { self.f_ua = value; true }
|
||||
"f_power" => { self.f_power = value; true }
|
||||
"f_etav" => { self.f_etav = value; true }
|
||||
_ => false
|
||||
match normalize_factor_name(factor) {
|
||||
Some(Z_FLOW) => {
|
||||
self.z_flow = value;
|
||||
true
|
||||
}
|
||||
Some(Z_FLOW_ECO) => {
|
||||
self.z_flow_eco = value;
|
||||
true
|
||||
}
|
||||
Some(Z_DP) => {
|
||||
self.z_dp = value;
|
||||
true
|
||||
}
|
||||
Some(Z_UA) => {
|
||||
self.z_ua = value;
|
||||
true
|
||||
}
|
||||
Some(Z_POWER) => {
|
||||
self.z_power = value;
|
||||
true
|
||||
}
|
||||
Some(Z_ETAV) => {
|
||||
self.z_etav = value;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
if !(MIN_Z..=MAX_Z).contains(&value) {
|
||||
Err(CalibValidationError {
|
||||
factor: name,
|
||||
value,
|
||||
@@ -130,11 +258,12 @@ impl Calib {
|
||||
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)?;
|
||||
check(Z_FLOW, self.z_flow)?;
|
||||
check(Z_FLOW_ECO, self.z_flow_eco)?;
|
||||
check(Z_DP, self.z_dp)?;
|
||||
check(Z_UA, self.z_ua)?;
|
||||
check(Z_POWER, self.z_power)?;
|
||||
check(Z_ETAV, self.z_etav)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -146,85 +275,103 @@ mod tests {
|
||||
#[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_eq!(c.z_flow, 1.0);
|
||||
assert_eq!(c.z_flow_eco, 1.0);
|
||||
assert_eq!(c.z_dp, 1.0);
|
||||
assert_eq!(c.z_ua, 1.0);
|
||||
assert_eq!(c.z_power, 1.0);
|
||||
assert_eq!(c.z_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,
|
||||
z_flow: 0.5,
|
||||
z_flow_eco: 2.0,
|
||||
z_dp: 1.0,
|
||||
z_ua: 2.0,
|
||||
z_power: 1.0,
|
||||
z_etav: 1.0,
|
||||
calibration_source: None,
|
||||
};
|
||||
assert!(ok.validate().is_ok());
|
||||
|
||||
let bad_m = Calib {
|
||||
f_m: 0.4,
|
||||
z_flow: 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);
|
||||
assert_eq!(err.factor, Z_FLOW);
|
||||
|
||||
let bad_high = Calib {
|
||||
f_ua: 2.1,
|
||||
z_ua: 2.1,
|
||||
..Default::default()
|
||||
};
|
||||
let err2 = bad_high.validate().unwrap_err();
|
||||
assert_eq!(err2.factor, "f_ua");
|
||||
assert_eq!(err2.factor, Z_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,
|
||||
z_flow: 1.1,
|
||||
z_flow_eco: 0.97,
|
||||
z_dp: 0.9,
|
||||
z_ua: 1.0,
|
||||
z_power: 1.05,
|
||||
z_etav: 1.0,
|
||||
calibration_source: None,
|
||||
};
|
||||
let json = serde_json::to_string(&c).unwrap();
|
||||
assert!(json.contains("z_flow"));
|
||||
assert!(json.contains("z_flow_eco"));
|
||||
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}"#;
|
||||
fn test_calib_legacy_f_aliases() {
|
||||
let json = r#"{"f_m": 1.2, "f_dp": 0.9, "f_ua": 1.1, "f_power": 1.05, "f_etav": 0.98}"#;
|
||||
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);
|
||||
assert_eq!(c.z_flow, 1.2);
|
||||
assert_eq!(c.z_flow_eco, 1.0);
|
||||
assert_eq!(c.z_dp, 0.9);
|
||||
assert_eq!(c.z_ua, 1.1);
|
||||
assert_eq!(c.z_power, 1.05);
|
||||
assert_eq!(c.z_etav, 0.98);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calib_calibration_source() {
|
||||
let c = Calib {
|
||||
f_m: 1.05,
|
||||
calibration_source: Some("test-bench-2024-01-15".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_string(&c).unwrap();
|
||||
let c2: Calib = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(c2.calibration_source.as_deref(), Some("test-bench-2024-01-15"));
|
||||
fn test_calib_bolt_z_aliases() {
|
||||
let json = r#"{"Z_flow_suc": 1.002, "Z_flow_eco": 0.94, "Z_dpc": 0.95, "Z_Ucd": 1.04, "Z_power": 1.02}"#;
|
||||
let c: Calib = serde_json::from_str(json).unwrap();
|
||||
assert!((c.z_flow - 1.002).abs() < 1e-9);
|
||||
assert!((c.z_flow_eco - 0.94).abs() < 1e-9);
|
||||
assert!((c.z_dp - 0.95).abs() < 1e-9);
|
||||
assert!((c.z_ua - 1.04).abs() < 1e-9);
|
||||
assert!((c.z_power - 1.02).abs() < 1e-9);
|
||||
}
|
||||
|
||||
// Without calibration_source, field is absent from JSON
|
||||
let c_no = Calib::default();
|
||||
let json_no = serde_json::to_string(&c_no).unwrap();
|
||||
assert!(!json_no.contains("calibrationSource"));
|
||||
let c3: Calib = serde_json::from_str(&json_no).unwrap();
|
||||
assert_eq!(c3.calibration_source, None);
|
||||
#[test]
|
||||
fn test_normalize_factor_name() {
|
||||
assert_eq!(normalize_factor_name("f_m"), Some(Z_FLOW));
|
||||
assert_eq!(normalize_factor_name("z_flow"), Some(Z_FLOW));
|
||||
assert_eq!(normalize_factor_name("Z_flow_suc"), Some(Z_FLOW));
|
||||
assert_eq!(normalize_factor_name("Z_flow_eco"), Some(Z_FLOW_ECO));
|
||||
assert_eq!(normalize_factor_name("Z_power"), Some(Z_POWER));
|
||||
assert_eq!(normalize_factor_name("Z_Ucd"), Some(Z_UA));
|
||||
assert_eq!(normalize_factor_name("unknown"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_id_ends_with_calib_suffix() {
|
||||
assert_eq!(id_ends_with_calib_suffix("comp__z_flow"), Some(Z_FLOW));
|
||||
assert_eq!(
|
||||
id_ends_with_calib_suffix("comp__z_flow_eco"),
|
||||
Some(Z_FLOW_ECO)
|
||||
);
|
||||
assert_eq!(id_ends_with_calib_suffix("evap__f_ua"), Some(Z_UA));
|
||||
assert_eq!(id_ends_with_calib_suffix("comp__actuator"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod calib;
|
||||
pub mod smoothing;
|
||||
pub mod state;
|
||||
pub mod types;
|
||||
|
||||
@@ -63,7 +64,10 @@ pub use types::{
|
||||
};
|
||||
|
||||
// Re-export calibration types
|
||||
pub use calib::{Calib, CalibIndices, CalibValidationError};
|
||||
pub use calib::{
|
||||
id_ends_with_calib_suffix, normalize_factor_name, Calib, CalibIndices, CalibValidationError,
|
||||
Z_DP, Z_ETAV, Z_FLOW, Z_FLOW_ECO, Z_POWER, Z_UA,
|
||||
};
|
||||
|
||||
// Re-export system state
|
||||
pub use state::{InvalidStateLengthError, SystemState};
|
||||
|
||||
589
crates/core/src/smoothing.rs
Normal file
589
crates/core/src/smoothing.rs
Normal file
@@ -0,0 +1,589 @@
|
||||
//! Reusable C¹/C² smoothing primitives for correlation transitions.
|
||||
//!
|
||||
//! Equation-oriented Newton solvers require the residual functions — and
|
||||
//! therefore every correlation they depend on — to be at least **C¹**
|
||||
//! (continuous value *and* continuous first derivative). A naive `if/else`
|
||||
//! switch between two correlations (e.g. laminar `64/Re` below `Re = 2300`
|
||||
//! and a turbulent friction factor above it) introduces a slope discontinuity
|
||||
//! that makes the analytic Jacobian jump. Newton then oscillates or diverges,
|
||||
//! and finite-difference Jacobians produce spurious large entries near the
|
||||
//! switch. This is the failure mode behind real-world regressions where an
|
||||
//! `if/else` transition was activated on the wrong branch.
|
||||
//!
|
||||
//! This module centralises the smoothing primitives so that **every** component
|
||||
//! uses the same, tested, analytically-differentiable building blocks instead
|
||||
//! of ad-hoc `tanh`/`spliceFunction` code scattered across the codebase.
|
||||
//!
|
||||
//! # Primitives
|
||||
//!
|
||||
//! - [`smoothstep`] / [`smoothstep_derivative`] — C¹ Hermite cubic ramp on `[0, 1]`.
|
||||
//! - [`smootherstep`] / [`smootherstep_derivative`] / [`smootherstep_second_derivative`]
|
||||
//! — C² quintic ramp on `[0, 1]`.
|
||||
//! - [`cubic_blend`] / [`quintic_blend`] — blend two values across a transition window.
|
||||
//! - [`smooth_max`] / [`smooth_min`] — C^∞ soft maximum / minimum.
|
||||
//! - [`smooth_abs`] — C^∞ approximation of `|x|`.
|
||||
//! - [`smooth_clamp`] — C¹ clamp into `[lo, hi]`.
|
||||
//!
|
||||
//! # Example: smoothing a friction-factor transition
|
||||
//!
|
||||
//! ```rust
|
||||
//! use entropyk_core::smoothing::cubic_blend;
|
||||
//!
|
||||
//! // Blend laminar -> turbulent friction factor over the Re window [2300, 4000].
|
||||
//! let f_laminar = 64.0 / 2300.0;
|
||||
//! let f_turbulent = 0.04;
|
||||
//! let re = 3000.0;
|
||||
//! let f = cubic_blend(f_laminar, f_turbulent, re, 2300.0, 4000.0);
|
||||
//! assert!(f > f_turbulent.min(f_laminar) && f < f_laminar.max(f_turbulent));
|
||||
//! ```
|
||||
|
||||
/// Normalises `x` to `t = (x - edge0) / (edge1 - edge0)` clamped to `[0, 1]`.
|
||||
///
|
||||
/// Returns the clamped parameter together with `1 / (edge1 - edge0)` so callers
|
||||
/// can chain a chain-rule factor for derivatives. When `edge1 == edge0` (a
|
||||
/// degenerate, zero-width window) the inverse-width factor is `0.0` and the
|
||||
/// parameter is a hard step at the shared edge.
|
||||
#[inline]
|
||||
fn normalize(x: f64, edge0: f64, edge1: f64) -> (f64, f64) {
|
||||
let width = edge1 - edge0;
|
||||
if width == 0.0 {
|
||||
let t = if x < edge0 { 0.0 } else { 1.0 };
|
||||
return (t, 0.0);
|
||||
}
|
||||
let inv_width = 1.0 / width;
|
||||
let t = ((x - edge0) * inv_width).clamp(0.0, 1.0);
|
||||
(t, inv_width)
|
||||
}
|
||||
|
||||
/// C¹ Hermite cubic smoothstep on the window `[edge0, edge1]`.
|
||||
///
|
||||
/// Returns `0.0` for `x <= edge0`, `1.0` for `x >= edge1`, and the cubic
|
||||
/// `3t² - 2t³` (with `t = (x - edge0) / (edge1 - edge0)`) in between. Both the
|
||||
/// value and the first derivative are continuous everywhere; the first
|
||||
/// derivative is exactly `0` at both edges, so blending two constant regions
|
||||
/// stays C¹.
|
||||
///
|
||||
/// `edge0` must be strictly less than `edge1`; a zero-width window degenerates
|
||||
/// to a hard step at the shared edge.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_core::smoothing::smoothstep;
|
||||
///
|
||||
/// assert_eq!(smoothstep(0.0, 1.0, -0.5), 0.0);
|
||||
/// assert_eq!(smoothstep(0.0, 1.0, 1.5), 1.0);
|
||||
/// assert!((smoothstep(0.0, 1.0, 0.5) - 0.5).abs() < 1e-12);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn smoothstep(edge0: f64, edge1: f64, x: f64) -> f64 {
|
||||
let (t, _) = normalize(x, edge0, edge1);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// Analytic derivative of [`smoothstep`] with respect to `x`.
|
||||
///
|
||||
/// Equals `6t(1 - t) / (edge1 - edge0)` inside the window and `0.0` outside.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_core::smoothing::smoothstep_derivative;
|
||||
///
|
||||
/// // Derivative is zero at both edges (C¹ join with the flat regions).
|
||||
/// assert_eq!(smoothstep_derivative(0.0, 1.0, 0.0), 0.0);
|
||||
/// assert_eq!(smoothstep_derivative(0.0, 1.0, 1.0), 0.0);
|
||||
/// // ... and positive in the interior.
|
||||
/// assert!(smoothstep_derivative(0.0, 1.0, 0.5) > 0.0);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn smoothstep_derivative(edge0: f64, edge1: f64, x: f64) -> f64 {
|
||||
let (t, inv_width) = normalize(x, edge0, edge1);
|
||||
6.0 * t * (1.0 - t) * inv_width
|
||||
}
|
||||
|
||||
/// C² quintic smootherstep on the window `[edge0, edge1]`.
|
||||
///
|
||||
/// Returns `0.0` for `x <= edge0`, `1.0` for `x >= edge1`, and the quintic
|
||||
/// `6t⁵ - 15t⁴ + 10t³` in between. Value, first **and** second derivatives are
|
||||
/// continuous everywhere; both derivatives vanish at the edges. Prefer this
|
||||
/// over [`smoothstep`] when the consuming correlation is itself differentiated
|
||||
/// again (e.g. needs a C¹ Jacobian of a quantity that already contains a blend).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_core::smoothing::smootherstep;
|
||||
///
|
||||
/// assert_eq!(smootherstep(0.0, 1.0, -0.5), 0.0);
|
||||
/// assert_eq!(smootherstep(0.0, 1.0, 1.5), 1.0);
|
||||
/// assert!((smootherstep(0.0, 1.0, 0.5) - 0.5).abs() < 1e-12);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn smootherstep(edge0: f64, edge1: f64, x: f64) -> f64 {
|
||||
let (t, _) = normalize(x, edge0, edge1);
|
||||
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
|
||||
}
|
||||
|
||||
/// Analytic first derivative of [`smootherstep`] with respect to `x`.
|
||||
///
|
||||
/// Equals `30t²(t - 1)² / (edge1 - edge0)` inside the window and `0.0` outside.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_core::smoothing::smootherstep_derivative;
|
||||
///
|
||||
/// assert_eq!(smootherstep_derivative(0.0, 1.0, 0.0), 0.0);
|
||||
/// assert_eq!(smootherstep_derivative(0.0, 1.0, 1.0), 0.0);
|
||||
/// assert!(smootherstep_derivative(0.0, 1.0, 0.5) > 0.0);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn smootherstep_derivative(edge0: f64, edge1: f64, x: f64) -> f64 {
|
||||
let (t, inv_width) = normalize(x, edge0, edge1);
|
||||
30.0 * t * t * (t - 1.0) * (t - 1.0) * inv_width
|
||||
}
|
||||
|
||||
/// Analytic second derivative of [`smootherstep`] with respect to `x`.
|
||||
///
|
||||
/// Equals `60t(2t - 1)(t - 1) / (edge1 - edge0)²` inside the window and `0.0`
|
||||
/// outside. It vanishes at both edges, which is what makes [`smootherstep`] C².
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_core::smoothing::smootherstep_second_derivative;
|
||||
///
|
||||
/// assert_eq!(smootherstep_second_derivative(0.0, 1.0, 0.0), 0.0);
|
||||
/// assert_eq!(smootherstep_second_derivative(0.0, 1.0, 1.0), 0.0);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn smootherstep_second_derivative(edge0: f64, edge1: f64, x: f64) -> f64 {
|
||||
let (t, inv_width) = normalize(x, edge0, edge1);
|
||||
60.0 * t * (2.0 * t - 1.0) * (t - 1.0) * inv_width * inv_width
|
||||
}
|
||||
|
||||
/// Blends two values `a` (active for `x <= edge0`) and `b` (active for
|
||||
/// `x >= edge1`) with a C¹ [`smoothstep`] weight.
|
||||
///
|
||||
/// Equivalent to `a + (b - a) * smoothstep(edge0, edge1, x)`. Use this to
|
||||
/// transition between two correlations (e.g. laminar and turbulent friction
|
||||
/// factor) without a slope discontinuity.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_core::smoothing::cubic_blend;
|
||||
///
|
||||
/// assert_eq!(cubic_blend(10.0, 20.0, -1.0, 0.0, 1.0), 10.0); // below window -> a
|
||||
/// assert_eq!(cubic_blend(10.0, 20.0, 2.0, 0.0, 1.0), 20.0); // above window -> b
|
||||
/// assert!((cubic_blend(10.0, 20.0, 0.5, 0.0, 1.0) - 15.0).abs() < 1e-12);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn cubic_blend(a: f64, b: f64, x: f64, edge0: f64, edge1: f64) -> f64 {
|
||||
a + (b - a) * smoothstep(edge0, edge1, x)
|
||||
}
|
||||
|
||||
/// Derivative of [`cubic_blend`] with respect to `x`.
|
||||
///
|
||||
/// Equals `(b - a) * smoothstep_derivative(edge0, edge1, x)`. The endpoint
|
||||
/// values `a` and `b` are treated as constants; if they themselves depend on
|
||||
/// `x`, add their contributions via the chain rule at the call site.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_core::smoothing::cubic_blend_derivative;
|
||||
///
|
||||
/// // Flat (zero slope) at both edges.
|
||||
/// assert_eq!(cubic_blend_derivative(10.0, 20.0, 0.0, 0.0, 1.0), 0.0);
|
||||
/// assert_eq!(cubic_blend_derivative(10.0, 20.0, 1.0, 0.0, 1.0), 0.0);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn cubic_blend_derivative(a: f64, b: f64, x: f64, edge0: f64, edge1: f64) -> f64 {
|
||||
(b - a) * smoothstep_derivative(edge0, edge1, x)
|
||||
}
|
||||
|
||||
/// Blends two values `a` and `b` with a C² [`smootherstep`] weight.
|
||||
///
|
||||
/// Equivalent to `a + (b - a) * smootherstep(edge0, edge1, x)`. Prefer over
|
||||
/// [`cubic_blend`] when the blended quantity is differentiated again downstream.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_core::smoothing::quintic_blend;
|
||||
///
|
||||
/// assert_eq!(quintic_blend(10.0, 20.0, -1.0, 0.0, 1.0), 10.0);
|
||||
/// assert_eq!(quintic_blend(10.0, 20.0, 2.0, 0.0, 1.0), 20.0);
|
||||
/// assert!((quintic_blend(10.0, 20.0, 0.5, 0.0, 1.0) - 15.0).abs() < 1e-12);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn quintic_blend(a: f64, b: f64, x: f64, edge0: f64, edge1: f64) -> f64 {
|
||||
a + (b - a) * smootherstep(edge0, edge1, x)
|
||||
}
|
||||
|
||||
/// Derivative of [`quintic_blend`] with respect to `x`.
|
||||
///
|
||||
/// Equals `(b - a) * smootherstep_derivative(edge0, edge1, x)`, treating `a`
|
||||
/// and `b` as constants.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_core::smoothing::quintic_blend_derivative;
|
||||
///
|
||||
/// assert_eq!(quintic_blend_derivative(10.0, 20.0, 0.0, 0.0, 1.0), 0.0);
|
||||
/// assert_eq!(quintic_blend_derivative(10.0, 20.0, 1.0, 0.0, 1.0), 0.0);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn quintic_blend_derivative(a: f64, b: f64, x: f64, edge0: f64, edge1: f64) -> f64 {
|
||||
(b - a) * smootherstep_derivative(edge0, edge1, x)
|
||||
}
|
||||
|
||||
/// C^∞ smooth maximum of `a` and `b` with sharpness controlled by `k > 0`.
|
||||
///
|
||||
/// Uses the quadratic (square-root) soft-max
|
||||
/// `0.5 * (a + b + sqrt((a - b)² + k²))`. The result is always `>= max(a, b)`
|
||||
/// and converges to `max(a, b)` as `k -> 0`; the overshoot at `a == b` is
|
||||
/// exactly `k / 2`. Unlike a `LogSumExp` soft-max it never overflows for large
|
||||
/// arguments. A small `k` (relative to the scale of `a - b`) gives a sharp but
|
||||
/// still infinitely differentiable maximum.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_core::smoothing::smooth_max;
|
||||
///
|
||||
/// // Recovers the hard max away from the kink.
|
||||
/// assert!((smooth_max(5.0, 1.0, 1e-3) - 5.0).abs() < 1e-2);
|
||||
/// // Always an upper bound on the true max.
|
||||
/// assert!(smooth_max(2.0, 2.0, 0.1) >= 2.0);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn smooth_max(a: f64, b: f64, k: f64) -> f64 {
|
||||
0.5 * (a + b + ((a - b) * (a - b) + k * k).sqrt())
|
||||
}
|
||||
|
||||
/// C^∞ smooth minimum of `a` and `b` with sharpness controlled by `k > 0`.
|
||||
///
|
||||
/// Uses `0.5 * (a + b - sqrt((a - b)² + k²))`. Always `<= min(a, b)`, converging
|
||||
/// to `min(a, b)` as `k -> 0`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_core::smoothing::smooth_min;
|
||||
///
|
||||
/// assert!((smooth_min(5.0, 1.0, 1e-3) - 1.0).abs() < 1e-2);
|
||||
/// assert!(smooth_min(2.0, 2.0, 0.1) <= 2.0);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn smooth_min(a: f64, b: f64, k: f64) -> f64 {
|
||||
0.5 * (a + b - ((a - b) * (a - b) + k * k).sqrt())
|
||||
}
|
||||
|
||||
/// C^∞ approximation of the absolute value `|x|`.
|
||||
///
|
||||
/// Returns `sqrt(x² + eps²)`. Always `>= |x|`, converging to `|x|` as
|
||||
/// `eps -> 0`; the value at `x = 0` is exactly `eps`. The derivative
|
||||
/// `x / sqrt(x² + eps²)` is smooth through the origin, replacing the
|
||||
/// non-differentiable corner of `|x|` that would otherwise break Newton.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_core::smoothing::smooth_abs;
|
||||
///
|
||||
/// assert!((smooth_abs(0.0, 1e-3) - 1e-3).abs() < 1e-12);
|
||||
/// assert!((smooth_abs(5.0, 1e-3) - 5.0).abs() < 1e-3);
|
||||
/// assert!(smooth_abs(-3.0, 0.1) > 3.0);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn smooth_abs(x: f64, eps: f64) -> f64 {
|
||||
(x * x + eps * eps).sqrt()
|
||||
}
|
||||
|
||||
/// Analytic derivative of [`smooth_abs`] with respect to `x`.
|
||||
///
|
||||
/// Equals `x / sqrt(x² + eps²)`, a smooth approximation of `sign(x)` that
|
||||
/// passes continuously through `0` at the origin.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_core::smoothing::smooth_abs_derivative;
|
||||
///
|
||||
/// assert_eq!(smooth_abs_derivative(0.0, 1e-3), 0.0);
|
||||
/// assert!(smooth_abs_derivative(10.0, 1e-3) > 0.99);
|
||||
/// assert!(smooth_abs_derivative(-10.0, 1e-3) < -0.99);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn smooth_abs_derivative(x: f64, eps: f64) -> f64 {
|
||||
x / (x * x + eps * eps).sqrt()
|
||||
}
|
||||
|
||||
/// C¹ clamp of `x` into `[lo, hi]` with a smooth transition of width `width`
|
||||
/// at each bound.
|
||||
///
|
||||
/// Outside the transition zones the result equals `x` (for `lo + width <= x <=
|
||||
/// hi - width`) or saturates to `lo` / `hi`. Within `width` of a bound it eases
|
||||
/// smoothly via [`smoothstep`], so the first derivative is continuous (unlike
|
||||
/// `f64::clamp`, whose derivative jumps between `0` and `1`). The output is
|
||||
/// always within `[lo, hi]`. For a flat identity region to exist, pass
|
||||
/// `width <= (hi - lo) / 2`; larger widths still produce a bounded, C¹ result.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_core::smoothing::smooth_clamp;
|
||||
///
|
||||
/// // Interior values pass through unchanged.
|
||||
/// assert!((smooth_clamp(0.0, -1.0, 1.0, 0.1) - 0.0).abs() < 1e-9);
|
||||
/// // Values well outside saturate to the bounds.
|
||||
/// assert!((smooth_clamp(5.0, -1.0, 1.0, 0.1) - 1.0).abs() < 1e-9);
|
||||
/// assert!((smooth_clamp(-5.0, -1.0, 1.0, 0.1) + 1.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn smooth_clamp(x: f64, lo: f64, hi: f64, width: f64) -> f64 {
|
||||
// Lower ramp: equals `lo` for x <= lo, eases to identity `x` by x = lo + width.
|
||||
// Stays within [lo, x] and is C¹ at both joins (zero slope at lo, unit slope
|
||||
// at lo + width).
|
||||
let lower = lo + (x - lo) * smoothstep(lo, lo + width, x);
|
||||
// Upper ramp: equals `hi` for v >= hi, eases to identity `v` by v = hi - width.
|
||||
hi + (lower - hi) * smoothstep(hi, hi - width, lower)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Central finite-difference derivative helper.
|
||||
fn fd(f: impl Fn(f64) -> f64, x: f64, h: f64) -> f64 {
|
||||
(f(x + h) - f(x - h)) / (2.0 * h)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smoothstep_endpoints_and_midpoint() {
|
||||
assert_eq!(smoothstep(0.0, 1.0, -10.0), 0.0);
|
||||
assert_eq!(smoothstep(0.0, 1.0, 10.0), 1.0);
|
||||
assert_eq!(smoothstep(0.0, 1.0, 0.0), 0.0);
|
||||
assert_eq!(smoothstep(0.0, 1.0, 1.0), 1.0);
|
||||
assert!((smoothstep(0.0, 1.0, 0.5) - 0.5).abs() < 1e-12);
|
||||
// Symmetry: f(t) + f(1 - t) == 1
|
||||
for t in [0.1, 0.25, 0.4] {
|
||||
assert!((smoothstep(0.0, 1.0, t) + smoothstep(0.0, 1.0, 1.0 - t) - 1.0).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smoothstep_monotonic() {
|
||||
let mut prev = -1.0;
|
||||
for i in 0..=100 {
|
||||
let x = i as f64 / 100.0;
|
||||
let v = smoothstep(0.0, 1.0, x);
|
||||
assert!(v >= prev - 1e-12, "smoothstep not monotonic at {}", x);
|
||||
prev = v;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smoothstep_c1_at_edges() {
|
||||
// The defining property: zero slope at both edges => C¹ join with flats.
|
||||
assert_eq!(smoothstep_derivative(2.0, 5.0, 2.0), 0.0);
|
||||
assert_eq!(smoothstep_derivative(2.0, 5.0, 5.0), 0.0);
|
||||
assert_eq!(smoothstep_derivative(2.0, 5.0, 1.0), 0.0);
|
||||
assert_eq!(smoothstep_derivative(2.0, 5.0, 6.0), 0.0);
|
||||
assert!(smoothstep_derivative(2.0, 5.0, 3.5) > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smoothstep_derivative_matches_fd() {
|
||||
let (e0, e1) = (2.0, 5.0);
|
||||
for x in [2.3, 3.0, 3.5, 4.0, 4.7] {
|
||||
let analytic = smoothstep_derivative(e0, e1, x);
|
||||
let numeric = fd(|x| smoothstep(e0, e1, x), x, 1e-6);
|
||||
assert!(
|
||||
(analytic - numeric).abs() < 1e-5,
|
||||
"smoothstep deriv mismatch at {}: {} vs {}",
|
||||
x,
|
||||
analytic,
|
||||
numeric
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smootherstep_endpoints_and_midpoint() {
|
||||
assert_eq!(smootherstep(0.0, 1.0, -10.0), 0.0);
|
||||
assert_eq!(smootherstep(0.0, 1.0, 10.0), 1.0);
|
||||
assert!((smootherstep(0.0, 1.0, 0.5) - 0.5).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smootherstep_first_derivative_matches_fd() {
|
||||
let (e0, e1) = (-1.0, 3.0);
|
||||
for x in [-0.5, 0.0, 1.0, 2.0, 2.5] {
|
||||
let analytic = smootherstep_derivative(e0, e1, x);
|
||||
let numeric = fd(|x| smootherstep(e0, e1, x), x, 1e-6);
|
||||
assert!(
|
||||
(analytic - numeric).abs() < 1e-5,
|
||||
"smootherstep deriv mismatch at {}: {} vs {}",
|
||||
x,
|
||||
analytic,
|
||||
numeric
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smootherstep_second_derivative_matches_fd_and_zero_at_edges() {
|
||||
let (e0, e1) = (-1.0, 3.0);
|
||||
// C²: both first and second derivatives vanish at the edges.
|
||||
assert_eq!(smootherstep_derivative(e0, e1, e0), 0.0);
|
||||
assert_eq!(smootherstep_derivative(e0, e1, e1), 0.0);
|
||||
assert_eq!(smootherstep_second_derivative(e0, e1, e0), 0.0);
|
||||
assert_eq!(smootherstep_second_derivative(e0, e1, e1), 0.0);
|
||||
// Interior: second derivative matches finite difference of first derivative.
|
||||
for x in [-0.5, 0.0, 1.0, 2.0, 2.5] {
|
||||
let analytic = smootherstep_second_derivative(e0, e1, x);
|
||||
let numeric = fd(|x| smootherstep_derivative(e0, e1, x), x, 1e-5);
|
||||
assert!(
|
||||
(analytic - numeric).abs() < 1e-3,
|
||||
"smootherstep 2nd deriv mismatch at {}: {} vs {}",
|
||||
x,
|
||||
analytic,
|
||||
numeric
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cubic_blend_bounds_and_derivative() {
|
||||
assert_eq!(cubic_blend(10.0, 20.0, -1.0, 0.0, 1.0), 10.0);
|
||||
assert_eq!(cubic_blend(10.0, 20.0, 2.0, 0.0, 1.0), 20.0);
|
||||
assert!((cubic_blend(10.0, 20.0, 0.5, 0.0, 1.0) - 15.0).abs() < 1e-12);
|
||||
// Derivative vs finite difference.
|
||||
for x in [0.2, 0.5, 0.8] {
|
||||
let analytic = cubic_blend_derivative(10.0, 20.0, x, 0.0, 1.0);
|
||||
let numeric = fd(|x| cubic_blend(10.0, 20.0, x, 0.0, 1.0), x, 1e-6);
|
||||
assert!((analytic - numeric).abs() < 1e-4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quintic_blend_bounds_and_derivative() {
|
||||
assert_eq!(quintic_blend(10.0, 20.0, -1.0, 0.0, 1.0), 10.0);
|
||||
assert_eq!(quintic_blend(10.0, 20.0, 2.0, 0.0, 1.0), 20.0);
|
||||
for x in [0.2, 0.5, 0.8] {
|
||||
let analytic = quintic_blend_derivative(10.0, 20.0, x, 0.0, 1.0);
|
||||
let numeric = fd(|x| quintic_blend(10.0, 20.0, x, 0.0, 1.0), x, 1e-6);
|
||||
assert!((analytic - numeric).abs() < 1e-4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reynolds_transition_is_c1() {
|
||||
// Realistic use case: blend laminar 64/Re into a turbulent f over
|
||||
// [2300, 4000]. The blended function must be continuous in value AND
|
||||
// slope across the whole range (no PR#563-style jump).
|
||||
let f_turb = 0.04;
|
||||
let blend = |re: f64| cubic_blend(64.0 / re.max(1.0), f_turb, re, 2300.0, 4000.0);
|
||||
let mut prev = blend(2200.0);
|
||||
let mut prev_slope = fd(blend, 2200.0, 1.0);
|
||||
for re_i in (2300..=4000).step_by(50) {
|
||||
let re = re_i as f64;
|
||||
let v = blend(re);
|
||||
let slope = fd(blend, re, 1.0);
|
||||
// Value continuity (no big jumps between samples).
|
||||
assert!((v - prev).abs() < 0.05, "value jump near Re={}", re);
|
||||
// Slope continuity (no big slope jumps between samples).
|
||||
assert!(
|
||||
(slope - prev_slope).abs() < 5e-3,
|
||||
"slope jump near Re={}: {} vs {}",
|
||||
re,
|
||||
slope,
|
||||
prev_slope
|
||||
);
|
||||
prev = v;
|
||||
prev_slope = slope;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smooth_max_min_properties() {
|
||||
// Upper / lower bound property.
|
||||
for (a, b) in [(5.0, 1.0), (-2.0, 3.0), (0.0, 0.0)] {
|
||||
assert!(smooth_max(a, b, 0.1) >= a.max(b) - 1e-12);
|
||||
assert!(smooth_min(a, b, 0.1) <= a.min(b) + 1e-12);
|
||||
}
|
||||
// Converges to hard max/min as k -> 0.
|
||||
assert!((smooth_max(5.0, 1.0, 1e-4) - 5.0).abs() < 1e-3);
|
||||
assert!((smooth_min(5.0, 1.0, 1e-4) - 1.0).abs() < 1e-3);
|
||||
// Symmetry.
|
||||
assert!((smooth_max(2.0, 7.0, 0.5) - smooth_max(7.0, 2.0, 0.5)).abs() < 1e-12);
|
||||
// Overshoot at a == b is exactly k/2.
|
||||
assert!((smooth_max(3.0, 3.0, 0.2) - (3.0 + 0.1)).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smooth_max_derivative_matches_fd() {
|
||||
// d/da smooth_max = 0.5 * (1 + (a-b)/sqrt((a-b)^2 + k^2))
|
||||
let (b, k): (f64, f64) = (1.0, 0.3);
|
||||
for a in [-1.0, 0.5, 1.0, 1.5, 3.0] {
|
||||
let analytic = 0.5 * (1.0 + (a - b) / ((a - b) * (a - b) + k * k).sqrt());
|
||||
let numeric = fd(|a| smooth_max(a, b, k), a, 1e-6);
|
||||
assert!((analytic - numeric).abs() < 1e-5, "at a={}", a);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smooth_abs_properties_and_derivative() {
|
||||
assert!((smooth_abs(0.0, 1e-3) - 1e-3).abs() < 1e-12);
|
||||
assert!((smooth_abs(5.0, 1e-3) - 5.0).abs() < 1e-3);
|
||||
// Always an upper bound on |x|.
|
||||
for x in [-4.0, -0.1, 0.0, 0.1, 4.0] {
|
||||
assert!(smooth_abs(x, 0.2) >= x.abs() - 1e-12);
|
||||
}
|
||||
// Derivative passes smoothly through 0.
|
||||
assert_eq!(smooth_abs_derivative(0.0, 1e-3), 0.0);
|
||||
for x in [-2.0, -0.5, 0.5, 2.0] {
|
||||
let numeric = fd(|x| smooth_abs(x, 0.1), x, 1e-6);
|
||||
assert!(
|
||||
(smooth_abs_derivative(x, 0.1) - numeric).abs() < 1e-5,
|
||||
"at x={}",
|
||||
x
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smooth_clamp_saturates_and_passes_interior() {
|
||||
// Interior pass-through.
|
||||
assert!((smooth_clamp(0.0, -1.0, 1.0, 0.1) - 0.0).abs() < 1e-9);
|
||||
assert!((smooth_clamp(0.5, -1.0, 1.0, 0.1) - 0.5).abs() < 1e-9);
|
||||
// Saturation outside.
|
||||
assert!((smooth_clamp(5.0, -1.0, 1.0, 0.1) - 1.0).abs() < 1e-9);
|
||||
assert!((smooth_clamp(-5.0, -1.0, 1.0, 0.1) + 1.0).abs() < 1e-9);
|
||||
// Output stays within [lo, hi].
|
||||
for i in -200..=200 {
|
||||
let x = i as f64 / 50.0;
|
||||
let y = smooth_clamp(x, -1.0, 1.0, 0.1);
|
||||
assert!(
|
||||
y >= -1.0 - 1e-9 && y <= 1.0 + 1e-9,
|
||||
"out of bounds at x={}: {}",
|
||||
x,
|
||||
y
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_zero_width_window_degenerates_to_step() {
|
||||
// Degenerate window must not panic and behaves as a hard step.
|
||||
assert_eq!(smoothstep(3.0, 3.0, 2.9), 0.0);
|
||||
assert_eq!(smoothstep(3.0, 3.0, 3.1), 1.0);
|
||||
assert_eq!(smoothstep_derivative(3.0, 3.0, 3.1), 0.0);
|
||||
assert_eq!(smootherstep(3.0, 3.0, 3.1), 1.0);
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,9 @@
|
||||
//! let _invalid = p + t; // ERROR: mismatched types
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::ops::{Add, Div, Mul, Sub};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Pressure in Pascals (Pa).
|
||||
///
|
||||
@@ -1080,7 +1080,9 @@ impl From<f64> for ThermalConductance {
|
||||
/// let same: CircuitId = "primary".into();
|
||||
/// assert_eq!(from_str, same); // Deterministic hashing
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize)]
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize,
|
||||
)]
|
||||
pub struct CircuitId(pub u16);
|
||||
|
||||
impl CircuitId {
|
||||
|
||||
Reference in New Issue
Block a user