Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Some checks failed
CI / check (push) Has been cancelled
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:
@@ -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"
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ≤ (hi−lo)/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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
218
crates/components/src/heat_exchanger/sat_domain.rs
Normal file
218
crates/components/src/heat_exchanger/sat_domain.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
598
crates/components/src/heat_exchanger/tube_dp.rs
Normal file
598
crates/components/src/heat_exchanger/tube_dp.rs
Normal 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:
|
||||
/// ~150–220 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);
|
||||
}
|
||||
}
|
||||
@@ -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, 1−w]` 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 `(1−x)^(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(B−A)x, MSH = L·(1−x)^{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 + (1−2x)·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(B−A)x` at low x, then smooth to vapor-only at x→1 via
|
||||
/// `(dP/dz) = (A + 2(B−A)x)·(1−x)^(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 + (1−x)/ρ_l`. Positive when quality rises (evaporator).
|
||||
/// specific volume `v = x_c/ρ_v + (1−x_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 = −(1−x_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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
518
crates/components/src/jacobian_fd.rs
Normal file
518
crates/components/src/jacobian_fd.rs
Normal 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",
|
||||
&[],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
/// Darcy–Weisbach 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 Darcy–Weisbach 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,
|
||||
_ => {
|
||||
// Darcy–Weisbach 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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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ρ(Pc−Pe)) · [(Pb−Pe) − α] / δ for α < (Pb−Pe) ≤ δ
|
||||
// fully open when (Pb−Pe) ≥ δ → 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);
|
||||
}
|
||||
}
|
||||
|
||||
413
crates/components/tests/jacobian_health_sweep.rs
Normal file
413
crates/components/tests/jacobian_health_sweep.rs
Normal 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",
|
||||
®ion_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",
|
||||
®ion_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",
|
||||
®ion_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
|
||||
}
|
||||
107
crates/components/tests/test_exv_off.rs
Normal file
107
crates/components/tests/test_exv_off.rs
Normal 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());
|
||||
}
|
||||
66
crates/components/tests/test_msh_smoothness.rs
Normal file
66
crates/components/tests/test_msh_smoothness.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user