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:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

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

View File

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

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

View File

@@ -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 {