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

@@ -252,12 +252,14 @@ pub struct Pipe<State> {
/// Operational state
operational_state: OperationalState,
/// Design-point pressure drop (Pa) for the edge-coupled (P,h) solver model.
/// The (P,h) refrigeration solver has no mass-flow unknown, so the pressure
/// drop is imposed as a design-point value rather than computed from Darcy.
/// When `> 0`, ΔP is imposed as this constant. When `0`, ΔP is computed from
/// DarcyWeisbach using geometry + live mass flow (`inlet_m_idx`).
design_dp_pa: f64,
/// When true, the component uses the edge-coupled (P,h) solver model
/// (`n_equations() == 2`) instead of the legacy mass-flow model.
edge_coupled: bool,
/// Inlet edge mass-flow index (edge-coupled Darcy mode).
inlet_m_idx: Option<usize>,
/// Inlet edge pressure index in the global state vector (edge-coupled mode).
inlet_p_idx: Option<usize>,
/// Inlet edge enthalpy index in the global state vector (edge-coupled mode).
@@ -316,6 +318,7 @@ impl Pipe<Disconnected> {
operational_state: OperationalState::default(),
design_dp_pa: 0.0,
edge_coupled: false,
inlet_m_idx: None,
inlet_p_idx: None,
inlet_h_idx: None,
outlet_p_idx: None,
@@ -477,6 +480,7 @@ impl Pipe<Disconnected> {
operational_state: self.operational_state,
design_dp_pa: self.design_dp_pa,
edge_coupled: self.edge_coupled,
inlet_m_idx: self.inlet_m_idx,
inlet_p_idx: self.inlet_p_idx,
inlet_h_idx: self.inlet_h_idx,
outlet_p_idx: self.outlet_p_idx,
@@ -487,12 +491,13 @@ impl Pipe<Disconnected> {
}
impl Pipe<Connected> {
/// Enables the edge-coupled (P,h) solver model with a design-point pressure
/// drop, so the pipe participates in the refrigeration/hydronic graph solver.
/// Enables the edge-coupled (P,h) solver model so the pipe participates in
/// the refrigeration/hydronic graph solver.
///
/// In this mode the pipe owns 2 equations on its outlet edge:
/// - `r0 = P_out - (P_in - design_dp_pa)` (imposed pressure drop)
/// - `r1 = h_out - h_in` (adiabatic pass-through)
/// - `r0 = P_out - (P_in - ΔP)` — if `design_dp_pa > 0`, ΔP is that constant;
/// if `design_dp_pa == 0`, ΔP is DarcyWeisbach from geometry + live ṁ
/// - `r1 = h_out - h_in` — adiabatic pass-through
pub fn with_design_pressure_drop_pa(mut self, design_dp_pa: f64) -> Self {
self.design_dp_pa = design_dp_pa.max(0.0);
self.edge_coupled = true;
@@ -624,6 +629,7 @@ impl Component for Pipe<Connected> {
// Layout: [0] = incoming edge (upstream→pipe), [1] = outgoing edge (pipe→downstream)
// Triple: (m_idx, p_idx, h_idx)
if !external_edge_state_indices.is_empty() {
self.inlet_m_idx = Some(external_edge_state_indices[0].0);
self.inlet_p_idx = Some(external_edge_state_indices[0].1);
self.inlet_h_idx = Some(external_edge_state_indices[0].2);
}
@@ -654,9 +660,15 @@ impl Component for Pipe<Connected> {
}
let dp = match self.operational_state {
OperationalState::Bypass => 0.0,
_ => self.calib.z_dp * self.design_dp_pa,
_ if self.design_dp_pa > 0.0 => self.calib.z_dp * self.design_dp_pa,
_ => {
// DarcyWeisbach from geometry + live ṁ (design_dp unset/0).
let m = self.inlet_m_idx.map(|i| state[i]).unwrap_or(0.0);
let flow_m3 = m / self.fluid_density_kg_per_m3;
self.pressure_drop(flow_m3).abs()
}
};
// r0: imposed pressure drop across the pipe
// r0: pressure drop across the pipe
residuals[0] = state[out_p] - (state[in_p] - dp);
// r1: adiabatic pass-through (enthalpy conserved)
residuals[1] = state[out_h] - state[in_h];
@@ -736,6 +748,19 @@ impl Component for Pipe<Connected> {
// r0 = P_out - (P_in - dp) → ∂r0/∂P_out = 1, ∂r0/∂P_in = -1
jacobian.add_entry(0, out_p, 1.0);
jacobian.add_entry(0, in_p, -1.0);
// Darcy: dp depends on ṁ → ∂r0/∂ṁ = +∂(dp)/∂ṁ
if self.design_dp_pa <= 0.0 {
if let Some(m_idx) = self.inlet_m_idx {
let m = state[m_idx];
let h = 1e-6_f64.max(m.abs() * 1e-5);
let rho = self.fluid_density_kg_per_m3;
let dp_plus = self.pressure_drop((m + h) / rho).abs();
let dp_minus = self.pressure_drop((m - h) / rho).abs();
let ddp_dm = (dp_plus - dp_minus) / (2.0 * h);
// r0 = P_out - P_in + dp(m) → ∂r0/∂m = ∂dp/∂m
jacobian.add_entry(0, m_idx, ddp_dm);
}
}
// r1 = h_out - h_in → ∂r1/∂h_out = 1, ∂r1/∂h_in = -1
jacobian.add_entry(1, out_h, 1.0);
jacobian.add_entry(1, in_h, -1.0);
@@ -929,6 +954,7 @@ mod tests {
operational_state: OperationalState::default(),
design_dp_pa: 0.0,
edge_coupled: false,
inlet_m_idx: None,
inlet_p_idx: None,
inlet_h_idx: None,
outlet_p_idx: None,
@@ -1116,6 +1142,34 @@ mod tests {
assert!(dp2 > dp1);
}
#[test]
fn test_edge_coupled_zero_design_dp_uses_darcy() {
use crate::Component;
let mut pipe = create_test_pipe_connected().with_design_pressure_drop_pa(0.0);
// state layout: m, P_in, h_in, m_out, P_out, h_out
pipe.set_system_context(0, &[(0, 1, 2), (3, 4, 5)]);
let m = 5.0_f64; // kg/s
let flow_m3 = m / pipe.fluid_density();
let dp_expected = pipe.pressure_drop(flow_m3).abs();
assert!(
dp_expected > 100.0,
"test pipe should have meaningful Darcy ΔP"
);
let p_in = 300_000.0;
let h = 100_000.0;
let state = vec![m, p_in, h, m, p_in - dp_expected, h];
let mut residuals = vec![0.0; 2];
pipe.compute_residuals(&state, &mut residuals).unwrap();
assert_relative_eq!(residuals[0], 0.0, epsilon = 1e-6);
assert_relative_eq!(residuals[1], 0.0, epsilon = 1e-12);
// Wrong P_out → residual equals ΔP error
let state_bad = vec![m, p_in, h, m, p_in, h]; // isobaric — should fail
pipe.compute_residuals(&state_bad, &mut residuals).unwrap();
assert_relative_eq!(residuals[0], dp_expected, epsilon = 1.0);
}
#[test]
fn test_f_dp_scales_pressure_drop() {
let mut pipe = create_test_pipe_connected();