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

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

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

View File

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