Snapshot WIP: Probe calibration path, faer LU backend, and BPHX phase-change duty.
Some checks failed
CI / check (push) Has been cancelled

Checkpoint incomplete calibration work (cond SDT green, evap SST failing) plus related solver/UI changes so the next pass can fix and extend safely.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 21:44:01 +02:00
parent 2f1f7ecb80
commit 3808e0f11b
39 changed files with 3648 additions and 265 deletions

View File

@@ -555,6 +555,10 @@ impl Component for BphxCondenser {
self.inner.energy_transfers(state)
}
fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option<f64> {
self.inner.measure_output(kind, state)
}
fn set_fluid_backend_from_builder(
&mut self,
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,

View File

@@ -36,7 +36,10 @@ impl BphxDpCorrelation {
/// Parse CLI/UI identifiers. Rejects unknown strings (no silent fallback).
pub fn parse(name: &str) -> Result<Self, String> {
let key = name.trim().to_ascii_lowercase().replace(['-', '_', ' '], "");
let key = name
.trim()
.to_ascii_lowercase()
.replace(['-', '_', ' '], "");
match key.as_str() {
"simplifiedchannel" | "simplified" | "channel" | "default" => {
Ok(Self::SimplifiedChannel)
@@ -104,13 +107,7 @@ fn simplified_channel_fanning(re: f64) -> (f64, f64) {
return (f_turb, df_turb);
}
let f = entropyk_core::smoothing::cubic_blend(
f_lam,
f_turb,
re,
RE_LAMINAR,
RE_TURBULENT,
);
let f = entropyk_core::smoothing::cubic_blend(f_lam, f_turb, re, RE_LAMINAR, RE_TURBULENT);
// d/dRe of blend(f_lam(Re), f_turb(Re), Re): product rule on cubic Hermite.
let df_blend_dx = entropyk_core::smoothing::cubic_blend_derivative(
f_lam,
@@ -178,12 +175,10 @@ fn martin1996_fanning(re: f64, chevron_angle_deg: f64) -> (f64, f64) {
let t = ((re_safe - RE_LO) / (RE_HI - RE_LO)).clamp(0.0, 1.0);
let w_b = t * t * (3.0 - 2.0 * t);
let w_a = 1.0 - w_b;
let df0_blend = entropyk_core::smoothing::cubic_blend_derivative(
f0_l, f0_t, re_safe, RE_LO, RE_HI,
);
let df1_blend = entropyk_core::smoothing::cubic_blend_derivative(
f1_l, f1_t, re_safe, RE_LO, RE_HI,
);
let df0_blend =
entropyk_core::smoothing::cubic_blend_derivative(f0_l, f0_t, re_safe, RE_LO, RE_HI);
let df1_blend =
entropyk_core::smoothing::cubic_blend_derivative(f1_l, f1_t, re_safe, RE_LO, RE_HI);
let df0 = df0_blend + w_a * df0_l + w_b * df0_t;
let df1 = df1_blend + w_a * df1_l + w_b * df1_t;
(f0, df0, f1, df1)
@@ -324,14 +319,9 @@ mod tests {
1.0,
)
.unwrap();
let martin = evaluate_channel_pressure_drop(
BphxDpCorrelation::Martin1996,
&geo(),
g,
rho,
1.0,
)
.unwrap();
let martin =
evaluate_channel_pressure_drop(BphxDpCorrelation::Martin1996, &geo(), g, rho, 1.0)
.unwrap();
assert!(
(simp.delta_p_pa - martin.delta_p_pa).abs() > 1.0,
"Martin and Simplified should differ: {} vs {}",
@@ -381,14 +371,9 @@ mod tests {
fn martin_rejects_invalid_chevron() {
let mut g = geo();
g.chevron_angle = f64::NAN;
let err = evaluate_channel_pressure_drop(
BphxDpCorrelation::Martin1996,
&g,
30.0,
1000.0,
1.0,
)
.unwrap_err();
let err =
evaluate_channel_pressure_drop(BphxDpCorrelation::Martin1996, &g, 30.0, 1000.0, 1.0)
.unwrap_err();
assert!(format!("{err}").contains("chevron"));
}
@@ -403,10 +388,8 @@ mod tests {
] {
let eval = evaluate_channel_pressure_drop(corr, &geo, g0, rho, 1.0).unwrap();
let h = 1e-4;
let plus =
evaluate_channel_pressure_drop(corr, &geo, g0 + h, rho, 1.0).unwrap();
let minus =
evaluate_channel_pressure_drop(corr, &geo, g0 - h, rho, 1.0).unwrap();
let plus = evaluate_channel_pressure_drop(corr, &geo, g0 + h, rho, 1.0).unwrap();
let minus = evaluate_channel_pressure_drop(corr, &geo, g0 - h, rho, 1.0).unwrap();
let fd = (plus.delta_p_pa - minus.delta_p_pa) / (2.0 * h);
let rel = (eval.d_delta_p_d_g - fd).abs() / fd.abs().max(1.0);
assert!(

View File

@@ -517,6 +517,10 @@ impl Component for BphxEvaporator {
self.inner.energy_transfers(state)
}
fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option<f64> {
self.inner.measure_output(kind, state)
}
fn set_fluid_backend_from_builder(
&mut self,
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,

View File

@@ -40,11 +40,14 @@ use super::correlation_registry::{
};
use super::eps_ntu::{EpsNtuModel, ExchangerType};
use super::exchanger::{HeatExchanger, HxSideConditions};
use super::phase_change_entu::{condenser_duty, evaporator_duty};
use super::sat_domain;
use crate::state_machine::{CircuitId, OperationalState, StateManageable};
use crate::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::{Calib, Enthalpy, MassFlow, Power};
use entropyk_core::{Calib, Enthalpy, MassFlow, Power, Pressure};
use entropyk_fluids::{FluidId, FluidState, Property, Quality};
use std::cell::{Cell, RefCell};
use std::sync::Arc;
@@ -498,8 +501,7 @@ impl BphxExchanger {
let dp_hot = self.side_channel_dp(state[m_h], rho_hot, z_dp)?;
let dp_cold = self.side_channel_dp(state[m_c], rho_cold, z_dp)?;
let a_flow =
self.geometry.channel_flow_area() * self.geometry.n_channels_per_side() as f64;
let a_flow = self.geometry.channel_flow_area() * self.geometry.n_channels_per_side() as f64;
if a_flow <= 1e-30 {
return Err(ComponentError::InvalidState(
"BPHX channel flow area too small for pressure-drop Jacobian".into(),
@@ -528,6 +530,193 @@ impl BphxExchanger {
let ua = h * self.geometry.area * self.calib().z_ua;
self.inner.set_ua_scale(ua / self.inner.ua_nominal());
}
/// Condenser / evaporator plate HX use Shah \(C^*\to 0\) duty instead of
/// two-stream sensible ε-NTU (see `phase_change_entu`).
fn uses_phase_change_duty(&self) -> bool {
matches!(
self.geometry.exchanger_type,
BphxType::Condenser | BphxType::Evaporator
) && self.fluid_backend.is_some()
}
/// \(T_{\mathrm{sat}}(P)\) [K] for the refrigerant fluid id, domain-clamped.
fn tsat_k(&self, p_pa: f64, refrigerant_id: &str) -> Result<f64, ComponentError> {
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::CalculationFailed(
"BphxExchanger: FluidBackend required for Tsat".into(),
)
})?;
let p_pa =
sat_domain::clamp_to_saturation_domain(backend, refrigerant_id, p_pa).unwrap_or(p_pa);
backend
.property(
FluidId::new(refrigerant_id),
Property::Temperature,
FluidState::from_px(Pressure::from_pascals(p_pa), Quality::new(0.5)),
)
.map_err(ComponentError::from_fluid_error)
}
/// Secondary inlet \((T [K], C_{\mathrm{sec}} [W/K])\) without querying refrigerant \(c_p\).
fn secondary_inlet_capacity(&self, state: &StateSlice) -> Result<(f64, f64), ComponentError> {
let edges = self.inner.four_port_edges().ok_or_else(|| {
ComponentError::InvalidState(
"BphxExchanger: phase-change duty needs live four-port edges".into(),
)
})?;
let (m_idx, p_idx, h_idx) = match self.geometry.exchanger_type {
// Condenser: cold = secondary. Evaporator (remapped): hot = secondary.
BphxType::Condenser => edges.cold_in,
BphxType::Evaporator => edges.hot_in,
BphxType::Generic => {
return Err(ComponentError::InvalidState(
"BphxExchanger: Generic type has no phase-change secondary stream".into(),
));
}
};
let max_idx = m_idx.max(p_idx).max(h_idx);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
let m = state[m_idx].max(0.0);
let p = state[p_idx];
let h = state[h_idx];
let (t, cp) = match self.geometry.exchanger_type {
BphxType::Condenser => (
self.inner.cold_side_temperature(p, h)?,
self.inner.cold_side_cp(p, h)?,
),
BphxType::Evaporator => (
self.inner.hot_side_temperature(p, h)?,
self.inner.hot_side_cp(p, h)?,
),
BphxType::Generic => unreachable!(),
};
Ok((t, m * cp))
}
/// Replace sensible ε-NTU energy rows with phase-change duty.
///
/// Layout (unchanged): `r0` = hot energy, `r1` = cold energy, then DP rows.
fn overwrite_energy_with_phase_change(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
if residuals.len() < 2 {
return Err(ComponentError::InvalidResidualDimensions {
expected: 2,
actual: residuals.len(),
});
}
let edges = self.inner.four_port_edges().ok_or_else(|| {
ComponentError::InvalidState(
"BphxExchanger: phase-change duty needs live four-port edges".into(),
)
})?;
let (m_h, p_h_in, h_h_in) = edges.hot_in;
let (_, _, h_h_out) = edges.hot_out;
let (m_c, p_c_in, h_c_in) = edges.cold_in;
let (_, _, h_c_out) = edges.cold_out;
let max_idx = [m_h, p_h_in, h_h_in, h_h_out, m_c, p_c_in, h_c_in, h_c_out]
.into_iter()
.max()
.unwrap_or(0);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
let (t_sec, c_sec) = self.secondary_inlet_capacity(state)?;
// Calibration redesign (WS-4): when a z_ua calibration variable is
// wired, the residual must track `state[z_ua]` (plain embedding), not
// the cached parameter — otherwise ∂Q/∂z_ua = 0 and the calibration
// Jacobian is singular. Mirrors exchanger.rs's `dynamic_f_ua` pattern.
let ua = match self.inner.calib_indices_ref().z_ua {
Some(z_idx) if z_idx < state.len() => self.inner.ua_nominal() * state[z_idx],
_ => self.ua(),
};
let m_hot = state[m_h].max(0.0);
let m_cold = state[m_c].max(0.0);
let q = match self.geometry.exchanger_type {
BphxType::Condenser => {
// hot = refrigerant, cold = secondary
let t_sat = self.tsat_k(state[p_h_in], self.inner.hot_fluid_id_str())?;
condenser_duty(ua, c_sec, t_sat, t_sec)
}
BphxType::Evaporator => {
// remapped: hot = secondary, cold = refrigerant
let t_sat = self.tsat_k(state[p_c_in], self.inner.cold_fluid_id_str())?;
evaporator_duty(ua, c_sec, t_sat, t_sec)
}
BphxType::Generic => 0.0,
};
// Same sign convention as EpsNtuModel::compute_residuals.
residuals[0] = m_hot * (state[h_h_in] - state[h_h_out]) - q;
residuals[1] = m_cold * (state[h_c_out] - state[h_c_in]) - q;
Ok(())
}
/// Finite-difference Jacobian through `self.compute_residuals` so energy
/// rows see phase-change duty (inner HeatExchanger FD would stay sensible).
fn fd_jacobian_via_self(
&self,
state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
let Some(edges) = self.inner.four_port_edges() else {
return Ok(());
};
let (m_h, p_h_in, h_h_in) = edges.hot_in;
let (_, p_h_out, h_h_out) = edges.hot_out;
let (m_c, p_c_in, h_c_in) = edges.cold_in;
let (_, p_c_out, h_c_out) = edges.cold_out;
let mut cols = vec![
m_h, p_h_in, h_h_in, p_h_out, h_h_out, m_c, p_c_in, h_c_in, p_c_out, h_c_out,
];
if let Some(z_idx) = self.inner.calib_indices_ref().z_dp {
cols.push(z_idx);
}
if let Some(z_idx) = self.inner.calib_indices_ref().z_ua {
cols.push(z_idx);
}
cols.sort_unstable();
cols.dedup();
cols.retain(|c| *c < state.len());
let n = self.n_equations();
let compute = |s: &[f64]| -> Result<Vec<f64>, ComponentError> {
let mut r = vec![0.0; n];
self.compute_residuals(s, &mut r)?;
Ok(r)
};
for &col in &cols {
let h = (state[col].abs() * 1e-6).max(1e-3);
let mut sp = state.to_vec();
sp[col] += h;
let rp = compute(&sp)?;
let mut sm = state.to_vec();
sm[col] -= h;
let rm = compute(&sm)?;
for row in 0..n {
let fd = (rp[row] - rm[row]) / (2.0 * h);
if fd.abs() > 1e-15 {
jacobian.add_entry(row, col, fd);
}
}
}
Ok(())
}
}
impl Component for BphxExchanger {
@@ -540,6 +729,23 @@ impl Component for BphxExchanger {
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
if self.uses_phase_change_duty() {
// Skip HeatExchanger sensible ε-NTU (needs refrigerant cp, fails in
// the two-phase dome). Write Shah C*→0 energy + channel ΔP only.
let n = self.n_equations();
if residuals.len() < n {
return Err(ComponentError::InvalidResidualDimensions {
expected: n,
actual: residuals.len(),
});
}
for r in residuals.iter_mut().take(n) {
*r = 0.0;
}
self.overwrite_energy_with_phase_change(state, residuals)?;
self.overwrite_pressure_closures_with_dp(state, residuals)?;
return Ok(());
}
self.inner.compute_residuals(state, residuals)?;
// Replace isobaric P_out P_in with channel ΔP on both sides.
self.overwrite_pressure_closures_with_dp(state, residuals)
@@ -550,6 +756,10 @@ impl Component for BphxExchanger {
state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
if self.uses_phase_change_duty() {
// FD must call this Component's residuals (phase-change energy + DP).
return self.fd_jacobian_via_self(state, jacobian);
}
self.inner.jacobian_entries(state, jacobian)?;
// Inner already wrote ∂r/∂P_out=+1, ∂r/∂P_in=1; add ṁ and z_dp terms.
self.append_pressure_dp_jacobian(state, jacobian)
@@ -587,6 +797,80 @@ impl Component for BphxExchanger {
self.inner.energy_transfers(state)
}
fn measure_output(&self, kind: crate::MeasuredOutput, state: &StateSlice) -> Option<f64> {
use crate::MeasuredOutput::*;
let edges = self.inner.four_port_edges()?;
let (m_h, p_h_in, h_h_in) = edges.hot_in;
let (_, p_h_out, h_h_out) = edges.hot_out;
let (m_c, p_c_in, h_c_in) = edges.cold_in;
let (_, _, h_c_out) = edges.cold_out;
let max_idx = [
m_h, p_h_in, h_h_in, p_h_out, h_h_out, m_c, p_c_in, h_c_in, h_c_out,
]
.into_iter()
.max()?;
if max_idx >= state.len() {
return None;
}
// Refrigerant side: hot for a condenser, cold (remapped) for an evaporator.
let (p_ref_in, p_ref_out, h_ref_out, m_ref, ref_fluid) = match self.geometry.exchanger_type
{
BphxType::Condenser => (p_h_in, p_h_out, h_h_out, m_h, self.inner.hot_fluid_id_str()),
BphxType::Evaporator => (p_c_in, p_c_in, h_c_out, m_c, self.inner.cold_fluid_id_str()),
BphxType::Generic => return None,
};
match kind {
SaturationTemperature => self.tsat_k(state[p_ref_in], ref_fluid).ok(),
Superheat | Subcooling => {
let backend = self.fluid_backend.as_ref()?;
let tsat = self.tsat_k(state[p_ref_out], ref_fluid).ok()?;
let t = backend
.property(
FluidId::new(ref_fluid),
Property::Temperature,
FluidState::from_ph(
entropyk_core::Pressure::from_pascals(state[p_ref_out]),
entropyk_core::Enthalpy::from_joules_per_kg(state[h_ref_out]),
),
)
.ok()?;
if !t.is_finite() || t <= 0.0 {
return None;
}
match kind {
Superheat => Some(t - tsat),
_ => Some(tsat - t),
}
}
Capacity | HeatTransferRate => {
let q_hot = state[m_h].abs() * (state[h_h_in] - state[h_h_out]).abs();
let q_cold = state[m_c].abs() * (state[h_c_out] - state[h_c_in]).abs();
match (q_hot.is_finite(), q_cold.is_finite()) {
(true, true) => Some(0.5 * (q_hot + q_cold)),
(true, false) => Some(q_hot),
(false, true) => Some(q_cold),
_ => None,
}
}
MassFlowRate => Some(state[m_ref].abs()),
Pressure => Some(state[p_ref_in]),
Temperature => {
let backend = self.fluid_backend.as_ref()?;
backend
.property(
FluidId::new(ref_fluid),
Property::Temperature,
FluidState::from_ph(
entropyk_core::Pressure::from_pascals(state[p_ref_out]),
entropyk_core::Enthalpy::from_joules_per_kg(state[h_ref_out]),
),
)
.ok()
.filter(|t| t.is_finite() && *t > 0.0)
}
}
}
fn set_fluid_backend_from_builder(
&mut self,
backend: std::sync::Arc<dyn entropyk_fluids::FluidBackend>,
@@ -983,8 +1267,8 @@ mod tests {
// State layout per port triple (ṁ, P, h):
// hot_in 0..3, hot_out 3..6, cold_in 6..9, cold_out 9..12
let mut hx = BphxExchanger::new(test_geometry())
.with_fluid_backend(Arc::new(TestBackend::new()));
let mut hx =
BphxExchanger::new(test_geometry()).with_fluid_backend(Arc::new(TestBackend::new()));
hx.set_hot_fluid("Water");
hx.set_cold_fluid("Water");
hx.set_port_context(&[
@@ -1027,8 +1311,8 @@ mod tests {
);
// z_dp scale: half calib → half residual contribution at same P_out=P_in
let mut hx_half = BphxExchanger::new(test_geometry())
.with_fluid_backend(Arc::new(TestBackend::new()));
let mut hx_half =
BphxExchanger::new(test_geometry()).with_fluid_backend(Arc::new(TestBackend::new()));
hx_half.set_hot_fluid("Water");
hx_half.set_cold_fluid("Water");
hx_half.set_port_context(&[
@@ -1078,8 +1362,8 @@ mod tests {
state[10] = 200_000.0;
state[11] = 60_000.0;
let mut simp = BphxExchanger::new(test_geometry())
.with_fluid_backend(Arc::new(TestBackend::new()));
let mut simp =
BphxExchanger::new(test_geometry()).with_fluid_backend(Arc::new(TestBackend::new()));
simp.set_hot_fluid("Water");
simp.set_cold_fluid("Water");
simp.set_port_context(&ports);
@@ -1102,4 +1386,110 @@ mod tests {
"Martin residual should differ from Simplified"
);
}
#[test]
fn test_bphx_condenser_energy_uses_phase_change_duty() {
use entropyk_fluids::TestBackend;
use std::sync::Arc;
// Condenser geometry → Shah C*→0 path (not sensible ṁ·cp).
let geo = test_geometry().with_exchanger_type(BphxType::Condenser);
let mut hx = BphxExchanger::new(geo).with_fluid_backend(Arc::new(TestBackend::new()));
hx.set_hot_fluid("R134a");
hx.set_cold_fluid("Water");
hx.set_port_context(&[
Some((0, 1, 2)),
Some((3, 4, 5)),
Some((6, 7, 8)),
Some((9, 10, 11)),
]);
let mut state = vec![0.0; 12];
// Refrigerant ~10 bar, superheated-ish enthalpy table values
state[0] = 0.02;
state[1] = 1_000_000.0;
state[2] = 430_000.0;
state[3] = 0.02;
state[4] = 1_000_000.0;
state[5] = 250_000.0;
// Water secondary ~2 bar, ~30 °C
state[6] = 0.40;
state[7] = 200_000.0;
state[8] = 125_000.0;
state[9] = 0.40;
state[10] = 200_000.0;
state[11] = 140_000.0;
assert!(hx.uses_phase_change_duty());
let n = hx.n_equations();
let mut residuals = vec![0.0; n];
hx.compute_residuals(&state, &mut residuals).unwrap();
let t_sat = hx.tsat_k(state[1], "R134a").unwrap();
let (t_sec, c_sec) = hx.secondary_inlet_capacity(&state).unwrap();
let q = condenser_duty(hx.ua(), c_sec, t_sat, t_sec);
let q_hot = state[0] * (state[2] - state[5]);
let expected_r0 = q_hot - q;
assert!(
(residuals[0] - expected_r0).abs() < 1e-4 * expected_r0.abs().max(1.0),
"energy r0 must match condenser_duty: got {} expected {} (Q={}, Tsat={:.2}K)",
residuals[0],
expected_r0,
q,
t_sat
);
// Approach must use Tsat, not gas temperature — Q grows if Tsat rises.
let q_higher = condenser_duty(hx.ua(), c_sec, t_sat + 10.0, t_sec);
assert!(q_higher > q, "higher Tsat must increase condenser duty");
}
#[test]
fn test_bphx_evaporator_energy_uses_phase_change_duty() {
use entropyk_fluids::TestBackend;
use std::sync::Arc;
let geo = test_geometry().with_exchanger_type(BphxType::Evaporator);
let mut hx = BphxExchanger::new(geo).with_fluid_backend(Arc::new(TestBackend::new()));
// Same remapping as BphxEvaporator: hot = secondary, cold = refrigerant.
hx.set_hot_fluid("Water");
hx.set_cold_fluid("R134a");
hx.set_port_context(&[
Some((0, 1, 2)),
Some((3, 4, 5)),
Some((6, 7, 8)),
Some((9, 10, 11)),
]);
let mut state = vec![0.0; 12];
// Water (hot) ~12 °C
state[0] = 0.50;
state[1] = 300_000.0;
state[2] = 50_000.0;
state[3] = 0.50;
state[4] = 300_000.0;
state[5] = 40_000.0;
// Refrigerant (cold) ~3.5 bar
state[6] = 0.02;
state[7] = 350_000.0;
state[8] = 250_000.0;
state[9] = 0.02;
state[10] = 350_000.0;
state[11] = 400_000.0;
assert!(hx.uses_phase_change_duty());
let mut residuals = vec![0.0; hx.n_equations()];
hx.compute_residuals(&state, &mut residuals).unwrap();
let t_sat = hx.tsat_k(state[7], "R134a").unwrap();
let (t_sec, c_sec) = hx.secondary_inlet_capacity(&state).unwrap();
let q = evaporator_duty(hx.ua(), c_sec, t_sat, t_sec);
let q_cold = state[6] * (state[11] - state[8]);
let expected_r1 = q_cold - q;
assert!(
(residuals[1] - expected_r1).abs() < 1e-4 * expected_r1.abs().max(1.0),
"energy r1 must match evaporator_duty: got {} expected {}",
residuals[1],
expected_r1
);
}
}

View File

@@ -588,6 +588,34 @@ impl<Model: HeatTransferModel + 'static> HeatExchanger<Model> {
})
}
/// Hot-side temperature [K] at `(P, h)` (live backend).
pub(crate) fn hot_side_temperature(
&self,
p_pa: f64,
h_jkg: f64,
) -> Result<f64, ComponentError> {
self.hot_temperature(p_pa, h_jkg)
}
/// Hot-side \(c_p\) [J/(kg·K)] at `(P, h)`.
pub(crate) fn hot_side_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
self.hot_cp(p_pa, h_jkg)
}
/// Cold-side temperature [K] at `(P, h)`.
pub(crate) fn cold_side_temperature(
&self,
p_pa: f64,
h_jkg: f64,
) -> Result<f64, ComponentError> {
self.cold_temperature(p_pa, h_jkg)
}
/// Cold-side \(c_p\) [J/(kg·K)] at `(P, h)`.
pub(crate) fn cold_side_cp(&self, p_pa: f64, h_jkg: f64) -> Result<f64, ComponentError> {
self.cold_cp(p_pa, h_jkg)
}
fn live_state_required_error(&self) -> ComponentError {
ComponentError::InvalidState(format!(
"{} requires live four-port edge state (hot_inlet, hot_outlet, cold_inlet, cold_outlet); inlet-only boundary conditions cannot define outlet states",

View File

@@ -72,6 +72,7 @@ pub mod lmtd;
pub mod mchx_condenser_coil;
pub mod model;
pub mod moving_boundary_hx;
pub mod phase_change_entu;
pub mod pool_boiling;
pub mod sat_domain;
pub mod shell_and_tube;
@@ -119,6 +120,9 @@ 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;
pub use phase_change_entu::{
condenser_duty, d_eps_c_d_c, evaporator_duty, phase_change_effectiveness,
};
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,

View File

@@ -0,0 +1,87 @@
//! Phase-change ε-NTU helpers (Shah / Incropera \(C^*\to 0\)).
//!
//! For condensers and evaporators where the refrigerant changes phase at
//! (ideally) constant \(T_{\mathrm{sat}}(P)\), the refrigerant heat-capacity
//! rate approaches infinity. Then \(C_{\min}=C_{\mathrm{sec}}\) and
//! \(\varepsilon = 1 - \exp(-\mathrm{UA}/C_{\mathrm{sec}})\).
//!
//! See Shah & Sekulić, *Fundamentals of Heat Exchanger Design* (Wiley 2003), §3.3.2.
/// Effectiveness for a single-stream (phase-change) exchanger:
/// \(\varepsilon = 1 - \exp(-\mathrm{UA}/C_{\mathrm{sec}})\).
#[inline]
pub fn phase_change_effectiveness(ua: f64, c_sec: f64) -> f64 {
if c_sec <= 1e-10 || ua <= 0.0 {
return 0.0;
}
1.0 - (-ua / c_sec).exp()
}
/// \(g'(C)\) where \(g(C)=C\cdot\varepsilon(C)=C\cdot(1-e^{-\mathrm{UA}/C})\).
///
/// Used for \(\partial Q/\partial\dot{m}_{\mathrm{sec}}\).
#[inline]
pub fn d_eps_c_d_c(ua: f64, c_sec: f64) -> f64 {
if c_sec <= 1e-10 || ua <= 0.0 {
return 0.0;
}
let e = (-ua / c_sec).exp();
(1.0 - e) - (ua / c_sec) * e
}
/// Condenser duty [W]: \(Q = \varepsilon\,C_{\mathrm{sec}}\,(T_{\mathrm{sat}}-T_{\mathrm{sec,in}})\).
///
/// Positive \(Q\) = heat rejected by the refrigerant into the secondary.
#[inline]
pub fn condenser_duty(ua: f64, c_sec: f64, t_sat_k: f64, t_sec_in_k: f64) -> f64 {
let eps = phase_change_effectiveness(ua, c_sec);
eps * c_sec * (t_sat_k - t_sec_in_k)
}
/// Evaporator duty [W]: \(Q = \varepsilon\,C_{\mathrm{sec}}\,(T_{\mathrm{sec,in}}-T_{\mathrm{sat}})\).
///
/// Positive \(Q\) = heat absorbed by the refrigerant from the secondary.
#[inline]
pub fn evaporator_duty(ua: f64, c_sec: f64, t_sat_k: f64, t_sec_in_k: f64) -> f64 {
let eps = phase_change_effectiveness(ua, c_sec);
eps * c_sec * (t_sec_in_k - t_sat_k)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn effectiveness_cr_zero_matches_textbook() {
let ua = 2500.0;
let c_sec = 0.4 * 4180.0;
let eps = phase_change_effectiveness(ua, c_sec);
let expected = 1.0 - (-ua / c_sec).exp();
assert!((eps - expected).abs() < 1e-12);
assert!(eps > 0.7 && eps < 1.0);
}
#[test]
fn condenser_duty_zero_when_tsat_equals_tsec() {
let q = condenser_duty(5000.0, 1000.0, 303.15, 303.15);
assert!(q.abs() < 1e-9);
}
#[test]
fn evaporator_duty_positive_when_water_warmer_than_tsat() {
let q = evaporator_duty(2000.0, 2000.0, 278.15, 285.15);
assert!(q > 0.0);
}
#[test]
fn d_eps_c_matches_fd() {
let ua = 3000.0;
let c = 1500.0;
let h = 1e-3 * c;
let gp = (c + h) * phase_change_effectiveness(ua, c + h);
let gm = (c - h) * phase_change_effectiveness(ua, c - h);
let fd = (gp - gm) / (2.0 * h);
let an = d_eps_c_d_c(ua, c);
assert!((an - fd).abs() / fd.abs().max(1e-9) < 1e-5);
}
}

View File

@@ -97,6 +97,7 @@ pub mod params;
pub mod pipe;
pub mod polynomials;
pub mod port;
pub mod probe;
pub mod pump;
pub mod python_components;
pub mod refrigerant_boundary;
@@ -150,6 +151,7 @@ pub use port::{
validate_port_continuity, Connected, ConnectedPort, ConnectionError, Disconnected, FluidId,
Port, PortKind,
};
pub use probe::{Probe, ProbeMeasure};
pub use pump::{Pump, PumpCurves};
pub use python_components::{
PyAirSinkReal, PyAirSourceReal, PyBrineSinkReal, PyBrineSourceReal, PyCompressorReal,

View File

@@ -0,0 +1,907 @@
//! Probe — zero-residual measurement tap on a connection line.
//!
//! Per the calibration redesign (see
//! `_bmad-output/implementation-artifacts/calibration-probe-node-plain-embedding.md`),
//! **every** calibration measurement lives on a `Probe` node the user drops
//! onto a wire. The calibrated z-factor (`z_ua`, `z_dp`, `z_flow`, ...) on a
//! component is freed, and a `Probe` elsewhere on the graph supplies the
//! matching measurement via its [`Component::measure_output`] override.
//!
//! ## Design
//!
//! - **Zero residual, zero Jacobian** — `n_equations() == 0`. The Probe is a
//! measurement tap, not a physical element. The calibration `Constraint`
//! (one residual, one unknown z-factor) closes the system.
//! - **Two ports `inlet`/`outlet`** — same typestate pattern as `Pipe`/`Pump`/
//! `Node`. The Probe splices into an edge A→B (becomes A→Probe→B) exactly
//! like `Pipe` does in `apps/web/src/lib/edgeInsert.ts`, so the UI drop-on-
//! wire interaction reuses `insertOnEdge`.
//! - **Reads live edge state** via [`Component::set_system_context`]. The
//! Probe stores the `(ṁ, P, h)` triples for both its incident edges (the
//! two halves of the spliced wire) and uses them in `measure_output`.
//!
//! ## Measure kinds
//!
//! [`ProbeMeasure`] is the exhaustive list the user named. SST/SDT/DGT/DSH/
//! SH/SC all derive from `(P, T)` on the same edge via CoolProp — the user
//! picks the semantic kind for clarity, the math is P/T/Tsat:
//!
//! | Kind | Formula |
//! |------|---------|
//! | [`Sst`](ProbeMeasure::Sst) / [`Sdt`](ProbeMeasure::Sdt) | `Tsat(P_edge)` |
//! | [`Dgt`](ProbeMeasure::Dgt) / [`T`](ProbeMeasure::T) | `T_edge` |
//! | [`Dsh`](ProbeMeasure::Dsh) / [`Sh`](ProbeMeasure::Sh) | `T_edge Tsat(P_edge)` |
//! | [`Sc`](ProbeMeasure::Sc) | `Tsat(P_edge) T_edge` |
//! | [`P`](ProbeMeasure::P) | `P_edge` |
//! | [`MassFlow`](ProbeMeasure::MassFlow) | `ṁ_edge` |
//! | [`Enthalpy`](ProbeMeasure::Enthalpy) | `h_edge` |
//! | [`Capacity`](ProbeMeasure::Capacity) | `ṁ_edge · |h_inlet h_outlet|` |
//!
//! `Tsat(P)` reuses the `tsat_k` pattern from
//! `crates/components/src/heat_exchanger/bphx_exchanger.rs:543-559` (clamps
//! the query pressure into the detected saturation domain via
//! `sat_domain::clamp_to_saturation_domain`).
use crate::heat_exchanger::sat_domain;
use crate::port::{Connected, Disconnected, Port};
use crate::state_machine::StateManageable;
use crate::{
CircuitId, Component, ComponentError, ConnectedPort, JacobianBuilder, MeasuredOutput,
OperationalState, ResidualVector, StateSlice,
};
use entropyk_core::{CalibIndices, MassFlow, Power};
use entropyk_fluids::{FluidBackend, FluidId as BackendFluidId, FluidState, Property, Quality};
use std::marker::PhantomData;
use std::sync::Arc;
/// Semantic quantity a [`Probe`] extracts from its edge.
///
/// Exhaustive list per the user mandate (see module docs). SST/SDT and DSH/SH
/// share formulas by design — the user picks the label that matches the
/// physical sensor location (suction vs discharge).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProbeMeasure {
/// Saturated suction temperature = `Tsat(P_edge)`.
Sst,
/// Saturated discharge temperature = `Tsat(P_edge)` (same formula, label only).
Sdt,
/// Discharge gas temperature = `T_edge` (raw temperature at the probe).
Dgt,
/// Discharge superheat = `T_edge Tsat(P_edge)`.
Dsh,
/// (Suction) superheat = `T_edge Tsat(P_edge)` (same formula as Dsh).
Sh,
/// Subcooling = `Tsat(P_edge) T_edge`.
Sc,
/// Raw temperature = `T_edge`.
T,
/// Raw pressure = `P_edge`.
P,
/// Mass flow rate = `ṁ_edge`.
MassFlow,
/// Heat duty = `ṁ · |Δh|` across the probe (see [`Probe`] module docs).
Capacity,
/// Raw specific enthalpy = `h_edge`.
Enthalpy,
}
impl ProbeMeasure {
/// Parses a measure-kind string from the JSON config (case-insensitive).
/// Accepts the canonical names plus a few common aliases.
pub fn parse(s: &str) -> Result<Self, String> {
let lower = s.trim().to_ascii_lowercase();
let m = match lower.as_str() {
"sst" => ProbeMeasure::Sst,
"sdt" => ProbeMeasure::Sdt,
"dgt" => ProbeMeasure::Dgt,
"dsh" => ProbeMeasure::Dsh,
"sh" | "superheat" => ProbeMeasure::Sh,
"sc" | "subcooling" => ProbeMeasure::Sc,
"t" | "temperature" => ProbeMeasure::T,
"p" | "pressure" => ProbeMeasure::P,
"massflow" | "mass_flow" | "mass flow" | "massflowrate" | "mass_flow_rate" => {
ProbeMeasure::MassFlow
}
"capacity" | "heatduty" | "heat_transfer_rate" | "heatrate" | "duty" => {
ProbeMeasure::Capacity
}
"enthalpy" | "h" => ProbeMeasure::Enthalpy,
other => {
return Err(format!(
"unknown Probe measure '{other}' (expected one of: SST, SDT, DGT, DSH, SH, SC, T, P, MassFlow, Capacity, Enthalpy)"
))
}
};
Ok(m)
}
/// Returns the canonical JSON name for this measure (round-trips through `parse`).
pub fn as_str(&self) -> &'static str {
match self {
ProbeMeasure::Sst => "SST",
ProbeMeasure::Sdt => "SDT",
ProbeMeasure::Dgt => "DGT",
ProbeMeasure::Dsh => "DSH",
ProbeMeasure::Sh => "SH",
ProbeMeasure::Sc => "SC",
ProbeMeasure::T => "T",
ProbeMeasure::P => "P",
ProbeMeasure::MassFlow => "MassFlow",
ProbeMeasure::Capacity => "Capacity",
ProbeMeasure::Enthalpy => "Enthalpy",
}
}
/// Maps this measure to the solver-side [`MeasuredOutput`] the calibration
/// constraint will ask for. SST/SDT → `SaturationTemperature`; DSH/SH →
/// `Superheat`; SC → `Subcooling`; DGT/T → `Temperature`; P → `Pressure`;
/// MassFlow → `MassFlowRate`; Capacity → `Capacity`; Enthalpy →
/// `HeatTransferRate` is NOT right, so Enthalpy returns `Temperature` as a
/// placeholder (the Probe still serves it via `measure_output(Temperature)`
/// returning T, and Enthalpy is read directly via the measure-output hook
/// below — see [`Probe::measure_output`] which honours the configured kind).
pub fn to_measured_output(&self) -> MeasuredOutput {
match self {
ProbeMeasure::Sst | ProbeMeasure::Sdt => MeasuredOutput::SaturationTemperature,
ProbeMeasure::Dsh | ProbeMeasure::Sh => MeasuredOutput::Superheat,
ProbeMeasure::Sc => MeasuredOutput::Subcooling,
ProbeMeasure::Dgt | ProbeMeasure::T => MeasuredOutput::Temperature,
ProbeMeasure::P => MeasuredOutput::Pressure,
ProbeMeasure::MassFlow => MeasuredOutput::MassFlowRate,
ProbeMeasure::Capacity => MeasuredOutput::Capacity,
// No dedicated Enthalpy variant in MeasuredOutput — collapse onto
// HeatTransferRate so the constraint resolves; measure_output
// detects the configured Enthalpy kind and returns h_edge.
ProbeMeasure::Enthalpy => MeasuredOutput::HeatTransferRate,
}
}
}
/// A zero-residual measurement tap on a connection line.
///
/// See the module docs for the calibration redesign context.
///
/// Like `Pipe` and `Pump`, `Probe` uses a typestate (`Disconnected` →
/// `Connected`) so the compiler enforces port connection before the component
/// is added to a system.
#[derive(Clone)]
pub struct Probe<State> {
measure: ProbeMeasure,
fluid_id_str: String,
port_inlet: Port<State>,
port_outlet: Port<State>,
fluid_backend: Option<Arc<dyn FluidBackend>>,
circuit_id: CircuitId,
operational_state: OperationalState,
calib_indices: CalibIndices,
// Live edge-state indices populated by `set_system_context`.
inlet_m_idx: Option<usize>,
inlet_p_idx: Option<usize>,
inlet_h_idx: Option<usize>,
outlet_m_idx: Option<usize>,
outlet_p_idx: Option<usize>,
outlet_h_idx: Option<usize>,
_state: PhantomData<State>,
}
impl<State> std::fmt::Debug for Probe<State> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Probe")
.field("measure", &self.measure)
.field("fluid", &self.fluid_id_str)
.field("has_backend", &self.fluid_backend.is_some())
.field("circuit_id", &self.circuit_id)
.field("operational_state", &self.operational_state)
.finish()
}
}
impl Probe<Disconnected> {
/// Creates a new disconnected Probe.
///
/// `fluid` is used for `Tsat`/`SH`/`SC` derivation via the configured
/// fluid backend. The backend itself is injected later by
/// [`Component::set_fluid_backend_from_builder`] when the system is built.
pub fn new(
measure: ProbeMeasure,
fluid: impl Into<String>,
port_inlet: Port<Disconnected>,
port_outlet: Port<Disconnected>,
) -> Self {
Self {
measure,
fluid_id_str: fluid.into(),
port_inlet,
port_outlet,
fluid_backend: None,
circuit_id: CircuitId::default(),
operational_state: OperationalState::default(),
calib_indices: CalibIndices::default(),
inlet_m_idx: None,
inlet_p_idx: None,
inlet_h_idx: None,
outlet_m_idx: None,
outlet_p_idx: None,
outlet_h_idx: None,
_state: PhantomData,
}
}
/// Attaches a fluid backend for property queries (Tsat, T from P,h).
pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
self.fluid_backend = Some(backend);
self
}
/// Connects the Probe to its inlet and outlet ports, transitioning to
/// `Probe<Connected>`. Mirrors `Pipe::connect` / `Pump::connect`.
pub fn connect(
self,
inlet: Port<Disconnected>,
outlet: Port<Disconnected>,
) -> Result<Probe<Connected>, ComponentError> {
let (p_in, _) = self
.port_inlet
.connect(inlet)
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
let (p_out, _) = self
.port_outlet
.connect(outlet)
.map_err(|e| ComponentError::InvalidState(e.to_string()))?;
Ok(Probe {
measure: self.measure,
fluid_id_str: self.fluid_id_str,
port_inlet: p_in,
port_outlet: p_out,
fluid_backend: self.fluid_backend,
circuit_id: self.circuit_id,
operational_state: self.operational_state,
calib_indices: self.calib_indices,
inlet_m_idx: self.inlet_m_idx,
inlet_p_idx: self.inlet_p_idx,
inlet_h_idx: self.inlet_h_idx,
outlet_m_idx: self.outlet_m_idx,
outlet_p_idx: self.outlet_p_idx,
outlet_h_idx: self.outlet_h_idx,
_state: PhantomData,
})
}
}
impl Probe<Connected> {
/// Returns the configured measure kind.
pub fn measure(&self) -> ProbeMeasure {
self.measure
}
/// Returns the fluid identifier string the Probe derives properties for.
pub fn fluid_id_str(&self) -> &str {
&self.fluid_id_str
}
/// Returns the inlet port.
pub fn port_inlet(&self) -> &Port<Connected> {
&self.port_inlet
}
/// Returns the outlet port.
pub fn port_outlet(&self) -> &Port<Connected> {
&self.port_outlet
}
/// Returns both ports as a slice.
pub fn get_ports_slice(&self) -> [&Port<Connected>; 2] {
[&self.port_inlet, &self.port_outlet]
}
/// \(T_{\mathrm{sat}}(P)\) [K] for the configured fluid, domain-clamped.
/// Mirrors `BphxExchanger::tsat_k` (`bphx_exchanger.rs:544-559`).
fn tsat_k(&self, p_pa: f64) -> Result<f64, ComponentError> {
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::CalculationFailed(
"Probe: FluidBackend required for Tsat/SH/SC derivation".into(),
)
})?;
let p_pa =
sat_domain::clamp_to_saturation_domain(backend, &self.fluid_id_str, p_pa).unwrap_or(p_pa);
backend
.property(
BackendFluidId::new(&self.fluid_id_str),
Property::Temperature,
FluidState::from_px(
entropyk_core::Pressure::from_pascals(p_pa),
Quality::new(0.5),
),
)
.map_err(ComponentError::from_fluid_error)
}
/// Temperature at the probe edge from `(P, h)` via the fluid backend.
fn temperature_k(&self, p_pa: f64, h_j_kg: f64) -> Result<f64, ComponentError> {
let backend = self.fluid_backend.as_ref().ok_or_else(|| {
ComponentError::CalculationFailed(
"Probe: FluidBackend required for temperature derivation".into(),
)
})?;
let t = backend
.property(
BackendFluidId::new(&self.fluid_id_str),
Property::Temperature,
FluidState::from_ph(
entropyk_core::Pressure::from_pascals(p_pa),
entropyk_core::Enthalpy::from_joules_per_kg(h_j_kg),
),
)
.map_err(ComponentError::from_fluid_error)?;
if !t.is_finite() || t <= 0.0 {
return Err(ComponentError::CalculationFailed(format!(
"Probe: non-physical temperature {t} for P={p_pa} Pa, h={h_j_kg} J/kg"
)));
}
Ok(t)
}
/// Reads `(ṁ, P, h)` of the inlet edge from the global state, or `None`
/// when the indices have not been wired yet.
fn inlet_state(&self, state: &StateSlice) -> Option<(f64, f64, f64)> {
let (m, p, h) = (
self.inlet_m_idx?,
self.inlet_p_idx?,
self.inlet_h_idx?,
);
if m >= state.len() || p >= state.len() || h >= state.len() {
return None;
}
Some((state[m], state[p], state[h]))
}
/// Reads the outlet-edge `(P, h)` from the global state, or `None`.
fn outlet_state(&self, state: &StateSlice) -> Option<(f64, f64)> {
let (p, h) = (self.outlet_p_idx?, self.outlet_h_idx?);
if p >= state.len() || h >= state.len() {
return None;
}
Some((state[p], state[h]))
}
}
impl Component for Probe<Connected> {
fn set_system_context(
&mut self,
_state_offset: usize,
external_edge_state_indices: &[(usize, usize, usize)],
) {
// The Probe is a 2-port splice; layout mirrors Pipe/Pump:
// [0] = incoming edge (upstream→probe), [1] = outgoing edge (probe→downstream).
// The Probe measures at its location; for SST/SDT/DGT/DSH/SH/SC/T/P we
// use the inlet edge (the edge the user dropped the probe onto). For
// Capacity we use both edges (ṁ·|Δh| across the splice).
if let Some(&(m, p, h)) = external_edge_state_indices.first() {
self.inlet_m_idx = Some(m);
self.inlet_p_idx = Some(p);
self.inlet_h_idx = Some(h);
}
if let Some(&(m, p, h)) = external_edge_state_indices.get(1) {
self.outlet_m_idx = Some(m);
self.outlet_p_idx = Some(p);
self.outlet_h_idx = Some(h);
}
}
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// Two topology residuals any 2-port splice must impose to keep the
// DoF balance closed (mirrors `Anchor` and edge-coupled `Pipe`):
// r0 = P_out P_in (zero-resistance pressure pass-through)
// r1 = h_out h_in (adiabatic enthalpy pass-through)
// These are NOT calibration residuals — they are the continuity that
// makes the Probe a transparent tap. The calibration `Constraint`
// (measure target) supplies the only calibration-related residual.
if residuals.len() < 2 {
return Err(ComponentError::InvalidResidualDimensions {
expected: 2,
actual: residuals.len(),
});
}
let (in_p_idx, in_h_idx, out_p_idx, out_h_idx) = match (
self.inlet_p_idx,
self.inlet_h_idx,
self.outlet_p_idx,
self.outlet_h_idx,
) {
(Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d),
_ => {
return Err(ComponentError::InvalidState(
"Probe requires live inlet and outlet edge state indices".to_string(),
));
}
};
let max_idx = in_p_idx.max(in_h_idx).max(out_p_idx).max(out_h_idx);
if max_idx >= state.len() {
return Err(ComponentError::InvalidStateDimensions {
expected: max_idx + 1,
actual: state.len(),
});
}
residuals[0] = state[out_p_idx] - state[in_p_idx];
residuals[1] = state[out_h_idx] - state[in_h_idx];
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// ∂r0/∂P_out = +1, ∂r0/∂P_in = 1 ; ∂r1/∂h_out = +1, ∂r1/∂h_in = 1
let (in_p_idx, in_h_idx, out_p_idx, out_h_idx) = match (
self.inlet_p_idx,
self.inlet_h_idx,
self.outlet_p_idx,
self.outlet_h_idx,
) {
(Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d),
_ => {
return Err(ComponentError::InvalidState(
"Probe Jacobian requires live inlet and outlet edge state indices".to_string(),
));
}
};
jacobian.add_entry(0, out_p_idx, 1.0);
jacobian.add_entry(0, in_p_idx, -1.0);
jacobian.add_entry(1, out_h_idx, 1.0);
jacobian.add_entry(1, in_h_idx, -1.0);
Ok(())
}
fn equation_roles(&self) -> Vec<crate::EquationRole> {
vec![
crate::EquationRole::Continuity { quantity: "P" },
crate::EquationRole::Continuity { quantity: "h" },
]
}
fn n_equations(&self) -> usize {
// 2 topology-continuity residuals (P, h) — see `compute_residuals`.
// NOT a calibration residual: the calibration Constraint supplies that.
2
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
fn port_names(&self) -> Vec<String> {
vec!["inlet".to_string(), "outlet".to_string()]
}
fn flow_paths(&self) -> Vec<(usize, usize)> {
// Single series path through the tap.
vec![(0, 1)]
}
fn port_mass_flows(
&self,
state: &StateSlice,
) -> Result<Vec<MassFlow>, ComponentError> {
let m_in = match self.inlet_m_idx {
Some(idx) if idx < state.len() => state[idx],
_ => 0.0,
};
let m_out = match self.outlet_m_idx {
Some(idx) if idx < state.len() => state[idx],
_ => m_in,
};
Ok(vec![
MassFlow::from_kg_per_s(m_in),
MassFlow::from_kg_per_s(-m_out),
])
}
fn port_enthalpies(
&self,
state: &StateSlice,
) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
let h_in = match self.inlet_h_idx {
Some(idx) if idx < state.len() => state[idx],
_ => self.port_inlet.enthalpy().to_joules_per_kg(),
};
let h_out = match self.outlet_h_idx {
Some(idx) if idx < state.len() => state[idx],
_ => self.port_outlet.enthalpy().to_joules_per_kg(),
};
Ok(vec![
entropyk_core::Enthalpy::from_joules_per_kg(h_in),
entropyk_core::Enthalpy::from_joules_per_kg(h_out),
])
}
fn set_calib_indices(&mut self, indices: CalibIndices) {
// A Probe owns no z-factors; the slot is kept only for trait
// compatibility with the System finalize path.
self.calib_indices = indices;
}
fn set_fluid_backend_from_builder(
&mut self,
backend: Arc<dyn FluidBackend>,
) {
if self.fluid_backend.is_none() {
self.fluid_backend = Some(Arc::clone(&backend));
}
}
fn energy_transfers(&self, _state: &StateSlice) -> Option<(Power, Power)> {
// A measurement tap is adiabatic.
Some((Power::from_watts(0.0), Power::from_watts(0.0)))
}
fn measure_output(&self, kind: MeasuredOutput, state: &StateSlice) -> Option<f64> {
// Resolve which edge we measure on. SST/SDT use the inlet edge's
// pressure; DGT/T/P/MassFlow/Enthalpy also use the inlet edge (the
// physical sensor location). For DSH/SH/SC we need (P, T) on the same
// edge. For Capacity we use ṁ and |Δh| across the splice.
let (m_in, p_in, h_in) = self.inlet_state(state)?;
// When the configured measure is Enthalpy, return h_edge regardless of
// the `kind` the constraint asks for (we map Enthalpy →
// HeatTransferRate at the routing layer because MeasuredOutput has no
// Enthalpy variant).
if self.measure == ProbeMeasure::Enthalpy {
return Some(h_in);
}
match kind {
MeasuredOutput::SaturationTemperature => Some(self.tsat_k(p_in).ok()?),
MeasuredOutput::Pressure => Some(p_in),
MeasuredOutput::MassFlowRate => Some(m_in.abs()),
MeasuredOutput::Temperature => {
// DGT/T → raw temperature at the probe. Falls back to None
// when the backend is unavailable or returns a non-physical T.
self.temperature_k(p_in, h_in).ok()
}
MeasuredOutput::Superheat | MeasuredOutput::Subcooling => {
let tsat = self.tsat_k(p_in).ok()?;
let t = self.temperature_k(p_in, h_in).ok()?;
if !t.is_finite() || t <= 0.0 {
return None;
}
match kind {
MeasuredOutput::Superheat => Some(t - tsat),
_ => Some(tsat - t),
}
}
MeasuredOutput::Capacity | MeasuredOutput::HeatTransferRate => {
// Capacity = ṁ · |Δh| across the probe splice. Uses both edges
// when available; if the outlet index isn't wired (defensive),
// falls back to zero duty.
if let Some((_, h_out)) = self.outlet_state(state) {
let dh = (h_in - h_out).abs();
let q = m_in.abs() * dh;
if q.is_finite() {
return Some(q);
}
}
Some(0.0)
}
}
}
fn signature(&self) -> String {
format!(
"Probe(measure={}, fluid={}, circuit={})",
self.measure.as_str(),
self.fluid_id_str,
self.circuit_id.0
)
}
fn to_params(&self) -> crate::ComponentParams {
crate::ComponentParams::new("Probe")
.with_param("measure", self.measure.as_str().to_string())
.with_param("fluid", self.fluid_id_str.clone())
.with_param("circuitId", self.circuit_id.0)
}
}
impl StateManageable for Probe<Connected> {
fn state(&self) -> OperationalState {
self.operational_state
}
fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
if self.operational_state.can_transition_to(state) {
self.operational_state = state;
Ok(())
} else {
Err(ComponentError::InvalidStateTransition {
from: self.operational_state,
to: state,
reason: "Transition not allowed".to_string(),
})
}
}
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;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::port::FluidId;
use entropyk_core::{Enthalpy, Pressure};
use entropyk_fluids::TestBackend;
fn build_probe(measure: ProbeMeasure) -> Probe<Connected> {
let backend: Arc<dyn FluidBackend> = Arc::new(TestBackend::new());
let mk = || {
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(3.0),
Enthalpy::from_joules_per_kg(400_000.0),
)
};
let probe_disc = Probe::new(measure, "R134a", mk(), mk()).with_fluid_backend(backend);
// The connect() call needs a second pair of ports (mirrors Pipe/Pump
// CLI idiom — both pairs carry identical nominal values).
probe_disc.connect(mk(), mk()).expect("probe connect")
}
/// Helper: equip a Probe with live edge indices pointing at a synthetic
/// state vector `[ṁ_in, P_in, h_in, ṁ_out, P_out, h_out]`.
fn wire_state_indices(probe: &mut Probe<Connected>) {
probe.inlet_m_idx = Some(0);
probe.inlet_p_idx = Some(1);
probe.inlet_h_idx = Some(2);
probe.outlet_m_idx = Some(3);
probe.outlet_p_idx = Some(4);
probe.outlet_h_idx = Some(5);
}
#[test]
fn probe_imposes_continuity_only() {
// The Probe is a zero-resistance tap: it contributes 2 topology
// residuals (P_out = P_in, h_out = h_in) so the spliced edge stays
// DoF-balanced. It does NOT contribute a calibration residual —
// the calibration Constraint (measure target) supplies that.
let probe = build_probe(ProbeMeasure::Sh);
assert_eq!(probe.n_equations(), 2);
let roles = probe.equation_roles();
assert_eq!(roles.len(), 2);
// Continuity residuals: inlet edge state (ṁ=0.1, P=5e5, h=300e3),
// outlet edge state (ṁ=0.1, P=5e5, h=300e3) → r0 = 0, r1 = 0.
let mut probe = build_probe(ProbeMeasure::Sh);
wire_state_indices(&mut probe);
let state = vec![0.1, 5.0e5, 300_000.0, 0.1, 5.0e5, 300_000.0];
let mut residuals = vec![0.0; 2];
probe
.compute_residuals(&state, &mut residuals)
.expect("residuals compute");
assert!(residuals[0].abs() < 1e-9, "P continuity: {}", residuals[0]);
assert!(residuals[1].abs() < 1e-9, "h continuity: {}", residuals[1]);
// Mismatched inlet/outlet → non-zero continuity residuals.
let state2 = vec![0.1, 5.0e5, 300_000.0, 0.1, 4.0e5, 290_000.0];
probe
.compute_residuals(&state2, &mut residuals)
.expect("residuals compute");
assert!((residuals[0] - (-1.0e5)).abs() < 1e-3);
assert!((residuals[1] - (-10_000.0)).abs() < 1e-6);
// Jacobian: ∂r0/∂P_out=+1, ∂r0/∂P_in=1, ∂r1/∂h_out=+1, ∂r1/∂h_in=1.
let mut jac = JacobianBuilder::new();
probe
.jacobian_entries(&state, &mut jac)
.expect("jacobian computes");
let entries = jac.entries();
assert!(entries.contains(&(0, 4, 1.0)), "missing ∂r0/∂P_out");
assert!(entries.contains(&(0, 1, -1.0)), "missing ∂r0/∂P_in");
assert!(entries.contains(&(1, 5, 1.0)), "missing ∂r1/∂h_out");
assert!(entries.contains(&(1, 2, -1.0)), "missing ∂r1/∂h_in");
}
#[test]
fn probe_port_names() {
let probe = build_probe(ProbeMeasure::Sh);
assert_eq!(probe.port_names(), vec!["inlet", "outlet"]);
assert_eq!(probe.flow_paths(), vec![(0, 1)]);
}
#[test]
fn probe_measure_parse_round_trip() {
for kind in [
ProbeMeasure::Sst,
ProbeMeasure::Sdt,
ProbeMeasure::Dgt,
ProbeMeasure::Dsh,
ProbeMeasure::Sh,
ProbeMeasure::Sc,
ProbeMeasure::T,
ProbeMeasure::P,
ProbeMeasure::MassFlow,
ProbeMeasure::Capacity,
ProbeMeasure::Enthalpy,
] {
let parsed = ProbeMeasure::parse(kind.as_str()).expect("parses");
assert_eq!(parsed, kind, "round-trip for {:?}", kind);
}
}
#[test]
fn probe_measure_parse_rejects_unknown() {
assert!(ProbeMeasure::parse("NOPE").is_err());
assert!(ProbeMeasure::parse("").is_err());
}
#[test]
fn probe_measure_parse_accepts_aliases() {
assert_eq!(
ProbeMeasure::parse("superheat").unwrap(),
ProbeMeasure::Sh
);
assert_eq!(
ProbeMeasure::parse("mass_flow_rate").unwrap(),
ProbeMeasure::MassFlow
);
assert_eq!(
ProbeMeasure::parse("subcooling").unwrap(),
ProbeMeasure::Sc
);
}
#[test]
fn probe_returns_none_without_state_indices() {
// No `set_system_context` call → indices are None → measure_output
// must return None gracefully instead of panicking.
let probe = build_probe(ProbeMeasure::Sh);
let state = vec![0.0; 6];
assert!(probe
.measure_output(MeasuredOutput::Superheat, &state)
.is_none());
assert!(probe
.measure_output(MeasuredOutput::Pressure, &state)
.is_none());
}
/// Sanity-check the Tsat derivation against TestBackend's R134a table at a
/// known pressure. R134a at 3 bar saturated → Tsat ≈ 273.4 K on TestBackend
/// (the table is approximate; we just assert the value is in a sane band).
#[test]
fn probe_saturation_temperature_in_sane_band() {
let mut probe = build_probe(ProbeMeasure::Sst);
wire_state_indices(&mut probe);
// 3 bar, h whatever — Tsat only uses P.
let state = vec![0.05, 3.0e5, 400_000.0, 0.05, 3.0e5, 400_000.0];
let tsat = probe
.measure_output(MeasuredOutput::SaturationTemperature, &state)
.expect("Tsat must resolve");
assert!(
(250.0..=320.0).contains(&tsat),
"Tsat out of sane band: {tsat}"
);
}
#[test]
fn probe_pressure_and_mass_flow_passthrough() {
let mut probe = build_probe(ProbeMeasure::P);
wire_state_indices(&mut probe);
let state = vec![0.42, 5.0e5, 250_000.0, 0.42, 5.0e5, 250_000.0];
let p = probe
.measure_output(MeasuredOutput::Pressure, &state)
.unwrap();
assert!((p - 5.0e5).abs() < 1e-6);
probe.measure = ProbeMeasure::MassFlow;
let m = probe
.measure_output(MeasuredOutput::MassFlowRate, &state)
.unwrap();
assert!((m - 0.42).abs() < 1e-9);
}
#[test]
fn probe_enthalpy_kind_returns_h_edge() {
let mut probe = build_probe(ProbeMeasure::Enthalpy);
wire_state_indices(&mut probe);
let state = vec![0.1, 3.0e5, 412_300.0, 0.1, 3.0e5, 412_300.0];
// Enthalpy maps to HeatTransferRate in to_measured_output, but the
// measure_output override detects the configured Enthalpy kind and
// returns h_edge regardless.
let h = probe
.measure_output(MeasuredOutput::HeatTransferRate, &state)
.unwrap();
assert!((h - 412_300.0).abs() < 1e-6);
}
#[test]
fn probe_capacity_is_mass_flow_times_delta_h() {
let mut probe = build_probe(ProbeMeasure::Capacity);
wire_state_indices(&mut probe);
// Inlet h = 420 kJ/kg, outlet h = 400 kJ/kg, ṁ = 0.5 kg/s → Q = 10 kW.
let state = vec![0.5, 3.0e5, 420_000.0, 0.5, 3.0e5, 400_000.0];
let q = probe
.measure_output(MeasuredOutput::Capacity, &state)
.unwrap();
assert!((q - 10_000.0).abs() < 1e-6, "expected 10 kW, got {q}");
}
#[test]
fn probe_superheat_subcooling_signs() {
let backend: Arc<dyn FluidBackend> = Arc::new(TestBackend::new());
let mk = || {
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(3.0),
Enthalpy::from_joules_per_kg(400_000.0),
)
};
// Probe with SH measure: should be T_edge Tsat(P_edge).
let mut sh_probe = Probe::new(ProbeMeasure::Sh, "R134a", mk(), mk())
.with_fluid_backend(Arc::clone(&backend))
.connect(mk(), mk())
.unwrap();
wire_state_indices(&mut sh_probe);
let tsat = sh_probe.tsat_k(3.0e5).expect("tsat resolves");
// Pick h that gives a known T via TestBackend. We can't know T without
// querying, so just assert SH and SC are negatives of each other for
// the same (P, h).
let state = vec![0.1, 3.0e5, 430_000.0, 0.1, 3.0e5, 430_000.0];
let sh = sh_probe
.measure_output(MeasuredOutput::Superheat, &state)
.expect("SH resolves");
let mut sc_probe = Probe::new(ProbeMeasure::Sc, "R134a", mk(), mk())
.with_fluid_backend(Arc::clone(&backend))
.connect(mk(), mk())
.unwrap();
wire_state_indices(&mut sc_probe);
let sc = sc_probe
.measure_output(MeasuredOutput::Subcooling, &state)
.expect("SC resolves");
assert!(
(sh + sc).abs() < 1e-6,
"SH and SC must be opposite signs for same (P,h): SH={sh}, SC={sc}, tsat={tsat}"
);
}
#[test]
fn probe_to_measured_output_mapping_is_exhaustive() {
// Every variant maps to a non-panic MeasuredOutput.
for kind in [
ProbeMeasure::Sst,
ProbeMeasure::Sdt,
ProbeMeasure::Dgt,
ProbeMeasure::Dsh,
ProbeMeasure::Sh,
ProbeMeasure::Sc,
ProbeMeasure::T,
ProbeMeasure::P,
ProbeMeasure::MassFlow,
ProbeMeasure::Capacity,
ProbeMeasure::Enthalpy,
] {
let _ = kind.to_measured_output();
}
}
#[test]
fn probe_signature_and_params_include_measure_and_fluid() {
let probe = build_probe(ProbeMeasure::Sst);
let sig = probe.signature();
assert!(sig.contains("SST"), "signature: {sig}");
assert!(sig.contains("R134a"));
let params = probe.to_params();
assert_eq!(params.component_type, "Probe");
}
}