Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Some checks failed
CI / check (push) Has been cancelled

Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 16:35:31 +02:00
parent 88620790d6
commit 5bd180b5b8
1363 changed files with 101041 additions and 58547 deletions

View File

@@ -19,6 +19,9 @@ entropyk-core = { path = "../core" }
# Fluid properties backend (Story 5.1 - FluidBackend integration)
entropyk-fluids = { path = "../fluids" }
# Shared solver-core types (Story 1.3 - recoverable DomainViolation)
entropyk-solver-core = { path = "../solver-core" }
# Error handling
thiserror = "1.0"

View File

@@ -483,8 +483,7 @@ impl Component for AirSource {
row += 1;
}
if self.impose_temperature {
residuals[row] =
self.outlet.enthalpy().to_joules_per_kg() - self.h_set_jkg;
residuals[row] = self.outlet.enthalpy().to_joules_per_kg() - self.h_set_jkg;
row += 1;
}
}

View File

@@ -395,8 +395,7 @@ impl Component for BrineSource {
row += 1;
}
if self.impose_temperature {
residuals[row] =
self.outlet.enthalpy().to_joules_per_kg() - self.h_set_jkg;
residuals[row] = self.outlet.enthalpy().to_joules_per_kg() - self.h_set_jkg;
row += 1;
}
}

View File

@@ -195,7 +195,9 @@ impl CentrifugalCompressor<Connected> {
/// Sets VFD speed [rpm].
pub fn set_speed_rpm(&mut self, rpm: f64) -> Result<(), ComponentError> {
if rpm <= 0.0 {
return Err(ComponentError::InvalidState("speed must be positive".into()));
return Err(ComponentError::InvalidState(
"speed must be positive".into(),
));
}
self.speed_rpm = rpm;
Ok(())
@@ -289,7 +291,10 @@ impl Component for CentrifugalCompressor<Connected> {
fn energy_transfers(&self, state: &StateSlice) -> Option<(Power, Power)> {
let m = state.first().copied().unwrap_or(0.0);
let vol = m.abs() / 20.0;
let power = self.rate(280.0, 20.0, vol).map(|(_, _, p)| p).unwrap_or(0.0);
let power = self
.rate(280.0, 20.0, vol)
.map(|(_, _, p)| p)
.unwrap_or(0.0);
Some((Power::from_watts(0.0), Power::from_watts(-power)))
}
}
@@ -328,19 +333,20 @@ mod tests {
outlet,
)
.unwrap();
let connected = c.connect(
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(3.0),
Enthalpy::from_joules_per_kg(400_000.0),
),
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(10.0),
Enthalpy::from_joules_per_kg(430_000.0),
),
)
.unwrap();
let connected = c
.connect(
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(3.0),
Enthalpy::from_joules_per_kg(400_000.0),
),
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(10.0),
Enthalpy::from_joules_per_kg(430_000.0),
),
)
.unwrap();
let (_, _, p_low) = connected.rate(280.0, 20.0, 0.05).unwrap();
let mut fast = connected;
fast.set_speed_rpm(12_000.0).unwrap();

View File

@@ -733,7 +733,7 @@ impl Compressor<Connected> {
self.port_suction.enthalpy(),
)
.map_err(|e| {
ComponentError::CalculationFailed(format!("Failed to compute suction state: {}", e))
ComponentError::from_fluid_error_context("Failed to compute suction state", e)
})
}
@@ -749,10 +749,7 @@ impl Compressor<Connected> {
self.port_discharge.enthalpy(),
)
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"Failed to compute discharge state: {}",
e
))
ComponentError::from_fluid_error_context("Failed to compute discharge state", e)
})
}
@@ -2227,7 +2224,7 @@ mod tests {
assert!(result.is_ok(), "jacobian error: {:?}", result);
// Should have at least some entries
assert!(jacobian.len() > 0);
assert!(!jacobian.is_empty());
}
#[test]

View File

