Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Some checks failed
CI / check (push) Has been cancelled
Some checks failed
CI / check (push) Has been cancelled
Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user