Snapshot WIP: Probe calibration path, faer LU backend, and BPHX phase-change duty.
Some checks failed
CI / check (push) Has been cancelled
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:
@@ -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>,
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
87
crates/components/src/heat_exchanger/phase_change_entu.rs
Normal file
87
crates/components/src/heat_exchanger/phase_change_entu.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
907
crates/components/src/probe.rs
Normal file
907
crates/components/src/probe.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user