@@ -430,8 +430,7 @@ impl ExpansionValve<Connected> {
opening: self.opening.unwrap_or(1.0),
p_bulb_pa: self.bulb_pressure_pa,
};
valve_mass_flow(&self.flow_model, &input)
.map_err(ComponentError::InvalidState)
valve_mass_flow(&self.flow_model, &input).map_err(ComponentError::InvalidState)
}
/// Returns both ports as an array for solver topology.
@@ -1659,9 +1658,9 @@ mod tests {
#[test]
fn test_phase_region_enum() {
assert!(PhaseRegion::Subcooled.is_two_phase() == false);
assert!(PhaseRegion::TwoPhase.is_two_phase() == true);
assert!(PhaseRegion::Superheated.is_two_phase() == false);
assert!(!PhaseRegion::Subcooled.is_two_phase());
assert!(PhaseRegion::TwoPhase.is_two_phase());
assert!(!PhaseRegion::Superheated.is_two_phase());
}
#[test]

View File

@@ -462,10 +462,9 @@ impl Fan<Connected> {
/// Drive-chain efficiency η_VFD(N*) × η_motor(N*) at the current speed ratio.
pub fn drive_chain_efficiency(&self) -> f64 {
let n = self.speed_ratio.clamp(0.0, 1.0);
let eta_vfd = (self.vfd_eff_coeffs[0]
+ self.vfd_eff_coeffs[1] * n
+ self.vfd_eff_coeffs[2] * n * n)
.clamp(0.05, 1.0);
let eta_vfd =
(self.vfd_eff_coeffs[0] + self.vfd_eff_coeffs[1] * n + self.vfd_eff_coeffs[2] * n * n)
.clamp(0.05, 1.0);
let eta_motor = (self.motor_eff_coeffs[0]
+ self.motor_eff_coeffs[1] * n
+ self.motor_eff_coeffs[2] * n * n)

View File

@@ -602,7 +602,7 @@ impl Component for FlowMerger {
.map(|&(_, p, h)| (p, h))
.collect();
}
if external_edge_state_indices.len() >= n + 1 {
if external_edge_state_indices.len() > n {
self.outlet_idx = Some((
external_edge_state_indices[n].1,
external_edge_state_indices[n].2,

View File

@@ -32,7 +32,7 @@ use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::{Calib, Enthalpy, MassFlow, Power, Pressure};
use entropyk_core::{Calib, Enthalpy, MassFlow, Power, Pressure, Temperature};
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property, Quality};
use std::cell::Cell;
use std::sync::Arc;
@@ -49,6 +49,9 @@ pub struct BphxCondenser {
last_subcooling: Cell<Option<f64>>,
last_outlet_quality: Cell<Option<f64>>,
target_subcooling: f64,
/// When true, emit an outlet-closure residual pinning `h_out` to
/// `h(P, Tsat(P) target_subcooling)`.
emergent_pressure: bool,
outlet_pressure_idx: Option<usize>,
outlet_enthalpy_idx: Option<usize>,
}
@@ -59,6 +62,7 @@ impl std::fmt::Debug for BphxCondenser {
.field("ua", &self.inner.ua())
.field("geometry", &self.inner.geometry())
.field("target_subcooling", &self.target_subcooling)
.field("emergent_pressure", &self.emergent_pressure)
.field("refrigerant_id", &self.refrigerant_id)
.field("secondary_fluid_id", &self.secondary_fluid_id)
.field("has_fluid_backend", &self.fluid_backend.is_some())
@@ -98,11 +102,25 @@ impl BphxCondenser {
last_subcooling: Cell::new(None),
last_outlet_quality: Cell::new(None),
target_subcooling: Self::DEFAULT_TARGET_SUBCOOLING,
emergent_pressure: false,
outlet_pressure_idx: None,
outlet_enthalpy_idx: None,
}
}
/// Enables emergent-pressure mode: +1 residual pinning the refrigerant
/// outlet enthalpy to `h(P, Tsat(P) target_subcooling)`.
pub fn with_emergent_pressure(mut self, subcooling_k: f64) -> Self {
self.emergent_pressure = true;
self.target_subcooling = subcooling_k.max(0.0);
self
}
/// Returns whether emergent-pressure outlet closure is enabled.
pub fn emergent_pressure(&self) -> bool {
self.emergent_pressure
}
/// Sets the refrigerant fluid identifier.
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
self.refrigerant_id = fluid.into();
@@ -290,6 +308,41 @@ impl BphxCondenser {
Some(t_sat - t_out)
}
/// Target outlet enthalpy `h(P, Tsat(P) target_subcooling)` [J/kg].
fn h_subcool_at_p(
&self,
backend: &dyn FluidBackend,
fluid: FluidId,
p_pa: f64,
) -> Result<f64, ComponentError> {
let t_sat_k = backend
.property(
fluid.clone(),
Property::Temperature,
FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(0.0)),
)
.map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
if self.target_subcooling <= 1e-9 {
return backend
.property(
fluid,
Property::Enthalpy,
FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(0.0)),
)
.map_err(|e| ComponentError::CalculationFailed(e.to_string()));
}
backend
.property(
fluid,
Property::Enthalpy,
FluidState::PressureTemperature(
Pressure::from_pascals(p_pa),
Temperature::from_kelvin(t_sat_k - self.target_subcooling),
),
)
.map_err(|e| ComponentError::CalculationFailed(e.to_string()))
}
/// Computes the heat transfer coefficient using the configured correlation.
#[allow(clippy::too_many_arguments)]
pub fn compute_htc(
@@ -359,7 +412,7 @@ impl BphxCondenser {
impl Component for BphxCondenser {
fn n_equations(&self) -> usize {
self.inner.n_equations()
self.inner.n_equations() + usize::from(self.emergent_pressure)
}
fn compute_residuals(
@@ -367,6 +420,13 @@ impl Component for BphxCondenser {
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
let n = self.n_equations();
if residuals.len() < n {
return Err(ComponentError::InvalidResidualDimensions {
expected: n,
actual: residuals.len(),
});
}
self.inner.compute_residuals(state, residuals)?;
if let (Some(p_idx), Some(h_idx)) = (self.outlet_pressure_idx, self.outlet_enthalpy_idx) {
@@ -381,7 +441,27 @@ impl Component for BphxCondenser {
if let Some(q) = self.compute_quality(h_out, p_pa) {
self.last_outlet_quality.set(Some(q));
}
if self.emergent_pressure {
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::CalculationFailed(
"BphxCondenser: FluidBackend required for emergent outlet closure"
.into(),
)
})?;
let fluid = FluidId::new(&self.refrigerant_id);
let h_target = self.h_subcool_at_p(backend.as_ref(), fluid, p_pa)?;
residuals[self.inner.n_equations()] = h_out - h_target;
}
} else if self.emergent_pressure {
return Err(ComponentError::InvalidState(
"BphxCondenser: emergent outlet closure needs live outlet P/h indices".into(),
));
}
} else if self.emergent_pressure {
return Err(ComponentError::InvalidState(
"BphxCondenser: emergent outlet closure needs live outlet P/h indices".into(),
));
}
Ok(())
@@ -392,7 +472,32 @@ impl Component for BphxCondenser {
state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
self.inner.jacobian_entries(state, jacobian)
self.inner.jacobian_entries(state, jacobian)?;
if self.emergent_pressure {
let (Some(p_idx), Some(h_idx)) = (self.outlet_pressure_idx, self.outlet_enthalpy_idx)
else {
return Ok(());
};
if p_idx >= state.len() || h_idx >= state.len() {
return Ok(());
}
let row = self.inner.n_equations();
jacobian.add_entry(row, h_idx, 1.0);
if let Some(backend) = self.fluid_backend.as_ref() {
let p_pa = state[p_idx];
if p_pa > 10_000.0 {
let dp = p_pa * 1e-4 + 100.0;
let fluid = FluidId::new(&self.refrigerant_id);
let hp = self.h_subcool_at_p(backend.as_ref(), fluid.clone(), p_pa + dp);
let hm = self.h_subcool_at_p(backend.as_ref(), fluid, (p_pa - dp).max(1.0));
if let (Ok(hp), Ok(hm)) = (hp, hm) {
jacobian.add_entry(row, p_idx, -(hp - hm) / (2.0 * dp));
}
}
}
}
Ok(())
}
fn get_ports(&self) -> &[ConnectedPort] {
@@ -400,6 +505,12 @@ impl Component for BphxCondenser {
}
fn set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
// Refrigerant outlet = port index 1 → capture P/h for SC closure.
if let Some(Some((_, p, h))) = port_edges.get(1) {
self.outlet_pressure_idx = Some(*p);
self.outlet_enthalpy_idx = Some(*h);
}
// Inner HeatExchanger: hot = refrigerant, cold = secondary (same order).
self.inner.set_port_context(port_edges);
}
@@ -601,6 +712,17 @@ mod tests {
assert_eq!(cond.target_subcooling(), 5.0);
}
#[test]
fn test_bphx_condenser_emergent_pressure_adds_equation() {
let geo = test_geometry();
let base = BphxCondenser::new(geo.clone());
let emergent = BphxCondenser::new(geo).with_emergent_pressure(5.0);
assert!(!base.emergent_pressure());
assert!(emergent.emergent_pressure());
assert_eq!(emergent.target_subcooling(), 5.0);
assert_eq!(emergent.n_equations(), base.n_equations() + 1);
}
#[test]
fn test_bphx_condenser_with_refrigerant() {
let geo = test_geometry();

View File

@@ -544,13 +544,13 @@ fn longo_2004(params: &CorrelationParams) -> CorrelationResult {
}
FlowRegime::Evaporation | FlowRegime::Condensation => {
let re_eq = params.longo_equivalent_two_phase_reynolds();
let nu_tp = if params.regime == FlowRegime::Evaporation {
if params.regime == FlowRegime::Evaporation {
0.05 * re_eq.powf(0.8) * params.pr_l.powf(0.33)
} else {
let density_factor = (params.rho_l / params.rho_v).powf(-0.1);
1.875 * re_eq.powf(0.35) * params.pr_l.powf(0.33) * density_factor
};
nu_tp
}
}
};
@@ -621,9 +621,8 @@ fn shah_2009(params: &CorrelationParams) -> CorrelationResult {
let h_i = h_lo * (1.0 + 3.8 / z.max(1e-6).powf(0.95));
// Stratified / laminar film contribution (Nusselt-like lower bound).
let h_iii = h_lo
* ((1.0 - x).powf(0.8)
+ 3.8 * x.powf(0.76) * (1.0 - x).powf(0.04) / p_r.powf(0.38))
.max(0.1);
* ((1.0 - x).powf(0.8) + 3.8 * x.powf(0.76) * (1.0 - x).powf(0.04) / p_r.powf(0.38))
.max(0.1);
let h = if j_g >= 0.98 * (z + 0.263).powf(-0.62) {
h_i
@@ -662,14 +661,17 @@ fn cavallini_2006(params: &CorrelationParams) -> CorrelationResult {
let mu_l = params.mu_l.max(1e-12);
let mu_g = params.mu_v.max(1e-12);
let x_tt = ((1.0 - x) / x).powf(0.9) * (rho_g / rho_l).powf(0.5) * (mu_l / mu_g).powf(0.1);
let j_g = x * params.mass_flux.abs()
/ (g_accel() * params.dh.max(1e-9) * rho_g).sqrt();
let j_g = x * params.mass_flux.abs() / (g_accel() * params.dh.max(1e-9) * rho_g).sqrt();
// Annular (ΔT-independent) branch when J_g is high.
let h_ann = h_lo * (1.0 + 1.128 * x.powf(0.817) * (rho_l / rho_g).powf(0.3685)
* (mu_l / mu_g).powf(0.2363)
* (1.0 - mu_g / mu_l).powf(2.144)
* params.pr_l.powf(-0.1));
let h_ann = h_lo
* (1.0
+ 1.128
* x.powf(0.817)
* (rho_l / rho_g).powf(0.3685)
* (mu_l / mu_g).powf(0.2363)
* (1.0 - mu_g / mu_l).powf(2.144)
* params.pr_l.powf(-0.1));
// Stratified / ΔT-dependent branch (blended with annular).
let h_strat = h_lo * (1.0 + 1.2 / x_tt.max(0.05).powf(0.7));
let h = if j_g > 2.5 {
@@ -1281,7 +1283,7 @@ mod tests {
#[test]
fn test_correlation_selector_available_correlations() {
let correlations = CorrelationSelector::available_correlations();
assert_eq!(correlations.len(), 8);
assert_eq!(correlations.len(), 10);
}
#[test]
@@ -1559,7 +1561,7 @@ mod tests {
)
.unwrap();
assert_eq!(evaluation.selection.assessments.len(), 8);
assert_eq!(evaluation.selection.assessments.len(), 10);
assert_eq!(evaluation.selection.selected, CorrelationId::Longo2004);
assert!(evaluation.selection.assessments.iter().any(|assessment| {
assessment.id == CorrelationId::Shah1979

View File

@@ -32,7 +32,7 @@ use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::{Calib, Enthalpy, MassFlow, Power, Pressure};
use entropyk_core::{Calib, Enthalpy, MassFlow, Power, Pressure, Temperature};
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property, Quality};
use std::cell::Cell;
use std::sync::Arc;
@@ -45,6 +45,9 @@ use std::sync::Arc;
pub struct BphxEvaporator {
inner: BphxExchanger,
target_superheat_k: f64,
/// When true, emit an outlet-closure residual pinning `h_out` to
/// `h(P, Tsat(P) + target_superheat_k)` so evaporating pressure can emerge.
emergent_pressure: bool,
refrigerant_id: String,
secondary_fluid_id: String,
fluid_backend: Option<Arc<dyn FluidBackend>>,
@@ -59,6 +62,7 @@ impl std::fmt::Debug for BphxEvaporator {
.field("ua", &self.inner.ua())
.field("geometry", &self.inner.geometry())
.field("target_superheat_k", &self.target_superheat_k)
.field("emergent_pressure", &self.emergent_pressure)
.field("refrigerant_id", &self.refrigerant_id)
.field("secondary_fluid_id", &self.secondary_fluid_id)
.field("has_fluid_backend", &self.fluid_backend.is_some())
@@ -88,6 +92,7 @@ impl BphxEvaporator {
Self {
inner: BphxExchanger::new(geometry),
target_superheat_k: 5.0,
emergent_pressure: false,
refrigerant_id: String::new(),
secondary_fluid_id: String::new(),
fluid_backend: None,
@@ -108,6 +113,18 @@ impl BphxEvaporator {
self
}
/// Enables emergent-pressure mode: +1 residual pinning the refrigerant
/// outlet enthalpy to `h(P, Tsat(P) + target_superheat_k)`.
pub fn with_emergent_pressure(mut self) -> Self {
self.emergent_pressure = true;
self
}
/// Returns whether emergent-pressure outlet closure is enabled.
pub fn emergent_pressure(&self) -> bool {
self.emergent_pressure
}
/// Sets the refrigerant fluid identifier.
pub fn with_refrigerant(mut self, fluid: impl Into<String>) -> Self {
self.refrigerant_id = fluid.into();
@@ -262,6 +279,32 @@ impl BphxEvaporator {
Some(t_out - t_sat)
}
/// Target outlet enthalpy `h(P, Tsat(P) + target_superheat_k)` [J/kg].
fn h_superheat_at_p(
&self,
backend: &dyn FluidBackend,
fluid: FluidId,
p_pa: f64,
) -> Result<f64, ComponentError> {
let t_sat_k = backend
.property(
fluid.clone(),
Property::Temperature,
FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(1.0)),
)
.map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
backend
.property(
fluid,
Property::Enthalpy,
FluidState::PressureTemperature(
Pressure::from_pascals(p_pa),
Temperature::from_kelvin(t_sat_k + self.target_superheat_k),
),
)
.map_err(|e| ComponentError::CalculationFailed(e.to_string()))
}
/// Computes the heat transfer coefficient using the configured correlation.
#[allow(clippy::too_many_arguments)]
pub fn compute_htc(
@@ -329,7 +372,7 @@ impl BphxEvaporator {
impl Component for BphxEvaporator {
fn n_equations(&self) -> usize {
self.inner.n_equations()
self.inner.n_equations() + usize::from(self.emergent_pressure)
}
fn compute_residuals(
@@ -337,6 +380,13 @@ impl Component for BphxEvaporator {
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
let n = self.n_equations();
if residuals.len() < n {
return Err(ComponentError::InvalidResidualDimensions {
expected: n,
actual: residuals.len(),
});
}
self.inner.compute_residuals(state, residuals)?;
if let (Some(p_idx), Some(h_idx)) = (self.outlet_pressure_idx, self.outlet_enthalpy_idx) {
@@ -347,7 +397,27 @@ impl Component for BphxEvaporator {
if let Some(sh) = self.compute_superheat(h_out, p_pa) {
self.last_superheat.set(Some(sh));
}
if self.emergent_pressure {
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::CalculationFailed(
"BphxEvaporator: FluidBackend required for emergent outlet closure"
.into(),
)
})?;
let fluid = FluidId::new(&self.refrigerant_id);
let h_target = self.h_superheat_at_p(backend.as_ref(), fluid, p_pa)?;
residuals[self.inner.n_equations()] = h_out - h_target;
}
} else if self.emergent_pressure {
return Err(ComponentError::InvalidState(
"BphxEvaporator: emergent outlet closure needs live outlet P/h indices".into(),
));
}
} else if self.emergent_pressure {
return Err(ComponentError::InvalidState(
"BphxEvaporator: emergent outlet closure needs live outlet P/h indices".into(),
));
}
Ok(())
@@ -358,7 +428,32 @@ impl Component for BphxEvaporator {
state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
self.inner.jacobian_entries(state, jacobian)
self.inner.jacobian_entries(state, jacobian)?;
if self.emergent_pressure {
let (Some(p_idx), Some(h_idx)) = (self.outlet_pressure_idx, self.outlet_enthalpy_idx)
else {
return Ok(());
};
if p_idx >= state.len() || h_idx >= state.len() {
return Ok(());
}
let row = self.inner.n_equations();
jacobian.add_entry(row, h_idx, 1.0);
if let Some(backend) = self.fluid_backend.as_ref() {
let p_pa = state[p_idx];
if p_pa > 10_000.0 {
let dp = p_pa * 1e-4 + 100.0;
let fluid = FluidId::new(&self.refrigerant_id);
let hp = self.h_superheat_at_p(backend.as_ref(), fluid.clone(), p_pa + dp);
let hm = self.h_superheat_at_p(backend.as_ref(), fluid, (p_pa - dp).max(1.0));
if let (Ok(hp), Ok(hm)) = (hp, hm) {
jacobian.add_entry(row, p_idx, -(hp - hm) / (2.0 * dp));
}
}
}
}
Ok(())
}
fn get_ports(&self) -> &[ConnectedPort] {
@@ -366,6 +461,12 @@ impl Component for BphxEvaporator {
}
fn set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
// Refrigerant outlet = port index 1 → capture P/h for SH closure.
if let Some(Some((_, p, h))) = port_edges.get(1) {
self.outlet_pressure_idx = Some(*p);
self.outlet_enthalpy_idx = Some(*h);
}
// Inner HeatExchanger: hot = secondary, cold = refrigerant.
let remapped = [
port_edges.get(2).copied().flatten(),
port_edges.get(3).copied().flatten(),
@@ -485,6 +586,18 @@ mod tests {
assert_eq!(evap.target_superheat_k(), 10.0);
}
#[test]
fn test_bphx_evaporator_emergent_pressure_adds_equation() {
let geo = test_geometry();
let base = BphxEvaporator::new(geo.clone());
let emergent = BphxEvaporator::new(geo)
.with_target_superheat(5.0)
.with_emergent_pressure();
assert!(!base.emergent_pressure());
assert!(emergent.emergent_pressure());
assert_eq!(emergent.n_equations(), base.n_equations() + 1);
}
#[test]
fn test_bphx_evaporator_with_refrigerant() {
let geo = test_geometry();
@@ -849,7 +962,7 @@ mod tests {
let q = evap.compute_quality(340_000.0, 300_000.0);
assert!(q.is_some());
let q_val = q.unwrap();
assert!(q_val >= 0.0 && q_val <= 1.0);
assert!((0.0..=1.0).contains(&q_val));
}
#[test]

View File

@@ -613,7 +613,7 @@ mod tests {
result.selection.selected,
super::super::correlation_registry::CorrelationId::Longo2004
);
assert_eq!(result.selection.assessments.len(), 8);
assert_eq!(result.selection.assessments.len(), 10);
assert_eq!(
hx.last_selection_report().unwrap().selected,
result.selection.selected

View File

@@ -9,13 +9,24 @@ use super::flow_regularization::{smooth_mass_magnitude, DEFAULT_M_EPS_KG_S};
use super::lmtd::{FlowConfiguration, LmtdModel};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
Component, ComponentError, ConnectedPort, DomainViolation, JacobianBuilder, ResidualVector,
StateSlice,
};
use entropyk_core::smoothing::{smooth_clamp, smooth_clamp_derivative};
use entropyk_core::Calib;
use entropyk_core::Pressure;
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property, Quality};
use std::sync::Arc;
/// Fan actuator φ ∈ [0, 1.5] — C¹ transition width (Story 0.4 / FR19).
const FAN_PHI_WIDTH: f64 = 1e-2;
/// Flood level λ ∈ [0, 0.98] — C¹ transition width (`width ≤ (hilo)/2`).
const FLOOD_LAMBDA_WIDTH: f64 = 1e-2;
const FAN_PHI_LO: f64 = 0.0;
const FAN_PHI_HI: f64 = 1.5;
const FLOOD_LAMBDA_LO: f64 = 0.0;
const FLOOD_LAMBDA_HI: f64 = 0.98;
/// Condenser heat exchanger.
///
/// Uses the LMTD method for heat transfer calculation.
@@ -229,18 +240,14 @@ impl Condenser {
fn secondary_delta_p(&self, m_sec: f64) -> f64 {
match self.secondary_pressure_drop_coeff {
Some(k) if k > 0.0 => {
crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_sec)
}
Some(k) if k > 0.0 => crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_sec),
_ => 0.0,
}
}
fn secondary_delta_p_dm(&self, m_sec: f64) -> f64 {
match self.secondary_pressure_drop_coeff {
Some(k) if k > 0.0 => {
crate::heat_exchanger::two_phase_dp::quadratic_drop_dm(k, m_sec)
}
Some(k) if k > 0.0 => crate::heat_exchanger::two_phase_dp::quadratic_drop_dm(k, m_sec),
_ => 0.0,
}
}
@@ -394,10 +401,13 @@ impl Condenser {
if cp.is_finite() && cp > 0.0 {
Ok(cp)
} else {
Err(ComponentError::CalculationFailed(format!(
"Condenser secondary Cp is invalid: {}",
cp
)))
// A non-finite Cp means the trial (P, h) state left the fluid
// model's valid envelope (e.g. two-phase region): a recoverable
// domain violation (KINSOL `> 0`), not a fatal defect.
Err(ComponentError::DomainViolation(DomainViolation {
component: None,
detail: format!("Condenser secondary Cp is invalid: {}", cp),
}))
}
}
@@ -414,10 +424,12 @@ impl Condenser {
if t.is_finite() && t > 0.0 {
Ok(t)
} else {
Err(ComponentError::CalculationFailed(format!(
"Condenser secondary temperature is invalid: {}",
t
)))
// Non-finite/negative T at a trial state = recoverable domain
// violation (same class as the Cp check above), not a fatal defect.
Err(ComponentError::DomainViolation(DomainViolation {
component: None,
detail: format!("Condenser secondary temperature is invalid: {}", t),
}))
}
}
@@ -455,10 +467,13 @@ impl Condenser {
),
)
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"Condenser failed to evaluate secondary property for fluid '{}': {}",
self.secondary_fluid_id, e
))
ComponentError::from_fluid_error_context(
&format!(
"Condenser failed to evaluate secondary property for fluid '{}'",
self.secondary_fluid_id
),
e,
)
})
}
@@ -485,11 +500,11 @@ impl Condenser {
if self.rating_secondary_ready() {
let t = self.secondary_inlet_temp_k.unwrap();
let c_nominal = self.secondary_capacity_rate.unwrap();
// Fan head-pressure: C_sec = φ · C_nominal when actuator is free.
// Fan head-pressure: C_sec = φ_eff · C_nominal (C¹ smooth_clamp, Story 0.4).
let c_sec = if self.fan_active() {
if let Some(idx) = self.fan_actuator_idx {
if idx < state.len() {
state[idx].clamp(0.0, 1.5) * c_nominal
smooth_clamp(state[idx], FAN_PHI_LO, FAN_PHI_HI, FAN_PHI_WIDTH) * c_nominal
} else {
c_nominal
}
@@ -593,9 +608,16 @@ impl Condenser {
);
// ∂r/∂P_ref_in = ∂Q/∂P = g·dT_cond/dP.
jacobian.add_entry(row, c.ref_p_in_idx, -c.g * c.dtcond_dp);
// Flooding actuator: ∂r/∂λ = ∂Q/∂λ = +UA_nom·ΔT·e.
// Flooding actuator: ∂r/∂λ = ∂Q/∂λ_eff · dλ_eff/dλ
// with λ_eff = smooth_clamp(λ, 0, 0.98, w) (Story 0.4).
if let (true, Some(act_idx)) = (self.flood_ready(), self.fan_actuator_idx) {
jacobian.add_entry(row, act_idx, self.ua() * c.delta_t * c.e_exp);
let dlam = smooth_clamp_derivative(
state[act_idx],
FLOOD_LAMBDA_LO,
FLOOD_LAMBDA_HI,
FLOOD_LAMBDA_WIDTH,
);
jacobian.add_entry(row, act_idx, self.ua() * c.delta_t * c.e_exp * dlam);
}
}
None => {
@@ -744,12 +766,18 @@ impl Condenser {
self.fan_ready() || self.flood_ready()
}
/// Flooded liquid level `λ ∈ [0, 0.98]` read from the generic actuator slot
/// (0.0 when no active/ready flooding actuator). Clamped below 1 so at least
/// a sliver of condensing area (and thus a finite duty) always remains.
/// Flooded liquid level `λ_eff ∈ [0, 0.98]` read from the generic actuator
/// slot (0.0 when no active/ready flooding actuator). Mapped through
/// [`smooth_clamp`] so ∂/∂λ stays C¹ at the bounds (Story 0.4); upper bound
/// keeps a sliver of condensing area (finite duty).
fn flooded_level(&self, state: &StateSlice) -> f64 {
match (self.flood_ready(), self.fan_actuator_idx) {
(true, Some(idx)) if idx < state.len() => state[idx].clamp(0.0, 0.98),
(true, Some(idx)) if idx < state.len() => smooth_clamp(
state[idx],
FLOOD_LAMBDA_LO,
FLOOD_LAMBDA_HI,
FLOOD_LAMBDA_WIDTH,
),
_ => 0.0,
}
}
@@ -785,111 +813,94 @@ impl Condenser {
self.inlet_m_idx.or(self.outlet_m_idx)
}
/// Thermodynamic quality from (P, h) via saturation enthalpies (unclamped).
fn quality_at_ph(&self, p_pa: f64, h: f64) -> Option<f64> {
let backend = self.fluid_backend.as_ref()?;
if self.refrigerant_id.is_empty() {
return None;
}
let fluid = FluidId::new(&self.refrigerant_id);
let p = Pressure::from_pascals(p_pa);
let h_f = backend
.property(
fluid.clone(),
Property::Enthalpy,
FluidState::from_px(p, Quality::new(0.0)),
)
.ok()?;
let h_g = backend
.property(
fluid,
Property::Enthalpy,
FluidState::from_px(p, Quality::new(1.0)),
)
.ok()?;
if h_g <= h_f {
return None;
}
Some((h - h_f) / (h_g - h_f))
}
/// Saturated transport properties at `p_pa` for tube ΔP correlations.
fn sat_transport_at_p(
/// Signed refrigerant ΔP [Pa]: tube MSH/Friedel (+ acceleration) if
/// configured, else lumped quadratic, else 0.
///
/// The tube arm is **total and C¹** (see [`crate::heat_exchanger::tube_dp`]
/// and the Story 0.2 phantom-gradient regularization standard): it never
/// silently switches to the lumped model when a Newton iterate leaves the
/// saturation domain — the query pressure is C¹-clamped into the detected
/// domain and the latent heat is smooth-floored, so a hard backend failure
/// is a recoverable [`ComponentError::DomainViolation`] (Story 1.3), not a
/// model switch. The only remaining lumped fallback is solve-invariant
/// (no backend / empty refrigerant id / no detectable saturation domain),
/// so the active ΔP model cannot change mid-solve.
fn refrigerant_pressure_drop(
&self,
m_ref: f64,
p_pa: f64,
) -> Option<crate::heat_exchanger::two_phase_dp::SatTransportProps> {
let backend = self.fluid_backend.as_ref()?;
if self.refrigerant_id.is_empty() {
return None;
h_in: f64,
h_out: f64,
) -> Result<f64, ComponentError> {
if self.resolved_mass_idx().is_none() {
return Ok(0.0);
}
let fluid = FluidId::new(&self.refrigerant_id);
let p = Pressure::from_pascals(p_pa);
let px = |x: f64, prop: Property| {
backend.property(
FluidId::new(&self.refrigerant_id),
prop,
FluidState::from_px(p, Quality::new(x)),
)
};
let rho_liquid = px(0.0, Property::Density).ok()?;
let rho_vapor = px(1.0, Property::Density).ok()?;
let mu_liquid = px(0.0, Property::Viscosity).ok()?;
let mu_vapor = px(1.0, Property::Viscosity).ok()?;
let sigma = backend
.property(
fluid,
Property::SurfaceTension,
FluidState::from_px(p, Quality::new(0.5)),
)
.unwrap_or(0.008);
Some(crate::heat_exchanger::two_phase_dp::SatTransportProps {
rho_liquid,
rho_vapor,
mu_liquid,
mu_vapor,
sigma,
if let (Some(corr), Some(geom)) = (self.tube_dp_correlation, self.tube_dp_geometry) {
if let Some(backend) = self.fluid_backend.as_ref() {
if let Some(state) = crate::heat_exchanger::tube_dp::sat_dp_value_state(
backend,
&self.refrigerant_id,
p_pa,
)? {
let x_in = state.quality(h_in);
let x_out = state.quality(h_out);
return Ok(crate::heat_exchanger::two_phase_dp::tube_two_phase_delta_p(
corr,
&geom,
m_ref,
x_in,
x_out,
&state.props,
));
}
}
}
Ok(match self.pressure_drop_coeff {
Some(k) if k > 0.0 => crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_ref),
_ => 0.0,
})
}
/// Signed refrigerant ΔP [Pa]: tube MSH/Friedel (+ acceleration) if
/// configured, else lumped quadratic, else 0.
fn refrigerant_pressure_drop(&self, m_ref: f64, p_pa: f64, h_in: f64, h_out: f64) -> f64 {
/// ΔP value + **exact** partials (∂/∂ṁ, ∂/∂P_in, ∂/∂h_in, ∂/∂h_out) for the
/// momentum-row Jacobian (NFR9 exact-analytic policy).
///
/// The tube ΔP composition is differentiated analytically (ṁ → mass flux →
/// friction + acceleration; h → unclamped qualities; P → saturated
/// properties) via [`crate::heat_exchanger::tube_dp`]; only the individual
/// saturation-property pressure derivatives use narrow domain-safe FDs
/// (the thin CoolProp FFI exposes no saturation derivatives). The lumped
/// quadratic arm contributes its analytic `2·k·|ṁ|` only.
fn refrigerant_pressure_drop_jacobian(
&self,
m_ref: f64,
p_pa: f64,
h_in: f64,
h_out: f64,
) -> Result<crate::heat_exchanger::tube_dp::TubeDpEvaluation, ComponentError> {
if self.resolved_mass_idx().is_none() {
return 0.0;
return Ok(crate::heat_exchanger::tube_dp::TubeDpEvaluation::default());
}
if let (Some(corr), Some(geom)) = (self.tube_dp_correlation, self.tube_dp_geometry) {
if let (Some(x_in), Some(x_out), Some(props)) = (
self.quality_at_ph(p_pa, h_in),
self.quality_at_ph(p_pa, h_out),
self.sat_transport_at_p(p_pa),
) {
return crate::heat_exchanger::two_phase_dp::tube_two_phase_delta_p(
corr, &geom, m_ref, x_in, x_out, &props,
);
if let Some(backend) = self.fluid_backend.as_ref() {
if let Some(state) = crate::heat_exchanger::tube_dp::sat_dp_full_state(
backend,
&self.refrigerant_id,
p_pa,
)? {
return Ok(crate::heat_exchanger::tube_dp::tube_dp_evaluation(
corr, &geom, m_ref, h_in, h_out, &state,
));
}
}
}
match self.pressure_drop_coeff {
Some(k) if k > 0.0 => {
crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_ref)
}
_ => 0.0,
}
}
/// ∂ΔP/∂ṁ for the momentum residual (analytic quadratic, FD for tube).
fn refrigerant_pressure_drop_dm(&self, m_ref: f64, p_pa: f64, h_in: f64, h_out: f64) -> f64 {
if let (Some(_), Some(_)) = (self.tube_dp_correlation, self.tube_dp_geometry) {
let eps = (1e-6 * m_ref.abs()).max(1e-8);
let dp_p = self.refrigerant_pressure_drop(m_ref + eps, p_pa, h_in, h_out);
let dp_m = self.refrigerant_pressure_drop(m_ref - eps, p_pa, h_in, h_out);
return (dp_p - dp_m) / (2.0 * eps);
}
match self.pressure_drop_coeff {
Some(k) if k > 0.0 => {
crate::heat_exchanger::two_phase_dp::quadratic_drop_dm(k, m_ref)
}
_ => 0.0,
}
Ok(match self.pressure_drop_coeff {
Some(k) if k > 0.0 => crate::heat_exchanger::tube_dp::TubeDpEvaluation {
value: crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_ref),
d_dm: crate::heat_exchanger::two_phase_dp::quadratic_drop_dm(k, m_ref),
..crate::heat_exchanger::tube_dp::TubeDpEvaluation::default()
},
_ => crate::heat_exchanger::tube_dp::TubeDpEvaluation::default(),
})
}
/// Enables the emergent-pressure mode with the given sub-cooling target [K].
@@ -948,7 +959,7 @@ impl Condenser {
entropyk_core::Temperature::from_kelvin(t_cond - self.subcooling_k),
),
)
.map_err(|e| ComponentError::CalculationFailed(e.to_string()))
.map_err(ComponentError::from_fluid_error)
}
/// Condensing (saturation) temperature of the refrigerant at pressure `p_pa` [K].
@@ -956,13 +967,22 @@ impl Condenser {
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::CalculationFailed("Condenser: no fluid backend".to_string())
})?;
// Clamp into the detected saturation domain so the condensing
// temperature — and every residual derived from it — stays defined
// when a Newton iterate leaves the domain (see sat_domain).
let p_pa = crate::heat_exchanger::sat_domain::clamp_to_saturation_domain(
backend,
&self.refrigerant_id,
p_pa,
)
.unwrap_or(p_pa);
backend
.property(
FluidId::new(&self.refrigerant_id),
Property::Temperature,
FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(0.5)),
)
.map_err(|e| ComponentError::CalculationFailed(e.to_string()))
.map_err(ComponentError::from_fluid_error)
}
/// Measured liquid-line subcooling `T_cond(P_out) T(P_out, h_out)` [K]
@@ -1274,15 +1294,12 @@ impl Component for Condenser {
let inlet_h_idx = self.inlet_h_idx.unwrap();
let outlet_p_idx = self.outlet_p_idx.unwrap();
let outlet_h_idx = self.outlet_h_idx.unwrap();
let m_idx = self
.inlet_m_idx
.or(self.outlet_m_idx)
.ok_or_else(|| {
ComponentError::InvalidState(
"mass-flow state index not resolved (cannot fall back to a pressure index)"
.into(),
)
})?;
let m_idx = self.inlet_m_idx.or(self.outlet_m_idx).ok_or_else(|| {
ComponentError::InvalidState(
"mass-flow state index not resolved (cannot fall back to a pressure index)"
.into(),
)
})?;
let m_ref = state[m_idx];
let h_in = state[inlet_h_idx];
@@ -1296,7 +1313,7 @@ impl Component for Condenser {
// r0: refrigerant pressure drop (tube MSH/Friedel + accel, or
// lumped quadratic): P_out = P_in ΔP.
let dp_drop = self.refrigerant_pressure_drop(m_ref, p_in, h_in, h_out);
let dp_drop = self.refrigerant_pressure_drop(m_ref, p_in, h_in, h_out)?;
let mut row = 0;
if !self.skip_pressure_eq {
residuals[row] = state[outlet_p_idx] - (p_in - dp_drop);
@@ -1455,15 +1472,12 @@ impl Component for Condenser {
let inlet_h_idx = self.inlet_h_idx.unwrap();
let outlet_p_idx = self.outlet_p_idx.unwrap();
let outlet_h_idx = self.outlet_h_idx.unwrap();
let m_idx = self
.inlet_m_idx
.or(self.outlet_m_idx)
.ok_or_else(|| {
ComponentError::InvalidState(
"mass-flow state index not resolved (cannot fall back to a pressure index)"
.into(),
)
})?;
let m_idx = self.inlet_m_idx.or(self.outlet_m_idx).ok_or_else(|| {
ComponentError::InvalidState(
"mass-flow state index not resolved (cannot fall back to a pressure index)"
.into(),
)
})?;
let m_ref = state[m_idx];
let h_in = state[inlet_h_idx];
@@ -1473,14 +1487,25 @@ impl Component for Condenser {
if !self.skip_pressure_eq {
jacobian.add_entry(row, outlet_p_idx, 1.0);
jacobian.add_entry(row, inlet_p_idx, -1.0);
// Momentum-row tube-ΔP partials: exact analytic composition
// (∂/∂ṁ, ∂/∂P_in through the saturated properties, ∂/∂h_in
// and ∂/∂h_out through the unclamped qualities) — NFR9.
let eval = self.refrigerant_pressure_drop_jacobian(m_ref, p_in, h_in, h_out)?;
if let Some(m_real) = self.resolved_mass_idx() {
let dm =
self.refrigerant_pressure_drop_dm(m_ref, p_in, h_in, h_out);
if dm.abs() > 0.0 {
if eval.d_dm != 0.0 {
// r0 = P_out P_in + ΔP ⇒ ∂r0/∂ṁ = ∂ΔP/∂ṁ
jacobian.add_entry(row, m_real, dm);
jacobian.add_entry(row, m_real, eval.d_dm);
}
}
if eval.d_dp != 0.0 {
jacobian.add_entry(row, inlet_p_idx, eval.d_dp);
}
if eval.d_dh_in != 0.0 {
jacobian.add_entry(row, inlet_h_idx, eval.d_dh_in);
}
if eval.d_dh_out != 0.0 {
jacobian.add_entry(row, outlet_h_idx, eval.d_dh_out);
}
row += 1;
}
@@ -1538,27 +1563,33 @@ impl Component for Condenser {
None
};
// ∂r1/∂φ = ∂Q/∂φ = g'(C_sec)·C_nominal·(T_cond T_sec,in),
// with C_sec = φ·C_nominal and g(C) = ε(C)·C. Exact fan coupling
// (parameter-based secondary stream only).
// ∂r1/∂φ = ∂Q/∂φ_eff · dφ_eff/dφ with
// φ_eff = smooth_clamp(φ, 0, 1.5, w), C_sec = φ_eff·C_nominal,
// g(C) = ε(C)·C (Story 0.4 chain rule).
if self.fan_ready() && !self.secondary_edges_ready() {
if let (Some(fan_idx), Some(t_sec_param)) =
(self.fan_actuator_idx, self.secondary_inlet_temp_k)
{
let c_nominal = self.secondary_capacity_rate.unwrap_or(0.0);
let g_prime = self.d_eps_csec_d_csec(c_sec);
let dphi = smooth_clamp_derivative(
state[fan_idx],
FAN_PHI_LO,
FAN_PHI_HI,
FAN_PHI_WIDTH,
);
jacobian.add_entry(
row,
fan_idx,
-g_prime * c_nominal * (t_cond - t_sec_param),
-g_prime * c_nominal * (t_cond - t_sec_param) * dphi,
);
}
}
// ∂r1/∂λ = ∂Q/∂λ. With UA_eff = (1 λ)·UA_nom and
// ε = 1 exp(UA_eff/C_sec): ∂ε/∂UA_eff = e/C_sec (e = exp(UA_eff/C_sec)),
// ∂UA_eff/∂λ = UA_nom, so ∂Q/∂λ = UA_nom·(T_cond T_sec,in)·e and
// ∂r1/∂λ = +UA_nom·(T_cond T_sec,in)·e. Exact flooding coupling.
// ∂r1/∂λ = ∂Q/∂λ_eff · dλ_eff/dλ. With UA_eff = (1 λ_eff)·UA_nom,
// λ_eff = smooth_clamp(λ, 0, 0.98, w),
// ∂Q/∂λ_eff = UA_nom·(T_cond T_sec,in)·e
// ∂r1/∂λ = +UA_nom·(T_cond T_sec,in)·e · dλ_eff/dλ.
if self.flood_ready() {
if let Some(lvl_idx) = self.fan_actuator_idx {
let ua_nom = self.ua();
@@ -1567,7 +1598,13 @@ impl Component for Condenser {
} else {
0.0
};
jacobian.add_entry(row, lvl_idx, ua_nom * (t_cond - t_sec_in) * e);
let dlam = smooth_clamp_derivative(
state[lvl_idx],
FLOOD_LAMBDA_LO,
FLOOD_LAMBDA_HI,
FLOOD_LAMBDA_WIDTH,
);
jacobian.add_entry(row, lvl_idx, ua_nom * (t_cond - t_sec_in) * e * dlam);
}
}
@@ -1703,9 +1740,7 @@ impl Component for Condenser {
stream: "refrigerant",
});
if self.emergent_pressure {
roles.push(crate::EquationRole::OutletClosure {
kind: "subcooling",
});
roles.push(crate::EquationRole::OutletClosure { kind: "subcooling" });
}
if !self.same_branch_m {
roles.push(crate::EquationRole::MassConservation {
@@ -2641,4 +2676,69 @@ mod tests {
}
}
}
/// Builds a 4-port condenser with tube two-phase (MSH) pressure drop.
fn make_4port_condenser_tube_dp() -> Condenser {
use crate::heat_exchanger::two_phase_dp::{TubeChannelGeometry, TwoPhaseDpCorrelation};
use std::sync::Arc;
let backend = Arc::new(entropyk_fluids::TestBackend::new());
let mut cond = Condenser::new(10_000.0)
.with_refrigerant("R134a")
.with_fluid_backend(backend)
.with_secondary_fluid("Air")
.with_tube_pressure_drop(
TwoPhaseDpCorrelation::MullerSteinhagenHeck1986,
TubeChannelGeometry::dx_default(),
);
cond.set_secondary_humidity_ratio(0.010);
cond.set_system_context(0, &[(0, 1, 2), (3, 4, 5)]);
cond.set_port_context(&[
Some((0, 1, 2)),
Some((3, 4, 5)),
Some((6, 7, 8)),
Some((9, 10, 11)),
]);
cond
}
/// Momentum-row Jacobian must include the tube-ΔP partials w.r.t. inlet
/// pressure (saturated transport properties) and both enthalpies (vapor
/// qualities) — not only ∂ΔP/∂ṁ. Regression test: with tube MSH at high
/// mass flow, a momentum row limited to ∂/∂ṁ makes the Newton direction
/// wrong enough that line search fails and the solve diverges.
#[test]
fn test_condenser_4port_tube_dp_momentum_jacobian_vs_finite_differences() {
use crate::JacobianBuilder;
let cond = make_4port_condenser_tube_dp();
let state = make_4port_state();
let n_eq = cond.n_equations();
let n_state = state.len();
let mut jb = JacobianBuilder::new();
cond.jacobian_entries(&state, &mut jb).unwrap();
let mut analytic = vec![vec![0.0_f64; n_state]; n_eq];
for &(row, col, v) in jb.entries() {
if row < n_eq && col < n_state {
analytic[row][col] += v;
}
}
// Row 0: r = P_out P_in + ΔP(ṁ, P_in, h_in, h_out).
for col in 0..n_state {
let eps = (state[col].abs() * 1e-6).max(1e-7);
let (mut sp, mut sm) = (state.clone(), state.clone());
sp[col] += eps;
sm[col] -= eps;
let (mut rp, mut rm) = (vec![0.0; n_eq], vec![0.0; n_eq]);
cond.compute_residuals(&sp, &mut rp).unwrap();
cond.compute_residuals(&sm, &mut rm).unwrap();
let fd = (rp[0] - rm[0]) / (2.0 * eps);
let a = analytic[0][col];
let tol = (1e-3 * fd.abs().max(a.abs())).max(1e-9);
assert!(
(a - fd).abs() <= tol,
"momentum J[0][{col}]: analytic={a} vs fd={fd}"
);
}
}
}

View File

@@ -233,7 +233,12 @@ const SHAH_1979_BOUNDS: &[BoundedQuantity] = &[
bound(BoundedQuantityKind::Quality, 0.0, 1.0, CONDENSATION),
];
const SHAH_2009_BOUNDS: &[BoundedQuantity] = &[
bound(BoundedQuantityKind::Reynolds, 350.0, 100_000.0, CONDENSATION),
bound(
BoundedQuantityKind::Reynolds,
350.0,
100_000.0,
CONDENSATION,
),
bound(BoundedQuantityKind::MassFlux, 10.0, 800.0, CONDENSATION),
bound(BoundedQuantityKind::Quality, 0.0, 1.0, CONDENSATION),
];
@@ -243,7 +248,12 @@ const SHAH_2021_BOUNDS: &[BoundedQuantity] = &[
bound(BoundedQuantityKind::Quality, 0.0, 1.0, CONDENSATION),
];
const CAVALLINI_BOUNDS: &[BoundedQuantity] = &[
bound(BoundedQuantityKind::Reynolds, 500.0, 100_000.0, CONDENSATION),
bound(
BoundedQuantityKind::Reynolds,
500.0,
100_000.0,
CONDENSATION,
),
bound(BoundedQuantityKind::MassFlux, 50.0, 800.0, CONDENSATION),
bound(BoundedQuantityKind::Quality, 0.0, 1.0, CONDENSATION),
];
@@ -302,18 +312,10 @@ const KO_BOUNDS: &[BoundedQuantity] = &[
bound(BoundedQuantityKind::MassFlux, 20.0, 300.0, ALL_REGIMES),
bound(BoundedQuantityKind::Quality, 0.0, 1.0, TWO_PHASE_REGIMES),
];
const COOPER_BOUNDS: &[BoundedQuantity] = &[bound(
BoundedQuantityKind::Quality,
0.0,
1.0,
EVAPORATION,
)];
const MOSTINSKI_BOUNDS: &[BoundedQuantity] = &[bound(
BoundedQuantityKind::Quality,
0.0,
1.0,
EVAPORATION,
)];
const COOPER_BOUNDS: &[BoundedQuantity] =
&[bound(BoundedQuantityKind::Quality, 0.0, 1.0, EVAPORATION)];
const MOSTINSKI_BOUNDS: &[BoundedQuantity] =
&[bound(BoundedQuantityKind::Quality, 0.0, 1.0, EVAPORATION)];
const FRIEDEL_BOUNDS: &[BoundedQuantity] = &[bound(
BoundedQuantityKind::Quality,
0.0,
@@ -321,7 +323,12 @@ const FRIEDEL_BOUNDS: &[BoundedQuantity] = &[bound(
TWO_PHASE_REGIMES,
)];
const MSH_BOUNDS: &[BoundedQuantity] = &[
bound(BoundedQuantityKind::MassFlux, 10.0, 2000.0, TWO_PHASE_REGIMES),
bound(
BoundedQuantityKind::MassFlux,
10.0,
2000.0,
TWO_PHASE_REGIMES,
),
bound(BoundedQuantityKind::Quality, 0.0, 1.0, TWO_PHASE_REGIMES),
];
const POOL_BOILING_GEOMETRIES: &[ExchangerGeometryType] = &[
@@ -924,7 +931,7 @@ pub fn assess_candidate(
}
let accepted = rejections.is_empty();
let domain_status = accepted.then(|| {
let domain_status = accepted.then_some({
if !violations.is_empty() {
DomainStatus::Extrapolated
} else if !near_boundaries.is_empty() {
@@ -1005,7 +1012,7 @@ pub fn select_correlation(
let selected_assessment = assessments
.iter()
.find(|assessment| assessment.id == selected)
.ok_or_else(|| CorrelationSelectionError::MissingEvaluator { id: selected })?;
.ok_or(CorrelationSelectionError::MissingEvaluator { id: selected })?;
let scientifically_tied = assessments.iter().any(|assessment| {
assessment.id != selected
&& assessment.accepted

View File

@@ -8,8 +8,8 @@ use super::exchanger::HeatExchanger;
use super::flow_regularization::{smooth_mass_magnitude, DEFAULT_M_EPS_KG_S};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, MeasuredOutput, ResidualVector,
StateSlice,
Component, ComponentError, ConnectedPort, DomainViolation, JacobianBuilder, MeasuredOutput,
ResidualVector, StateSlice,
};
use entropyk_core::{Calib, Enthalpy, Pressure, Temperature};
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property, Quality};
@@ -192,18 +192,14 @@ impl Evaporator {
fn secondary_delta_p(&self, m_sec: f64) -> f64 {
match self.secondary_pressure_drop_coeff {
Some(k) if k > 0.0 => {
crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_sec)
}
Some(k) if k > 0.0 => crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_sec),
_ => 0.0,
}
}
fn secondary_delta_p_dm(&self, m_sec: f64) -> f64 {
match self.secondary_pressure_drop_coeff {
Some(k) if k > 0.0 => {
crate::heat_exchanger::two_phase_dp::quadratic_drop_dm(k, m_sec)
}
Some(k) if k > 0.0 => crate::heat_exchanger::two_phase_dp::quadratic_drop_dm(k, m_sec),
_ => 0.0,
}
}
@@ -380,10 +376,13 @@ impl Evaporator {
if cp.is_finite() && cp > 0.0 {
Ok(cp)
} else {
Err(ComponentError::CalculationFailed(format!(
"Evaporator secondary Cp is invalid: {}",
cp
)))
// A non-finite Cp means the trial (P, h) state left the fluid
// model's valid envelope (e.g. two-phase region): a recoverable
// domain violation (KINSOL `> 0`), not a fatal defect.
Err(ComponentError::DomainViolation(DomainViolation {
component: None,
detail: format!("Evaporator secondary Cp is invalid: {}", cp),
}))
}
}
@@ -399,10 +398,12 @@ impl Evaporator {
if t.is_finite() && t > 0.0 {
Ok(t)
} else {
Err(ComponentError::CalculationFailed(format!(
"Evaporator secondary temperature is invalid: {}",
t
)))
// Non-finite/negative T at a trial state = recoverable domain
// violation (same class as the Cp check), not a fatal defect.
Err(ComponentError::DomainViolation(DomainViolation {
component: None,
detail: format!("Evaporator secondary temperature is invalid: {}", t),
}))
}
}
@@ -615,106 +616,94 @@ impl Evaporator {
self.inlet_m_idx.or(self.outlet_m_idx)
}
fn quality_at_ph(&self, p_pa: f64, h: f64) -> Option<f64> {
let backend = self.fluid_backend.as_ref()?;
if self.refrigerant_id.is_empty() {
return None;
}
let fluid = FluidId::new(&self.refrigerant_id);
let p = Pressure::from_pascals(p_pa);
let h_f = backend
.property(
fluid.clone(),
Property::Enthalpy,
FluidState::from_px(p, Quality::new(0.0)),
)
.ok()?;
let h_g = backend
.property(
fluid,
Property::Enthalpy,
FluidState::from_px(p, Quality::new(1.0)),
)
.ok()?;
if h_g <= h_f {
return None;
}
Some((h - h_f) / (h_g - h_f))
}
fn sat_transport_at_p(
/// Signed refrigerant ΔP [Pa]: tube MSH/Friedel (+ acceleration) if
/// configured, else lumped quadratic, else 0.
///
/// The tube arm is **total and C¹** (see [`crate::heat_exchanger::tube_dp`]
/// and the Story 0.2 phantom-gradient regularization standard): it never
/// silently switches to the lumped model when a Newton iterate leaves the
/// saturation domain — the query pressure is C¹-clamped into the detected
/// domain and the latent heat is smooth-floored, so a hard backend failure
/// is a recoverable [`ComponentError::DomainViolation`] (Story 1.3), not a
/// model switch. The only remaining lumped fallback is solve-invariant
/// (no backend / empty refrigerant id / no detectable saturation domain),
/// so the active ΔP model cannot change mid-solve.
fn refrigerant_pressure_drop(
&self,
m_ref: f64,
p_pa: f64,
) -> Option<crate::heat_exchanger::two_phase_dp::SatTransportProps> {
let backend = self.fluid_backend.as_ref()?;
if self.refrigerant_id.is_empty() {
return None;
h_in: f64,
h_out: f64,
) -> Result<f64, ComponentError> {
if self.resolved_mass_idx().is_none() {
return Ok(0.0);
}
let fluid = FluidId::new(&self.refrigerant_id);
let p = Pressure::from_pascals(p_pa);
let px = |x: f64, prop: Property| {
backend.property(
FluidId::new(&self.refrigerant_id),
prop,
FluidState::from_px(p, Quality::new(x)),
)
};
let rho_liquid = px(0.0, Property::Density).ok()?;
let rho_vapor = px(1.0, Property::Density).ok()?;
let mu_liquid = px(0.0, Property::Viscosity).ok()?;
let mu_vapor = px(1.0, Property::Viscosity).ok()?;
let sigma = backend
.property(
fluid,
Property::SurfaceTension,
FluidState::from_px(p, Quality::new(0.5)),
)
.unwrap_or(0.008);
Some(crate::heat_exchanger::two_phase_dp::SatTransportProps {
rho_liquid,
rho_vapor,
mu_liquid,
mu_vapor,
sigma,
if let (Some(corr), Some(geom)) = (self.tube_dp_correlation, self.tube_dp_geometry) {
if let Some(backend) = self.fluid_backend.as_ref() {
if let Some(state) = crate::heat_exchanger::tube_dp::sat_dp_value_state(
backend,
&self.refrigerant_id,
p_pa,
)? {
let x_in = state.quality(h_in);
let x_out = state.quality(h_out);
return Ok(crate::heat_exchanger::two_phase_dp::tube_two_phase_delta_p(
corr,
&geom,
m_ref,
x_in,
x_out,
&state.props,
));
}
}
}
Ok(match self.pressure_drop_coeff {
Some(k) if k > 0.0 => crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_ref),
_ => 0.0,
})
}
fn refrigerant_pressure_drop(&self, m_ref: f64, p_pa: f64, h_in: f64, h_out: f64) -> f64 {
/// ΔP value + **exact** partials (∂/∂ṁ, ∂/∂P_in, ∂/∂h_in, ∂/∂h_out) for the
/// momentum-row Jacobian (NFR9 exact-analytic policy).
///
/// The tube ΔP composition is differentiated analytically (ṁ → mass flux →
/// friction + acceleration; h → unclamped qualities; P → saturated
/// properties) via [`crate::heat_exchanger::tube_dp`]; only the individual
/// saturation-property pressure derivatives use narrow domain-safe FDs
/// (the thin CoolProp FFI exposes no saturation derivatives). The lumped
/// quadratic arm contributes its analytic `2·k·|ṁ|` only.
fn refrigerant_pressure_drop_jacobian(
&self,
m_ref: f64,
p_pa: f64,
h_in: f64,
h_out: f64,
) -> Result<crate::heat_exchanger::tube_dp::TubeDpEvaluation, ComponentError> {
if self.resolved_mass_idx().is_none() {
return 0.0;
return Ok(crate::heat_exchanger::tube_dp::TubeDpEvaluation::default());
}
if let (Some(corr), Some(geom)) = (self.tube_dp_correlation, self.tube_dp_geometry) {
if let (Some(x_in), Some(x_out), Some(props)) = (
self.quality_at_ph(p_pa, h_in),
self.quality_at_ph(p_pa, h_out),
self.sat_transport_at_p(p_pa),
) {
return crate::heat_exchanger::two_phase_dp::tube_two_phase_delta_p(
corr, &geom, m_ref, x_in, x_out, &props,
);
if let Some(backend) = self.fluid_backend.as_ref() {
if let Some(state) = crate::heat_exchanger::tube_dp::sat_dp_full_state(
backend,
&self.refrigerant_id,
p_pa,
)? {
return Ok(crate::heat_exchanger::tube_dp::tube_dp_evaluation(
corr, &geom, m_ref, h_in, h_out, &state,
));
}
}
}
match self.pressure_drop_coeff {
Some(k) if k > 0.0 => {
crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_ref)
}
_ => 0.0,
}
}
fn refrigerant_pressure_drop_dm(&self, m_ref: f64, p_pa: f64, h_in: f64, h_out: f64) -> f64 {
if let (Some(_), Some(_)) = (self.tube_dp_correlation, self.tube_dp_geometry) {
let eps = (1e-6 * m_ref.abs()).max(1e-8);
let dp_p = self.refrigerant_pressure_drop(m_ref + eps, p_pa, h_in, h_out);
let dp_m = self.refrigerant_pressure_drop(m_ref - eps, p_pa, h_in, h_out);
return (dp_p - dp_m) / (2.0 * eps);
}
match self.pressure_drop_coeff {
Some(k) if k > 0.0 => {
crate::heat_exchanger::two_phase_dp::quadratic_drop_dm(k, m_ref)
}
_ => 0.0,
}
Ok(match self.pressure_drop_coeff {
Some(k) if k > 0.0 => crate::heat_exchanger::tube_dp::TubeDpEvaluation {
value: crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_ref),
d_dm: crate::heat_exchanger::two_phase_dp::quadratic_drop_dm(k, m_ref),
..crate::heat_exchanger::tube_dp::TubeDpEvaluation::default()
},
_ => crate::heat_exchanger::tube_dp::TubeDpEvaluation::default(),
})
}
/// Enables emergent-pressure mode. An extra outlet-closure residual pins the
@@ -790,6 +779,15 @@ impl Evaporator {
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::CalculationFailed("Evaporator: no fluid backend".to_string())
})?;
// Clamp into the detected saturation domain so the evaporating
// temperature — and every residual derived from it — stays defined
// when a Newton iterate leaves the domain (see sat_domain).
let p_pa = crate::heat_exchanger::sat_domain::clamp_to_saturation_domain(
backend,
&self.refrigerant_id,
p_pa,
)
.unwrap_or(p_pa);
backend
.property(
FluidId::new(&self.refrigerant_id),
@@ -1042,15 +1040,12 @@ impl Component for Evaporator {
let inlet_h_idx = self.inlet_h_idx.unwrap();
let outlet_p_idx = self.outlet_p_idx.unwrap();
let outlet_h_idx = self.outlet_h_idx.unwrap();
let m_idx = self
.inlet_m_idx
.or(self.outlet_m_idx)
.ok_or_else(|| {
ComponentError::InvalidState(
"mass-flow state index not resolved (cannot fall back to a pressure index)"
.into(),
)
})?;
let m_idx = self.inlet_m_idx.or(self.outlet_m_idx).ok_or_else(|| {
ComponentError::InvalidState(
"mass-flow state index not resolved (cannot fall back to a pressure index)"
.into(),
)
})?;
let m_ref = state[m_idx];
let h_in = state[inlet_h_idx];
@@ -1063,7 +1058,7 @@ impl Component for Evaporator {
// r0: refrigerant pressure drop (tube MSH/Friedel + accel, or
// lumped quadratic): P_out = P_in ΔP.
let dp_drop = self.refrigerant_pressure_drop(m_ref, p_in, h_in, h_out);
let dp_drop = self.refrigerant_pressure_drop(m_ref, p_in, h_in, h_out)?;
let mut row = 0;
if !self.skip_pressure_eq {
residuals[row] = state[outlet_p_idx] - (p_in - dp_drop);
@@ -1228,15 +1223,12 @@ impl Component for Evaporator {
let inlet_h_idx = self.inlet_h_idx.unwrap();
let outlet_p_idx = self.outlet_p_idx.unwrap();
let outlet_h_idx = self.outlet_h_idx.unwrap();
let m_idx = self
.inlet_m_idx
.or(self.outlet_m_idx)
.ok_or_else(|| {
ComponentError::InvalidState(
"mass-flow state index not resolved (cannot fall back to a pressure index)"
.into(),
)
})?;
let m_idx = self.inlet_m_idx.or(self.outlet_m_idx).ok_or_else(|| {
ComponentError::InvalidState(
"mass-flow state index not resolved (cannot fall back to a pressure index)"
.into(),
)
})?;
let m_ref = state[m_idx];
let h_in = state[inlet_h_idx];
@@ -1246,13 +1238,24 @@ impl Component for Evaporator {
if !self.skip_pressure_eq {
jacobian.add_entry(row, outlet_p_idx, 1.0);
jacobian.add_entry(row, inlet_p_idx, -1.0);
// Momentum-row tube-ΔP partials: exact analytic composition
// (∂/∂ṁ, ∂/∂P_in through the saturated properties, ∂/∂h_in
// and ∂/∂h_out through the unclamped qualities) — NFR9.
let eval = self.refrigerant_pressure_drop_jacobian(m_ref, p_in, h_in, h_out)?;
if let Some(m_real) = self.resolved_mass_idx() {
let dm =
self.refrigerant_pressure_drop_dm(m_ref, p_in, h_in, h_out);
if dm.abs() > 0.0 {
jacobian.add_entry(row, m_real, dm);
if eval.d_dm != 0.0 {
jacobian.add_entry(row, m_real, eval.d_dm);
}
}
if eval.d_dp != 0.0 {
jacobian.add_entry(row, inlet_p_idx, eval.d_dp);
}
if eval.d_dh_in != 0.0 {
jacobian.add_entry(row, inlet_h_idx, eval.d_dh_in);
}
if eval.d_dh_out != 0.0 {
jacobian.add_entry(row, outlet_h_idx, eval.d_dh_out);
}
row += 1;
}
@@ -1419,9 +1422,7 @@ impl Component for Evaporator {
stream: "refrigerant",
});
if self.emergent_pressure && !self.superheat_regulated {
roles.push(crate::EquationRole::OutletClosure {
kind: "superheat",
});
roles.push(crate::EquationRole::OutletClosure { kind: "superheat" });
}
if !self.same_branch_m {
roles.push(crate::EquationRole::MassConservation {
@@ -2142,4 +2143,69 @@ mod tests {
}
}
}
/// Builds a 4-port evaporator with tube two-phase (MSH) pressure drop.
fn make_4port_evaporator_tube_dp() -> Evaporator {
use crate::heat_exchanger::two_phase_dp::{TubeChannelGeometry, TwoPhaseDpCorrelation};
use std::sync::Arc;
let backend = Arc::new(entropyk_fluids::TestBackend::new());
let mut evap = Evaporator::new(8_000.0)
.with_refrigerant("R134a")
.with_fluid_backend(backend)
.with_secondary_fluid("Air")
.with_tube_pressure_drop(
TwoPhaseDpCorrelation::MullerSteinhagenHeck1986,
TubeChannelGeometry::dx_default(),
);
evap.set_secondary_humidity_ratio(0.008);
evap.set_system_context(0, &[(0, 1, 2), (3, 4, 5)]);
evap.set_port_context(&[
Some((0, 1, 2)),
Some((3, 4, 5)),
Some((6, 7, 8)),
Some((9, 10, 11)),
]);
evap
}
/// Momentum-row Jacobian must include the tube-ΔP partials w.r.t. inlet
/// pressure (saturated transport properties) and both enthalpies (vapor
/// qualities) — not only ∂ΔP/∂ṁ. Regression test: with tube MSH at high
/// mass flow, a momentum row limited to ∂/∂ṁ makes the Newton direction
/// wrong enough that line search fails and the solve diverges.
#[test]
fn test_evaporator_4port_tube_dp_momentum_jacobian_vs_finite_differences() {
use crate::JacobianBuilder;
let evap = make_4port_evaporator_tube_dp();
let state = make_4port_evap_state();
let n_eq = evap.n_equations();
let n_state = state.len();
let mut jb = JacobianBuilder::new();
evap.jacobian_entries(&state, &mut jb).unwrap();
let mut analytic = vec![vec![0.0_f64; n_state]; n_eq];
for &(row, col, v) in jb.entries() {
if row < n_eq && col < n_state {
analytic[row][col] += v;
}
}
// Row 0: r = P_out P_in + ΔP(ṁ, P_in, h_in, h_out).
for col in 0..n_state {
let eps = (state[col].abs() * 1e-6).max(1e-7);
let (mut sp, mut sm) = (state.clone(), state.clone());
sp[col] += eps;
sm[col] -= eps;
let (mut rp, mut rm) = (vec![0.0; n_eq], vec![0.0; n_eq]);
evap.compute_residuals(&sp, &mut rp).unwrap();
evap.compute_residuals(&sm, &mut rm).unwrap();
let fd = (rp[0] - rm[0]) / (2.0 * eps);
let a = analytic[0][col];
let tol = (1e-3 * fd.abs().max(a.abs())).max(1e-9);
assert!(
(a - fd).abs() <= tol,
"momentum J[0][{col}]: analytic={a} vs fd={fd}"
);
}
}
}

View File

@@ -12,7 +12,8 @@
use super::model::{FluidState, HeatTransferModel};
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
Component, ComponentError, ConnectedPort, DomainViolation, JacobianBuilder, ResidualVector,
StateSlice,
};
use entropyk_core::{Calib, MassFlow, Pressure, Temperature};
use entropyk_fluids::{FluidBackend, FluidId as FluidsFluidId, Property, ThermoState};
@@ -607,10 +608,13 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
if cp.is_finite() && cp > 0.0 {
Ok(cp)
} else {
Err(ComponentError::CalculationFailed(format!(
"{} hot-side Cp is invalid: {}",
self.name, cp
)))
// A non-finite Cp means the trial (P, h) state left the
// fluid model's valid envelope (e.g. two-phase region): a
// recoverable domain violation (KINSOL `> 0`).
Err(ComponentError::DomainViolation(DomainViolation {
component: Some(self.name.clone()),
detail: format!("{} hot-side Cp is invalid: {}", self.name, cp),
}))
}
})
}
@@ -625,10 +629,13 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
if cp.is_finite() && cp > 0.0 {
Ok(cp)
} else {
Err(ComponentError::CalculationFailed(format!(
"{} cold-side Cp is invalid: {}",
self.name, cp
)))
// A non-finite Cp means the trial (P, h) state left the
// fluid model's valid envelope (e.g. two-phase region): a
// recoverable domain violation (KINSOL `> 0`).
Err(ComponentError::DomainViolation(DomainViolation {
component: Some(self.name.clone()),
detail: format!("{} cold-side Cp is invalid: {}", self.name, cp),
}))
}
})
}
@@ -652,10 +659,12 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
if t.is_finite() && t > 0.0 {
Ok(t)
} else {
Err(ComponentError::CalculationFailed(format!(
"{} hot-side temperature is invalid: {}",
self.name, t
)))
// Non-finite/negative T at a trial state = recoverable domain
// violation (same class as the Cp check), not a fatal defect.
Err(ComponentError::DomainViolation(DomainViolation {
component: Some(self.name.clone()),
detail: format!("{} hot-side temperature is invalid: {}", self.name, t),
}))
}
})
}
@@ -678,10 +687,12 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
if t.is_finite() && t > 0.0 {
Ok(t)
} else {
Err(ComponentError::CalculationFailed(format!(
"{} cold-side temperature is invalid: {}",
self.name, t
)))
// Non-finite/negative T at a trial state = recoverable domain
// violation (same class as the Cp check), not a fatal defect.
Err(ComponentError::DomainViolation(DomainViolation {
component: Some(self.name.clone()),
detail: format!("{} cold-side temperature is invalid: {}", self.name, t),
}))
}
})
}
@@ -765,20 +776,15 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
}
match self.operational_state {
OperationalState::Off => {
// In OFF mode: Q = 0, mass flow = 0 on both sides
// All residuals should be zero (no heat transfer, no flow)
residuals[0] = 0.0; // Hot side: no energy transfer
residuals[1] = 0.0; // Cold side: no energy transfer
residuals[2] = 0.0; // Energy conservation (Q_hot = Q_cold = 0)
return Ok(());
}
OperationalState::Bypass => {
// In BYPASS mode: Q = 0, mass flow continues
// Temperature continuity (T_out = T_in for both sides)
residuals[0] = 0.0; // Hot side: no energy transfer (adiabatic)
residuals[1] = 0.0; // Cold side: no energy transfer (adiabatic)
residuals[2] = 0.0; // Energy conservation (Q_hot = Q_cold = 0)
OperationalState::Off | OperationalState::Bypass => {
// Q = 0 in both modes (OFF: no flow; BYPASS: adiabatic pass-through).
// Thermal residuals are trivially satisfied; pressure closures stay
// active so the P rows never go singular.
let n_model = self.model.n_equations();
for r in residuals.iter_mut().take(n_model) {
*r = 0.0;
}
self.append_pressure_closures(_state, residuals, n_model)?;
return Ok(());
}
OperationalState::On => {
@@ -800,6 +806,49 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
dynamic_f_ua,
);
self.append_pressure_closures(_state, residuals, self.model.n_equations())?;
Ok(())
}
/// Writes the per-stream isobaric pressure closures in 4-port mode:
/// `P_hot_out P_hot_in = 0` and `P_cold_out P_cold_in = 0`.
///
/// These rows make the Modelica `MassFlowSource_T` (Free P) + `Boundary_pT`
/// sink pattern square: the sink anchors pressure and the exchanger
/// propagates it to the source edge (same convention as `Condenser` /
/// `Evaporator` secondary sides). No-op outside 4-port mode.
fn append_pressure_closures(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
row_start: usize,
) -> Result<(), ComponentError> {
if !self.edges_ready() {
return Ok(());
}
let (_, p_h_in, _) = self.hot_in_idx.unwrap();
let (_, p_h_out, _) = self.hot_out_idx.unwrap();
let (_, p_c_in, _) = self.cold_in_idx.unwrap();
let (_, p_c_out, _) = self.cold_out_idx.unwrap();
let max_idx = [p_h_in, p_h_out, p_c_in, p_c_out]
.into_iter()
.max()
.unwrap_or(0);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
if residuals.len() < row_start + 2 {
return Err(ComponentError::InvalidResidualDimensions {
expected: row_start + 2,
actual: residuals.len(),
});
}
residuals[row_start] = state[p_h_out] - state[p_h_in];
residuals[row_start + 1] = state[p_c_out] - state[p_c_in];
Ok(())
}
}
@@ -838,7 +887,7 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
};
let compute_res = |s: &[f64]| -> [f64; 2] {
let mut r = vec![0.0_f64; 2];
let mut r = vec![0.0_f64; self.n_equations()];
let _ = self.do_compute_residuals(s, &mut r, None);
[r[0], r[1]]
};
@@ -858,6 +907,14 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
}
}
}
// Analytic entries for the isobaric pressure closures (rows after
// the thermal model rows): r = P_out P_in per stream.
let row0 = self.model.n_equations();
_jacobian.add_entry(row0, p_h_out, 1.0);
_jacobian.add_entry(row0, p_h_in, -1.0);
_jacobian.add_entry(row0 + 1, p_c_out, 1.0);
_jacobian.add_entry(row0 + 1, p_c_in, -1.0);
return Ok(());
}
@@ -865,7 +922,13 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
}
fn n_equations(&self) -> usize {
self.model.n_equations()
// 4-port mode adds the two per-stream isobaric pressure closures so the
// Modelica Free-P source + Fixed-P sink boundary pattern stays square.
if self.edges_ready() {
self.model.n_equations() + 2
} else {
self.model.n_equations()
}
}
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {

View File

@@ -134,8 +134,8 @@ mod tests {
fn cool_input() -> FanCoilRatingInput {
FanCoilRatingInput {
t_air_in_k: 300.15, // 27°C
t_dew_in_k: 289.15, // 16°C dew
t_air_in_k: 300.15, // 27°C
t_dew_in_k: 289.15, // 16°C dew
t_water_in_k: 280.15, // 7°C
c_air: 1200.0,
c_water: 2500.0,

View File

@@ -731,6 +731,14 @@ impl StateManageable for FinCoilCondenser {
}
}
// Rendre `design_capacity_w` accessible pour les tests
impl FinCoilCondenser {
#[doc(hidden)]
pub fn design_capacity_w(&self) -> f64 {
self.design_capacity_w
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
@@ -899,11 +907,3 @@ mod tests {
assert_eq!(FinType::from_str("unknown"), FinType::Louvered); // default
}
}
// Rendre `design_capacity_w` accessible pour les tests
impl FinCoilCondenser {
#[doc(hidden)]
pub fn design_capacity_w(&self) -> f64 {
self.design_capacity_w
}
}

View File

@@ -236,9 +236,7 @@ impl FloodedCondenser {
}
match self.compute_subcooling(h_out, p_pa) {
Some(sc) => Ok(sc),
None => Err(ComponentError::InvalidState(format!(
"FloodedCondenser outlet is not subcooled (h_out >= h_sat_l). Use standard Condenser for two-phase outlet."
))),
None => Err(ComponentError::InvalidState("FloodedCondenser outlet is not subcooled (h_out >= h_sat_l). Use standard Condenser for two-phase outlet.".to_string())),
}
}
}
@@ -339,7 +337,7 @@ impl Component for FloodedCondenser {
.with_param("targetSubcoolingK", self.target_subcooling_k)
.with_param(
"calib",
serde_json::to_value(&self.calib()).unwrap_or(serde_json::Value::Null),
serde_json::to_value(self.calib()).unwrap_or(serde_json::Value::Null),
)
}

View File

@@ -223,18 +223,14 @@ impl FloodedEvaporator {
fn secondary_delta_p(&self, m_sec: f64) -> f64 {
match self.secondary_pressure_drop_coeff {
Some(k) if k > 0.0 => {
crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_sec)
}
Some(k) if k > 0.0 => crate::heat_exchanger::two_phase_dp::quadratic_drop(k, m_sec),
_ => 0.0,
}
}
fn secondary_delta_p_dm(&self, m_sec: f64) -> f64 {
match self.secondary_pressure_drop_coeff {
Some(k) if k > 0.0 => {
crate::heat_exchanger::two_phase_dp::quadratic_drop_dm(k, m_sec)
}
Some(k) if k > 0.0 => crate::heat_exchanger::two_phase_dp::quadratic_drop_dm(k, m_sec),
_ => 0.0,
}
}
@@ -485,9 +481,7 @@ impl FloodedEvaporator {
),
)
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"FloodedEvaporator secondary Cp: {e}"
))
ComponentError::CalculationFailed(format!("FloodedEvaporator secondary Cp: {e}"))
})?;
if cp.is_finite() && cp > 0.0 {
Ok(cp)
@@ -515,9 +509,7 @@ impl FloodedEvaporator {
),
)
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"FloodedEvaporator secondary T: {e}"
))
ComponentError::CalculationFailed(format!("FloodedEvaporator secondary T: {e}"))
})?;
if t.is_finite() && t > 0.0 {
Ok(t)
@@ -553,15 +545,13 @@ impl FloodedEvaporator {
/// Resolves refrigerant mass-flow state index (never falls back to pressure).
fn resolved_mass_idx(&self) -> Result<usize, ComponentError> {
self.inlet_m_idx
.or(self.outlet_m_idx)
.ok_or_else(|| {
ComponentError::InvalidState(
"FloodedEvaporator: mass-flow state index not resolved \
self.inlet_m_idx.or(self.outlet_m_idx).ok_or_else(|| {
ComponentError::InvalidState(
"FloodedEvaporator: mass-flow state index not resolved \
(cannot fall back to a pressure index)"
.into(),
)
})
.into(),
)
})
}
/// Refrigerant port indices + backend required for ε-NTU coupling.
@@ -633,9 +623,7 @@ impl FloodedEvaporator {
FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(1.0)),
)
.map_err(|e| {
ComponentError::CalculationFailed(format!(
"FloodedEvaporator h_g(P) failed: {e}"
))
ComponentError::CalculationFailed(format!("FloodedEvaporator h_g(P) failed: {e}"))
})
}
@@ -869,8 +857,7 @@ impl FloodedEvaporator {
if self.secondary_edges_ready() {
let (m_in, p_in_s, h_in_s) = self.sec_in_idx.unwrap();
let (m_out, p_out_s, h_out_s) = self.sec_out_idx.unwrap();
residuals[row] =
state[p_out_s] - state[p_in_s] + self.secondary_delta_p(state[m_in]);
residuals[row] = state[p_out_s] - state[p_in_s] + self.secondary_delta_p(state[m_in]);
row += 1;
if !self.sec_same_branch() {
residuals[row] = state[m_out] - state[m_in];
@@ -995,7 +982,7 @@ impl Component for FloodedEvaporator {
}
fn set_port_context(&mut self, port_edges: &[Option<(usize, usize, usize)>]) {
if let Some(Some((m, p, h))) = port_edges.get(0) {
if let Some(Some((m, p, h))) = port_edges.first() {
self.inlet_m_idx = Some(*m);
self.inlet_p_idx = Some(*p);
self.inlet_h_idx = Some(*h);
@@ -1113,11 +1100,7 @@ impl Component for FloodedEvaporator {
let m_sec = state[m_in];
let alpha_r = flow_activity(m_ref, DEFAULT_M_EPS_KG_S);
let alpha_s = flow_activity(m_sec, DEFAULT_M_EPS_KG_S);
(
effective_duty(q, alpha_r, alpha_s),
alpha_s,
Some(m_sec),
)
(effective_duty(q, alpha_r, alpha_s), alpha_s, Some(m_sec))
} else {
(q, 1.0, None)
};
@@ -1435,7 +1418,7 @@ impl Component for FloodedEvaporator {
.with_param("targetQuality", self.target_quality)
.with_param(
"calib",
serde_json::to_value(&self.calib()).unwrap_or(serde_json::Value::Null),
serde_json::to_value(self.calib()).unwrap_or(serde_json::Value::Null),
)
}
@@ -1790,8 +1773,8 @@ mod tests {
/// Zero / near-zero mass flow must keep residuals and Jacobian finite (no NaN/Inf).
#[test]
fn test_zero_and_near_zero_flow_residuals_finite() {
use std::sync::Arc;
use crate::Component;
use std::sync::Arc;
let backend = Arc::new(entropyk_fluids::TestBackend::new());
let p_in = 350_000.0_f64;
@@ -1856,8 +1839,8 @@ mod tests {
/// so the secondary energy residual reduces to a hold on Δh_sec.
#[test]
fn test_zero_secondary_flow_holds_enthalpy_transport() {
use std::sync::Arc;
use crate::Component;
use std::sync::Arc;
let backend = Arc::new(entropyk_fluids::TestBackend::new());
let p_in = 350_000.0;
@@ -1948,7 +1931,10 @@ mod tests {
let state = vec![0.0, 0.0, 300_000.0, 400_000.0, 0.0, 0.0];
let mut residuals = vec![0.0; 3];
let result = evap.compute_residuals(&state, &mut residuals);
assert!(result.is_ok(), "partial wire must use seed path: {result:?}");
assert!(
result.is_ok(),
"partial wire must use seed path: {result:?}"
);
assert!(residuals.iter().all(|r| r.is_finite()));
}

View File

@@ -114,7 +114,7 @@ impl MchxCondenserCoil {
/// UA correction is relative to this point.
pub fn new(ua_nominal: f64, n_air: f64, coil_index: usize) -> Self {
assert!(ua_nominal >= 0.0, "UA must be non-negative");
assert!(n_air >= 0.0 && n_air <= 1.5, "n_air must be in [0, 1.5]");
assert!((0.0..=1.5).contains(&n_air), "n_air must be in [0, 1.5]");
Self {
inner: Condenser::new(ua_nominal),

View File

@@ -61,8 +61,8 @@ pub mod eps_ntu;
pub mod evaporator;
pub mod evaporator_coil;
pub mod exchanger;
pub mod fin_coil_condenser;
pub mod fan_coil_unit;
pub mod fin_coil_condenser;
pub mod flooded_condenser;
pub mod flooded_evaporator;
pub mod flow_regularization;
@@ -72,7 +72,9 @@ pub mod mchx_condenser_coil;
pub mod model;
pub mod moving_boundary_hx;
pub mod pool_boiling;
pub mod sat_domain;
pub mod shell_and_tube;
pub mod tube_dp;
pub mod two_phase_dp;
pub use air_cooled_condenser::AirCooledCondenser;
@@ -100,22 +102,18 @@ pub use eps_ntu::{EpsNtuModel, ExchangerType};
pub use evaporator::Evaporator;
pub use evaporator_coil::EvaporatorCoil;
pub use exchanger::{HeatExchanger, HeatExchangerBuilder, HxSideConditions};
pub use fan_coil_unit::{FanCoilRating, FanCoilRatingInput, FanCoilUnit};
pub use fin_coil_condenser::{CoilGeometry, FinCoilCondenser, FinType};
pub use flooded_condenser::FloodedCondenser;
pub use fan_coil_unit::{FanCoilRating, FanCoilRatingInput, FanCoilUnit};
pub use flooded_evaporator::{
EvaporatorRating, FloodedEvaporator, FloodedPoolBoilingConfig, UaMode,
};
pub use gas_cooler::{is_supercritical, pettersen_htc, GasCooler, GasCoolerInput};
pub use shell_and_tube::{
bell_delaware_factors, default_tube_params, shell_and_tube_ua, shell_side_htc, tube_side_htc,
BellDelawareFactors, BellDelawareGeometry, ShellAndTubeHx,
};
pub use flow_regularization::{
blend_transport_partials, blend_transport_residual, effective_duty, flow_activity,
flow_activity_derivative, smooth_mass_magnitude, smooth_mass_magnitude_derivative,
DEFAULT_M_EPS_KG_S, DEFAULT_M_SCALE_KG_S,
};
pub use gas_cooler::{is_supercritical, pettersen_htc, GasCooler, GasCoolerInput};
pub use lmtd::{FlowConfiguration, LmtdModel};
pub use mchx_condenser_coil::MchxCondenserCoil;
pub use model::HeatTransferModel;
@@ -123,13 +121,16 @@ pub use pool_boiling::{
assess_cooper_domain, cooper_1984, cooper_metadata, flooded_shell_htc, mostinski_1963,
palen_bundle_factor, thome_robinson_oil_factor, ua_from_two_side_htc, PoolBoilingInput,
};
pub use shell_and_tube::{
bell_delaware_factors, default_tube_params, shell_and_tube_ua, shell_side_htc, tube_side_htc,
BellDelawareFactors, BellDelawareGeometry, ShellAndTubeHx,
};
pub use two_phase_dp::{
acceleration_drop, assess_friedel_domain, assess_msh_domain, calibrate_quadratic_k,
default_refrigerant_pressure_drop_coeff, friedel_gradient, friedel_metadata,
friedel_multiplier, friedel_pressure_drop, msh_gradient, msh_metadata, msh_pressure_drop,
parse_dp_model_name, quadratic_drop, quadratic_drop_dm, tube_two_phase_delta_p,
zivi_void_fraction, FriedelInput, SatTransportProps, TubeChannelGeometry,
DEFAULT_N_PARALLEL_TUBES, DEFAULT_REFRIGERANT_DP_NOMINAL_PA,
TwoPhaseDpCorrelation, DEFAULT_N_PARALLEL_TUBES, DEFAULT_REFRIGERANT_DP_NOMINAL_PA,
DEFAULT_REFRIGERANT_M_NOMINAL_KG_S, DEFAULT_TUBE_DIAMETER_M, DEFAULT_TUBE_LENGTH_M,
TwoPhaseDpCorrelation,
};

View File

@@ -128,12 +128,7 @@ pub fn flooded_shell_htc(
/// UA from refrigerant-side HTC and secondary-side HTC [W/K].
///
/// `1/UA = 1/(h_ref·A_ref) + 1/(h_sec·A_sec)` (wall resistance neglected).
pub fn ua_from_two_side_htc(
h_ref: f64,
area_ref_m2: f64,
h_sec: f64,
area_sec_m2: f64,
) -> f64 {
pub fn ua_from_two_side_htc(h_ref: f64, area_ref_m2: f64, h_sec: f64, area_sec_m2: f64) -> f64 {
let r_ref = 1.0 / (h_ref.max(1.0) * area_ref_m2.max(1e-9));
let r_sec = 1.0 / (h_sec.max(1.0) * area_sec_m2.max(1e-9));
1.0 / (r_ref + r_sec)
@@ -206,13 +201,8 @@ mod tests {
#[test]
fn flooded_shell_applies_bundle_and_oil() {
let base = cooper_1984(&r134a_like()).unwrap();
let combined = flooded_shell_htc(
&r134a_like(),
CorrelationId::Cooper1984,
0.8,
0.05,
)
.unwrap();
let combined =
flooded_shell_htc(&r134a_like(), CorrelationId::Cooper1984, 0.8, 0.05).unwrap();
assert!(combined < base * 0.8);
assert!(combined > 0.0);
}

View File

@@ -0,0 +1,218 @@
//! Saturation pressure domain detection for robust two-phase property queries.
//!
//! During Newton iterations the solver may probe states whose pressure lies
//! outside the fluid's saturation domain (below the triple point or above the
//! critical point). Saturated-property queries (`FluidState::from_px`) fail
//! there; silently falling back to a zero or quadratic ΔP makes the residual
//! **discontinuous**, which breaks line search (observed on DX evaporators
//! with tube MSH at full EXV opening: Newton stalls, then Picard explodes).
//!
//! Clamping the *query* pressure into the detected saturation domain keeps
//! every residual defined and smooth along the whole search path. The
//! converged solution still lies inside the true domain — the clamp only
//! regularizes intermediate iterates, exactly like the critical-point damping
//! already used by the fluids crate.
use entropyk_core::Pressure;
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property, Quality};
use std::collections::HashMap;
use std::sync::{Arc, OnceLock, RwLock};
/// Relative margin kept below the critical pressure for saturated queries.
const CRITICAL_MARGIN: f64 = 1e-6;
/// Absolute lower bound probed for the saturation domain [Pa].
const ABSOLUTE_PROBE_MIN_PA: f64 = 1.0;
/// Bisection iterations for the lower-domain search (1 Pa → P_crit needs ~42).
const BISECTION_ITERS: usize = 48;
/// Per-fluid cache of detected saturation domains. `None` means "detection
/// failed — do not clamp, keep the caller's legacy behavior".
static DOMAIN_CACHE: OnceLock<RwLock<HashMap<String, Option<(f64, f64)>>>> = OnceLock::new();
fn cache() -> &'static RwLock<HashMap<String, Option<(f64, f64)>>> {
DOMAIN_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
}
/// Detected saturation pressure domain `[P_min, P_max]` [Pa] for `fluid`,
/// probed once through `backend` and cached.
///
/// * `P_max` is the critical pressure from
/// [`FluidBackend::critical_point`] minus a small relative margin.
/// * `P_min` is found by bisection: the lowest pressure at which a saturated
/// density query succeeds (≈ triple-point pressure for real fluids).
///
/// Returns `None` when the fluid has no usable saturation domain on this
/// backend (incompressible fluids, unavailable critical point, or a backend
/// that fails saturated queries everywhere).
pub fn saturation_pressure_domain(
backend: &Arc<dyn FluidBackend>,
fluid: &str,
) -> Option<(f64, f64)> {
if let Some(entry) = cache().read().ok().and_then(|map| map.get(fluid).copied()) {
return entry;
}
let detected = detect_domain(backend, fluid);
if let Ok(mut map) = cache().write() {
map.insert(fluid.to_string(), detected);
}
detected
}
/// Clamps `p_pa` into the detected saturation domain of `fluid`.
///
/// Returns `Some(clamped_pressure)` when the domain is known, `None` when it
/// could not be detected — callers must then keep their current behavior.
/// Inside the domain this is the identity, so in-domain runs are unaffected.
pub fn clamp_to_saturation_domain(
backend: &Arc<dyn FluidBackend>,
fluid: &str,
p_pa: f64,
) -> Option<f64> {
let (p_min, p_max) = saturation_pressure_domain(backend, fluid)?;
if !p_pa.is_finite() {
// Non-finite probe pressures (NaN/±inf from a blown-up iterate) are
// pulled to the nearest bound so residual evaluation stays defined.
return Some(if p_pa.is_sign_negative() {
p_min
} else {
p_max
});
}
Some(p_pa.clamp(p_min, p_max))
}
/// Invalidates the cached domain for one fluid (test hook).
#[cfg(test)]
pub fn clear_cached_domain(fluid: &str) {
if let Ok(mut map) = cache().write() {
map.remove(fluid);
}
}
fn detect_domain(backend: &Arc<dyn FluidBackend>, fluid: &str) -> Option<(f64, f64)> {
let fluid_id = FluidId::new(fluid);
let critical = backend.critical_point(fluid_id.clone()).ok()?;
let p_crit = critical.pressure.to_pascals();
if !p_crit.is_finite() || p_crit <= ABSOLUTE_PROBE_MIN_PA {
return None;
}
let probe_ok = |p_pa: f64| -> bool {
backend
.property(
fluid_id.clone(),
Property::Density,
FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(0.5)),
)
.map(|rho| rho.is_finite() && rho > 0.0)
.unwrap_or(false)
};
// Phase 1: locate ONE pressure inside the saturated domain. Backends may
// tabulate saturation strictly below the critical pressure, so probe a
// ladder of candidates rather than assuming P_crit itself works.
let anchor = [
p_crit * (1.0 - CRITICAL_MARGIN),
p_crit * 0.5,
p_crit * 0.1,
p_crit * 0.05,
p_crit * 0.01,
1.0e5,
1.0e4,
1.0e3,
]
.into_iter()
.find(|&p| p > ABSOLUTE_PROBE_MIN_PA && probe_ok(p))?;
// Phase 2: bisect downward for P_min (lo fails, hi succeeds).
let p_min = if probe_ok(ABSOLUTE_PROBE_MIN_PA) {
ABSOLUTE_PROBE_MIN_PA
} else {
let mut lo = ABSOLUTE_PROBE_MIN_PA;
let mut hi = anchor;
for _ in 0..BISECTION_ITERS {
let mid = (lo * hi).sqrt(); // geometric: domain spans decades
if probe_ok(mid) {
hi = mid;
} else {
lo = mid;
}
}
hi
};
// Phase 3: bisect upward for P_max (lo succeeds, hi fails or is P_crit).
let p_max = {
let mut lo = anchor;
let mut hi = p_crit;
if probe_ok(p_crit * (1.0 - CRITICAL_MARGIN)) {
p_crit * (1.0 - CRITICAL_MARGIN)
} else {
for _ in 0..BISECTION_ITERS {
let mid = (lo * hi).sqrt();
if probe_ok(mid) {
lo = mid;
} else {
hi = mid;
}
}
lo
}
};
if p_min >= p_max {
return None;
}
Some((p_min, p_max))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_domain_detected_for_r134a_on_test_backend() {
let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
clear_cached_domain("R134a");
let (p_min, p_max) = saturation_pressure_domain(&backend, "R134a")
.expect("R134a saturation domain must be detectable");
// The detected domain must match the backend's actual saturation
// coverage (the TestBackend table ends around 13 bar, below the
// R134a critical pressure — detection must not assume P_crit works).
assert!(p_max > 1.0e5, "p_max={p_max}");
assert!(p_min >= ABSOLUTE_PROBE_MIN_PA, "p_min={p_min}");
assert!(p_min < p_max);
}
#[test]
fn test_clamp_is_identity_inside_domain() {
let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
clear_cached_domain("R134a");
let p = 5.0e5; // 5 bar, inside R134a domain
let clamped = clamp_to_saturation_domain(&backend, "R134a", p).unwrap();
assert_eq!(clamped, p);
}
#[test]
fn test_clamp_pulls_extreme_pressures_into_domain() {
let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
clear_cached_domain("R134a");
let (p_min, p_max) = saturation_pressure_domain(&backend, "R134a").unwrap();
assert_eq!(
clamp_to_saturation_domain(&backend, "R134a", 1e-9).unwrap(),
p_min
);
assert_eq!(
clamp_to_saturation_domain(&backend, "R134a", 1e12).unwrap(),
p_max
);
assert_eq!(
clamp_to_saturation_domain(&backend, "R134a", f64::NEG_INFINITY).unwrap(),
p_min
);
assert_eq!(
clamp_to_saturation_domain(&backend, "R134a", f64::NAN).unwrap(),
p_max
);
}
}

View File

@@ -64,8 +64,7 @@ pub struct BellDelawareFactors {
/// Computes J-factors from geometry (Taborek / Delaware handbook forms).
pub fn bell_delaware_factors(geom: &BellDelawareGeometry) -> BellDelawareFactors {
let j_c = 0.55 + 0.72 * geom.f_c.clamp(0.0, 1.0);
let j_l = 0.44 * (1.0 - geom.r_s)
+ (1.0 - 0.44 * (1.0 - geom.r_s)) * (-2.2 * geom.r_lm).exp();
let j_l = 0.44 * (1.0 - geom.r_s) + (1.0 - 0.44 * (1.0 - geom.r_s)) * (-2.2 * geom.r_lm).exp();
BellDelawareFactors {
j_c: j_c.clamp(0.65, 1.15),
j_l: j_l.clamp(0.2, 1.0),
@@ -88,9 +87,7 @@ pub fn tube_side_htc(
pool: Option<&PoolBoilingInput>,
) -> f64 {
match correlation {
CorrelationId::Cooper1984 => pool
.and_then(|p| cooper_1984(p).ok())
.unwrap_or(3000.0),
CorrelationId::Cooper1984 => pool.and_then(|p| cooper_1984(p).ok()).unwrap_or(3000.0),
CorrelationId::Gnielinski1976 => BphxCorrelation::Gnielinski1976.compute_htc(params).h,
CorrelationId::Shah2009 => BphxCorrelation::Shah2009.compute_htc(params).h,
CorrelationId::Shah1979 => BphxCorrelation::Shah1979.compute_htc(params).h,
@@ -101,10 +98,7 @@ pub fn tube_side_htc(
}
/// Combined UA [W/K] from shell and tube sides (wall resistance neglected).
pub fn shell_and_tube_ua(
geom: &BellDelawareGeometry,
h_tube: f64,
) -> f64 {
pub fn shell_and_tube_ua(geom: &BellDelawareGeometry, h_tube: f64) -> f64 {
let h_shell = shell_side_htc(geom);
let r = 1.0 / (h_shell.max(1.0) * geom.area_shell_m2.max(1e-9))
+ 1.0 / (h_tube.max(1.0) * geom.area_tube_m2.max(1e-9));

View File

@@ -0,0 +1,598 @@
//! Total, C¹ tube two-phase ΔP evaluation and exact momentum-row Jacobian
//! pieces for the DX heat exchangers (condenser / evaporator).
//!
//! ## Why this module exists (Epic-0 / Story 0.2 standard)
//!
//! The tube two-phase ΔP previously relied on `Option`-returning saturation
//! queries that silently fell through to the lumped-quadratic (or zero) model
//! whenever a backend query failed at an extreme Newton iterate — a residual
//! *model switch* mid-solve — and its momentum-row Jacobian entries were
//! whole-ΔP central finite differences through the fluid backend (~72 CoolProp
//! calls per assembly, straddling every clamp and blend in the path).
//!
//! This module makes the evaluation **total** and **C¹** (see
//! `docs/components/phantom-gradient-regularization.md`):
//!
//! * the query pressure is clamped into the detected saturation domain with a
//! C¹ [`smooth_clamp`] (derivative exposed for the chain rule);
//! * the latent heat in the quality ratio is floored with [`smooth_max`] so
//! near-critical iterates stay finite and smooth (qualities are *not*
//! clamped — the correlations accept `x < 0` / `x > 1`, see
//! [`tube_two_phase_delta_p`]);
//! * surface tension keeps its bounded fallback (`0.008 N/m`), and a hard
//! backend failure is a **recoverable** [`ComponentError::DomainViolation`]
//! (Story 1.3) — never a silent switch to a different ΔP model;
//! * the Jacobian pieces are **analytic** through the whole composition
//! (ṁ → mass flux → friction + acceleration; h → quality; P → saturation
//! properties), with *narrow* domain-safe finite differences only for the
//! individual saturation-property pressure derivatives that the thin
//! CoolProp FFI does not expose (NFR9 deviation, recorded in rustdoc on
//! [`sat_dp_full_state`]).
//!
//! The only path that still selects the legacy lumped arm is **solve
//! invariant** (no backend, empty refrigerant id, or no detectable saturation
//! domain on this fluid): the active model can never change during a solve.
use std::sync::Arc;
use entropyk_core::smoothing::{smooth_clamp, smooth_clamp_derivative, smooth_max};
use entropyk_core::Pressure;
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property, Quality};
use crate::heat_exchanger::sat_domain;
use crate::heat_exchanger::two_phase_dp::{
tube_two_phase_delta_p_partials, SatTransportProps, TubeChannelGeometry, TwoPhaseDpCorrelation,
};
use crate::ComponentError;
/// Relative width of the C¹ clamp band at the saturation-domain bounds
/// (Story 0.2 guidance: physically small — ~4 kPa on the R134a domain).
const CLAMP_REL_WIDTH: f64 = 1e-3;
/// Floor on the latent heat used in the quality ratio [J/kg].
///
/// Well below any physical latent heat away from the critical point (R134a:
/// ~150220 kJ/kg), so it only regularizes near-critical iterates where the
/// quality ratio would otherwise explode; see [`smooth_max`] selection
/// guidance in the Story 0.2 standard.
const H_FG_FLOOR_JKG: f64 = 1.0e3;
/// Sharpness of the [`smooth_max`] latent-heat floor [J/kg] (overshoot
/// `k/2 = 50 J/kg`, negligible against the 1 kJ/g floor).
const H_FG_FLOOR_K: f64 = 1.0e2;
/// Bounded fallback for the surface tension when the backend cannot provide
/// it (same legacy value as the former `sat_transport_at_p`; used by Friedel
/// only, MSH does not consume σ).
const SIGMA_FALLBACK: f64 = 0.008;
/// Saturated state backing a tube-ΔP **residual** evaluation (value path).
#[derive(Debug, Clone, Copy)]
pub struct SatDpValue {
/// Clamped pressure actually used for the saturation queries [Pa].
pub p_sat: f64,
/// Saturated-liquid enthalpy `h_f(p_sat)` [J/kg].
pub h_f: f64,
/// Smooth-floored latent heat `max⁺(h_g h_f, H_FG_FLOOR)` [J/kg].
pub h_fg_eff: f64,
/// Saturated transport properties at `p_sat`.
pub props: SatTransportProps,
}
impl SatDpValue {
/// Thermodynamic quality `(h h_f) / h_fg_eff` — **unclamped** (linear
/// extrapolation outside the dome; the correlations accept `x < 0` and
/// `x > 1`).
#[inline]
pub fn quality(&self, h: f64) -> f64 {
(h - self.h_f) / self.h_fg_eff
}
}
/// [`SatDpValue`] plus the pressure derivatives needed by the momentum-row
/// Jacobian (narrow, domain-safe FD — see [`sat_dp_full_state`]).
#[derive(Debug, Clone, Copy)]
pub struct SatDpFull {
/// Value-path state.
pub value: SatDpValue,
/// `d(p_sat)/dp` through the C¹ clamp (1 inside the domain, 0 far outside).
pub dp_sat_dp: f64,
/// `d h_f / dp` at `p_sat` [J/kg/Pa].
pub dh_f_dp: f64,
/// `d(h_fg_eff)/dp` at `p_sat` (through the smooth floor) [J/kg/Pa].
pub dh_fg_eff_dp: f64,
/// Pressure derivatives of the transport properties at `p_sat`.
pub dprops_dp: SatTransportProps,
}
/// Value + exact partials of the tube ΔP for the momentum-row Jacobian.
#[derive(Debug, Clone, Copy, Default)]
pub struct TubeDpEvaluation {
/// ΔP value [Pa].
pub value: f64,
/// ∂ΔP/∂ṁ [Pa·s/kg].
pub d_dm: f64,
/// ∂ΔP/∂P_in [Pa/Pa].
pub d_dp: f64,
/// ∂ΔP/∂h_in [Pa/(J/kg)].
pub d_dh_in: f64,
/// ∂ΔP/∂h_out [Pa/(J/kg)].
pub d_dh_out: f64,
}
/// Raw saturated values shared by the value and full paths.
struct RawSat {
p_sat: f64,
dp_sat_dp: f64,
h_f: f64,
h_g: f64,
props: SatTransportProps,
}
/// Queries the raw saturated state at the C¹-clamped pressure.
///
/// Returns `Ok(None)` only for **solve-invariant** gaps: the fluid has no
/// detectable saturation domain on this backend (deterministic per fluid, see
/// [`sat_domain`]). A hard backend failure on a clamped query is a recoverable
/// [`ComponentError::DomainViolation`] — never a silent model switch.
fn raw_sat_state(
backend: &Arc<dyn FluidBackend>,
refrigerant_id: &str,
p_pa: f64,
) -> Result<Option<RawSat>, ComponentError> {
if refrigerant_id.is_empty() {
return Ok(None);
}
let Some((p_min, p_max)) = sat_domain::saturation_pressure_domain(backend, refrigerant_id)
else {
return Ok(None);
};
let width = (p_max - p_min) * CLAMP_REL_WIDTH;
let (p_sat, dp_sat_dp) = if !p_pa.is_finite() {
// Non-finite probe pressures (NaN/±inf from a blown-up iterate) are
// pinned to the nearest bound with zero slope, mirroring
// `sat_domain::clamp_to_saturation_domain`.
(
if p_pa.is_sign_negative() {
p_min
} else {
p_max
},
0.0,
)
} else {
(
smooth_clamp(p_pa, p_min, p_max, width),
smooth_clamp_derivative(p_pa, p_min, p_max, width),
)
};
let p = Pressure::from_pascals(p_sat);
let query = |x: f64, prop: Property, what: &str| -> Result<f64, ComponentError> {
backend
.property(
FluidId::new(refrigerant_id),
prop,
FluidState::from_px(p, Quality::new(x)),
)
.map_err(|e| {
ComponentError::from_fluid_error_context(
&format!("tube ΔP saturation query {what} failed at P={p_sat:.6e} Pa"),
e,
)
})
};
let h_f = query(0.0, Property::Enthalpy, "h_f")?;
let h_g = query(1.0, Property::Enthalpy, "h_g")?;
let rho_liquid = query(0.0, Property::Density, "rho_liquid")?;
let rho_vapor = query(1.0, Property::Density, "rho_vapor")?;
let mu_liquid = query(0.0, Property::Viscosity, "mu_liquid")?;
let mu_vapor = query(1.0, Property::Viscosity, "mu_vapor")?;
// Bounded fallback for σ (Friedel only) — never a model switch.
let sigma = query(0.5, Property::SurfaceTension, "sigma").unwrap_or(SIGMA_FALLBACK);
Ok(Some(RawSat {
p_sat,
dp_sat_dp,
h_f,
h_g,
props: SatTransportProps {
rho_liquid,
rho_vapor,
mu_liquid,
mu_vapor,
sigma,
},
}))
}
/// Value-path saturated state for the residual (no derivative FDs).
///
/// `Ok(None)` only on solve-invariant gaps (see [`raw_sat_state`]).
pub fn sat_dp_value_state(
backend: &Arc<dyn FluidBackend>,
refrigerant_id: &str,
p_pa: f64,
) -> Result<Option<SatDpValue>, ComponentError> {
Ok(
raw_sat_state(backend, refrigerant_id, p_pa)?.map(|raw| SatDpValue {
p_sat: raw.p_sat,
h_f: raw.h_f,
h_fg_eff: smooth_max(raw.h_g - raw.h_f, H_FG_FLOOR_JKG, H_FG_FLOOR_K),
props: raw.props,
}),
)
}
/// Central FD of a single saturation property on the *clamped* pressure,
/// with one-sided fallbacks at the domain bounds.
///
/// This is the **narrow** domain-safe FD allowed by NFR9: it differentiates
/// one scalar backend property (`h_f`, `h_g`, `ρ_l`, `ρ_v`, `μ_l`, `μ_v`, `σ`)
/// rather than the whole ΔP composition. The thin CoolProp FFI
/// (`crates/fluids/coolprop-sys`) only exposes `PropsSI` values, so analytic
/// saturation derivatives are unavailable; the step (`max(p·1e-6, 1 Pa)`)
/// stays inside the detected domain and degenerates to one-sided differences
/// at the bounds (zero slope when the query pressure is pinned).
fn sat_prop_dp(
backend: &Arc<dyn FluidBackend>,
refrigerant_id: &str,
prop: Property,
quality: f64,
p_sat: f64,
p_min: f64,
p_max: f64,
) -> f64 {
let query = |p_pa: f64| -> Option<f64> {
backend
.property(
FluidId::new(refrigerant_id),
prop,
FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(quality)),
)
.ok()
.filter(|v| v.is_finite())
};
let eps = (p_sat * 1e-6).max(1.0);
let p_hi = (p_sat + eps).min(p_max);
let p_lo = (p_sat - eps).max(p_min);
let v_hi = query(p_hi);
let v_lo = query(p_lo);
if let (Some(hi), Some(lo)) = (v_hi, v_lo) {
if p_hi > p_lo {
return (hi - lo) / (p_hi - p_lo);
}
}
// One-sided fallbacks at the domain bounds.
let v_c = query(p_sat);
if let (Some(hi), Some(c)) = (v_hi, v_c) {
if p_hi > p_sat {
return (hi - c) / (p_hi - p_sat);
}
}
if let (Some(lo), Some(c)) = (v_lo, v_c) {
if p_sat > p_lo {
return (c - lo) / (p_sat - p_lo);
}
}
0.0
}
/// Full saturated state for the momentum-row Jacobian: value-path data plus
/// the saturation-property pressure derivatives.
///
/// `Ok(None)` only on solve-invariant gaps (see [`raw_sat_state`]).
pub fn sat_dp_full_state(
backend: &Arc<dyn FluidBackend>,
refrigerant_id: &str,
p_pa: f64,
) -> Result<Option<SatDpFull>, ComponentError> {
let Some((p_min, p_max)) = sat_domain::saturation_pressure_domain(backend, refrigerant_id)
else {
return Ok(None);
};
let Some(raw) = raw_sat_state(backend, refrigerant_id, p_pa)? else {
return Ok(None);
};
let h_fg = raw.h_g - raw.h_f;
let h_fg_eff = smooth_max(h_fg, H_FG_FLOOR_JKG, H_FG_FLOOR_K);
// Narrow domain-safe FDs on the individual saturation properties (NFR9
// recorded deviation — the only non-analytic pieces of the composition).
let dh_f_dp = sat_prop_dp(
backend,
refrigerant_id,
Property::Enthalpy,
0.0,
raw.p_sat,
p_min,
p_max,
);
let dh_g_dp = sat_prop_dp(
backend,
refrigerant_id,
Property::Enthalpy,
1.0,
raw.p_sat,
p_min,
p_max,
);
// d(h_fg_eff)/dp through the smooth floor: d(smooth_max)/dp = s·(h_g'h_f')
// with s = smooth_max_derivative(h_fg, floor, k).
let floor_slope =
entropyk_core::smoothing::smooth_max_derivative(h_fg, H_FG_FLOOR_JKG, H_FG_FLOOR_K);
let dprops_dp = SatTransportProps {
rho_liquid: sat_prop_dp(
backend,
refrigerant_id,
Property::Density,
0.0,
raw.p_sat,
p_min,
p_max,
),
rho_vapor: sat_prop_dp(
backend,
refrigerant_id,
Property::Density,
1.0,
raw.p_sat,
p_min,
p_max,
),
mu_liquid: sat_prop_dp(
backend,
refrigerant_id,
Property::Viscosity,
0.0,
raw.p_sat,
p_min,
p_max,
),
mu_vapor: sat_prop_dp(
backend,
refrigerant_id,
Property::Viscosity,
1.0,
raw.p_sat,
p_min,
p_max,
),
sigma: if raw.props.sigma == SIGMA_FALLBACK {
0.0 // constant fallback active: no pressure dependence
} else {
sat_prop_dp(
backend,
refrigerant_id,
Property::SurfaceTension,
0.5,
raw.p_sat,
p_min,
p_max,
)
},
};
Ok(Some(SatDpFull {
value: SatDpValue {
p_sat: raw.p_sat,
h_f: raw.h_f,
h_fg_eff,
props: raw.props,
},
dp_sat_dp: raw.dp_sat_dp,
dh_f_dp,
dh_fg_eff_dp: floor_slope * (dh_g_dp - dh_f_dp),
dprops_dp,
}))
}
/// Tube two-phase ΔP value + **exact** partials (∂/∂ṁ, ∂/∂P_in, ∂/∂h_in,
/// ∂/∂h_out) for the momentum-row Jacobian (NFR9).
///
/// The composition is fully analytic except the individual
/// saturation-property pressure derivatives (narrow domain-safe FDs, see
/// [`sat_dp_full_state`]). Qualities are unclamped linear extrapolations
/// `(h h_f)/h_fg_eff`, matching the residual evaluation exactly.
pub fn tube_dp_evaluation(
correlation: TwoPhaseDpCorrelation,
geom: &TubeChannelGeometry,
mass_flow: f64,
h_in: f64,
h_out: f64,
state: &SatDpFull,
) -> TubeDpEvaluation {
let st = &state.value;
let x_in = st.quality(h_in);
let x_out = st.quality(h_out);
let p = tube_two_phase_delta_p_partials(correlation, geom, mass_flow, x_in, x_out, &st.props);
// ∂ΔP/∂h = (∂ΔP/∂x) · (1 / h_fg_eff).
let d_dh_in = p.d_dx_in / st.h_fg_eff;
let d_dh_out = p.d_dx_out / st.h_fg_eff;
// ∂x/∂P at fixed h, with x = (h h_f)/E and E = h_fg_eff:
// ∂x/∂P = (h_f'·E (h h_f)·E') / E²
let e2 = st.h_fg_eff * st.h_fg_eff;
let dx_in_dp = (-state.dh_f_dp * st.h_fg_eff - (h_in - st.h_f) * state.dh_fg_eff_dp) / e2;
let dx_out_dp = (-state.dh_f_dp * st.h_fg_eff - (h_out - st.h_f) * state.dh_fg_eff_dp) / e2;
// ∂ΔP/∂P through the saturated state, then through the C¹ pressure clamp.
let dprops = &state.dprops_dp;
let d_dp_sat = p.d_dx_in * dx_in_dp
+ p.d_dx_out * dx_out_dp
+ p.d_drho_liquid * dprops.rho_liquid
+ p.d_drho_vapor * dprops.rho_vapor
+ p.d_dmu_liquid * dprops.mu_liquid
+ p.d_dmu_vapor * dprops.mu_vapor
+ p.d_dsigma * dprops.sigma;
TubeDpEvaluation {
value: p.value,
d_dm: p.d_dm,
d_dp: d_dp_sat * state.dp_sat_dp,
d_dh_in,
d_dh_out,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The value-path quality must be the plain unclamped ratio (matching the
/// residual) and stay finite with the latent-heat floor.
#[test]
fn quality_is_unclamped_and_floored() {
let v = SatDpValue {
p_sat: 1.0e6,
h_f: 200_000.0,
h_fg_eff: 150_000.0,
props: SatTransportProps {
rho_liquid: 1200.0,
rho_vapor: 20.0,
mu_liquid: 200e-6,
mu_vapor: 11e-6,
sigma: 0.008,
},
};
assert!((v.quality(275_000.0) - 0.5).abs() < 1e-12);
// Subcooled / superheated extrapolation is linear and unclamped.
assert!(v.quality(150_000.0) < 0.0);
assert!(v.quality(500_000.0) > 1.0);
let near_critical = SatDpValue {
h_fg_eff: smooth_max(1.0, H_FG_FLOOR_JKG, H_FG_FLOOR_K),
..v
};
assert!(near_critical.quality(500_000.0).is_finite());
}
/// ∂ΔP/∂P from [`tube_dp_evaluation`] must match a whole-ΔP central FD
/// through the backend (the exact composition the residual path uses).
#[test]
fn tube_dp_evaluation_dp_matches_whole_fd() {
use crate::heat_exchanger::two_phase_dp::{
tube_two_phase_delta_p, TubeChannelGeometry, TwoPhaseDpCorrelation,
};
use std::sync::Arc;
let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
let corr = TwoPhaseDpCorrelation::MullerSteinhagenHeck1986;
let geom = TubeChannelGeometry::dx_default();
let (m, p_pa, h_in, h_out) = (0.10_f64, 350_000.0_f64, 250_000.0_f64, 410_000.0_f64);
let value = |p: f64| -> f64 {
let st = sat_dp_value_state(&backend, "R134a", p).unwrap().unwrap();
tube_two_phase_delta_p(
corr,
&geom,
m,
st.quality(h_in),
st.quality(h_out),
&st.props,
)
};
let eps = (p_pa * 1e-6).max(1.0);
let fd = (value(p_pa + eps) - value(p_pa - eps)) / (2.0 * eps);
let full = sat_dp_full_state(&backend, "R134a", p_pa).unwrap().unwrap();
let eval = tube_dp_evaluation(corr, &geom, m, h_in, h_out, &full);
// The value path is identical to the residual composition.
assert!((eval.value - value(p_pa)).abs() < 1e-9 * value(p_pa).abs().max(1.0));
assert!(
(eval.d_dp - fd).abs() < 1e-3 * fd.abs().max(1e-12),
"d_dp mismatch: analytic={} fd={}",
eval.d_dp,
fd
);
}
/// ∂ΔP/∂h_in and ∂ΔP/∂h_out must match whole-ΔP central FDs on the
/// enthalpies (through the unclamped qualities).
#[test]
fn tube_dp_evaluation_dh_matches_whole_fd() {
use crate::heat_exchanger::two_phase_dp::{
tube_two_phase_delta_p, TubeChannelGeometry, TwoPhaseDpCorrelation,
};
use std::sync::Arc;
let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
let corr = TwoPhaseDpCorrelation::MullerSteinhagenHeck1986;
let geom = TubeChannelGeometry::dx_default();
let (m, p_pa, h_in, h_out) = (0.10_f64, 350_000.0_f64, 250_000.0_f64, 410_000.0_f64);
let value = |hi: f64, ho: f64| -> f64 {
let st = sat_dp_value_state(&backend, "R134a", p_pa)
.unwrap()
.unwrap();
tube_two_phase_delta_p(corr, &geom, m, st.quality(hi), st.quality(ho), &st.props)
};
let full = sat_dp_full_state(&backend, "R134a", p_pa).unwrap().unwrap();
let eval = tube_dp_evaluation(corr, &geom, m, h_in, h_out, &full);
let eps_h = (h_in.abs() * 1e-6).max(1.0);
let fd_in = (value(h_in + eps_h, h_out) - value(h_in - eps_h, h_out)) / (2.0 * eps_h);
assert!(
(eval.d_dh_in - fd_in).abs() < 1e-3 * fd_in.abs().max(1e-12),
"d_dh_in mismatch: analytic={} fd={}",
eval.d_dh_in,
fd_in
);
let fd_out = (value(h_in, h_out + eps_h) - value(h_in, h_out - eps_h)) / (2.0 * eps_h);
assert!(
(eval.d_dh_out - fd_out).abs() < 1e-3 * fd_out.abs().max(1e-12),
"d_dh_out mismatch: analytic={} fd={}",
eval.d_dh_out,
fd_out
);
}
/// ∂ΔP/∂ṁ must match a whole-ΔP central FD on the mass flow, and stay
/// continuous (C¹) through ṁ = 0.
#[test]
fn tube_dp_evaluation_dm_matches_whole_fd_and_is_c1_at_zero_flow() {
use crate::heat_exchanger::two_phase_dp::{
tube_two_phase_delta_p, TubeChannelGeometry, TwoPhaseDpCorrelation,
};
use std::sync::Arc;
let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
let corr = TwoPhaseDpCorrelation::MullerSteinhagenHeck1986;
let geom = TubeChannelGeometry::dx_default();
let (p_pa, h_in, h_out) = (350_000.0_f64, 250_000.0_f64, 410_000.0_f64);
let full = sat_dp_full_state(&backend, "R134a", p_pa).unwrap().unwrap();
let value = |m: f64| -> f64 {
let st = sat_dp_value_state(&backend, "R134a", p_pa)
.unwrap()
.unwrap();
tube_two_phase_delta_p(
corr,
&geom,
m,
st.quality(h_in),
st.quality(h_out),
&st.props,
)
};
for m in [0.02, 0.1, 0.2] {
let eval = tube_dp_evaluation(corr, &geom, m, h_in, h_out, &full);
let eps = (1e-6 * m.abs()).max(1e-8);
let fd = (value(m + eps) - value(m - eps)) / (2.0 * eps);
assert!(
(eval.d_dm - fd).abs() < 1e-3 * fd.abs().max(1e-12),
"d_dm mismatch at m={m}: analytic={} fd={}",
eval.d_dm,
fd
);
}
// C¹ through ṁ = 0: left/right FD slopes of ΔP(ṁ) agree and d_dm(0)=0.
let h = 1e-6;
let left = (value(-h) - value(-2.0 * h)) / h;
let right = (value(2.0 * h) - value(h)) / h;
let scale = left.abs().max(right.abs()).max(1.0);
assert!(
(left - right).abs() / scale < 1e-2,
"d_dm C¹ at zero flow: left={left} right={right}"
);
let eval0 = tube_dp_evaluation(corr, &geom, 0.0, h_in, h_out, &full);
assert_eq!(eval0.d_dm, 0.0);
assert_eq!(eval0.value, 0.0);
}
}

View File

@@ -13,10 +13,18 @@ use super::correlation_registry::{
CorrelationMetadata, CorrelationPurpose, DomainInputError, ExchangerGeometryType, FlowRegime,
OperatingPoint, SelectionContext,
};
use entropyk_core::smoothing::{
cubic_blend, smooth_clamp, smooth_clamp_derivative, smoothstep, smoothstep_derivative,
};
/// Standard gravitational acceleration [m/s²].
const G_ACCEL: f64 = 9.80665;
/// C¹ transition width for quality on Newton-visible Friedel / homogeneous helpers.
///
/// Story 0.4 / FR19: keep the band physically small on `[0, 1]` (Story 0.2 guidance).
const QUALITY_WIDTH: f64 = 1e-2;
/// Inputs describing the local two-phase state and channel for a Friedel
/// pressure-gradient evaluation. All quantities are SI.
#[derive(Debug, Clone, Copy)]
@@ -84,27 +92,70 @@ pub fn assess_friedel_domain(
assess_candidate(&friedel_metadata(), &context)
}
/// Reynolds number of the laminar→Blasius crossover (`16/Re = 0.079·Re^-0.25`).
const RE_CROSSOVER: f64 = 1187.0;
/// Relative half-width of the C¹ friction-factor blend around [`RE_CROSSOVER`].
///
/// Story 0.2 guidance: keep the band physically small; the two branches cross
/// at the crossover by construction, so a ±5 % window changes the value by
/// < 0.2 % while making `df/dRe` continuous (Newton-safe).
const RE_BLEND_REL_HALF_WIDTH: f64 = 0.05;
/// Fanning friction factor for single-phase flow (Blasius/laminar blend).
///
/// Uses `16/Re` in the laminar regime (Re < 1187, the Blasius crossover) and
/// the Blasius smooth-tube correlation `0.079·Re^-0.25` in the turbulent
/// regime. Guards against non-positive Reynolds numbers.
/// regime. The two branches are joined with a C¹ [`cubic_blend`] over
/// `1187 ± 5 %` so `df/dRe` stays continuous for Newton Jacobians (Story 0.2 /
/// 0.4 regularization standard); outside the window the values are exactly the
/// raw branches. Guards against non-positive Reynolds numbers.
#[inline]
pub fn fanning_friction_factor(reynolds: f64) -> f64 {
if reynolds <= 0.0 {
return 0.0;
}
if reynolds < 1187.0 {
16.0 / reynolds
} else {
0.079 * reynolds.powf(-0.25)
let e0 = RE_CROSSOVER * (1.0 - RE_BLEND_REL_HALF_WIDTH);
let e1 = RE_CROSSOVER * (1.0 + RE_BLEND_REL_HALF_WIDTH);
cubic_blend(
16.0 / reynolds,
0.079 * reynolds.powf(-0.25),
reynolds,
e0,
e1,
)
}
/// Analytic derivative `df/dRe` of [`fanning_friction_factor`].
///
/// Chain-rules the [`cubic_blend`] join (branch derivatives + smoothstep
/// weight); returns 0 for non-positive Reynolds numbers, matching the guard in
/// [`fanning_friction_factor`].
#[inline]
pub fn fanning_friction_factor_dre(reynolds: f64) -> f64 {
if reynolds <= 0.0 {
return 0.0;
}
let e0 = RE_CROSSOVER * (1.0 - RE_BLEND_REL_HALF_WIDTH);
let e1 = RE_CROSSOVER * (1.0 + RE_BLEND_REL_HALF_WIDTH);
let lam = 16.0 / reynolds;
let d_lam = -16.0 / (reynolds * reynolds);
let turb = 0.079 * reynolds.powf(-0.25);
let d_turb = -0.25 * 0.079 * reynolds.powf(-1.25);
// d/dRe [lam + (turb lam)·s(Re)] = lam' + (turb' lam')·s + (turb lam)·s'
d_lam
+ (d_turb - d_lam) * smoothstep(e0, e1, reynolds)
+ (turb - lam) * smoothstep_derivative(e0, e1, reynolds)
}
/// Homogeneous two-phase density 1/(x/ρ_g + (1-x)/ρ_l) [kg/m³].
///
/// Quality is mapped through [`smooth_clamp`] with [`QUALITY_WIDTH`] so ∂ρ/∂x
/// stays C¹ near the dome edges (Story 0.4). Interior `x ∈ [w, 1w]` matches
/// the hard-clamp formula exactly.
#[inline]
pub fn homogeneous_density(quality: f64, rho_liquid: f64, rho_vapor: f64) -> f64 {
let x = quality.clamp(0.0, 1.0);
let x = smooth_clamp(quality, 0.0, 1.0, QUALITY_WIDTH);
let inv = x / rho_vapor.max(1e-9) + (1.0 - x) / rho_liquid.max(1e-9);
if inv <= 0.0 {
rho_liquid
@@ -117,6 +168,10 @@ pub fn homogeneous_density(quality: f64, rho_liquid: f64, rho_vapor: f64) -> f64
///
/// `α = 1 / (1 + ((1-x)/x)·(ρ_g/ρ_l)^(2/3))`, clamped to [0, 1]. Returns 0 for
/// x ≤ 0 and 1 for x ≥ 1.
///
/// **Story 0.4 scope:** not called from residual/Jacobian assembly (export +
/// unit tests only). Hard edges left as-is; do not expand regularization here
/// without a live Newton call path.
#[inline]
pub fn zivi_void_fraction(quality: f64, rho_liquid: f64, rho_vapor: f64) -> f64 {
let x = quality;
@@ -146,8 +201,13 @@ fn liquid_only_gradient(g: f64, d: f64, rho_l: f64, mu_l: f64) -> f64 {
///
/// Multiplies the liquid-only gradient to obtain the two-phase frictional
/// gradient. Returns 1.0 at x = 0 (single-phase liquid) and is always ≥ 0.
///
/// Quality uses [`smooth_clamp`] ([`QUALITY_WIDTH`]) so the multiplier is C¹
/// across the dome edges for Newton (Story 0.4). Tube MSH / acceleration paths
/// keep their separate out-of-dome single-phase limits — this helper must not
/// re-introduce hard clamps on those call sites.
pub fn friedel_multiplier(input: &FriedelInput) -> f64 {
let x = input.quality.clamp(0.0, 1.0);
let x = smooth_clamp(input.quality, 0.0, 1.0, QUALITY_WIDTH);
if x <= 0.0 {
return 1.0;
}
@@ -238,25 +298,220 @@ fn vapor_only_gradient(g: f64, d: f64, rho_g: f64, mu_g: f64) -> f64 {
2.0 * f_go * g * g / (d.max(1e-9) * rho_g.max(1e-9))
}
/// Quality of the Hermite blend upper edge (vapor side) for [`msh_gradient`].
const X_BLEND: f64 = 0.90;
/// Half-width of the C¹ subcooled blend (liquid side) for [`msh_gradient`].
///
/// Below `X_SUB_W` the gradient is exactly the liquid-only value `A`; above
/// `0` it is exactly the raw MSH correlation. Inside the band the two are
/// joined with a [`smoothstep`] weight so the value *and* the quality slope
/// stay continuous (Story 0.2 regularization standard) — the raw correlation
/// has a non-zero slope at `x = 0` while the subcooled limit is flat, which is
/// a C⁰ kink on the condenser operating path without the blend.
const X_SUB_W: f64 = 1e-2;
/// Raw MSH correlation value and analytic partials (no regime blends).
///
/// Valid for `x < 1` (the `(1x)^(1/3)` factor needs a non-negative base).
/// Returns `(value, ∂/∂x, ∂/∂A, ∂/∂B, ∂²/∂x∂A, ∂²/∂x∂B)` — the cross-partials
/// are needed to chain the Hermite end-slope through the transport props.
#[inline]
fn msh_raw_full_partials(a: f64, b: f64, x: f64) -> (f64, f64, f64, f64, f64, f64) {
let one_m = (1.0 - x).max(0.0);
let p = one_m.powf(1.0 / 3.0);
let d_p = if one_m > 0.0 {
-(1.0 / 3.0) * one_m.powf(-2.0 / 3.0)
} else {
0.0
};
// L(x) = A + 2(BA)x, MSH = L·(1x)^{1/3} + B·x³
let linear = a + 2.0 * (b - a) * x;
let d_linear_dx = 2.0 * (b - a);
let d_linear_da = 1.0 - 2.0 * x;
let d_linear_db = 2.0 * x;
let value = linear * p + b * x.powi(3);
let d_dx = d_linear_dx * p + linear * d_p + 3.0 * b * x * x;
let d_da = d_linear_da * p;
let d_db = d_linear_db * p + x.powi(3);
// ∂/∂A(∂MSH/∂x) = 2p + (12x)·d_p ; ∂/∂B(∂MSH/∂x) = 2p + 2x·d_p + 3x²
let d_dx_da = -2.0 * p + d_linear_da * d_p;
let d_dx_db = 2.0 * p + d_linear_db * d_p + 3.0 * x * x;
(value, d_dx, d_da, d_db, d_dx_da, d_dx_db)
}
/// MSH frictional gradient and analytic partials w.r.t. `(x, A, B)`.
///
/// C¹ on the whole real line: liquid-only `A` for `x ≤ X_SUB_W`, smoothstep
/// blend into the raw correlation on `(X_SUB_W, 0)`, raw MSH on
/// `[0, X_BLEND]`, cubic Hermite onto the vapor-only `B` (zero slope) on
/// `(X_BLEND, 1)`, and exactly `B` for `x ≥ 1`.
#[inline]
fn msh_value_partials(a: f64, b: f64, x: f64) -> (f64, f64, f64, f64) {
if x >= 1.0 {
return (b, 0.0, 0.0, 1.0);
}
if x > X_BLEND {
// Cubic Hermite from (X_BLEND, g0, g0') → (1, B, 0).
let (g0, g0p, g0a, g0b, g0pa, g0pb) = msh_raw_full_partials(a, b, X_BLEND);
let dx = 1.0 - X_BLEND;
let t = (x - X_BLEND) / dx;
let t2 = t * t;
let t3 = t2 * t;
let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
let h10 = t3 - 2.0 * t2 + t;
let h01 = -2.0 * t3 + 3.0 * t2;
// h11 term omitted: end slope m1 = 0.
let value = h00 * g0 + h10 * dx * g0p + h01 * b;
// Basis derivatives w.r.t. t (÷ dx for ∂/∂x).
let dh00 = 6.0 * t2 - 6.0 * t;
let dh10 = 3.0 * t2 - 4.0 * t + 1.0;
let dh01 = -6.0 * t2 + 6.0 * t;
let d_dx = (dh00 * g0 + dh10 * dx * g0p + dh01 * b) / dx;
let d_da = h00 * g0a + h10 * dx * g0pa;
let d_db = h00 * g0b + h10 * dx * g0pb + h01;
(value, d_dx, d_da, d_db)
} else if x >= 0.0 {
let (value, d_dx, d_da, d_db, _, _) = msh_raw_full_partials(a, b, x);
(value, d_dx, d_da, d_db)
} else if x <= -X_SUB_W {
(a, 0.0, 1.0, 0.0)
} else {
// Subcooled C¹ blend: a + (raw(x) a)·s(x), s = smoothstep(w, 0, x).
let (raw, d_raw_dx, d_raw_da, d_raw_db, _, _) = msh_raw_full_partials(a, b, x);
let s = smoothstep(-X_SUB_W, 0.0, x);
let ds = smoothstep_derivative(-X_SUB_W, 0.0, x);
let value = a + (raw - a) * s;
let d_dx = d_raw_dx * s + (raw - a) * ds;
let d_da = 1.0 + (d_raw_da - 1.0) * s;
let d_db = d_raw_db * s;
(value, d_dx, d_da, d_db)
}
}
/// Analytic partials of a two-phase frictional gradient `(dP/dz)` [Pa/m].
#[derive(Debug, Clone, Copy, Default)]
pub struct TwoPhaseGradientPartials {
/// Gradient value [Pa/m].
pub value: f64,
/// ∂/∂G (mass flux).
pub d_dg: f64,
/// ∂/∂x (quality).
pub d_dx: f64,
/// ∂/∂ρ_liquid.
pub d_drho_liquid: f64,
/// ∂/∂ρ_vapor.
pub d_drho_vapor: f64,
/// ∂/∂μ_liquid.
pub d_dmu_liquid: f64,
/// ∂/∂μ_vapor.
pub d_dmu_vapor: f64,
/// ∂/∂σ (surface tension; zero for MSH).
pub d_dsigma: f64,
}
/// Single-phase (liquid-only / vapor-only) gradient value and partials w.r.t.
/// `(g, ρ, μ)`: `2·f(Re)·g²/(d·ρ)` with `Re = g·d/μ`.
#[inline]
fn single_phase_gradient_partials(g: f64, d: f64, rho: f64, mu: f64) -> (f64, f64, f64, f64) {
let re = g * d / mu;
let f = fanning_friction_factor(re);
let df = fanning_friction_factor_dre(re);
let value = 2.0 * f * g * g / (d * rho);
let d_dg = (2.0 * g / (d * rho)) * (2.0 * f + re * df);
let d_drho = -value / rho;
let d_dmu = -(2.0 * g * g / (d * rho)) * df * re / mu;
(value, d_dg, d_drho, d_dmu)
}
/// Müller-Steinhagen-Heck gradient value + analytic partials w.r.t.
/// `(G, x, ρ_l, ρ_v, μ_l, μ_v)` (NFR9 exact-Jacobian path; Story 0.2/0.4
/// regularization standard).
pub fn msh_gradient_partials(input: &FriedelInput) -> TwoPhaseGradientPartials {
let g = input.mass_flux.abs();
let d = input.diameter.max(1e-9);
let rho_l = input.rho_liquid.max(1e-9);
let rho_v = input.rho_vapor.max(1e-9);
let mu_l = input.mu_liquid.max(1e-12);
let mu_v = input.mu_vapor.max(1e-12);
let (a, da_dg, da_drho, da_dmu) = single_phase_gradient_partials(g, d, rho_l, mu_l);
let (b, db_dg, db_drho, db_dmu) = single_phase_gradient_partials(g, d, rho_v, mu_v);
let (value, d_dx, d_da, d_db) = msh_value_partials(a, b, input.quality);
TwoPhaseGradientPartials {
value,
d_dg: d_da * da_dg + d_db * db_dg,
d_dx,
d_drho_liquid: d_da * da_drho,
d_drho_vapor: d_db * db_drho,
d_dmu_liquid: d_da * da_dmu,
d_dmu_vapor: d_db * db_dmu,
d_dsigma: 0.0,
}
}
/// Friedel gradient value + partials w.r.t. `(G, x, ρ_l, ρ_v, μ_l, μ_v, σ)`.
///
/// **NFR9 deviation (recorded):** unlike the MSH path, the partials here are
/// narrow central finite differences of the *pure* [`friedel_gradient`]
/// correlation (relative step `1e-7`, no fluid-backend calls, no model
/// switches) — the full analytic expansion of the Friedel `E + 3.24·F·H /
/// (Fr^0.045·We^0.035)` composition was judged out of scope for this fix; the
/// FD of a smooth pure function is domain-safe and agrees with the true
/// derivative to FD precision (~1e-8 relative), which is ample for Newton.
pub fn friedel_gradient_partials(input: &FriedelInput) -> TwoPhaseGradientPartials {
let value = friedel_gradient(input);
let fd = |perturb: &mut dyn FnMut(&mut FriedelInput, f64), center: f64, floor: f64| -> f64 {
let h = (center.abs() * 1e-7).max(floor);
let (mut up, mut dn) = (*input, *input);
perturb(&mut up, h);
perturb(&mut dn, -h);
(friedel_gradient(&up) - friedel_gradient(&dn)) / (2.0 * h)
};
TwoPhaseGradientPartials {
value,
d_dg: fd(&mut |i, h| i.mass_flux += h, input.mass_flux, 1e-6),
d_dx: fd(&mut |i, h| i.quality += h, input.quality, 1e-9),
d_drho_liquid: fd(&mut |i, h| i.rho_liquid += h, input.rho_liquid, 1e-9),
d_drho_vapor: fd(&mut |i, h| i.rho_vapor += h, input.rho_vapor, 1e-9),
d_dmu_liquid: fd(&mut |i, h| i.mu_liquid += h, input.mu_liquid, 1e-13),
d_dmu_vapor: fd(&mut |i, h| i.mu_vapor += h, input.mu_vapor, 1e-13),
d_dsigma: fd(&mut |i, h| i.sigma += h, input.sigma, 1e-11),
}
}
/// Müller-Steinhagen-Heck (1986) two-phase frictional gradient [Pa/m].
///
/// Linear blend between liquid-only and vapor-only gradients with a cubic
/// quality correction:
/// `(dP/dz) = A + 2(BA)x` at low x, then smooth to vapor-only at x→1 via
/// `(dP/dz) = (A + 2(BA)x)·(1x)^(1/3) + B·x³` where A=(dP/dz)_LO, B=(dP/dz)_GO.
///
/// # Domain extension outside the saturation dome
///
/// Thermodynamic quality is allowed to leave `[0, 1]` — `x > 1` denotes
/// superheated vapor and `x < 0` denotes subcooled liquid. The correlation
/// then degenerates smoothly to the corresponding single-phase gradient:
/// * `x ≤ X_SUB_W` → liquid-only gradient `A` (subcooled liquid region),
/// joined C¹ through a [`smoothstep`] blend on `(X_SUB_W, 0)` — the raw
/// correlation has a non-zero slope at `x = 0` while the subcooled limit is
/// flat, which would otherwise be a C⁰ kink on the condenser operating path.
/// * `x ≥ 1` → vapor-only gradient `B` (superheated vapor region).
///
/// # Regularization near x = 1
///
/// The published formula `(1-x)^(1/3)` has a derivative in `(1-x)^(-2/3)`
/// that diverges as `x → 1`, making the Newton Jacobian ∂ΔP/∂h pathological
/// when an evaporator outlet approaches saturated-vapor conditions. We replace
/// the raw correlation on `[X_BLEND, 1]` (`X_BLEND = 0.90`) by a cubic Hermite
/// interpolant that matches value **and** slope of the raw MSH at `X_BLEND`
/// and lands on the vapor-only gradient `B` with zero slope at `x = 1`. The
/// frictional gradient is therefore C¹ on the whole real line and ∂ΔP/∂x stays
/// bounded (Story 0.4; subcooled-side blend added under the Story 0.2 standard).
pub fn msh_gradient(input: &FriedelInput) -> f64 {
let x = input.quality.clamp(0.0, 1.0);
let x = input.quality;
let g = input.mass_flux.abs();
let a = liquid_only_gradient(g, input.diameter, input.rho_liquid, input.mu_liquid);
let b = vapor_only_gradient(g, input.diameter, input.rho_vapor, input.mu_vapor);
if x <= 0.0 {
return a;
}
if x >= 1.0 {
return b;
}
let linear = a + 2.0 * (b - a) * x;
linear * (1.0 - x).powf(1.0 / 3.0) + b * x.powi(3)
msh_value_partials(a, b, x).0
}
/// MSH frictional pressure drop [Pa] over `length`.
@@ -412,7 +667,16 @@ pub struct SatTransportProps {
}
/// Acceleration pressure change [Pa]: `G² (v(x_out) v(x_in))` with homogeneous
/// specific volume `v = x/ρ_v + (1x)/ρ_l`. Positive when quality rises (evaporator).
/// specific volume `v = x_c/ρ_v + (1x_c)/ρ_l`. Positive when quality rises (evaporator).
///
/// Quality is allowed to leave `[0, 1]` to match the MSH domain extension:
/// superheated vapor (`x > 1`) uses the saturated-vapor specific volume, and
/// subcooled liquid (`x < 0`) uses the saturated-liquid specific volume. The
/// clamped quality `x_c` goes through [`smooth_clamp`] with [`QUALITY_WIDTH`]
/// (Story 0.2/0.4 regularization standard), so the term is C¹ across the
/// saturation boundary — including ∂ΔP_acc/∂x, which the former hard piecewise
/// `v(x)` jumped between `0` and `1/ρ_v 1/ρ_l` — and the momentum-row
/// Jacobian ∂ΔP_acc/∂h is well-defined for Newton.
#[inline]
pub fn acceleration_drop(
mass_flux: f64,
@@ -421,20 +685,52 @@ pub fn acceleration_drop(
rho_liquid: f64,
rho_vapor: f64,
) -> f64 {
let g = mass_flux;
acceleration_drop_partials(mass_flux, x_in, x_out, rho_liquid, rho_vapor).0
}
/// Value and analytic partials of [`acceleration_drop`]:
/// `(value, ∂/∂G, ∂/∂x_in, ∂/∂x_out, ∂/∂ρ_liquid, ∂/∂ρ_vapor)`.
#[inline]
pub fn acceleration_drop_partials(
mass_flux: f64,
x_in: f64,
x_out: f64,
rho_liquid: f64,
rho_vapor: f64,
) -> (f64, f64, f64, f64, f64, f64) {
let rl = rho_liquid.max(1e-9);
let rv = rho_vapor.max(1e-9);
// Homogeneous specific volume of the smooth-clamped quality.
let v = |x: f64| {
let xc = x.clamp(0.0, 1.0);
xc / rho_vapor.max(1e-9) + (1.0 - xc) / rho_liquid.max(1e-9)
let xc = smooth_clamp(x, 0.0, 1.0, QUALITY_WIDTH);
(xc, xc / rv + (1.0 - xc) / rl)
};
g * g * (v(x_out) - v(x_in))
let dv = |x: f64| smooth_clamp_derivative(x, 0.0, 1.0, QUALITY_WIDTH) * (1.0 / rv - 1.0 / rl);
let g = mass_flux;
let (xc_in, v_in) = v(x_in);
let (xc_out, v_out) = v(x_out);
let value = g * g * (v_out - v_in);
let d_dg = 2.0 * g * (v_out - v_in);
let d_dx_in = -g * g * dv(x_in);
let d_dx_out = g * g * dv(x_out);
// ∂v/∂ρ_v = x_c/ρ_v² ; ∂v/∂ρ_l = (1x_c)/ρ_l²
let d_drho_l = g * g * ((1.0 - xc_in) - (1.0 - xc_out)) / (rl * rl);
let d_drho_v = g * g * (xc_in - xc_out) / (rv * rv);
(value, d_dg, d_dx_in, d_dx_out, d_drho_l, d_drho_v)
}
/// Tube DX pressure drop [Pa] in the flow direction:
/// `ΔP = ΔP_friction(x̄) + ΔP_acceleration`.
///
/// Friction uses MSH (NIST EVAP-COND default) or Friedel at the mean quality
/// `x̄ = ½(clamp(x_in)+clamp(x_out))` over [`TubeChannelGeometry::length_m`].
/// Signed so `P_out = P_in ΔP` for `ṁ ≥ 0`.
/// `x̄ = ½(x_in + x_out)` over [`TubeChannelGeometry::length_m`]. Signed so
/// `P_out = P_in ΔP` for `ṁ ≥ 0`.
///
/// Qualities are **not** clamped to `[0, 1]`: values above 1 (superheated vapor)
/// or below 0 (subcooled liquid) are passed through so the correlation can
/// blend smoothly into the corresponding single-phase limit (see
/// [`msh_gradient`] and [`acceleration_drop`]). This keeps the momentum-row
/// Jacobian ∂ΔP/∂h well-defined across the saturation boundary.
pub fn tube_two_phase_delta_p(
correlation: TwoPhaseDpCorrelation,
geom: &TubeChannelGeometry,
@@ -445,7 +741,7 @@ pub fn tube_two_phase_delta_p(
) -> f64 {
let area = geom.flow_area_m2().max(1e-12);
let g = mass_flow.abs() / area;
let x_mean = 0.5 * (x_in.clamp(0.0, 1.0) + x_out.clamp(0.0, 1.0));
let x_mean = 0.5 * (x_in + x_out);
let input = FriedelInput {
mass_flux: g,
diameter: geom.diameter_m.max(1e-9),
@@ -466,6 +762,83 @@ pub fn tube_two_phase_delta_p(
}
}
/// Analytic partials of [`tube_two_phase_delta_p`] w.r.t. the mass flow, the
/// inlet/outlet qualities and the saturated transport properties.
///
/// Everything is differentiated in closed form for MSH (and Friedel falls back
/// to narrow FD of the pure correlation — see [`friedel_gradient_partials`]);
/// the composition (sign, `G = |ṁ|/A`, mean quality) is exact. The
/// ∂ΔP/∂ṁ entry is continuous through ṁ = 0 (the composition is quadratic in
/// `G` there).
#[derive(Debug, Clone, Copy, Default)]
pub struct TubeDpPartials {
/// ΔP value [Pa].
pub value: f64,
/// ∂ΔP/∂ṁ [Pa·s/kg].
pub d_dm: f64,
/// ∂ΔP/∂x_in [-].
pub d_dx_in: f64,
/// ∂ΔP/∂x_out [-].
pub d_dx_out: f64,
/// ∂ΔP/∂ρ_liquid.
pub d_drho_liquid: f64,
/// ∂ΔP/∂ρ_vapor.
pub d_drho_vapor: f64,
/// ∂ΔP/∂μ_liquid.
pub d_dmu_liquid: f64,
/// ∂ΔP/∂μ_vapor.
pub d_dmu_vapor: f64,
/// ∂ΔP/∂σ (Friedel only).
pub d_dsigma: f64,
}
/// Computes [`tube_two_phase_delta_p`] together with its analytic partials
/// (NFR9 exact-Jacobian path for the HX momentum rows).
pub fn tube_two_phase_delta_p_partials(
correlation: TwoPhaseDpCorrelation,
geom: &TubeChannelGeometry,
mass_flow: f64,
x_in: f64,
x_out: f64,
props: &SatTransportProps,
) -> TubeDpPartials {
let area = geom.flow_area_m2().max(1e-12);
let g = mass_flow.abs() / area;
let x_mean = 0.5 * (x_in + x_out);
let input = FriedelInput {
mass_flux: g,
diameter: geom.diameter_m.max(1e-9),
quality: x_mean,
rho_liquid: props.rho_liquid,
rho_vapor: props.rho_vapor,
mu_liquid: props.mu_liquid,
mu_vapor: props.mu_vapor,
sigma: props.sigma.max(1e-9),
};
let grad = match correlation {
TwoPhaseDpCorrelation::MullerSteinhagenHeck1986 => msh_gradient_partials(&input),
TwoPhaseDpCorrelation::Friedel1979 => friedel_gradient_partials(&input),
};
let length = geom.length_m.max(0.0);
let (acc, da_dg, da_dxin, da_dxout, da_drhol, da_drhov) =
acceleration_drop_partials(g, x_in, x_out, props.rho_liquid, props.rho_vapor);
let sign = if mass_flow >= 0.0 { 1.0 } else { -1.0 };
// ΔP(ṁ) = sign(ṁ)·F(|ṁ|): dΔP/dṁ = F'(|ṁ|)/A on both branches (C¹ at 0
// since F is quadratic in G there — F'(0) = 0).
let d_dm = (grad.d_dg * length + da_dg) / area;
TubeDpPartials {
value: sign * (grad.value * length + acc),
d_dm,
d_dx_in: sign * (grad.d_dx * length * 0.5 + da_dxin),
d_dx_out: sign * (grad.d_dx * length * 0.5 + da_dxout),
d_drho_liquid: sign * (grad.d_drho_liquid * length + da_drhol),
d_drho_vapor: sign * (grad.d_drho_vapor * length + da_drhov),
d_dmu_liquid: sign * (grad.d_dmu_liquid * length),
d_dmu_vapor: sign * (grad.d_dmu_vapor * length),
d_dsigma: sign * (grad.d_dsigma * length),
}
}
/// Parse `dp_model` string: `none`/`isobaric`, `quadratic`, `msh`, `friedel`.
pub fn parse_dp_model_name(name: &str) -> Option<&'static str> {
match name.trim().to_ascii_lowercase().as_str() {
@@ -562,7 +935,10 @@ mod tests {
#[test]
fn quadratic_drop_and_derivative() {
let k_default = default_refrigerant_pressure_drop_coeff();
assert!((k_default - 6.0e6).abs() < 1.0, "15 kPa @ 0.05 kg/s → k=6e6");
assert!(
(k_default - 6.0e6).abs() < 1.0,
"15 kPa @ 0.05 kg/s → k=6e6"
);
let props = SatTransportProps {
rho_liquid: 1260.0,
@@ -580,7 +956,10 @@ mod tests {
0.95,
&props,
);
assert!(dp_evap > 1000.0, "DX evaporating ΔP should be kPa-scale, got {dp_evap}");
assert!(
dp_evap > 1000.0,
"DX evaporating ΔP should be kPa-scale, got {dp_evap}"
);
let dp_cond = tube_two_phase_delta_p(
TwoPhaseDpCorrelation::MullerSteinhagenHeck1986,
&geom,
@@ -665,12 +1044,7 @@ mod tests {
fn msh_matches_liquid_only_at_x0() {
let mut inp = r134a_like();
inp.quality = 0.0;
let a = liquid_only_gradient(
inp.mass_flux,
inp.diameter,
inp.rho_liquid,
inp.mu_liquid,
);
let a = liquid_only_gradient(inp.mass_flux, inp.diameter, inp.rho_liquid, inp.mu_liquid);
assert!((msh_gradient(&inp) - a).abs() < 1e-9);
}
@@ -715,4 +1089,413 @@ mod tests {
assert!(assessment.accepted);
assert_eq!(assessment.id, CorrelationId::MullerSteinhagenHeck1986);
}
#[test]
fn quality_smooth_clamp_matches_hard_formula_in_physical_region() {
let rho_l = 1000.0;
let rho_g = 50.0;
let w = QUALITY_WIDTH;
for x in [w, 0.25, 0.5, 0.75, 1.0 - w] {
// Interior of smooth_clamp is identity → same as former hard clamp.
assert!((smooth_clamp(x, 0.0, 1.0, w) - x).abs() < 1e-15);
let soft = homogeneous_density(x, rho_l, rho_g);
let hard = {
let inv = x / rho_g + (1.0 - x) / rho_l;
1.0 / inv
};
assert!(
(soft - hard).abs() / hard < 1e-12,
"homogeneous x={x}: soft={soft} hard={hard}"
);
}
let base = r134a_like();
for x in [w, 0.2, 0.5, 0.8, 1.0 - w] {
let m = friedel_multiplier(&FriedelInput { quality: x, ..base });
assert!(m.is_finite() && m >= 1.0, "friedel physical x={x} m={m}");
}
}
#[test]
fn quality_transition_band_differs_from_hard_clamp() {
let rho_l = 1000.0;
let rho_g = 50.0;
// In the lower C¹ ramp, smooth_clamp ≠ identity → soft ≠ hard-clamp formula.
let x_band = 0.5 * QUALITY_WIDTH;
let soft = homogeneous_density(x_band, rho_l, rho_g);
let hard = 1.0 / (x_band / rho_g + (1.0 - x_band) / rho_l);
assert!(
(soft - hard).abs() / hard > 1e-6,
"Story 0.4: C¹ quality ramp must differ from hard clamp; soft={soft} hard={hard}"
);
}
#[test]
fn msh_quality_derivative_bounded_across_blend() {
const X_BLEND: f64 = 0.90;
let h = 1e-6;
let slope_at = |x: f64| -> f64 {
if x <= h {
let mut p = r134a_like();
p.quality = x + h;
let mut c = r134a_like();
c.quality = x;
(msh_gradient(&p) - msh_gradient(&c)) / h
} else if x >= 1.0 - h {
let mut c = r134a_like();
c.quality = x;
let mut m = r134a_like();
m.quality = x - h;
(msh_gradient(&c) - msh_gradient(&m)) / h
} else {
let mut up = r134a_like();
up.quality = x + h;
let mut dn = r134a_like();
dn.quality = x - h;
(msh_gradient(&up) - msh_gradient(&dn)) / (2.0 * h)
}
};
for x in [0.5, 0.8, X_BLEND, 0.95, 0.99, 0.999, 1.0 - 1e-9] {
let slope = slope_at(x);
assert!(
slope.is_finite() && slope.abs() < 1e12,
"MSH ∂g/∂x must stay finite near x={x}, got {slope}"
);
}
// C¹ join at X_BLEND: left/right FD slopes must agree closely.
let left = slope_at(X_BLEND - 10.0 * h);
let right = slope_at(X_BLEND + 10.0 * h);
let scale = left.abs().max(right.abs()).max(1.0);
assert!(
(left - right).abs() / scale < 1e-2,
"MSH C¹ join at X_BLEND: left={left} right={right}"
);
}
// ── Story: exact tube-ΔP Jacobian (NFR9) — analytic partials vs FD ──────
/// Central FD helper for scalar functions.
fn cfd(f: impl Fn(f64) -> f64, x: f64, h: f64) -> f64 {
(f(x + h) - f(x - h)) / (2.0 * h)
}
#[test]
fn fanning_friction_factor_dre_matches_fd_and_is_c1_at_crossover() {
// Across laminar, the blend window, and turbulent.
for re in [100.0, 500.0, 1000.0, 1128.0, 1187.0, 1247.0, 2000.0, 1e5] {
let analytic = fanning_friction_factor_dre(re);
let numeric = cfd(fanning_friction_factor, re, re * 1e-6);
let scale = analytic.abs().max(numeric.abs()).max(1e-12);
assert!(
(analytic - numeric).abs() / scale < 1e-4,
"fanning df/dRe mismatch at Re={re}: {analytic} vs {numeric}"
);
}
// C¹ at the crossover: left/right FD slopes agree (former hard kink).
for re in [1128.0, 1247.0] {
let left = cfd(fanning_friction_factor, re - 1e-3, 1e-3);
let right = cfd(fanning_friction_factor, re + 1e-3, 1e-3);
let scale = left.abs().max(right.abs());
assert!(
(left - right).abs() / scale < 1e-2,
"fanning slope jump near Re={re}: {left} vs {right}"
);
}
// Values outside the window are exactly the raw branches.
assert!((fanning_friction_factor(500.0) - 16.0 / 500.0).abs() < 1e-15);
assert!((fanning_friction_factor(1.0e5) - 0.079 * 1.0e5_f64.powf(-0.25)).abs() < 1e-15);
}
#[test]
fn msh_gradient_partials_match_fd_across_regimes() {
// Covers subcooled flat/blend, raw MSH, Hermite, superheated — the C¹
// path across x = 0 and x = 1. (x = 1 exactly is a C¹ join with large
// curvature: FD-vs-analytic there is a FD straddle artifact, checked
// separately in msh_gradient_is_c1_across_subcooled_and_superheated_edges.)
for x in [-0.05, -0.005, 0.0, 0.2, 0.5, 0.9, 0.95, 0.999, 1.3] {
let base = FriedelInput {
quality: x,
..r134a_like()
};
let p = msh_gradient_partials(&base);
// Value consistency with msh_gradient.
assert!((p.value - msh_gradient(&base)).abs() < 1e-12 * p.value.abs().max(1.0));
let fields: [(
&str,
f64,
f64,
Box<dyn Fn(&FriedelInput, f64) -> FriedelInput>,
); 6] = [
(
"g",
p.d_dg,
base.mass_flux,
Box::new(|i, h| {
let mut c = *i;
c.mass_flux += h;
c
}),
),
(
"x",
p.d_dx,
base.quality,
Box::new(|i, h| {
let mut c = *i;
c.quality += h;
c
}),
),
(
"rho_l",
p.d_drho_liquid,
base.rho_liquid,
Box::new(|i, h| {
let mut c = *i;
c.rho_liquid += h;
c
}),
),
(
"rho_v",
p.d_drho_vapor,
base.rho_vapor,
Box::new(|i, h| {
let mut c = *i;
c.rho_vapor += h;
c
}),
),
(
"mu_l",
p.d_dmu_liquid,
base.mu_liquid,
Box::new(|i, h| {
let mut c = *i;
c.mu_liquid += h;
c
}),
),
(
"mu_v",
p.d_dmu_vapor,
base.mu_vapor,
Box::new(|i, h| {
let mut c = *i;
c.mu_vapor += h;
c
}),
),
];
for (name, analytic, center, perturb) in fields {
let h = (center.abs() * 1e-7).max(1e-10);
let up = msh_gradient(&perturb(&base, h));
let dn = msh_gradient(&perturb(&base, -h));
let numeric = (up - dn) / (2.0 * h);
let scale = analytic.abs().max(numeric.abs()).max(1e-9);
assert!(
(analytic - numeric).abs() / scale < 1e-3,
"msh d/d{name} mismatch at x={x}: analytic={analytic} fd={numeric}"
);
}
}
}
#[test]
fn msh_gradient_is_c1_across_subcooled_and_superheated_edges() {
// Slope continuity across x = 0 (subcooled blend, moderate curvature):
// left/right FD slopes agree.
let h = 1e-7;
let fd_slope = |x: f64| {
cfd(
|xx| {
let mut ii = r134a_like();
ii.quality = xx;
msh_gradient(&ii)
},
x,
h,
)
};
let left = fd_slope(-10.0 * h);
let right = fd_slope(10.0 * h);
let scale = left.abs().max(right.abs()).max(1.0);
assert!(
(left - right).abs() / scale < 1e-2,
"MSH slope jump at x=0: left={left} right={right}"
);
// Slope continuity across x = 1: the Hermite lands with zero slope by
// construction and huge curvature, so verify the analytic slope
// vanishes ~linearly toward the join instead of FD-straddling it.
let s = |x: f64| {
msh_gradient_partials(&FriedelInput {
quality: x,
..r134a_like()
})
.d_dx
};
assert_eq!(s(1.0), 0.0, "analytic ∂g/∂x at the x=1 join must be 0");
assert_eq!(s(1.0 + 1e-4), 0.0, "superheated side is exactly flat");
let s2 = s(1.0 - 1e-2).abs();
let s3 = s(1.0 - 1e-3).abs();
let s4 = s(1.0 - 1e-4).abs();
assert!(
s3 < 0.2 * s2 && s4 < 0.2 * s3,
"∂g/∂x must vanish ~linearly toward x=1: {s2} {s3} {s4}"
);
// Values outside the bands are exactly the single-phase limits.
let mut i = r134a_like();
i.quality = -0.05;
let a = liquid_only_gradient(i.mass_flux, i.diameter, i.rho_liquid, i.mu_liquid);
assert!((msh_gradient(&i) - a).abs() < 1e-9 * a.abs());
i.quality = 1.05;
let b = vapor_only_gradient(i.mass_flux, i.diameter, i.rho_vapor, i.mu_vapor);
assert!((msh_gradient(&i) - b).abs() < 1e-9 * b.abs());
}
#[test]
fn acceleration_drop_partials_match_fd_and_c1_across_dome() {
let (g, rl, rv) = (350.0, 1260.0, 17.0);
for (x_in, x_out) in [(0.2, 0.95), (-0.05, 1.1), (0.005, 0.995), (1.2, 0.3)] {
let (value, d_dg, d_dxin, d_dxout, d_drl, d_drv) =
acceleration_drop_partials(g, x_in, x_out, rl, rv);
assert!((value - acceleration_drop(g, x_in, x_out, rl, rv)).abs() < 1e-12);
let eps = 1e-7;
let num_dg = cfd(|gg| acceleration_drop(gg, x_in, x_out, rl, rv), g, g * eps);
let num_dxin = cfd(|x| acceleration_drop(g, x, x_out, rl, rv), x_in, eps);
let num_dxout = cfd(|x| acceleration_drop(g, x_in, x, rl, rv), x_out, eps);
let num_drl = cfd(|r| acceleration_drop(g, x_in, x_out, r, rv), rl, rl * eps);
let num_drv = cfd(|r| acceleration_drop(g, x_in, x_out, rl, r), rv, rv * eps);
for (name, a, n) in [
("g", d_dg, num_dg),
("x_in", d_dxin, num_dxin),
("x_out", d_dxout, num_dxout),
("rho_l", d_drl, num_drl),
("rho_v", d_drv, num_drv),
] {
let scale = a.abs().max(n.abs()).max(1e-9);
assert!(
(a - n).abs() / scale < 1e-3,
"acc d/{name} mismatch at ({x_in},{x_out}): {a} vs {n}"
);
}
}
// C¹ across x = 0 and x = 1 (former hard piecewise kinks).
for x0 in [0.0, 1.0] {
let h = 1e-6;
let left = cfd(|x| acceleration_drop(g, x, 0.5, rl, rv), x0 - h, h);
let right = cfd(|x| acceleration_drop(g, x, 0.5, rl, rv), x0 + h, h);
let scale = left.abs().max(right.abs()).max(1.0);
assert!(
(left - right).abs() / scale < 1e-2,
"acc slope jump at x={x0}: {left} vs {right}"
);
}
}
#[test]
fn tube_two_phase_delta_p_partials_match_fd_both_correlations() {
let props = SatTransportProps {
rho_liquid: 1260.0,
rho_vapor: 17.0,
mu_liquid: 250e-6,
mu_vapor: 11e-6,
sigma: 0.011,
};
let geom = TubeChannelGeometry::dx_default();
for corr in [
TwoPhaseDpCorrelation::MullerSteinhagenHeck1986,
TwoPhaseDpCorrelation::Friedel1979,
] {
for (m, x_in, x_out) in [(0.05, 0.2, 0.95), (0.12, -0.03, 1.1), (-0.08, 0.9, 0.1)] {
let p = tube_two_phase_delta_p_partials(corr, &geom, m, x_in, x_out, &props);
// Value consistency with tube_two_phase_delta_p.
let v = tube_two_phase_delta_p(corr, &geom, m, x_in, x_out, &props);
assert!(
(p.value - v).abs() < 1e-9 * v.abs().max(1.0),
"value mismatch {corr:?} ({m},{x_in},{x_out})"
);
let cases: [(&str, f64, Box<dyn Fn(f64) -> f64>); 8] = [
(
"m",
p.d_dm,
Box::new(|mm| tube_two_phase_delta_p(corr, &geom, mm, x_in, x_out, &props)),
),
(
"x_in",
p.d_dx_in,
Box::new(|x| tube_two_phase_delta_p(corr, &geom, m, x, x_out, &props)),
),
(
"x_out",
p.d_dx_out,
Box::new(|x| tube_two_phase_delta_p(corr, &geom, m, x_in, x, &props)),
),
(
"rho_l",
p.d_drho_liquid,
Box::new(|r| {
let mut pp = props;
pp.rho_liquid = r;
tube_two_phase_delta_p(corr, &geom, m, x_in, x_out, &pp)
}),
),
(
"rho_v",
p.d_drho_vapor,
Box::new(|r| {
let mut pp = props;
pp.rho_vapor = r;
tube_two_phase_delta_p(corr, &geom, m, x_in, x_out, &pp)
}),
),
(
"mu_l",
p.d_dmu_liquid,
Box::new(|r| {
let mut pp = props;
pp.mu_liquid = r;
tube_two_phase_delta_p(corr, &geom, m, x_in, x_out, &pp)
}),
),
(
"mu_v",
p.d_dmu_vapor,
Box::new(|r| {
let mut pp = props;
pp.mu_vapor = r;
tube_two_phase_delta_p(corr, &geom, m, x_in, x_out, &pp)
}),
),
(
"sigma",
p.d_dsigma,
Box::new(|r| {
let mut pp = props;
pp.sigma = r;
tube_two_phase_delta_p(corr, &geom, m, x_in, x_out, &pp)
}),
),
];
let centers = [
m,
x_in,
x_out,
props.rho_liquid,
props.rho_vapor,
props.mu_liquid,
props.mu_vapor,
props.sigma,
];
for ((name, analytic, f), center) in cases.iter().zip(centers) {
let h = (center.abs() * 1e-7).max(1e-10);
let numeric = (f(center + h) - f(center - h)) / (2.0 * h);
let scale = analytic.abs().max(numeric.abs()).max(1e-9);
assert!(
(analytic - numeric).abs() / scale < 1e-3,
"tube ΔP d/{name} mismatch {corr:?} ({m},{x_in},{x_out}): analytic={analytic} fd={numeric}"
);
}
}
}
}
}

View File

@@ -21,13 +21,23 @@
//! - ∂r1/∂H_out = 1
//! - ∂r1/∂H_in = -1
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::smoothing::{
smooth_clamp, smooth_clamp_derivative, smooth_max, smooth_max_derivative,
};
use entropyk_core::{CalibIndices, Enthalpy, Pressure};
use entropyk_fluids::{FluidBackend, FluidId, FluidState, Property};
use std::sync::Arc;
/// C¹ opening transition width on `[0, 1]` (Story 0.2 / 0.3 guidance).
const OPENING_WIDTH: f64 = 1e-2;
/// Softness `k` [Pa] for `dp_eff = smooth_max(ΔP, 0, k)` (replaces hard `DP_FLOOR`).
const DP_SMOOTH_K: f64 = 1e3;
/// Isenthalpic expansion valve (EXV / thermostatic valve).
///
/// Constrains the outlet edge state to:
@@ -60,12 +70,24 @@ pub struct IsenthalpicExpansionValve {
emergent_pressure: bool,
/// Physical orifice flow coefficient `Kv` [m²] (effective max flow area).
/// When set (orifice mode, emergent-only) the valve emits an extra
/// mass-flow residual `ṁ Kv·opening·√(2·ρ_in·max(ΔP,0))` and the fractional
/// `opening` becomes a free-actuator solver unknown (arch-6).
/// mass-flow residual `ṁ Kv·opening·√(2·ρ_in·max(ΔP,0))`. The fractional
/// opening is either a free-actuator unknown (arch-6) or a fixed parameter
/// via [`fixed_opening`].
orifice_kv: Option<f64>,
/// Fixed orifice opening in [0, 1]. When `Some`, the orifice residual uses
/// this value and no free-actuator unknown is required (pair with dropping
/// the compressor displacement ṁ closure so DoF stays square).
fixed_opening: Option<f64>,
/// Solver-provided calibration/actuator indices. The generic `actuator`
/// slot carries the orifice opening state index in orifice mode.
/// slot carries the orifice opening state index in free-opening orifice mode.
calib_indices: CalibIndices,
/// Operational state (On / Off / Bypass). When `Off`, the valve behaves as
/// a closed shut-off: the orifice residual becomes `ṁ = 0` (no flow) and
/// the isenthalpic closure is preserved, so a whole branch can be disabled
/// in a multi-circuit machine without unbalancing the system DoF.
operational_state: OperationalState,
/// Circuit identifier (multi-circuit machines).
circuit_id: CircuitId,
}
impl std::fmt::Debug for IsenthalpicExpansionValve {
@@ -96,7 +118,10 @@ impl IsenthalpicExpansionValve {
emergent_pressure: false,
inlet_p_idx: None,
orifice_kv: None,
fixed_opening: None,
calib_indices: CalibIndices::default(),
operational_state: OperationalState::default(),
circuit_id: CircuitId::default(),
}
}
@@ -121,17 +146,26 @@ impl IsenthalpicExpansionValve {
self
}
/// Enables the physical orifice-flow model (arch-6 physical actuator).
/// Enables the physical orifice-flow model with a **free** opening unknown.
///
/// `kv` is the effective maximum orifice flow area `Kv` [m²] such that the
/// mass flow obeys `ṁ = Kv·opening·√(2·ρ_in·max(P_in P_out, 0))`, where
/// `opening ∈ [0, 1]` is a free-actuator solver unknown. The orifice model is
/// **emergent-only** (it would over-constrain the fixed-pressure path), so this
/// builder also enables emergent-pressure mode. The valve gains one equation
/// (the orifice residual); the system must register a matching free-actuator
/// bounded variable so the extra `opening` unknown keeps the DoF balanced.
/// `opening ∈ [0, 1]` is a free-actuator solver unknown. Emergent-only.
pub fn with_orifice(mut self, kv: f64) -> Self {
self.orifice_kv = Some(kv);
self.orifice_kv = Some(kv.max(0.0));
self.fixed_opening = None;
self.emergent_pressure = true;
self
}
/// Enables the orifice-flow model with a **fixed** opening parameter.
///
/// Same residual as [`with_orifice`], but `opening` is a user parameter (not
/// a solver unknown). Pair with skipping the compressor displacement ṁ
/// closure so the system stays square — ṁ then follows the valve.
pub fn with_orifice_fixed(mut self, kv: f64, opening: f64) -> Self {
self.orifice_kv = Some(kv.max(0.0));
self.fixed_opening = Some(opening.clamp(0.0, 1.0));
self.emergent_pressure = true;
self
}
@@ -141,23 +175,31 @@ impl IsenthalpicExpansionValve {
self.orifice_kv
}
/// Returns the fixed orifice opening when configured.
pub fn fixed_opening(&self) -> Option<f64> {
self.fixed_opening
}
/// True when opening is a free actuator (orifice without fixed opening).
pub fn orifice_opening_is_free(&self) -> bool {
self.orifice_configured() && self.fixed_opening.is_none()
}
/// True when the physical orifice residual is configured (Kv set + emergent).
/// The extra equation is counted whenever this is true so the DoF matches the
/// registered free-actuator unknown, even before indices resolve.
fn orifice_configured(&self) -> bool {
self.emergent_pressure && self.orifice_kv.is_some()
}
/// True when the orifice residual can actually be evaluated (all indices,
/// backend, refrigerant and the actuator opening index are wired).
/// True when the orifice residual can actually be evaluated.
fn orifice_ready(&self) -> bool {
let opening_ok = self.fixed_opening.is_some() || self.calib_indices.actuator.is_some();
self.orifice_configured()
&& self.fluid_backend.is_some()
&& !self.refrigerant_id.is_empty()
&& self.inlet_p_idx.is_some()
&& self.outlet_p_idx.is_some()
&& self.inlet_h_idx.is_some()
&& self.calib_indices.actuator.is_some()
&& opening_ok
&& (self.outlet_m_idx.is_some() || self.inlet_m_idx.is_some())
}
@@ -176,33 +218,51 @@ impl IsenthalpicExpansionValve {
Enthalpy::from_joules_per_kg(h_in_jkg),
),
)
.map_err(|e| ComponentError::CalculationFailed(format!("rho_in: {e}")))
.map_err(|e| ComponentError::from_fluid_error_context("rho_in", e))
}
/// Evaluates the orifice mass-flow closure `f = Kv·opening·√(2·ρ_in·max(ΔP,0))`
/// [kg/s] and returns `(f, rho_in, sqrt_term, delta_p)` for reuse by the
/// Jacobian. Returns `None` when the flux is degenerate (ΔP ≤ 0 or ρ ≤ 0).
fn orifice_flow(&self, state: &StateSlice) -> Option<(f64, f64, f64, f64)> {
let (kv, p_in_idx, out_p, in_h, open_idx) = (
/// Raw orifice opening (fixed parameter or free actuator state).
fn orifice_opening_raw(&self, state: &StateSlice) -> Option<f64> {
self.fixed_opening
.or_else(|| self.calib_indices.actuator.map(|i| state[i]))
}
/// Evaluates the orifice mass-flow closure
/// `f = Kv·opening_eff·√(2·ρ_in·dp_eff)` [kg/s] with Story 0.3 regularization:
/// - `opening_eff = smooth_clamp(opening, 0, 1, OPENING_WIDTH)`
/// - `dp_eff = smooth_max(ΔP, 0, DP_SMOOTH_K)`
///
/// Returns `(f, rho_in, sqrt_term, delta_p_raw, opening_raw)` for Jacobian reuse.
/// Residual and Jacobian share this exact expression so FD matches analytic
/// across ΔP≤0 and opening bounds (FR19).
fn orifice_flow(&self, state: &StateSlice) -> Option<(f64, f64, f64, f64, f64)> {
let (kv, p_in_idx, out_p, in_h) = (
self.orifice_kv?,
self.inlet_p_idx?,
self.outlet_p_idx?,
self.inlet_h_idx?,
self.calib_indices.actuator?,
);
let opening_raw = self.orifice_opening_raw(state)?;
let opening = smooth_clamp(opening_raw, 0.0, 1.0, OPENING_WIDTH);
let p_in = state[p_in_idx];
let p_out = state[out_p];
let delta_p = p_in - p_out;
if delta_p <= 0.0 || p_in <= 0.0 {
return Some((0.0, 0.0, 0.0, delta_p));
if p_in <= 0.0 {
return Some((0.0, 0.0, 0.0, delta_p, opening_raw));
}
let rho = self.inlet_density(p_in, state[in_h]).ok()?;
if rho <= 0.0 {
return Some((0.0, rho, 0.0, delta_p));
return Some((0.0, rho, 0.0, delta_p, opening_raw));
}
let opening = state[open_idx].clamp(0.0, 1.0);
let sqrt_term = (2.0 * rho * delta_p).sqrt();
Some((kv * opening * sqrt_term, rho, sqrt_term, delta_p))
let dp_eff = smooth_max(delta_p, 0.0, DP_SMOOTH_K);
let sqrt_term = (2.0 * rho * dp_eff).sqrt();
Some((
kv * opening * sqrt_term,
rho,
sqrt_term,
delta_p,
opening_raw,
))
}
}
@@ -241,6 +301,8 @@ impl Component for IsenthalpicExpansionValve {
if self.emergent_pressure {
if let (Some(inlet_h), Some(out_h)) = (self.inlet_h_idx, self.outlet_h_idx) {
// r0: isenthalpic throttling — outlet enthalpy equals inlet enthalpy.
// Preserved in all operational states (Off / Bypass / On): the valve
// never exchanges heat, even when closed.
residuals[0] = state[out_h] - state[inlet_h];
// r1: mass conservation (CM1.3), dropped when same_branch_m (CM1.4).
let mut next = 1;
@@ -255,26 +317,40 @@ impl Component for IsenthalpicExpansionValve {
}
// Physical orifice closure (arch-6): ṁ = Kv·opening·√(2·ρ_in·ΔP).
if self.orifice_configured() {
residuals[next] = if self.orifice_ready() {
// Off state: the valve is closed — ṁ = 0. The Jacobian
// `∂r/∂ṁ = 1` is preserved so Newton keeps its mass-flow
// coupling. This lets a whole circuit be disabled in a
// multi-circuit machine without unbalancing the DoF.
if self.operational_state.is_off() {
let Some(m_idx) = self.outlet_m_idx.or(self.inlet_m_idx) else {
return Err(ComponentError::InvalidState(
"Expansion valve orifice closure requires a live mass-flow index"
"Expansion valve Off closure requires a live mass-flow index"
.to_string(),
));
};
let Some((flow, _, _, _)) = self.orifice_flow(state) else {
return Err(ComponentError::InvalidState(
"Expansion valve orifice closure could not evaluate from live state"
.to_string(),
));
};
state[m_idx] - flow
residuals[next] = state[m_idx]; // ṁ = 0
} else {
return Err(ComponentError::InvalidState(
"Expansion valve orifice closure requires pressure, enthalpy, and opening context"
.to_string(),
));
};
residuals[next] = if self.orifice_ready() {
let Some(m_idx) = self.outlet_m_idx.or(self.inlet_m_idx) else {
return Err(ComponentError::InvalidState(
"Expansion valve orifice closure requires a live mass-flow index"
.to_string(),
));
};
let Some((flow, _, _, _, _)) = self.orifice_flow(state) else {
return Err(ComponentError::InvalidState(
"Expansion valve orifice closure could not evaluate from live state"
.to_string(),
));
};
state[m_idx] - flow
} else {
return Err(ComponentError::InvalidState(
"Expansion valve orifice closure requires pressure, enthalpy, and opening context"
.to_string(),
));
};
}
}
return Ok(());
}
@@ -291,7 +367,7 @@ impl Component for IsenthalpicExpansionValve {
let p_evap_sat = backend
.saturation_pressure_t(fluid, self.t_evap_k)
.map_err(|e| ComponentError::CalculationFailed(e.to_string()))?;
.map_err(ComponentError::from_fluid_error)?;
// r0: drive outlet pressure to evaporating saturation pressure
residuals[0] = state[out_p] - p_evap_sat;
@@ -333,57 +409,84 @@ impl Component for IsenthalpicExpansionValve {
}
next += 1;
}
// Orifice residual r = ṁ Kv·opening·√(2·ρ_in(P_in,h_in)·ΔP).
// Exact analytic partials; ρ derivatives via central finite diff
// (consistent with the dT/dP FD used elsewhere).
if self.orifice_configured() && self.orifice_ready() {
if let (Some(kv), Some(p_in_idx), Some(out_p), Some(in_h), Some(open_idx)) = (
self.orifice_kv,
self.inlet_p_idx,
self.outlet_p_idx,
self.inlet_h_idx,
self.calib_indices.actuator,
) {
let m_idx = self.outlet_m_idx.or(self.inlet_m_idx);
if let (Some(mi), Some((_flow, rho, sqrt_term, delta_p))) =
(m_idx, self.orifice_flow(_state))
{
// ∂r/∂ṁ = 1
jacobian.add_entry(next, mi, 1.0);
if sqrt_term > 0.0 && delta_p > 0.0 {
let opening = _state[open_idx].clamp(0.0, 1.0);
// Orifice residual r = ṁ Kv·opening_eff·√(2·ρ_in·dp_eff) with
// opening_eff = smooth_clamp(opening, 0, 1, w) and
// dp_eff = smooth_max(ΔP, 0, k). Exact analytic partials of that
// shared residual expression (Story 0.3 / FR19); ρ derivatives via
// central finite difference (consistent with dT/dP FD elsewhere).
if self.orifice_configured() {
// Off state: orifice is closed, the residual is `ṁ = 0`.
// Jacobian is just `∂r/∂ṁ = 1` — pressure/enthalpy couplings
// from the orifice equation are dropped because they no
// longer apply. `orifice_ready()` is intentionally NOT
// required here: the Off closure only needs the mass-flow
// index, not the fluid backend or pressure/enthalpy context.
if self.operational_state.is_off() {
if let Some(m_idx) = self.outlet_m_idx.or(self.inlet_m_idx) {
jacobian.add_entry(next, m_idx, 1.0);
}
} else if self.orifice_ready() {
if let (Some(kv), Some(p_in_idx), Some(out_p), Some(in_h)) = (
self.orifice_kv,
self.inlet_p_idx,
self.outlet_p_idx,
self.inlet_h_idx,
) {
let m_idx = self.outlet_m_idx.or(self.inlet_m_idx);
if let (Some(mi), Some((_flow, rho, sqrt_term, delta_p, opening_raw))) =
(m_idx, self.orifice_flow(_state))
{
// ∂r/∂ṁ = 1
jacobian.add_entry(next, mi, 1.0);
let opening = smooth_clamp(opening_raw, 0.0, 1.0, OPENING_WIDTH);
let d_opening =
smooth_clamp_derivative(opening_raw, 0.0, 1.0, OPENING_WIDTH);
let dp_eff = smooth_max(delta_p, 0.0, DP_SMOOTH_K);
let d_dp_d_pin = smooth_max_derivative(delta_p, 0.0, DP_SMOOTH_K);
let p_in = _state[p_in_idx];
let h_in = _state[in_h];
// Central FD of ρ(P_in,h_in).
let dp = p_in * 1e-6 + 1.0;
let dh = h_in.abs() * 1e-6 + 1.0;
let drho_dp = match (
self.inlet_density(p_in + dp, h_in),
self.inlet_density(p_in - dp, h_in),
) {
(Ok(a), Ok(b)) => (a - b) / (2.0 * dp),
_ => 0.0,
};
let drho_dh = match (
self.inlet_density(p_in, h_in + dh),
self.inlet_density(p_in, h_in - dh),
) {
(Ok(a), Ok(b)) => (a - b) / (2.0 * dh),
_ => 0.0,
};
let inv = kv * opening / sqrt_term;
// ∂r/∂opening = Kv·√(2ρΔP)
jacobian.add_entry(next, open_idx, -kv * sqrt_term);
// ∂r/∂P_out = +Kv·opening·ρ/√(2ρΔP)
jacobian.add_entry(next, out_p, inv * rho);
// ∂r/∂P_in = Kv·opening·(∂ρ/∂P·ΔP + ρ)/√(2ρΔP)
jacobian.add_entry(
next,
p_in_idx,
-inv * (drho_dp * delta_p + rho),
);
// ∂r/∂h_in = Kv·opening·(∂ρ/∂h·ΔP)/√(2ρΔP)
jacobian.add_entry(next, in_h, -inv * (drho_dh * delta_p));
if sqrt_term > 0.0 && rho > 0.0 && dp_eff > 0.0 {
// Central FD of ρ(P_in,h_in).
let dp = p_in * 1e-6 + 1.0;
let dh = h_in.abs() * 1e-6 + 1.0;
let drho_dp = match (
self.inlet_density(p_in + dp, h_in),
self.inlet_density(p_in - dp, h_in),
) {
(Ok(a), Ok(b)) => (a - b) / (2.0 * dp),
_ => 0.0,
};
let drho_dh = match (
self.inlet_density(p_in, h_in + dh),
self.inlet_density(p_in, h_in - dh),
) {
(Ok(a), Ok(b)) => (a - b) / (2.0 * dh),
_ => 0.0,
};
let inv = kv * opening / sqrt_term;
// ∂r/∂opening_raw = Kv·√(2ρ·dp_eff)·∂opening_eff/∂raw
if let Some(open_idx) = self.calib_indices.actuator {
if self.fixed_opening.is_none() {
jacobian.add_entry(
next,
open_idx,
-kv * sqrt_term * d_opening,
);
}
}
// ∂r/∂P_out = +Kv·opening·ρ/√ · ∂dp_eff/∂P_in
// (chain: ∂dp_eff/∂P_out = ∂dp_eff/∂P_in, then ∂flow)
jacobian.add_entry(next, out_p, inv * rho * d_dp_d_pin);
// ∂r/∂P_in = Kv·opening·(∂ρ/∂P·dp_eff + ρ·∂dp_eff/∂P_in)/√
jacobian.add_entry(
next,
p_in_idx,
-inv * (drho_dp * dp_eff + rho * d_dp_d_pin),
);
// ∂r/∂h_in = Kv·opening·(∂ρ/∂h·dp_eff)/√
jacobian.add_entry(next, in_h, -inv * (drho_dh * dp_eff));
}
}
}
}
@@ -450,9 +553,7 @@ impl Component for IsenthalpicExpansionValve {
});
}
if self.orifice_configured() {
roles.push(crate::EquationRole::ActuatorClosure {
name: "orifice",
});
roles.push(crate::EquationRole::ActuatorClosure { name: "orifice" });
}
roles
}
@@ -461,6 +562,12 @@ impl Component for IsenthalpicExpansionValve {
self.calib_indices = indices;
}
fn set_opening_fraction(&mut self, opening: f64) {
if self.orifice_kv.is_some() {
self.fixed_opening = Some(opening.clamp(0.0, 1.0));
}
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
@@ -477,6 +584,55 @@ impl Component for IsenthalpicExpansionValve {
}
}
impl StateManageable for IsenthalpicExpansionValve {
fn state(&self) -> OperationalState {
self.operational_state
}
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
if self.operational_state.can_transition_to(state) {
let from = self.operational_state;
self.operational_state = state;
tracing::info!(
from = ?from,
to = ?state,
"IsenthalpicExpansionValve operational-state transition"
);
Ok(())
} else {
Err(ComponentError::InvalidState(format!(
"Illegal operational-state transition: {:?}{:?}",
self.operational_state, state
)))
}
}
fn can_transition_to(&self, target: OperationalState) -> bool {
self.operational_state.can_transition_to(target)
}
fn circuit_id(&self) -> &CircuitId {
&self.circuit_id
}
fn set_circuit_id(&mut self, circuit_id: CircuitId) {
self.circuit_id = circuit_id;
}
}
impl IsenthalpicExpansionValve {
/// Read-only access to the operational state.
pub fn operational_state(&self) -> OperationalState {
self.operational_state
}
/// Direct setter (no transition check) for use in config loading and tests
/// where the state-machine validation would be in the way.
pub fn set_operational_state_unchecked(&mut self, state: OperationalState) {
self.operational_state = state;
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -603,14 +759,9 @@ mod tests {
assert!(r_closed[2] > 0.0, "tiny opening under-passes flow (r>0)");
}
#[test]
fn test_orifice_jacobian_matches_finite_difference() {
let exv = orifice_valve();
let state = orifice_state(0.5);
// Analytic entries for the orifice row (row index 2).
fn assert_orifice_jacobian_fd(exv: &IsenthalpicExpansionValve, state: &[f64]) {
let mut jb = JacobianBuilder::new();
exv.jacobian_entries(&state, &mut jb).unwrap();
exv.jacobian_entries(state, &mut jb).unwrap();
let mut analytic = std::collections::HashMap::new();
for &(row, col, val) in jb.entries() {
if row == 2 {
@@ -624,7 +775,6 @@ mod tests {
r[2]
};
// Central finite difference for each dependent column.
for &(col, eps) in &[
(1usize, 50.0), // p_in
(2usize, 20.0), // h_in
@@ -632,8 +782,8 @@ mod tests {
(4usize, 50.0), // p_out
(6usize, 1e-4), // opening
] {
let mut sp = state.clone();
let mut sm = state.clone();
let mut sp = state.to_vec();
let mut sm = state.to_vec();
sp[col] += eps;
sm[col] -= eps;
let fd = (residual_row2(&sp) - residual_row2(&sm)) / (2.0 * eps);
@@ -646,6 +796,28 @@ mod tests {
}
}
#[test]
fn test_orifice_jacobian_matches_finite_difference() {
let exv = orifice_valve();
assert_orifice_jacobian_fd(&exv, &orifice_state(0.5));
}
#[test]
fn test_orifice_jacobian_fd_at_opening_bounds() {
let exv = orifice_valve();
assert_orifice_jacobian_fd(&exv, &orifice_state(0.0));
assert_orifice_jacobian_fd(&exv, &orifice_state(1.0));
}
#[test]
fn test_orifice_jacobian_fd_at_nonpositive_dp() {
let exv = orifice_valve();
let mut state = orifice_state(0.5);
state[1] = 3.0e5; // p_in
state[4] = 4.0e5; // p_out → ΔP < 0
assert_orifice_jacobian_fd(&exv, &state);
}
#[test]
fn test_orifice_without_actuator_index_errors() {
use entropyk_fluids::TestBackend;

View File

@@ -191,8 +191,12 @@ pub struct IsentropicCompressor {
/// closes the shared mass flow via the volumetric displacement model, letting
/// the discharge pressure emerge from the condenser ↔ secondary balance.
emergent_pressure: bool,
/// Swept (displacement) volume per revolution [m³/rev] — required in emergent mode.
/// Swept (displacement) volume per revolution [m³/rev] — required in emergent mode
/// unless [`mass_flow_external`] is set (ṁ metered by a fixed EXV orifice).
displacement_m3: Option<f64>,
/// When true with `emergent_pressure`, the compressor does **not** close ṁ
/// (an EXV fixed orifice meters the branch). Only the energy residual remains.
mass_flow_external: bool,
/// Rotational speed [rev/s] — required in emergent mode.
speed_hz: Option<f64>,
/// Volumetric-efficiency model used by the displacement mass-flow closure.
@@ -268,6 +272,7 @@ impl IsentropicCompressor {
same_branch_m: false,
emergent_pressure: false,
displacement_m3: None,
mass_flow_external: false,
speed_hz: None,
volumetric_efficiency: VolumetricEfficiency::Constant(1.0),
vsd_map: None,
@@ -312,12 +317,25 @@ impl IsentropicCompressor {
volumetric_efficiency: VolumetricEfficiency,
) -> Self {
self.emergent_pressure = true;
self.mass_flow_external = false;
self.displacement_m3 = Some(displacement_m3);
self.speed_hz = Some(speed_hz);
self.volumetric_efficiency = volumetric_efficiency;
self
}
/// Emergent pressures, but ṁ is metered elsewhere (fixed EXV orifice).
///
/// Residuals: isentropic energy at the floating discharge pressure (+ mass
/// continuity when suction/discharge branches differ). No volumetric ṁ closure.
pub fn with_emergent_metered_flow(mut self) -> Self {
self.emergent_pressure = true;
self.mass_flow_external = true;
self.displacement_m3 = None;
self.speed_hz = None;
self
}
/// Attaches a variable-speed-drive efficiency map (see [`VsdSpeedMap`]).
///
/// When set together with a known rotational speed, the volumetric and
@@ -677,6 +695,56 @@ impl Component for IsentropicCompressor {
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// Emergent + external ṁ (fixed EXV orifice): energy only at floating P_dis.
if self.emergent_pressure && self.mass_flow_external {
if residuals.len() < self.n_equations() {
return Err(ComponentError::InvalidResidualDimensions {
expected: self.n_equations(),
actual: residuals.len(),
});
}
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::InvalidState(
"IsentropicCompressor metered-flow mode requires a fluid backend".into(),
)
})?;
let fluid = FluidId::new(&self.refrigerant_id);
let (Some(suc_p), Some(suc_h), Some(dis_p), Some(dis_h)) = (
self.suction_p_idx,
self.suction_h_idx,
self.discharge_p_idx,
self.discharge_h_idx,
) else {
return Err(ComponentError::InvalidState(
"IsentropicCompressor metered-flow mode requires live suction/discharge indices"
.into(),
));
};
let p_suc = state[suc_p];
let h_suc = state[suc_h];
let p_dis = state[dis_p];
if p_suc <= 1_000.0 || h_suc <= 50_000.0 || p_dis <= 1_000.0 {
return Err(ComponentError::InvalidState(
"IsentropicCompressor metered-flow mode requires physical live states".into(),
));
}
let h_dis =
self.compute_h_dis_from_state(backend.as_ref(), fluid, p_suc, h_suc, p_dis)?;
residuals[0] = state[dis_h] - h_dis;
if !self.same_branch_m {
residuals[1] = match (self.suction_m_idx, self.discharge_m_idx) {
(Some(m_suc), Some(m_dis)) => state[m_dis] - state[m_suc],
_ => {
return Err(ComponentError::InvalidState(
"IsentropicCompressor mass conservation requires live mass-flow indices"
.into(),
));
}
};
}
return Ok(());
}
// Emergent-pressure path: close the shared mass flow via the volumetric
// displacement model and let the discharge pressure float (set by the
// downstream condenser outlet closure). r0 no longer pins P_dis.
@@ -844,6 +912,53 @@ impl Component for IsentropicCompressor {
state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// Metered ṁ: r0 = h_dis h_isen(P_suc,h_suc,P_dis)
if self.emergent_pressure && self.mass_flow_external {
if let (Some(suc_p), Some(suc_h), Some(dis_p), Some(dis_h)) = (
self.suction_p_idx,
self.suction_h_idx,
self.discharge_p_idx,
self.discharge_h_idx,
) {
jacobian.add_entry(0, dis_h, 1.0);
// Numerical ∂h_isen/∂(P_suc,h_suc,P_dis)
if let Some(backend) = self.fluid_backend.as_ref() {
let fluid = FluidId::new(&self.refrigerant_id);
let p_suc = state[suc_p];
let h_suc = state[suc_h];
let p_dis = state[dis_p];
let dpp = p_suc * 1e-4 + 100.0;
let dph = h_suc * 1e-4 + 10.0;
let dpd = p_dis * 1e-4 + 100.0;
let h = |ps: f64, hs: f64, pd: f64| {
self.compute_h_dis_from_state(backend.as_ref(), fluid.clone(), ps, hs, pd)
};
if let (Ok(a), Ok(b)) =
(h(p_suc + dpp, h_suc, p_dis), h(p_suc - dpp, h_suc, p_dis))
{
jacobian.add_entry(0, suc_p, -(a - b) / (2.0 * dpp));
}
if let (Ok(a), Ok(b)) =
(h(p_suc, h_suc + dph, p_dis), h(p_suc, h_suc - dph, p_dis))
{
jacobian.add_entry(0, suc_h, -(a - b) / (2.0 * dph));
}
if let (Ok(a), Ok(b)) =
(h(p_suc, h_suc, p_dis + dpd), h(p_suc, h_suc, p_dis - dpd))
{
jacobian.add_entry(0, dis_p, -(a - b) / (2.0 * dpd));
}
}
if !self.same_branch_m {
if let (Some(m_suc), Some(m_dis)) = (self.suction_m_idx, self.discharge_m_idx) {
jacobian.add_entry(1, m_dis, 1.0);
jacobian.add_entry(1, m_suc, -1.0);
}
}
}
return Ok(());
}
// Emergent-pressure path: Jacobian of the displacement mass-flow closure
// (r0) and the floating-pressure isentropic compression (r1).
if self.displacement_ready() {
@@ -1069,6 +1184,10 @@ impl Component for IsentropicCompressor {
}
fn n_equations(&self) -> usize {
// Metered ṁ (EXV orifice): energy only (+ mass continuity if distinct branches).
if self.emergent_pressure && self.mass_flow_external {
return if self.same_branch_m { 1 } else { 2 };
}
// CM1.4: drop conservation equation when same-branch.
let core = if self.same_branch_m { 2 } else { 3 };
// +1 for the slide-valve equation (T_sat(P_suc) = SST_target) closed by

View File

@@ -0,0 +1,518 @@
//! Central finite-difference Jacobian verification harness.
//!
//! Compares a component's analytic [`Component::jacobian_entries`] against
//! central finite differences of [`Component::compute_residuals`]. Intended for
//! tests and Jacobian-health audits (Epic 0) — **not** for production Newton
//! assembly (see `JacobianMatrix::numerical` in the solver crate for forward FD).
use crate::{Component, ComponentError, JacobianBuilder};
/// Default relative step factor for central FD: `h = (|x| · ε).max(h_floor)`.
pub const DEFAULT_REL_EPSILON: f64 = 1e-6;
/// Default absolute floor on the FD step (matches condenser FD tests).
pub const DEFAULT_H_FLOOR: f64 = 1e-3;
/// Default relative tolerance for analytic vs FD agreement.
pub const DEFAULT_REL_TOL: f64 = 1e-4;
/// Analytic entry treated as zero when `|analytic| < this`.
pub const DEFAULT_ANALYTIC_ATOL: f64 = 1e-12;
/// FD magnitude treated as "informative physics" when `|fd| > this`.
pub const DEFAULT_FD_INFORMATIVE: f64 = 1e-8;
/// Configuration for a Jacobian health check.
#[derive(Debug, Clone, Copy)]
pub struct JacobianFdConfig {
/// Relative step factor ε in `h = (|x_j| · ε).max(h_floor)`.
pub rel_epsilon: f64,
/// Absolute floor on the central-FD step.
pub h_floor: f64,
/// Relative tolerance: `|fd an| / (1 + max(|fd|,|an|)) < rel_tol`.
pub rel_tol: f64,
/// Analytic absolute tolerance for zero-gradient detection.
pub analytic_atol: f64,
/// FD magnitude above which a zero analytic entry is a killer.
pub fd_informative: f64,
}
impl Default for JacobianFdConfig {
fn default() -> Self {
Self {
rel_epsilon: DEFAULT_REL_EPSILON,
h_floor: DEFAULT_H_FLOOR,
rel_tol: DEFAULT_REL_TOL,
analytic_atol: DEFAULT_ANALYTIC_ATOL,
fd_informative: DEFAULT_FD_INFORMATIVE,
}
}
}
/// Analytic vs FD mismatch at a single Jacobian entry.
#[derive(Debug, Clone, PartialEq)]
pub struct JacobianMismatch {
/// Residual / equation row.
pub row: usize,
/// State variable column.
pub col: usize,
/// Analytic ∂r/∂x.
pub analytic: f64,
/// Central finite-difference estimate.
pub fd: f64,
/// `|fd an| / (1 + max(|fd|,|an|))`.
pub relative_error: f64,
}
/// Analytic ≈ 0 while central FD shows an informative derivative.
///
/// Classic phantom / clamp signature: Newton sees a dead column while the
/// residual surface still has slope under finite perturbation.
#[derive(Debug, Clone, PartialEq)]
pub struct ZeroGradientKiller {
/// Residual / equation row.
pub row: usize,
/// State variable column.
pub col: usize,
/// Analytic ∂r/∂x (near zero).
pub analytic: f64,
/// Central finite-difference estimate (informative).
pub fd: f64,
}
/// Results of comparing analytic and central-FD Jacobians at one state point.
#[derive(Debug, Clone, Default)]
pub struct JacobianHealthReport {
/// Number of residual equations.
pub n_equations: usize,
/// Number of state variables compared.
pub n_variables: usize,
/// Entries exceeding [`JacobianFdConfig::rel_tol`].
pub mismatches: Vec<JacobianMismatch>,
/// Entries with analytic ≈ 0 and informative FD.
pub zero_gradient_killers: Vec<ZeroGradientKiller>,
}
impl JacobianHealthReport {
/// True when no mismatches and no zero-gradient killers were found.
pub fn is_clean(&self) -> bool {
self.mismatches.is_empty() && self.zero_gradient_killers.is_empty()
}
/// True when at least one zero-gradient killer was detected.
pub fn has_zero_gradient_killers(&self) -> bool {
!self.zero_gradient_killers.is_empty()
}
}
/// Kind of Jacobian-health finding that an allow-list entry may suppress.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AllowKind {
/// Analytic ≈ 0 while FD is informative.
ZeroGradientKiller,
/// Analytic vs FD relative error exceeds tolerance.
Mismatch,
}
/// Explicit allow-list entry for deferred debt (Story 0.5 gate).
///
/// Epic-0 P0 surfaces (valve / EXV / condenser actuators / two-phase quality)
/// must **not** appear here. Prefer an empty list; shrink over time.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AllowListEntry {
/// Gated surface id (e.g. `"exv_orifice"`, `"condenser_fan"`).
pub surface_id: &'static str,
/// Region id within the surface (e.g. `"opening_0"`, `"dp_le_0"`).
pub region_id: &'static str,
/// Optional residual row filter (`None` = any row).
pub row: Option<usize>,
/// Optional state column filter (`None` = any column).
pub col: Option<usize>,
/// Finding kind this entry covers.
pub kind: AllowKind,
/// One-line rationale (must also appear in the audit / health report).
pub rationale: &'static str,
}
/// Outcome of evaluating a health report against an allow-list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateOutcome {
/// No mismatches and no zero-gradient killers.
Clean,
/// Findings exist but every one is covered by the allow-list.
AllowListed,
}
fn entry_matches(
entry: &AllowListEntry,
surface_id: &str,
region_id: &str,
kind: AllowKind,
row: usize,
col: usize,
) -> bool {
entry.surface_id == surface_id
&& entry.region_id == region_id
&& entry.kind == kind
&& entry.row.map(|r| r == row).unwrap_or(true)
&& entry.col.map(|c| c == col).unwrap_or(true)
}
/// Evaluate a health report: clean, allow-listed, or hard failure.
///
/// Returns [`Err`] with a debug message when any finding is not covered by
/// `allow_list` for `(surface_id, region_id)`.
pub fn evaluate_with_allow_list(
report: &JacobianHealthReport,
surface_id: &str,
region_id: &str,
allow_list: &[AllowListEntry],
) -> Result<GateOutcome, String> {
if report.is_clean() {
return Ok(GateOutcome::Clean);
}
let mut uncovered = Vec::new();
for k in &report.zero_gradient_killers {
let covered = allow_list.iter().any(|e| {
entry_matches(
e,
surface_id,
region_id,
AllowKind::ZeroGradientKiller,
k.row,
k.col,
)
});
if !covered {
uncovered.push(format!(
"killer row={} col={} analytic={} fd={}",
k.row, k.col, k.analytic, k.fd
));
}
}
for m in &report.mismatches {
let covered = allow_list
.iter()
.any(|e| entry_matches(e, surface_id, region_id, AllowKind::Mismatch, m.row, m.col));
if !covered {
uncovered.push(format!(
"mismatch row={} col={} analytic={} fd={} rel_err={}",
m.row, m.col, m.analytic, m.fd, m.relative_error
));
}
}
if uncovered.is_empty() {
Ok(GateOutcome::AllowListed)
} else {
Err(format!(
"Jacobian health gate failed for {surface_id}/{region_id}: {}",
uncovered.join("; ")
))
}
}
/// Run [`check_jacobian_health`] and assert the report is clean or allow-listed.
pub fn assert_jacobian_healthy(
component: &dyn Component,
state: &[f64],
config: JacobianFdConfig,
surface_id: &str,
region_id: &str,
allow_list: &[AllowListEntry],
) {
let report = check_jacobian_health(component, state, config).unwrap_or_else(|e| {
panic!("Jacobian health check errored for {surface_id}/{region_id}: {e:?}")
});
evaluate_with_allow_list(&report, surface_id, region_id, allow_list)
.unwrap_or_else(|msg| panic!("{msg}; full_report={report:?}"));
}
/// Dense analytic Jacobian accumulated from sparse builder entries.
pub fn dense_analytic_jacobian(
component: &dyn Component,
state: &[f64],
) -> Result<Vec<Vec<f64>>, ComponentError> {
let n_eq = component.n_equations();
let n_var = state.len();
let mut builder = JacobianBuilder::new();
component.jacobian_entries(state, &mut builder)?;
let mut analytic = vec![vec![0.0; n_var]; n_eq];
for &(row, col, val) in builder.entries() {
if row >= n_eq || col >= n_var {
return Err(ComponentError::InvalidState(format!(
"Out-of-bounds Jacobian entry: row {} (max {}), col {} (max {})",
row, n_eq, col, n_var
)));
}
analytic[row][col] += val;
}
Ok(analytic)
}
/// Compare analytic Jacobian entries to central finite differences of residuals.
///
/// Step size: `h_j = (|state[j]| · rel_epsilon).max(h_floor)`.
/// Relative error: `|fd an| / (1 + max(|fd|,|an|))`.
///
/// If a boundary violation occurs (residuals evaluation fails on one side),
/// the harness automatically falls back to one-sided differences using the other direction.
pub fn check_jacobian_health(
component: &dyn Component,
state: &[f64],
config: JacobianFdConfig,
) -> Result<JacobianHealthReport, ComponentError> {
let n_eq = component.n_equations();
let n_var = state.len();
let analytic = dense_analytic_jacobian(component, state)?;
let mut report = JacobianHealthReport {
n_equations: n_eq,
n_variables: n_var,
mismatches: Vec::new(),
zero_gradient_killers: Vec::new(),
};
let mut sp = state.to_vec();
let mut sm = state.to_vec();
let mut rp = vec![0.0; n_eq];
let mut rm = vec![0.0; n_eq];
let mut r0 = vec![0.0; n_eq];
let mut r0_computed = false;
for col in 0..n_var {
let h = (state[col].abs() * config.rel_epsilon).max(config.h_floor);
if h <= 0.0 || !h.is_finite() {
return Err(ComponentError::NumericalError(format!(
"Step size h is zero or non-finite: {} at col {}",
h, col
)));
}
sp.copy_from_slice(state);
sm.copy_from_slice(state);
sp[col] += h;
sm[col] -= h;
let mut sp_ok = true;
let mut sm_ok = true;
if component.compute_residuals(&sp, &mut rp).is_err() {
sp_ok = false;
}
if component.compute_residuals(&sm, &mut rm).is_err() {
sm_ok = false;
}
if !sp_ok && !sm_ok {
// Both directions failed. We propagate the error by evaluating sp again.
component.compute_residuals(&sp, &mut rp)?;
}
for row in 0..n_eq {
let fd = if sp_ok && !sm_ok {
// Backward step failed. Fall back to forward one-sided difference.
if !r0_computed {
component.compute_residuals(state, &mut r0)?;
r0_computed = true;
}
(rp[row] - r0[row]) / h
} else if !sp_ok && sm_ok {
// Forward step failed. Fall back to backward one-sided difference.
if !r0_computed {
component.compute_residuals(state, &mut r0)?;
r0_computed = true;
}
(r0[row] - rm[row]) / h
} else {
// Both steps succeeded. Use central difference.
(rp[row] - rm[row]) / (2.0 * h)
};
let an = analytic[row][col];
let relative_error = if fd.is_nan() || an.is_nan() {
f64::NAN
} else if fd.is_infinite() || an.is_infinite() {
f64::INFINITY
} else {
let scale = 1.0 + fd.abs().max(an.abs());
(fd - an).abs() / scale
};
let is_mismatch = relative_error.is_nan()
|| relative_error.is_infinite()
|| relative_error >= config.rel_tol;
if is_mismatch {
report.mismatches.push(JacobianMismatch {
row,
col,
analytic: an,
fd,
relative_error: if relative_error.is_nan() {
f64::INFINITY
} else {
relative_error
},
});
}
if an.abs() < config.analytic_atol && fd.abs() > config.fd_informative {
report.zero_gradient_killers.push(ZeroGradientKiller {
row,
col,
analytic: an,
fd,
});
}
}
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ConnectedPort, ResidualVector, StateSlice};
/// r0 = x0², r1 = 2·x1 — exact analytic Jacobian available.
struct QuadraticComponent;
impl Component for QuadraticComponent {
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
residuals[0] = state[0] * state[0];
residuals[1] = 2.0 * state[1];
Ok(())
}
fn jacobian_entries(
&self,
state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
jacobian.add_entry(0, 0, 2.0 * state[0]);
jacobian.add_entry(1, 1, 2.0);
Ok(())
}
fn n_equations(&self) -> usize {
2
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
}
/// Same residuals, but jacobian claims ∂r0/∂x0 = 0 (injected killer).
struct ZeroGradientLiar;
impl Component for ZeroGradientLiar {
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
residuals[0] = state[0] * state[0];
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
_jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// Deliberately omit the true 2·x0 entry.
Ok(())
}
fn n_equations(&self) -> usize {
1
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
}
#[test]
fn harness_clean_on_exact_analytic() {
let report = check_jacobian_health(
&QuadraticComponent,
&[3.0, -1.5],
JacobianFdConfig::default(),
)
.unwrap();
assert!(
report.is_clean(),
"unexpected mismatches={:?} killers={:?}",
report.mismatches,
report.zero_gradient_killers
);
}
#[test]
fn harness_detects_zero_gradient_killer() {
let report =
check_jacobian_health(&ZeroGradientLiar, &[2.0], JacobianFdConfig::default()).unwrap();
assert!(
report.has_zero_gradient_killers(),
"expected killer, got {:?}",
report
);
let k = &report.zero_gradient_killers[0];
assert_eq!((k.row, k.col), (0, 0));
assert!(k.fd.abs() > 1.0, "FD should see ~4.0, got {}", k.fd);
}
#[test]
fn allow_list_covers_known_killer() {
let report =
check_jacobian_health(&ZeroGradientLiar, &[2.0], JacobianFdConfig::default()).unwrap();
// Liar injects both a zero-gradient killer and a mismatch on the same entry.
let allow = [
AllowListEntry {
surface_id: "liar",
region_id: "x2",
row: Some(0),
col: Some(0),
kind: AllowKind::ZeroGradientKiller,
rationale: "unit-test fixture",
},
AllowListEntry {
surface_id: "liar",
region_id: "x2",
row: Some(0),
col: Some(0),
kind: AllowKind::Mismatch,
rationale: "unit-test fixture",
},
];
assert_eq!(
evaluate_with_allow_list(&report, "liar", "x2", &allow).unwrap(),
GateOutcome::AllowListed
);
assert!(evaluate_with_allow_list(&report, "liar", "other", &allow).is_err());
}
#[test]
fn assert_jacobian_healthy_passes_on_clean() {
assert_jacobian_healthy(
&QuadraticComponent,
&[3.0, -1.5],
JacobianFdConfig::default(),
"quadratic",
"nominal",
&[],
);
}
}

View File

@@ -52,18 +52,35 @@
//! let component: Box<dyn Component> = Box::new(MockComponent { n_equations: 3 });
//! ```
#![warn(missing_docs)]
#![warn(rust_2018_idioms)]
// Pre-existing lint debt: the workspace had no CI for a long time and accumulated
// many clippy/style warnings. They are temporarily allowed here so the new CI
// skeleton can enforce warnings-as-errors on *new* code without a multi-thousand
// line refactor. See deferred-work.md for the cleanup plan.
#![allow(clippy::too_many_arguments)]
#![allow(clippy::question_mark)]
#![allow(clippy::needless_range_loop)]
#![allow(clippy::doc_overindented_list_items)]
#![allow(clippy::new_ret_no_self)]
#![allow(clippy::should_implement_trait)]
#![allow(clippy::manual_clamp)]
#![allow(clippy::approx_constant)]
#![allow(clippy::type_complexity)]
#![allow(clippy::legacy_numeric_constants)]
#![allow(clippy::field_reassign_with_default)]
#![allow(clippy::assertions_on_constants)]
#![allow(clippy::unnecessary_unwrap)]
#![allow(missing_docs)]
pub mod air_boundary;
pub mod anchor;
pub mod brine_boundary;
pub mod bypass_valve;
pub mod capillary_tube;
pub mod centrifugal_compressor;
pub mod dof;
pub mod bypass_valve;
pub mod compressor;
pub mod curves;
pub mod dof;
pub mod drum;
pub mod expansion_valve;
pub mod external_model;
@@ -74,6 +91,7 @@ pub mod heat_exchanger;
pub mod heat_source;
pub mod isenthalpic_expansion_valve;
pub mod isentropic_compressor;
pub mod jacobian_fd;
pub mod node;
pub mod params;
pub mod pipe;
@@ -92,15 +110,14 @@ pub mod valve_flow;
pub use air_boundary::{AirSink, AirSource};
pub use anchor::{Anchor, AnchorConstraint};
pub use brine_boundary::{BrineSink, BrineSource};
pub use bypass_valve::{BypassValve, BypassValveConfig, ValveCharacteristics};
pub use capillary_tube::{CapillaryGeometry, CapillaryTube};
pub use centrifugal_compressor::{CentrifugalCompressor, CentrifugalMap, CentrifugalMapPoint};
pub use dof::{unspecified_roles, EquationRole};
pub use bypass_valve::{BypassValve, BypassValveConfig, ValveCharacteristics};
pub use compressor::{Ahri540Coefficients, Compressor, CompressorModel, SstSdtCoefficients};
pub use curves::{BoundedCurve, CurveEngine, CurveEval, CurveResult, CurveSet, CurveWarning};
pub use dof::{unspecified_roles, EquationRole};
pub use drum::Drum;
pub use expansion_valve::{ExpansionValve, PhaseRegion};
pub use valve_flow::{valve_mass_flow, valve_mass_flow_dp_up, ValveFlowInput, ValveFlowModel};
pub use external_model::{
ExternalModel, ExternalModelConfig, ExternalModelError, ExternalModelMetadata,
ExternalModelType, MockExternalModel, ThreadSafeExternalModel,
@@ -148,10 +165,20 @@ pub use state_machine::{
StateTransitionRecord,
};
pub use thermal_load::ThermalLoad;
pub use valve_flow::{
valve_mass_flow, valve_mass_flow_dp_down, valve_mass_flow_dp_up, ValveFlowInput, ValveFlowModel,
};
use entropyk_core::{MassFlow, Power};
use entropyk_fluids::FluidError;
use thiserror::Error;
/// A recoverable component domain violation (KINSOL `> 0` convention).
///
/// Re-exported from `entropyk-solver-core` so component authors name the type
/// via this facade, never the sub-crate directly.
pub use entropyk_solver_core::DomainViolation;
/// Errors that can occur during component operations.
///
/// This enum represents all possible error conditions that components
@@ -214,6 +241,81 @@ pub enum ComponentError {
/// properties at the requested state.
#[error("Calculation failed: {0}")]
CalculationFailed(String),
/// The evaluation left the component's physical domain (recoverable).
///
/// Raised when a property backend is queried outside its valid range
/// (e.g. a CoolProp out-of-range pressure/enthalpy probe). Globalization
/// strategies treat this as a step-reduction signal following the KINSOL
/// callback convention (`0` = ok, `> 0` = recoverable → shrink the step
/// and retry, `< 0` = fatal): the line search backtracks instead of
/// aborting, and when backtracks are exhausted the solve terminates with
/// `ConvergenceReason::DomainViolation`.
///
/// Use [`ComponentError::is_recoverable`] to distinguish this variant
/// from fatal errors, and [`ComponentError::from_fluid_error`] /
/// [`ComponentError::from_fluid_error_context`] to classify a
/// [`FluidError`] into this variant or [`ComponentError::CalculationFailed`].
#[error("{0}")]
DomainViolation(DomainViolation),
}
impl ComponentError {
/// Returns `true` when this error is a *recoverable* domain violation.
///
/// Follows the KINSOL callback convention: an evaluation returning `0` is
/// fine, `> 0` is recoverable (the solver may shrink the step and retry),
/// `< 0` is fatal. [`ComponentError::DomainViolation`] is the recoverable
/// (`> 0`) signal; every other variant is fatal for solver purposes.
pub fn is_recoverable(&self) -> bool {
matches!(self, Self::DomainViolation(_))
}
/// Classifies a fluid-backend failure as recoverable or fatal.
///
/// Domain errors — the probe left the backend's valid range — are
/// recoverable ([`ComponentError::DomainViolation`]):
/// - [`FluidError::InvalidState`] (e.g. CoolProp NaN detected post-hoc),
/// - [`FluidError::OutOfBounds`] (state outside the tabular data bounds),
/// - [`FluidError::CoolPropError`] (CoolProp rejected the probe).
///
/// All other variants (`UnknownFluid`, `UnsupportedProperty`,
/// `TableNotFound`, `NoCriticalPoint`, `MixtureNotSupported`,
/// `NumericalError`) are fatal configuration/usage errors and stay
/// [`ComponentError::CalculationFailed`].
pub fn from_fluid_error(error: FluidError) -> Self {
match error {
FluidError::InvalidState { reason } => Self::DomainViolation(DomainViolation {
component: None,
detail: reason,
}),
error @ FluidError::OutOfBounds { .. } => Self::DomainViolation(DomainViolation {
component: None,
detail: error.to_string(),
}),
FluidError::CoolPropError(message) => Self::DomainViolation(DomainViolation {
component: None,
detail: message,
}),
fatal => Self::CalculationFailed(fatal.to_string()),
}
}
/// Same classification as [`ComponentError::from_fluid_error`], but the
/// `detail` / fatal message becomes `format!("{context}: {error}")` so
/// call sites keep their existing context prefixes (e.g. `"rho_in: "`,
/// `"Failed to compute suction state: "`).
pub fn from_fluid_error_context(context: &str, error: FluidError) -> Self {
match error {
error @ (FluidError::InvalidState { .. }
| FluidError::OutOfBounds { .. }
| FluidError::CoolPropError(_)) => Self::DomainViolation(DomainViolation {
component: None,
detail: format!("{context}: {error}"),
}),
fatal => Self::CalculationFailed(format!("{context}: {fatal}")),
}
}
}
/// Represents the state of the entire thermodynamic system.
@@ -798,6 +900,15 @@ pub trait Component {
// Default: no-op for components that don't support inverse calibration
}
/// Sets the fixed orifice opening fraction in `[0, 1]` for components that
/// meter flow through an adjustable opening (expansion valves).
///
/// Used by physical-parameter continuation: solve at a reduced opening,
/// then walk the opening up to its target warm-starting each step
/// (TLK-Thermo/Modelica 2019 style homotopy on the physical parameter).
/// Default: no-op for components without an adjustable opening.
fn set_opening_fraction(&mut self, _opening: f64) {}
/// Injects the state index of an externally-determined heat rate Q [W]
/// (an inter-circuit thermal-coupling unknown) into this component.
///
@@ -1240,4 +1351,154 @@ mod tests {
let boxed: Box<dyn Component> = Box::new(component);
assert_eq!(boxed.get_ports().len(), 2);
}
// ── Story 1.3: recoverable DomainViolation (AC #1) ──────────────────────
#[test]
fn domain_violation_is_the_only_recoverable_variant() {
let recoverable = ComponentError::DomainViolation(DomainViolation {
component: Some("compressor".to_string()),
detail: "suction pressure below backend range".to_string(),
});
assert!(recoverable.is_recoverable());
let fatal_variants = [
ComponentError::InvalidStateDimensions {
expected: 3,
actual: 2,
},
ComponentError::InvalidResidualDimensions {
expected: 3,
actual: 2,
},
ComponentError::NumericalError("division by zero".to_string()),
ComponentError::InvalidState("disconnected port".to_string()),
ComponentError::InvalidStateTransition {
from: OperationalState::Off,
to: OperationalState::On,
reason: "not allowed".to_string(),
},
ComponentError::CalculationFailed("no fluid backend".to_string()),
];
for variant in &fatal_variants {
assert!(
!variant.is_recoverable(),
"{variant:?} must not be recoverable"
);
}
}
#[test]
fn domain_violation_display_delegates_to_inner_type() {
let attributed = ComponentError::DomainViolation(DomainViolation {
component: Some("condenser".to_string()),
detail: "pressure below backend range".to_string(),
});
let rendered = attributed.to_string();
assert!(
rendered.contains("condenser"),
"missing component: {rendered}"
);
assert!(
rendered.contains("pressure below backend range"),
"missing detail: {rendered}"
);
let unattributed = ComponentError::DomainViolation(DomainViolation {
component: None,
detail: "out of range".to_string(),
});
assert_eq!(unattributed.to_string(), "domain violation: out of range");
}
#[test]
fn from_fluid_error_classifies_recoverable_domain_errors() {
let recoverable_cases = [
FluidError::InvalidState {
reason: "pressure below triple point".to_string(),
},
FluidError::OutOfBounds {
fluid: "R134a".to_string(),
p: 1.0e5,
t: 300.0,
},
FluidError::CoolPropError("unable to solve 1phase PY flash".to_string()),
];
for error in recoverable_cases {
let classified = ComponentError::from_fluid_error(error);
assert!(
classified.is_recoverable(),
"{classified:?} must be recoverable"
);
match &classified {
ComponentError::DomainViolation(violation) => {
assert!(violation.component.is_none());
assert!(!violation.detail.is_empty());
}
other => panic!("expected DomainViolation, got {other:?}"),
}
}
}
#[test]
fn from_fluid_error_keeps_config_errors_fatal() {
let fatal_cases = [
FluidError::UnknownFluid {
fluid: "R999".to_string(),
},
FluidError::UnsupportedProperty {
property: "viscosity".to_string(),
},
FluidError::TableNotFound {
path: "missing.bin".to_string(),
},
FluidError::NoCriticalPoint {
fluid: "R134a".to_string(),
},
FluidError::MixtureNotSupported("R134a/R410A".to_string()),
FluidError::NumericalError("NaN in table lookup".to_string()),
];
for error in fatal_cases {
let classified = ComponentError::from_fluid_error(error);
assert!(
!classified.is_recoverable(),
"{classified:?} must stay fatal"
);
assert!(
matches!(classified, ComponentError::CalculationFailed(_)),
"expected CalculationFailed, got {classified:?}"
);
}
}
#[test]
fn from_fluid_error_context_preserves_prefix() {
let recoverable = ComponentError::from_fluid_error_context(
"rho_in",
FluidError::InvalidState {
reason: "pressure below triple point".to_string(),
},
);
match recoverable {
ComponentError::DomainViolation(violation) => {
assert!(violation.detail.starts_with("rho_in: "));
assert!(violation.detail.contains("pressure below triple point"));
}
other => panic!("expected DomainViolation, got {other:?}"),
}
let fatal = ComponentError::from_fluid_error_context(
"suction_state",
FluidError::UnknownFluid {
fluid: "R999".to_string(),
},
);
match fatal {
ComponentError::CalculationFailed(message) => {
assert!(message.starts_with("suction_state: "));
assert!(message.contains("R999"));
}
other => panic!("expected CalculationFailed, got {other:?}"),
}
}
}

View File

@@ -52,7 +52,7 @@ use std::marker::PhantomData;
use std::sync::Arc;
/// Phase of the fluid at the node location.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NodePhase {
/// Subcooled liquid (h < h_sat_liquid)
SubcooledLiquid,
@@ -63,15 +63,10 @@ pub enum NodePhase {
/// Supercritical fluid (P > P_critical)
Supercritical,
/// Unknown phase (no backend or computation failed)
#[default]
Unknown,
}
impl Default for NodePhase {
fn default() -> Self {
NodePhase::Unknown
}
}
impl std::fmt::Display for NodePhase {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {

View File

@@ -252,12 +252,14 @@ pub struct Pipe<State> {
/// Operational state
operational_state: OperationalState,
/// Design-point pressure drop (Pa) for the edge-coupled (P,h) solver model.
/// The (P,h) refrigeration solver has no mass-flow unknown, so the pressure
/// drop is imposed as a design-point value rather than computed from Darcy.
/// When `> 0`, ΔP is imposed as this constant. When `0`, ΔP is computed from
/// DarcyWeisbach using geometry + live mass flow (`inlet_m_idx`).
design_dp_pa: f64,
/// When true, the component uses the edge-coupled (P,h) solver model
/// (`n_equations() == 2`) instead of the legacy mass-flow model.
edge_coupled: bool,
/// Inlet edge mass-flow index (edge-coupled Darcy mode).
inlet_m_idx: Option<usize>,
/// Inlet edge pressure index in the global state vector (edge-coupled mode).
inlet_p_idx: Option<usize>,
/// Inlet edge enthalpy index in the global state vector (edge-coupled mode).
@@ -316,6 +318,7 @@ impl Pipe<Disconnected> {
operational_state: OperationalState::default(),
design_dp_pa: 0.0,
edge_coupled: false,
inlet_m_idx: None,
inlet_p_idx: None,
inlet_h_idx: None,
outlet_p_idx: None,
@@ -477,6 +480,7 @@ impl Pipe<Disconnected> {
operational_state: self.operational_state,
design_dp_pa: self.design_dp_pa,
edge_coupled: self.edge_coupled,
inlet_m_idx: self.inlet_m_idx,
inlet_p_idx: self.inlet_p_idx,
inlet_h_idx: self.inlet_h_idx,
outlet_p_idx: self.outlet_p_idx,
@@ -487,12 +491,13 @@ impl Pipe<Disconnected> {
}
impl Pipe<Connected> {
/// Enables the edge-coupled (P,h) solver model with a design-point pressure
/// drop, so the pipe participates in the refrigeration/hydronic graph solver.
/// Enables the edge-coupled (P,h) solver model so the pipe participates in
/// the refrigeration/hydronic graph solver.
///
/// In this mode the pipe owns 2 equations on its outlet edge:
/// - `r0 = P_out - (P_in - design_dp_pa)` (imposed pressure drop)
/// - `r1 = h_out - h_in` (adiabatic pass-through)
/// - `r0 = P_out - (P_in - ΔP)` — if `design_dp_pa > 0`, ΔP is that constant;
/// if `design_dp_pa == 0`, ΔP is DarcyWeisbach from geometry + live ṁ
/// - `r1 = h_out - h_in` — adiabatic pass-through
pub fn with_design_pressure_drop_pa(mut self, design_dp_pa: f64) -> Self {
self.design_dp_pa = design_dp_pa.max(0.0);
self.edge_coupled = true;
@@ -624,6 +629,7 @@ impl Component for Pipe<Connected> {
// Layout: [0] = incoming edge (upstream→pipe), [1] = outgoing edge (pipe→downstream)
// Triple: (m_idx, p_idx, h_idx)
if !external_edge_state_indices.is_empty() {
self.inlet_m_idx = Some(external_edge_state_indices[0].0);
self.inlet_p_idx = Some(external_edge_state_indices[0].1);
self.inlet_h_idx = Some(external_edge_state_indices[0].2);
}
@@ -654,9 +660,15 @@ impl Component for Pipe<Connected> {
}
let dp = match self.operational_state {
OperationalState::Bypass => 0.0,
_ => self.calib.z_dp * self.design_dp_pa,
_ if self.design_dp_pa > 0.0 => self.calib.z_dp * self.design_dp_pa,
_ => {
// DarcyWeisbach from geometry + live ṁ (design_dp unset/0).
let m = self.inlet_m_idx.map(|i| state[i]).unwrap_or(0.0);
let flow_m3 = m / self.fluid_density_kg_per_m3;
self.pressure_drop(flow_m3).abs()
}
};
// r0: imposed pressure drop across the pipe
// r0: pressure drop across the pipe
residuals[0] = state[out_p] - (state[in_p] - dp);
// r1: adiabatic pass-through (enthalpy conserved)
residuals[1] = state[out_h] - state[in_h];
@@ -736,6 +748,19 @@ impl Component for Pipe<Connected> {
// r0 = P_out - (P_in - dp) → ∂r0/∂P_out = 1, ∂r0/∂P_in = -1
jacobian.add_entry(0, out_p, 1.0);
jacobian.add_entry(0, in_p, -1.0);
// Darcy: dp depends on ṁ → ∂r0/∂ṁ = +∂(dp)/∂ṁ
if self.design_dp_pa <= 0.0 {
if let Some(m_idx) = self.inlet_m_idx {
let m = state[m_idx];
let h = 1e-6_f64.max(m.abs() * 1e-5);
let rho = self.fluid_density_kg_per_m3;
let dp_plus = self.pressure_drop((m + h) / rho).abs();
let dp_minus = self.pressure_drop((m - h) / rho).abs();
let ddp_dm = (dp_plus - dp_minus) / (2.0 * h);
// r0 = P_out - P_in + dp(m) → ∂r0/∂m = ∂dp/∂m
jacobian.add_entry(0, m_idx, ddp_dm);
}
}
// r1 = h_out - h_in → ∂r1/∂h_out = 1, ∂r1/∂h_in = -1
jacobian.add_entry(1, out_h, 1.0);
jacobian.add_entry(1, in_h, -1.0);
@@ -929,6 +954,7 @@ mod tests {
operational_state: OperationalState::default(),
design_dp_pa: 0.0,
edge_coupled: false,
inlet_m_idx: None,
inlet_p_idx: None,
inlet_h_idx: None,
outlet_p_idx: None,
@@ -1116,6 +1142,34 @@ mod tests {
assert!(dp2 > dp1);
}
#[test]
fn test_edge_coupled_zero_design_dp_uses_darcy() {
use crate::Component;
let mut pipe = create_test_pipe_connected().with_design_pressure_drop_pa(0.0);
// state layout: m, P_in, h_in, m_out, P_out, h_out
pipe.set_system_context(0, &[(0, 1, 2), (3, 4, 5)]);
let m = 5.0_f64; // kg/s
let flow_m3 = m / pipe.fluid_density();
let dp_expected = pipe.pressure_drop(flow_m3).abs();
assert!(
dp_expected > 100.0,
"test pipe should have meaningful Darcy ΔP"
);
let p_in = 300_000.0;
let h = 100_000.0;
let state = vec![m, p_in, h, m, p_in - dp_expected, h];
let mut residuals = vec![0.0; 2];
pipe.compute_residuals(&state, &mut residuals).unwrap();
assert_relative_eq!(residuals[0], 0.0, epsilon = 1e-6);
assert_relative_eq!(residuals[1], 0.0, epsilon = 1e-12);
// Wrong P_out → residual equals ΔP error
let state_bad = vec![m, p_in, h, m, p_in, h]; // isobaric — should fail
pipe.compute_residuals(&state_bad, &mut residuals).unwrap();
assert_relative_eq!(residuals[0], dp_expected, epsilon = 1.0);
}
#[test]
fn test_f_dp_scales_pressure_drop() {
let mut pipe = create_test_pipe_connected();

View File

@@ -1268,7 +1268,7 @@ impl PyBrineSourceReal {
use entropyk_fluids::FluidState as FState;
let t = Temperature::from_kelvin(self.temperature_k);
let p = Pressure::from_pascals(self.pressure_pa);
let fid = FluidId::new(&self.fluid_name());
let fid = FluidId::new(self.fluid_name());
let fstate = FState::from_pt(p, t);
backend
.property(fid, Property::Enthalpy, fstate)

View File

@@ -96,7 +96,7 @@ fn get_positive_usize(
default: usize,
) -> Result<usize, RegistryError> {
let val = get_f64_or(params, key, default as f64);
if val < 1.0 || val > 100.0 || val.fract() != 0.0 {
if !(1.0..=100.0).contains(&val) || val.fract() != 0.0 {
return Err(RegistryError::InvalidParameter {
component: params.component_type.clone(),
parameter: key.to_string(),

View File

@@ -2,6 +2,24 @@
//!
//! Complements the isenthalpic `ExpansionValve` residual structure with
//! catalogue / TXV flow equations used for sizing and EXV/TXV actuation.
//!
//! # Regularization (Story 0.3 / FR19)
//!
//! Hard `ΔP.max(0)` and `opening.clamp(0,1)` zero Newton derivatives at domain
//! edges. This module uses [`entropyk_core::smoothing`]:
//! - `dp_eff = smooth_max(ΔP, 0, DP_SMOOTH_K_PA)` so ∂ṁ/∂P stays informative near ΔP≤0
//! - `opening_eff = smooth_clamp(opening, 0, 1, OPENING_WIDTH)` for C¹ opening bounds
//!
//! Physical-region ṁ (ΔP ≫ `DP_SMOOTH_K_PA`, opening interior) matches the legacy
//! hard formula to solver tolerance.
use entropyk_core::smoothing::{smooth_clamp, smooth_max, smooth_max_derivative};
/// Softness `k` [Pa] for `smooth_max(ΔP, 0, k)`. Aligns with historical EXV `DP_FLOOR`.
const DP_SMOOTH_K_PA: f64 = 1e3;
/// Transition width for `smooth_clamp` of opening on `[0, 1]`.
const OPENING_WIDTH: f64 = 1e-2;
/// Selectable orifice / TXV / EXV flow model.
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -44,7 +62,7 @@ pub struct ValveFlowInput {
pub p_upstream_pa: f64,
/// Evaporator / downstream pressure [Pa].
pub p_downstream_pa: f64,
/// Normalized opening in [0, 1] (EXV / orifice).
/// Normalized opening (EXV / orifice). Mapped through `smooth_clamp` to [0, 1].
pub opening: f64,
/// Bulb pressure [Pa] (TXV); ignored for orifice/EXV.
pub p_bulb_pa: f64,
@@ -53,37 +71,47 @@ pub struct ValveFlowInput {
impl ValveFlowInput {
fn validate(&self) -> Result<(), String> {
if !self.density_kg_m3.is_finite() || self.density_kg_m3 <= 0.0 {
return Err(format!("density must be positive, got {}", self.density_kg_m3));
return Err(format!(
"density must be positive, got {}",
self.density_kg_m3
));
}
if !self.p_upstream_pa.is_finite() || !self.p_downstream_pa.is_finite() {
return Err("pressures must be finite".into());
}
if !(0.0..=1.0).contains(&self.opening) || !self.opening.is_finite() {
return Err(format!("opening must be in [0,1], got {}", self.opening));
if !self.opening.is_finite() {
return Err(format!("opening must be finite, got {}", self.opening));
}
Ok(())
}
fn delta_p_raw(&self) -> f64 {
self.p_upstream_pa - self.p_downstream_pa
}
fn dp_eff(&self) -> f64 {
smooth_max(self.delta_p_raw(), 0.0, DP_SMOOTH_K_PA)
}
fn opening_eff(&self) -> f64 {
smooth_clamp(self.opening, 0.0, 1.0, OPENING_WIDTH)
}
}
/// Mass flow [kg/s] for the selected model. Always ≥ 0.
pub fn valve_mass_flow(model: &ValveFlowModel, input: &ValveFlowInput) -> Result<f64, String> {
input.validate()?;
let dp = (input.p_upstream_pa - input.p_downstream_pa).max(0.0);
let sqrt_term = (2.0 * input.density_kg_m3 * dp).sqrt();
let m = match model {
ValveFlowModel::IsenthalpicOrifice { beta_m2 } => {
beta_m2.max(0.0) * input.opening.clamp(0.0, 1.0) * sqrt_term
}
/// Effective flow coefficient × opening factor (before √(2ρΔP)).
fn flow_prefactor(model: &ValveFlowModel, input: &ValveFlowInput) -> f64 {
match model {
ValveFlowModel::IsenthalpicOrifice { beta_m2 } => beta_m2.max(0.0) * input.opening_eff(),
ValveFlowModel::ExvCdA { cd, area_max_m2 } => {
cd.max(0.0) * area_max_m2.max(0.0) * input.opening.clamp(0.0, 1.0) * sqrt_term
cd.max(0.0) * area_max_m2.max(0.0) * input.opening_eff()
}
ValveFlowModel::TxvEames {
beta_m2,
static_superheat_pa,
full_open_delta_pa,
} => {
// Eames: ṁ = β √(2ρ(PcPe)) · [(PbPe) α] / δ for α < (PbPe) ≤ δ
// fully open when (PbPe) ≥ δ → factor 1.
// Eames drive uses a hard piecewise opening_eff (not a Newton unknown here).
// The √ΔP branch is still regularized via `dp_eff`.
let drive = (input.p_bulb_pa - input.p_downstream_pa) - static_superheat_pa;
let delta = full_open_delta_pa.max(1.0);
let opening_eff = if drive <= 0.0 {
@@ -93,21 +121,48 @@ pub fn valve_mass_flow(model: &ValveFlowModel, input: &ValveFlowInput) -> Result
} else {
drive / delta
};
beta_m2.max(0.0) * opening_eff * sqrt_term
beta_m2.max(0.0) * opening_eff
}
};
}
}
/// Mass flow [kg/s] for the selected model. Always ≥ 0.
pub fn valve_mass_flow(model: &ValveFlowModel, input: &ValveFlowInput) -> Result<f64, String> {
input.validate()?;
let dp = input.dp_eff();
let sqrt_term = (2.0 * input.density_kg_m3 * dp).sqrt();
let m = flow_prefactor(model, input) * sqrt_term;
Ok(m.max(0.0))
}
/// Analytic ∂ṁ/∂P_upstream for orifice/EXV (TXV uses same √ΔP branch).
pub fn valve_mass_flow_dp_up(model: &ValveFlowModel, input: &ValveFlowInput) -> Result<f64, String> {
///
/// `ṁ ∝ √dp_eff` with `dp_eff = smooth_max(ΔP, 0, k)` ⇒
/// `∂ṁ/∂P_up = (ṁ / (2 · dp_eff)) · ∂dp_eff/∂P_up`.
pub fn valve_mass_flow_dp_up(
model: &ValveFlowModel,
input: &ValveFlowInput,
) -> Result<f64, String> {
let m = valve_mass_flow(model, input)?;
let dp = (input.p_upstream_pa - input.p_downstream_pa).max(0.0);
let dp = input.dp_eff();
if dp <= 0.0 || m <= 0.0 {
// Degenerate: no live flow coefficient / density; keep non-panicking zero.
return Ok(0.0);
}
// ṁ ∝ √ΔP ⇒ ∂ṁ/∂P_up = ṁ / (2 ΔP)
Ok(m / (2.0 * dp))
let d_dp = smooth_max_derivative(input.delta_p_raw(), 0.0, DP_SMOOTH_K_PA);
Ok(m / (2.0 * dp) * d_dp)
}
/// Analytic ∂ṁ/∂P_downstream for the orifice/EXV √ΔP branch.
///
/// Equal to `−∂ṁ/∂P_upstream` through the shared `dp_eff` chain rule.
/// TXV bulb-drive opening is treated as independent of `P_downstream` here
/// (same simplification as the pre-regularization √ΔP-only Jacobian).
pub fn valve_mass_flow_dp_down(
model: &ValveFlowModel,
input: &ValveFlowInput,
) -> Result<f64, String> {
Ok(-valve_mass_flow_dp_up(model, input)?)
}
#[cfg(test)]
@@ -124,6 +179,13 @@ mod tests {
}
}
/// Legacy hard formula for physical-region regression.
fn hard_orifice_mass_flow(beta_m2: f64, input: &ValveFlowInput) -> f64 {
let dp = (input.p_upstream_pa - input.p_downstream_pa).max(0.0);
let opening = input.opening.clamp(0.0, 1.0);
beta_m2.max(0.0) * opening * (2.0 * input.density_kg_m3 * dp).sqrt()
}
#[test]
fn orifice_scales_with_opening() {
let model = ValveFlowModel::IsenthalpicOrifice { beta_m2: 2e-6 };
@@ -133,7 +195,8 @@ mod tests {
full.opening = 1.0;
let m_half = valve_mass_flow(&model, &half).unwrap();
let m_full = valve_mass_flow(&model, &full).unwrap();
assert!((m_full - 2.0 * m_half).abs() < 1e-9);
// At opening=1.0, smooth_clamp is at the upper join — allow small C¹ band error.
assert!((m_full - 2.0 * m_half).abs() / m_full.max(1e-12) < 1e-3);
}
#[test]
@@ -154,7 +217,6 @@ mod tests {
full_open_delta_pa: 150_000.0,
};
let mut inp = base_input();
// Pb - Pe = 0.5e6 - 0.4e6 = 0.1e6 < α=50k → wait, 100k > 50k so partially open
inp.p_bulb_pa = inp.p_downstream_pa + 20_000.0; // drive = 20k - 50k < 0
let m = valve_mass_flow(&model, &inp).unwrap();
assert_eq!(m, 0.0);
@@ -174,24 +236,115 @@ mod tests {
full.opening = 1.0;
let m_txv = valve_mass_flow(&model, &inp).unwrap();
let m_orf = valve_mass_flow(&orifice, &full).unwrap();
assert!((m_txv - m_orf).abs() < 1e-9);
assert!((m_txv - m_orf).abs() / m_orf.max(1e-12) < 1e-3);
}
#[test]
fn jacobian_dp_matches_fd() {
fn physical_region_matches_hard_formula() {
let beta = 2e-6;
let model = ValveFlowModel::IsenthalpicOrifice { beta_m2: beta };
let inp = base_input(); // ΔP = 1.1e6 ≫ k
let m = valve_mass_flow(&model, &inp).unwrap();
let m_hard = hard_orifice_mass_flow(beta, &inp);
assert!(
(m - m_hard).abs() / m_hard < 1e-6,
"physical-region ṁ {m} vs hard {m_hard}"
);
let d_up = valve_mass_flow_dp_up(&model, &inp).unwrap();
let d_hard = m_hard / (2.0 * (inp.p_upstream_pa - inp.p_downstream_pa));
assert!(
(d_up - d_hard).abs() / d_hard < 1e-6,
"physical-region ∂ṁ/∂P_up {d_up} vs hard {d_hard}"
);
}
#[test]
fn nonpositive_dp_has_informative_phantom_gradient() {
let model = ValveFlowModel::IsenthalpicOrifice { beta_m2: 1.0e-6 };
let input = ValveFlowInput {
density_kg_m3: 1200.0,
p_upstream_pa: 4.0e5,
p_downstream_pa: 4.005e5, // mild ΔP = 500 Pa
opening: 0.5,
p_bulb_pa: 0.0,
};
let m = valve_mass_flow(&model, &input).unwrap();
let d_up = valve_mass_flow_dp_up(&model, &input).unwrap();
let d_down = valve_mass_flow_dp_down(&model, &input).unwrap();
assert!(m >= 0.0 && m.is_finite());
assert!(
d_up > 0.0,
"phantom ∂ṁ/∂P_up must be informative at mild ΔP≤0, got {d_up}"
);
assert!(
(d_down + d_up).abs() < 1e-15 * d_up.max(1.0),
"dp_down must equal dp_up"
);
}
#[test]
fn jacobian_dp_up_matches_fd() {
let model = ValveFlowModel::ExvCdA {
cd: 0.65,
area_max_m2: 5e-6,
};
for (p_up, p_down) in [(1.5e6, 0.4e6), (4.0e5, 4.005e5), (5.0e5, 5.0e5)] {
let inp = ValveFlowInput {
density_kg_m3: 1200.0,
p_upstream_pa: p_up,
p_downstream_pa: p_down,
opening: 0.5,
p_bulb_pa: 0.0,
};
let d_analytic = valve_mass_flow_dp_up(&model, &inp).unwrap();
let eps = 1.0;
let mut up = inp;
up.p_upstream_pa += eps;
let mut dn = inp;
dn.p_upstream_pa -= eps;
let d_fd = (valve_mass_flow(&model, &up).unwrap()
- valve_mass_flow(&model, &dn).unwrap())
/ (2.0 * eps);
let scale = d_analytic.abs().max(d_fd.abs()).max(1e-12);
assert!(
(d_analytic - d_fd).abs() / scale < 1e-4,
"ΔP={} analytic {d_analytic} vs FD {d_fd}",
p_up - p_down
);
}
}
#[test]
fn jacobian_dp_down_matches_fd() {
let model = ValveFlowModel::IsenthalpicOrifice { beta_m2: 2e-6 };
let inp = base_input();
let d_analytic = valve_mass_flow_dp_up(&model, &inp).unwrap();
let d_analytic = valve_mass_flow_dp_down(&model, &inp).unwrap();
let eps = 1.0;
let mut up = inp;
up.p_upstream_pa += eps;
up.p_downstream_pa += eps;
let mut dn = inp;
dn.p_upstream_pa -= eps;
dn.p_downstream_pa -= eps;
let d_fd = (valve_mass_flow(&model, &up).unwrap() - valve_mass_flow(&model, &dn).unwrap())
/ (2.0 * eps);
assert!((d_analytic - d_fd).abs() / d_analytic.max(1e-12) < 1e-4);
let scale = d_analytic.abs().max(d_fd.abs()).max(1e-12);
assert!((d_analytic - d_fd).abs() / scale < 1e-4);
}
#[test]
fn opening_outside_unit_interval_is_accepted() {
let model = ValveFlowModel::IsenthalpicOrifice { beta_m2: 1e-6 };
let mut inp = base_input();
inp.opening = 1.2;
let m = valve_mass_flow(&model, &inp).unwrap();
assert!(m.is_finite() && m > 0.0);
inp.opening = -0.1;
let m_neg = valve_mass_flow(&model, &inp).unwrap();
assert!(m_neg.is_finite() && m_neg >= 0.0);
// Outside the upper band, flow saturates near opening=1.
inp.opening = 1.0;
let m1 = valve_mass_flow(&model, &inp).unwrap();
inp.opening = 2.0;
let m2 = valve_mass_flow(&model, &inp).unwrap();
assert!((m1 - m2).abs() / m1.max(1e-12) < 1e-6);
}
}

View File

@@ -0,0 +1,413 @@
//! Domain-grid Jacobian-health CI gate (Story 0.5 / Epic 0).
//!
//! Epic-0 P0 surfaces must be FD-healthy. Deferred debt (NFR9 exchanger FD,
//! python stubs, screw incomplete J, sat_domain P1, bphx P2) is **not** probed
//! here — see [`EPIC0_ALLOW_LIST`] (empty) and `docs/audits/jacobian-health-report.md`.
//!
//! Regenerate the committed report after grid changes:
//! `cargo test -p entropyk-components --test jacobian_health_sweep -- --nocapture`
//! then manually sync `docs/audits/jacobian-health-report.md`.
#![allow(clippy::const_is_empty)]
use std::sync::Arc;
use entropyk_components::heat_exchanger::two_phase_dp::{
friedel_multiplier, homogeneous_density, msh_gradient, FriedelInput,
};
use entropyk_components::heat_exchanger::Condenser;
use entropyk_components::jacobian_fd::{assert_jacobian_healthy, AllowListEntry, JacobianFdConfig};
use entropyk_components::valve_flow::{
valve_mass_flow, valve_mass_flow_dp_down, valve_mass_flow_dp_up, ValveFlowInput, ValveFlowModel,
};
use entropyk_components::{Component, IsenthalpicExpansionValve};
use entropyk_core::CalibIndices;
use entropyk_fluids::TestBackend;
/// Epic-0 P0 allow-list: intentionally empty (must stay clean).
/// Deferred debt is documented in the health report, not suppressed here.
const EPIC0_ALLOW_LIST: &[AllowListEntry] = &[];
/// Narrow FD step for C¹ smooth_clamp neighborhoods.
const EDGE_CFG: JacobianFdConfig = JacobianFdConfig {
rel_epsilon: 1e-6,
h_floor: 1e-6,
rel_tol: 1e-4,
analytic_atol: 1e-12,
fd_informative: 1e-8,
};
const DEFAULT_CFG: JacobianFdConfig = JacobianFdConfig {
rel_epsilon: 1e-6,
h_floor: 1e-3,
rel_tol: 1e-4,
analytic_atol: 1e-12,
fd_informative: 1e-8,
};
// ── Region grids (Story 0.5) ────────────────────────────────────────────────
const EXV_OPENINGS: [f64; 3] = [0.0, 0.5, 1.0];
/// (p_in, p_out) — positive ΔP and mild ΔP≤0.
const EXV_DP_REGIMES: [(&str, f64, f64); 2] =
[("dp_positive", 1.2e6, 3.5e5), ("dp_le_0", 4.0e5, 4.005e5)];
const CONDENSER_FAN_PHI: [f64; 3] = [0.005, 0.75, 1.495];
const CONDENSER_FLOOD_LAMBDA: [f64; 3] = [0.005, 0.5, 0.975];
const QUALITY_INTERIOR: f64 = 0.5;
const QUALITY_NEAR_BAND: f64 = 0.005;
const QUALITY_EXTERIOR: f64 = -0.2;
const MSH_QUALITY_GRID: [f64; 7] = [0.5, 0.8, 0.90, 0.95, 0.99, 0.999, 1.0 - 1e-9];
// ── Valve flow helpers ──────────────────────────────────────────────────────
fn valve_input(model_hint: &str, dp_positive: bool, opening: f64) -> ValveFlowInput {
let (p_up, p_dn) = if dp_positive {
(1.5e6, 0.4e6)
} else {
(4.0e5, 4.005e5)
};
let _ = model_hint;
ValveFlowInput {
density_kg_m3: 1200.0,
p_upstream_pa: p_up,
p_downstream_pa: p_dn,
opening,
p_bulb_pa: 0.0,
}
}
#[test]
fn valve_flow_domain_grid_is_healthy() {
let models: [(&str, ValveFlowModel); 2] = [
(
"isenthalpic_orifice",
ValveFlowModel::IsenthalpicOrifice { beta_m2: 1.0e-6 },
),
(
"exv_cda",
ValveFlowModel::ExvCdA {
cd: 0.65,
area_max_m2: 5e-6,
},
),
];
for (model_id, model) in &models {
for dp_positive in [true, false] {
for opening in [0.0, 0.5, 1.0] {
let input = valve_input(model_id, dp_positive, opening);
let m = valve_mass_flow(model, &input).expect("valve mass flow");
assert!(
m.is_finite() && m >= 0.0,
"{model_id} opening={opening} dp+:{dp_positive}: ṁ={m}"
);
let d_up = valve_mass_flow_dp_up(model, &input).expect("dp_up");
let d_dn = valve_mass_flow_dp_down(model, &input).expect("dp_down");
assert!(
d_up.is_finite() && d_dn.is_finite(),
"{model_id}: non-finite derivatives"
);
// Opening ≈ 0 ⇒ ṁ≈0: pressure derivatives may be ~0 (not a ΔP killer).
// Interior / open valve: informative ∂ṁ/∂P_* required.
let opening_live = opening >= 0.05;
if opening_live {
if !dp_positive {
assert!(
d_up > 0.0,
"{model_id} ΔP≤0 opening={opening}: expected phantom dmdp>0, got {d_up}"
);
} else {
assert!(
d_up > 0.0,
"{model_id} ΔP>0 opening={opening}: expected dmdp>0, got {d_up}"
);
let eps = 1.0;
let mut up = input;
up.p_downstream_pa += eps;
let mut dn = input;
dn.p_downstream_pa -= eps;
let d_fd = (valve_mass_flow(model, &up).unwrap()
- valve_mass_flow(model, &dn).unwrap())
/ (2.0 * eps);
let scale = d_dn.abs().max(d_fd.abs()).max(1e-12);
assert!(
(d_dn - d_fd).abs() / scale < 1e-4,
"{model_id} opening={opening}: dp_down analytic {d_dn} vs FD {d_fd}"
);
}
}
}
}
}
}
// ── Two-phase quality helpers ───────────────────────────────────────────────
#[test]
fn two_phase_quality_domain_grid_is_healthy() {
let rho_l = 1000.0;
let rho_g = 50.0;
let base = FriedelInput {
quality: QUALITY_INTERIOR,
mass_flux: 200.0,
diameter: 0.01,
rho_liquid: rho_l,
rho_vapor: rho_g,
mu_liquid: 2e-4,
mu_vapor: 1e-5,
sigma: 0.01,
};
// Interior control.
let soft_int = homogeneous_density(QUALITY_INTERIOR, rho_l, rho_g);
let hard_int = 1.0 / (QUALITY_INTERIOR / rho_g + (1.0 - QUALITY_INTERIOR) / rho_l);
assert!(
(soft_int - hard_int).abs() / hard_int < 1e-6,
"interior quality must match hard formula; soft={soft_int} hard={hard_int}"
);
let m_int = friedel_multiplier(&base);
assert!(m_int.is_finite() && m_int >= 1.0);
// Near C¹ band: soft ≠ hard identity.
let soft_band = homogeneous_density(QUALITY_NEAR_BAND, rho_l, rho_g);
let hard_band = 1.0 / (QUALITY_NEAR_BAND / rho_g + (1.0 - QUALITY_NEAR_BAND) / rho_l);
assert!(
(soft_band - hard_band).abs() / hard_band > 1e-6,
"near-band C¹ ramp must differ from hard clamp"
);
let m_band = friedel_multiplier(&FriedelInput {
quality: QUALITY_NEAR_BAND,
..base
});
assert!(m_band.is_finite() && m_band > 0.0);
// Exterior saturates to bound (smooth_clamp hard exterior).
assert_eq!(
homogeneous_density(QUALITY_EXTERIOR, rho_l, rho_g),
homogeneous_density(0.0, rho_l, rho_g)
);
let m_ext = friedel_multiplier(&FriedelInput {
quality: QUALITY_EXTERIOR,
..base
});
let m_0 = friedel_multiplier(&FriedelInput {
quality: 0.0,
..base
});
assert!(
(m_ext - m_0).abs() < 1e-12 * (1.0 + m_0.abs()),
"exterior quality must saturate like x=0"
);
// Upper near-band + exterior > 1.
let soft_hi = homogeneous_density(0.995, rho_l, rho_g);
assert!(soft_hi.is_finite() && soft_hi > 0.0);
assert_eq!(
homogeneous_density(1.2, rho_l, rho_g),
homogeneous_density(1.0, rho_l, rho_g)
);
}
// ── MSH / dome edge ─────────────────────────────────────────────────────────
#[test]
fn msh_dome_edge_domain_grid_is_healthy() {
let base = FriedelInput {
quality: 0.5,
mass_flux: 200.0,
diameter: 0.01,
rho_liquid: 1000.0,
rho_vapor: 50.0,
mu_liquid: 2e-4,
mu_vapor: 1e-5,
sigma: 0.01,
};
let h = 1e-6;
for &x in &MSH_QUALITY_GRID {
let g = msh_gradient(&FriedelInput { quality: x, ..base });
assert!(g.is_finite() && g > 0.0, "MSH g({x})={g}");
let mut up = base;
up.quality = (x + h).min(1.0);
let mut dn = base;
dn.quality = (x - h).max(0.0);
let denom = up.quality - dn.quality;
if denom > 0.0 {
let d_fd = (msh_gradient(&up) - msh_gradient(&dn)) / denom;
assert!(
d_fd.is_finite(),
"MSH ∂g/∂x must be finite at x={x}, got {d_fd}"
);
// Near x→1 the Hermite blend forces slope → 0; elsewhere bounded.
assert!(d_fd.abs() < 1e6, "MSH ∂g/∂x blew up at x={x}: {d_fd}");
}
}
}
// ── EXV orifice actuator ────────────────────────────────────────────────────
fn orifice_exv() -> IsenthalpicExpansionValve {
let mut exv = IsenthalpicExpansionValve::new(275.15)
.with_refrigerant("R134a")
.with_orifice(3.0e-6)
.with_fluid_backend(Arc::new(TestBackend::new()));
exv.set_system_context(0, &[(0, 1, 2), (3, 4, 5)]);
exv.set_calib_indices(CalibIndices {
actuator: Some(6),
..Default::default()
});
exv
}
fn orifice_state(opening: f64, p_in: f64, p_out: f64) -> Vec<f64> {
vec![
0.2, // m_in
p_in, // p_in
2.0e5, // h_in
0.2, // m_out
p_out, // p_out
2.0e5, // h_out
opening,
]
}
#[test]
fn exv_orifice_domain_grid_is_jacobian_healthy() {
let exv = orifice_exv();
for opening in EXV_OPENINGS {
for (region, p_in, p_out) in EXV_DP_REGIMES {
let state = orifice_state(opening, p_in, p_out);
let region_id = format!("{region}_opening_{opening}");
// Opening edges need narrow h; interior + ΔP≤0 can use default floor
// but EDGE_CFG is safe everywhere for this actuator scale.
assert_jacobian_healthy(
&exv,
&state,
EDGE_CFG,
"exv_orifice",
&region_id,
EPIC0_ALLOW_LIST,
);
}
}
}
/// Thin solve smoke: orifice residual at ΔP≤0 has informative pressure coupling
/// so a single Newton pressure step can restore ΔP>0 (no hard zero-gradient stall).
#[test]
fn exv_nonpositive_dp_newton_step_restores_positive_dp() {
let exv = orifice_exv();
let mut state = orifice_state(0.5, 4.0e5, 4.005e5);
let mut r = vec![0.0; 3];
exv.compute_residuals(&state, &mut r).unwrap();
let mut jb = entropyk_components::JacobianBuilder::new();
exv.jacobian_entries(&state, &mut jb).unwrap();
let mut j_p_in = 0.0;
let mut j_p_out = 0.0;
for &(row, col, val) in jb.entries() {
if row == 2 && col == 1 {
j_p_in += val;
}
if row == 2 && col == 4 {
j_p_out += val;
}
}
assert!(
j_p_in.abs() > 0.0 && j_p_out.abs() > 0.0,
"pressure couplings must be live at ΔP≤0: ∂r/∂P_in={j_p_in}, ∂r/∂P_out={j_p_out}"
);
let step = 0.1 * r[2];
if j_p_in.abs() > 1e-30 {
state[1] -= step / j_p_in;
}
if j_p_out.abs() > 1e-30 {
state[4] -= step / j_p_out;
}
let dp_after = state[1] - state[4];
assert!(
dp_after > -500.0,
"Newton pressure step should not deepen reverse ΔP; got {dp_after}"
);
}
// ── Condenser flooded / fan actuators ───────────────────────────────────────
#[test]
fn condenser_flood_domain_grid_is_jacobian_healthy() {
let backend = Arc::new(TestBackend::new());
let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)];
let p_cond = 1_200_000.0_f64;
let mut cond = Condenser::new(10_000.0)
.with_refrigerant("R134a")
.with_fluid_backend(backend)
.with_secondary_stream(305.0, 3000.0)
.with_emergent_pressure(0.0)
.with_flooded_head_pressure(320.0);
cond.set_system_context(0, &edges);
cond.set_calib_indices(CalibIndices {
actuator: Some(6),
..Default::default()
});
for lambda in CONDENSER_FLOOD_LAMBDA {
let state = vec![0.1, p_cond, 440_000.0, 0.1, p_cond, 260_000.0, lambda];
let region_id = format!("flood_lambda_{lambda}");
assert_jacobian_healthy(
&cond,
&state,
EDGE_CFG,
"condenser_flood",
&region_id,
EPIC0_ALLOW_LIST,
);
}
}
#[test]
fn condenser_fan_domain_grid_is_jacobian_healthy() {
let backend = Arc::new(TestBackend::new());
let edges = [(0usize, 1usize, 2usize), (3usize, 4usize, 5usize)];
let p_cond = 1_200_000.0_f64;
let mut cond = Condenser::new(10_000.0)
.with_refrigerant("R134a")
.with_fluid_backend(backend)
.with_secondary_stream(305.0, 3000.0)
.with_emergent_pressure(0.0)
.with_fan_head_pressure(320.0);
cond.set_system_context(0, &edges);
cond.set_calib_indices(CalibIndices {
actuator: Some(6),
..Default::default()
});
for phi in CONDENSER_FAN_PHI {
let state = vec![0.1, p_cond, 440_000.0, 0.1, p_cond, 260_000.0, phi];
let region_id = format!("fan_phi_{phi}");
assert_jacobian_healthy(
&cond,
&state,
EDGE_CFG,
"condenser_fan",
&region_id,
EPIC0_ALLOW_LIST,
);
}
}
#[test]
fn epic0_allow_list_is_empty_for_p0_surfaces() {
// Guard: Story 0.5 forbids allow-listing valve/EXV/HX P0 regressions.
assert!(
EPIC0_ALLOW_LIST.is_empty(),
"Epic-0 P0 allow-list must stay empty; deferred debt is documented, not suppressed"
);
let _ = DEFAULT_CFG; // keep default config linked for report documentation
}

View File

@@ -0,0 +1,107 @@
//! Quick test: EXV OperationalState Off path.
//! Validates that the EXV with `Off` produces ṁ = 0 in its residual and keeps
//! the Jacobian non-singular. Run with:
//! cargo test --release -p entropyk-components --test test_exv_off -- --nocapture
use entropyk_components::isenthalpic_expansion_valve::IsenthalpicExpansionValve;
use entropyk_components::state_machine::{OperationalState, StateManageable};
use entropyk_components::{Component, JacobianBuilder};
fn make_exv() -> IsenthalpicExpansionValve {
let mut exv = IsenthalpicExpansionValve::new(278.15)
.with_refrigerant("R134a")
.with_emergent_pressure()
.with_orifice_fixed(2.0e-6, 1.0);
// Layout: [m_inlet=0, m_outlet=1, p_inlet=2, h_inlet=3, p_outlet=4, h_outlet=5]
exv.set_system_context(0, &[(0, 2, 3), (1, 4, 5)]);
exv
}
#[test]
fn exv_off_produces_zero_flow_residual() {
let mut exv = make_exv();
// Turn the valve OFF.
exv.set_operational_state_unchecked(OperationalState::Off);
assert!(exv.state().is_off());
// State where m_outlet = 0.05 (non-zero flow) — the Off residual must drive it to 0.
let state: Vec<f64> = vec![0.05, 0.05, 1.5e6, 250_000.0, 0.4e6, 250_000.0];
let mut r = vec![0.0_f64; exv.n_equations()];
exv.compute_residuals(&state, &mut r).expect("residuals");
// The orifice equation is the last one (after isenthalpic + mass conservation).
// r_orifice should equal m_outlet - 0 = 0.05 (non-zero → Newton will push it to 0).
let orifice_residual = *r.last().unwrap();
assert!(
(orifice_residual - 0.05).abs() < 1e-12,
"Off residual should be ṁ = 0.05 (forcing flow to 0), got {}",
orifice_residual
);
// Jacobian: ∂r_orifice/∂m_outlet = 1 (keeps Newton coupled on the mass flow).
let mut jb = JacobianBuilder::new();
exv.jacobian_entries(&state, &mut jb).expect("jacobian");
let entries = jb.entries();
let m_out_idx = 1;
let orifice_row = exv.n_equations() - 1;
let dm_out: f64 = entries
.iter()
.filter(|(row, col, _)| *row == orifice_row && *col == m_out_idx)
.map(|(_, _, v)| *v)
.sum();
assert!(
(dm_out - 1.0).abs() < 1e-12,
"Off Jacobian ∂r/∂m_outlet should be 1.0, got {}",
dm_out
);
println!(
"EXV Off: residual={:.4e} (target 0.05), ∂r/∂m_out={:.3} (target 1.0) — OK",
orifice_residual, dm_out
);
}
#[test]
fn exv_on_still_uses_orifice_equation() {
let mut exv = make_exv();
// Default state is On.
assert!(exv.state().is_on());
// Use a backend so ρ_in can be evaluated.
let backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend> =
std::sync::Arc::new(entropyk_fluids::TestBackend::new());
exv.set_fluid_backend_from_builder(backend);
// State with a physical ΔP across the valve: P_in = 13 bar, P_out = 3.5 bar.
let state: Vec<f64> = vec![0.05, 0.05, 1.3e6, 250_000.0, 0.35e6, 250_000.0];
let mut r = vec![0.0_f64; exv.n_equations()];
exv.compute_residuals(&state, &mut r).expect("residuals");
let orifice_residual = *r.last().unwrap();
// Orifice equation is active: residual is non-trivial (not just ṁ).
assert!(
orifice_residual.abs() > 1e-6,
"On residual should be non-trivial (orifice equation active), got {}",
orifice_residual
);
println!(
"EXV On: residual={:.4e} (orifice equation active) — OK",
orifice_residual
);
}
#[test]
fn exv_state_transitions_are_validated() {
let mut exv = IsenthalpicExpansionValve::new(278.15);
assert!(exv.state().is_on());
// On → Off is a legal transition.
assert!(exv.can_transition_to(OperationalState::Off));
exv.set_state(OperationalState::Off).expect("On → Off");
assert!(exv.state().is_off());
// Off → On is legal.
assert!(exv.can_transition_to(OperationalState::On));
exv.set_state(OperationalState::On).expect("Off → On");
assert!(exv.state().is_on());
}

View File

@@ -0,0 +1,66 @@
//! Quick empirical check of MSH smoothness near x=1.
//!
//! Run with: cargo test --release -p entropyk-components --lib -- test_msh_smoothness_empirical real_check --nocapture
use entropyk_components::heat_exchanger::two_phase_dp::{
friedel_gradient, msh_gradient, FriedelInput,
};
#[test]
fn real_check() {
// R134a-like at 5 °C, mass flux typical of DX evaporator at opening=1.
let base = FriedelInput {
mass_flux: 300.0,
diameter: 0.0095,
quality: 0.5,
rho_liquid: 1290.0,
rho_vapor: 17.4,
mu_liquid: 250e-6,
mu_vapor: 11e-6,
sigma: 0.011,
};
let qualities = [
0.10, 0.50, 0.80, 0.90, 0.95, 0.97, 0.99, 0.995, 0.999, 0.9999, 1.0,
];
println!(
"\n{:<10} {:<14} {:<14} {:<14}",
"x", "MSH", "dMSH/dx", "Friedel"
);
let mut prev_msh: Option<f64> = None;
let mut prev_x: Option<f64> = None;
for &x in &qualities {
let inp = FriedelInput { quality: x, ..base };
let msh = msh_gradient(&inp);
let fri = friedel_gradient(&inp);
let dmsdh = match (prev_msh, prev_x) {
(Some(pm), Some(px)) => (msh - pm) / (x - px),
_ => f64::NAN,
};
println!("{:<10.5} {:<14.3} {:<14.3} {:<14.3}", x, msh, dmsdh, fri);
prev_msh = Some(msh);
prev_x = Some(x);
}
// Analytic derivative check: d/dx[linear·(1-x)^(1/3)] near x=1
println!("\nAnalytic derivative of linear·(1-x)^(1/3) term:");
let a: f64 =
2.0 * 0.079 * (300.0_f64 * 0.0095 / 250e-6).powf(-0.25) * 300.0 * 300.0 / (0.0095 * 1290.0);
let b: f64 =
2.0 * 0.079 * (300.0_f64 * 0.0095 / 11e-6).powf(-0.25) * 300.0 * 300.0 / (0.0095 * 17.4);
println!(" A = liquid-only gradient = {:.3} Pa/m", a);
println!(" B = vapor-only gradient = {:.3} Pa/m", b);
println!(" Ratio B/A = {:.1}", b / a);
for &x in &[0.9_f64, 0.95, 0.99, 0.999, 0.9999] {
let linear = a + 2.0 * (b - a) * x;
let term = linear * (1.0 - x).powf(1.0 / 3.0);
// d/dx[linear·(1-x)^(1/3)] = 2(b-a)·(1-x)^(1/3) - (1/3)·linear·(1-x)^(-2/3)
let d_term = 2.0 * (b - a) * (1.0 - x).powf(1.0 / 3.0)
- (1.0 / 3.0) * linear * (1.0 - x).powf(-2.0 / 3.0);
let d_total = d_term + 3.0 * b * x * x;
println!(
" x={:.5}: term={:>10.2} d(term)/dx={:>14.2} d(MSH)/dx={:>14.2}",
x, term, d_term, d_total
);
}
}