Complete Story 0.2 phantom-gradient regularization standard.
Add analytic derivatives and C1 tests for smooth_max/min/clamp, document epsilon/width guidance, and declare flow_regularization as the HX reference pattern without changing component physics. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
//! C¹/C∞ flow regularization for zero-flow-safe heat-exchanger residuals.
|
||||
//!
|
||||
//! **Reference specialized pattern** (Story 0.2): keep these HX helpers here;
|
||||
//! the canonical general toolkit is [`entropyk_core::smoothing`]. See
|
||||
//! `docs/components/phantom-gradient-regularization.md`.
|
||||
//!
|
||||
//! Zero mass flow is a **valid** operating state (circuit staging, isolation,
|
||||
//! Newton trial steps). Hard branches `if |m| < ε { Q = 0 }` create Jacobian
|
||||
//! discontinuities and can leave energy residuals under-ranked.
|
||||
|
||||
@@ -24,6 +24,28 @@
|
||||
//! - [`smooth_abs`] — C^∞ approximation of `|x|`.
|
||||
//! - [`smooth_clamp`] — C¹ clamp into `[lo, hi]`.
|
||||
//!
|
||||
//! Analytic first derivatives are exported for every Newton-facing primitive
|
||||
//! ([`smooth_max_derivative`], [`smooth_min_derivative`], [`smooth_abs_derivative`],
|
||||
//! [`smooth_clamp_derivative`]) so component Jacobians stay exact.
|
||||
//!
|
||||
//! # ε / width selection (physical scale of the blend)
|
||||
//!
|
||||
//! | Primitive | Parameter | Meaning | Rule of thumb |
|
||||
//! |---|---|---|---|
|
||||
//! | [`smooth_abs`] | `eps` | value at `x = 0`; blend half-width | ~1% of characteristic `\|x\|`, or smaller if the kink is far from the operating region |
|
||||
//! | [`smooth_max`] / [`smooth_min`] | `k` | overshoot at equality = `k / 2` | `k` much smaller than `\|a - b\|` in the region you care about; never so large it shifts equilibrium |
|
||||
//! | [`smooth_clamp`] | `width` | transition length at each bound | `width <= (hi - lo) / 2`; keep the band physically small (e.g. opening `1e-3`..`1e-2` on `[0, 1]`) |
|
||||
//!
|
||||
//! Heat-exchanger mass-flow activity (`flow_activity`, `m_eps`) lives in
|
||||
//! `entropyk-components` as a specialized reference pattern — see
|
||||
//! `docs/components/phantom-gradient-regularization.md`.
|
||||
//!
|
||||
//! # Degenerate parameters (never panic)
|
||||
//!
|
||||
//! - `k <= 0` → hard [`f64::max`] / [`f64::min`]
|
||||
//! - `eps <= 0` → hard `|x|` / `sign(x)`
|
||||
//! - `width <= 0` or inverted `[lo, hi]` → hard [`f64::clamp`] (bounds swapped if needed)
|
||||
//!
|
||||
//! # Example: smoothing a friction-factor transition
|
||||
//!
|
||||
//! ```rust
|
||||
@@ -250,6 +272,8 @@ pub fn quintic_blend_derivative(a: f64, b: f64, x: f64, edge0: f64, edge1: f64)
|
||||
/// arguments. A small `k` (relative to the scale of `a - b`) gives a sharp but
|
||||
/// still infinitely differentiable maximum.
|
||||
///
|
||||
/// When `k <= 0`, returns the hard `a.max(b)` (no panic).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
@@ -259,17 +283,51 @@ pub fn quintic_blend_derivative(a: f64, b: f64, x: f64, edge0: f64, edge1: f64)
|
||||
/// 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);
|
||||
/// assert_eq!(smooth_max(5.0, 1.0, 0.0), 5.0);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn smooth_max(a: f64, b: f64, k: f64) -> f64 {
|
||||
if k <= 0.0 {
|
||||
return a.max(b);
|
||||
}
|
||||
0.5 * (a + b + ((a - b) * (a - b) + k * k).sqrt())
|
||||
}
|
||||
|
||||
/// Analytic derivative of [`smooth_max`] with respect to `a` (`b`, `k` fixed).
|
||||
///
|
||||
/// Equals `0.5 * (1 + (a - b) / sqrt((a - b)² + k²))` for `k > 0`. When
|
||||
/// `k <= 0`, returns the hard-max subgradient pick: `1` if `a > b`, `0` if
|
||||
/// `a < b`, and `0.5` if `a == b`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_core::smoothing::smooth_max_derivative;
|
||||
///
|
||||
/// assert!((smooth_max_derivative(5.0, 1.0, 1e-3) - 1.0).abs() < 1e-2);
|
||||
/// assert!((smooth_max_derivative(1.0, 5.0, 1e-3)).abs() < 1e-2);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn smooth_max_derivative(a: f64, b: f64, k: f64) -> f64 {
|
||||
if k <= 0.0 {
|
||||
return if a > b {
|
||||
1.0
|
||||
} else if a < b {
|
||||
0.0
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
}
|
||||
0.5 * (1.0 + (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`.
|
||||
///
|
||||
/// When `k <= 0`, returns the hard `a.min(b)` (no panic).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
@@ -277,12 +335,44 @@ pub fn smooth_max(a: f64, b: f64, k: f64) -> f64 {
|
||||
///
|
||||
/// 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);
|
||||
/// assert_eq!(smooth_min(5.0, 1.0, -1.0), 1.0);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn smooth_min(a: f64, b: f64, k: f64) -> f64 {
|
||||
if k <= 0.0 {
|
||||
return a.min(b);
|
||||
}
|
||||
0.5 * (a + b - ((a - b) * (a - b) + k * k).sqrt())
|
||||
}
|
||||
|
||||
/// Analytic derivative of [`smooth_min`] with respect to `a` (`b`, `k` fixed).
|
||||
///
|
||||
/// Equals `0.5 * (1 - (a - b) / sqrt((a - b)² + k²))` for `k > 0`. When
|
||||
/// `k <= 0`, returns the hard-min subgradient pick: `1` if `a < b`, `0` if
|
||||
/// `a > b`, and `0.5` if `a == b`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_core::smoothing::smooth_min_derivative;
|
||||
///
|
||||
/// assert!((smooth_min_derivative(1.0, 5.0, 1e-3) - 1.0).abs() < 1e-2);
|
||||
/// assert!((smooth_min_derivative(5.0, 1.0, 1e-3)).abs() < 1e-2);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn smooth_min_derivative(a: f64, b: f64, k: f64) -> f64 {
|
||||
if k <= 0.0 {
|
||||
return if a < b {
|
||||
1.0
|
||||
} else if a > b {
|
||||
0.0
|
||||
} else {
|
||||
0.5
|
||||
};
|
||||
}
|
||||
0.5 * (1.0 - (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
|
||||
@@ -290,6 +380,8 @@ pub fn smooth_min(a: f64, b: f64, k: f64) -> f64 {
|
||||
/// `x / sqrt(x² + eps²)` is smooth through the origin, replacing the
|
||||
/// non-differentiable corner of `|x|` that would otherwise break Newton.
|
||||
///
|
||||
/// When `eps <= 0`, returns the hard `|x|` (no panic).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
@@ -298,16 +390,21 @@ pub fn smooth_min(a: f64, b: f64, k: f64) -> f64 {
|
||||
/// 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);
|
||||
/// assert_eq!(smooth_abs(-3.0, 0.0), 3.0);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn smooth_abs(x: f64, eps: f64) -> f64 {
|
||||
if eps <= 0.0 {
|
||||
return x.abs();
|
||||
}
|
||||
(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.
|
||||
/// passes continuously through `0` at the origin. When `eps <= 0`, returns
|
||||
/// hard `sign(x)` with `0.0` at the origin.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
@@ -320,6 +417,15 @@ pub fn smooth_abs(x: f64, eps: f64) -> f64 {
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn smooth_abs_derivative(x: f64, eps: f64) -> f64 {
|
||||
if eps <= 0.0 {
|
||||
return if x > 0.0 {
|
||||
1.0
|
||||
} else if x < 0.0 {
|
||||
-1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
x / (x * x + eps * eps).sqrt()
|
||||
}
|
||||
|
||||
@@ -333,6 +439,10 @@ pub fn smooth_abs_derivative(x: f64, eps: f64) -> f64 {
|
||||
/// always within `[lo, hi]`. For a flat identity region to exist, pass
|
||||
/// `width <= (hi - lo) / 2`; larger widths still produce a bounded, C¹ result.
|
||||
///
|
||||
/// Degenerate cases (never panic):
|
||||
/// - `hi < lo` → bounds are swapped
|
||||
/// - `width <= 0` → hard `x.clamp(lo, hi)`
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
@@ -343,9 +453,14 @@ pub fn smooth_abs_derivative(x: f64, eps: f64) -> f64 {
|
||||
/// // 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);
|
||||
/// assert_eq!(smooth_clamp(5.0, -1.0, 1.0, 0.0), 1.0);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn smooth_clamp(x: f64, lo: f64, hi: f64, width: f64) -> f64 {
|
||||
let (lo, hi) = if hi < lo { (hi, lo) } else { (lo, hi) };
|
||||
if width <= 0.0 {
|
||||
return x.clamp(lo, hi);
|
||||
}
|
||||
// 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).
|
||||
@@ -354,6 +469,36 @@ pub fn smooth_clamp(x: f64, lo: f64, hi: f64, width: f64) -> f64 {
|
||||
hi + (lower - hi) * smoothstep(hi, hi - width, lower)
|
||||
}
|
||||
|
||||
/// Analytic derivative of [`smooth_clamp`] with respect to `x`.
|
||||
///
|
||||
/// Chain-rules the lower then upper [`smoothstep`] ramps. Outside the
|
||||
/// transition bands the slope is `0` (saturated) or `1` (identity interior).
|
||||
/// Degenerate parameters match [`smooth_clamp`]: hard-clamp derivative is `1`
|
||||
/// on the open interior and `0` elsewhere (including the kink points).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_core::smoothing::smooth_clamp_derivative;
|
||||
///
|
||||
/// // Flat exterior, unit slope deep interior.
|
||||
/// assert!((smooth_clamp_derivative(5.0, -1.0, 1.0, 0.1)).abs() < 1e-9);
|
||||
/// assert!((smooth_clamp_derivative(0.0, -1.0, 1.0, 0.1) - 1.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn smooth_clamp_derivative(x: f64, lo: f64, hi: f64, width: f64) -> f64 {
|
||||
let (lo, hi) = if hi < lo { (hi, lo) } else { (lo, hi) };
|
||||
if width <= 0.0 {
|
||||
return if x > lo && x < hi { 1.0 } else { 0.0 };
|
||||
}
|
||||
let s_lo = smoothstep(lo, lo + width, x);
|
||||
let d_lower = s_lo + (x - lo) * smoothstep_derivative(lo, lo + width, x);
|
||||
let lower = lo + (x - lo) * s_lo;
|
||||
let s_hi = smoothstep(hi, hi - width, lower);
|
||||
let ds_hi = smoothstep_derivative(hi, hi - width, lower);
|
||||
d_lower * (s_hi + (lower - hi) * ds_hi)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -528,15 +673,84 @@ mod tests {
|
||||
|
||||
#[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 analytic = smooth_max_derivative(a, b, k);
|
||||
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_min_derivative_matches_fd() {
|
||||
let (b, k): (f64, f64) = (1.0, 0.3);
|
||||
for a in [-1.0, 0.5, 1.0, 1.5, 3.0] {
|
||||
let analytic = smooth_min_derivative(a, b, k);
|
||||
let numeric = fd(|a| smooth_min(a, b, k), a, 1e-6);
|
||||
assert!((analytic - numeric).abs() < 1e-5, "at a={}", a);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smooth_clamp_derivative_matches_fd() {
|
||||
let (lo, hi, width) = (-1.0, 1.0, 0.1);
|
||||
// Exterior, both ramps, deep interior.
|
||||
for x in [-5.0, -1.05, -0.95, -0.5, 0.0, 0.5, 0.95, 1.05, 5.0] {
|
||||
let analytic = smooth_clamp_derivative(x, lo, hi, width);
|
||||
let numeric = fd(|x| smooth_clamp(x, lo, hi, width), x, 1e-6);
|
||||
assert!(
|
||||
(analytic - numeric).abs() < 1e-5,
|
||||
"smooth_clamp deriv mismatch at {}: {} vs {}",
|
||||
x,
|
||||
analytic,
|
||||
numeric
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smooth_clamp_c1_at_junctions() {
|
||||
// Slope continuity across the four joins: lo, lo+width, hi-width, hi.
|
||||
let (lo, hi, width) = (-1.0, 1.0, 0.2);
|
||||
let junctions = [lo, lo + width, hi - width, hi];
|
||||
let h = 1e-5;
|
||||
for j in junctions {
|
||||
let slope_left = fd(|x| smooth_clamp(x, lo, hi, width), j - h, h);
|
||||
let slope_right = fd(|x| smooth_clamp(x, lo, hi, width), j + h, h);
|
||||
let analytic = smooth_clamp_derivative(j, lo, hi, width);
|
||||
assert!(
|
||||
(slope_left - slope_right).abs() < 5e-3,
|
||||
"slope jump at {}: left={} right={}",
|
||||
j,
|
||||
slope_left,
|
||||
slope_right
|
||||
);
|
||||
assert!(
|
||||
(analytic - 0.5 * (slope_left + slope_right)).abs() < 5e-3,
|
||||
"analytic slope mismatch at {}: analytic={} fd_avg={}",
|
||||
j,
|
||||
analytic,
|
||||
0.5 * (slope_left + slope_right)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_degenerate_parameters_do_not_panic() {
|
||||
assert_eq!(smooth_max(3.0, 1.0, 0.0), 3.0);
|
||||
assert_eq!(smooth_max(3.0, 1.0, -0.5), 3.0);
|
||||
assert_eq!(smooth_min(3.0, 1.0, -1.0), 1.0);
|
||||
assert_eq!(smooth_abs(-2.0, 0.0), 2.0);
|
||||
assert_eq!(smooth_abs_derivative(-2.0, 0.0), -1.0);
|
||||
assert_eq!(smooth_clamp(5.0, -1.0, 1.0, 0.0), 1.0);
|
||||
assert_eq!(smooth_clamp_derivative(0.0, -1.0, 1.0, 0.0), 1.0);
|
||||
assert_eq!(smooth_clamp_derivative(5.0, -1.0, 1.0, 0.0), 0.0);
|
||||
// Inverted bounds swap.
|
||||
assert!((smooth_clamp(0.0, 1.0, -1.0, 0.1) - 0.0).abs() < 1e-9);
|
||||
assert_eq!(smooth_max_derivative(3.0, 1.0, 0.0), 1.0);
|
||||
assert_eq!(smooth_min_derivative(3.0, 1.0, 0.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_smooth_abs_properties_and_derivative() {
|
||||
assert!((smooth_abs(0.0, 1e-3) - 1e-3).abs() < 1e-12);
|
||||
|
||||
Reference in New Issue
Block a user