Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.

Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

@@ -144,6 +144,10 @@ pub struct FlowSplitter {
inlet: ConnectedPort,
/// Outlet ports (N branches).
outlets: Vec<ConnectedPort>,
/// Captured (P,h) global state indices of the inlet edge (incoming).
inlet_idx: Option<(usize, usize)>,
/// Captured (P,h) global state indices of the outlet edges (outgoing).
outlet_idx: Vec<(usize, usize)>,
}
impl FlowSplitter {
@@ -208,6 +212,8 @@ impl FlowSplitter {
fluid_id: fluid,
inlet,
outlets,
inlet_idx: None,
outlet_idx: Vec::new(),
})
}
@@ -240,18 +246,37 @@ impl FlowSplitter {
}
impl Component for FlowSplitter {
/// `2N 1` equations:
/// - `N1` pressure equalities
/// - `N1` enthalpy equalities
/// - `1` mass balance (represented as N-th outlet consistency)
/// `2N` equations — the splitter owns its `N` outgoing edges, contributing
/// 2 equations (pressure + enthalpy) per branch:
/// - `P_out_k P_in = 0` for k = 0..N
/// - `h_out_k h_in = 0` for k = 0..N
fn n_equations(&self) -> usize {
let n = self.outlets.len();
2 * n - 1
2 * self.outlets.len()
}
fn set_system_context(
&mut self,
_state_offset: usize,
external_edge_state_indices: &[(usize, usize, usize)],
) {
// Layout: [0] = incoming inlet edge, [1..] = outgoing outlet edges.
// Triple: (m_idx, p_idx, h_idx) — extract (p, h) pairs for residuals.
if external_edge_state_indices.is_empty() {
return;
}
self.inlet_idx = Some((
external_edge_state_indices[0].1,
external_edge_state_indices[0].2,
));
self.outlet_idx = external_edge_state_indices[1..]
.iter()
.map(|&(_, p, h)| (p, h))
.collect();
}
fn compute_residuals(
&self,
_state: &StateSlice,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
let n_eqs = self.n_equations();
@@ -262,38 +287,21 @@ impl Component for FlowSplitter {
});
}
// Inlet state indices come from the ConnectedPort.
// In the Entropyk solver, the state vector is indexed by edge:
// each edge contributes (P_idx, h_idx) set during System::finalize().
let p_in = self.inlet.pressure().to_pascals();
let h_in = self.inlet.enthalpy().to_joules_per_kg();
let (in_p, in_h) = match self.inlet_idx {
Some(idx) if self.outlet_idx.len() == self.outlets.len() => idx,
_ => {
return Err(ComponentError::InvalidState(
"FlowSplitter requires one live inlet edge and all live outlet edges"
.to_string(),
));
}
};
let n = self.outlets.len();
let mut r_idx = 0;
// --- Isobaric constraints: outlets 0..N-1 ---
for k in 0..(n - 1) {
let p_out_k = self.outlets[k].pressure().to_pascals();
residuals[r_idx] = p_out_k - p_in;
r_idx += 1;
for (k, &(out_p, out_h)) in self.outlet_idx.iter().enumerate() {
residuals[2 * k] = state[out_p] - state[in_p];
residuals[2 * k + 1] = state[out_h] - state[in_h];
}
// --- Isenthalpic constraints: outlets 0..N-1 ---
for k in 0..(n - 1) {
let h_out_k = self.outlets[k].enthalpy().to_joules_per_kg();
residuals[r_idx] = h_out_k - h_in;
r_idx += 1;
}
// --- Mass balance (1 equation) ---
// Express as: P_out_N = P_in AND h_out_N = h_in (implicitly guaranteed
// by the solver topology, but we add one algebraic check on the last branch).
// We use the last outlet as the "free" degree of freedom:
let p_out_last = self.outlets[n - 1].pressure().to_pascals();
residuals[r_idx] = p_out_last - p_in;
// Note: h_out_last = h_in is implied once all other constraints are met
// (conservation is guaranteed by the graph topology).
Ok(())
}
@@ -302,17 +310,23 @@ impl Component for FlowSplitter {
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// All residuals are linear differences → constant Jacobian.
// Each residual r_i depends on exactly two state variables with
// coefficients +1 and -1. Since the state indices are stored in the
// ConnectedPort pressure/enthalpy values (not raw state indices here),
// we emit a diagonal approximation: ∂r_i/∂x_i = 1.
//
// The full off-diagonal coupling is handled by the System assembler
// which maps port values to state vector positions.
let n_eqs = self.n_equations();
for i in 0..n_eqs {
jacobian.add_entry(i, i, 1.0);
let (in_p, in_h) = match self.inlet_idx {
Some(idx) if self.outlet_idx.len() == self.outlets.len() => idx,
_ => {
return Err(ComponentError::InvalidState(
"FlowSplitter Jacobian requires one live inlet edge and all live outlet edges"
.to_string(),
));
}
};
for (k, &(out_p, out_h)) in self.outlet_idx.iter().enumerate() {
// r[2k] = P_out_k - P_in
jacobian.add_entry(2 * k, out_p, 1.0);
jacobian.add_entry(2 * k, in_p, -1.0);
// r[2k+1] = h_out_k - h_in
jacobian.add_entry(2 * k + 1, out_h, 1.0);
jacobian.add_entry(2 * k + 1, in_h, -1.0);
}
Ok(())
}
@@ -386,7 +400,11 @@ impl Component for FlowSplitter {
}
fn signature(&self) -> String {
format!("FlowSplitter(fluid={}, outlets={})", self.fluid_id, self.outlets.len())
format!(
"FlowSplitter(fluid={}, outlets={})",
self.fluid_id,
self.outlets.len()
)
}
fn to_params(&self) -> crate::ComponentParams {
@@ -428,6 +446,10 @@ pub struct FlowMerger {
outlet: ConnectedPort,
/// Optional mass flow weights per inlet (kg/s). If None, equal weighting.
mass_flow_weights: Option<Vec<f64>>,
/// Captured (P,h) global state indices of the inlet edges (incoming).
inlet_idx: Vec<(usize, usize)>,
/// Captured (P,h) global state indices of the outlet edge (outgoing).
outlet_idx: Option<(usize, usize)>,
}
impl FlowMerger {
@@ -482,6 +504,8 @@ impl FlowMerger {
inlets,
outlet,
mass_flow_weights: None,
inlet_idx: Vec::new(),
outlet_idx: None,
})
}
@@ -536,81 +560,90 @@ impl FlowMerger {
// ── Mixing helpers ────────────────────────────────────────────────────────
/// Computes the mixed outlet enthalpy (weighted or equal).
fn mixed_enthalpy(&self) -> f64 {
/// Returns the mass-flow weights normalised so they sum to 1.0.
///
/// Falls back to equal weighting when no weights are set or the total is
/// non-positive.
fn normalized_weights(&self) -> Vec<f64> {
let n = self.inlets.len();
match &self.mass_flow_weights {
Some(weights) => {
let total_flow: f64 = weights.iter().sum();
if total_flow <= 0.0 {
// Fall back to equal weighting
self.inlets
.iter()
.map(|p| p.enthalpy().to_joules_per_kg())
.sum::<f64>()
/ n as f64
let total: f64 = weights.iter().sum();
if total <= 0.0 {
vec![1.0 / n as f64; n]
} else {
self.inlets
.iter()
.zip(weights.iter())
.map(|(p, &w)| p.enthalpy().to_joules_per_kg() * w)
.sum::<f64>()
/ total_flow
weights.iter().map(|&w| w / total).collect()
}
}
None => {
// Equal weighting
self.inlets
.iter()
.map(|p| p.enthalpy().to_joules_per_kg())
.sum::<f64>()
/ n as f64
}
None => vec![1.0 / n as f64; n],
}
}
}
impl Component for FlowMerger {
/// `N + 1` equations:
/// - `N1` pressure equalisations across inlets
/// - `1` mixing enthalpy for the outlet
/// - `1` outlet pressure consistency
/// `2` equations — the merger owns its single outgoing edge:
/// - `P_out P_in_0 = 0` (outlet pressure = reference inlet pressure)
/// - `h_out Σ wₖ·h_in_k = 0` (mass-weighted enthalpy mixing)
fn n_equations(&self) -> usize {
self.inlets.len() + 1
2
}
fn set_system_context(
&mut self,
_state_offset: usize,
external_edge_state_indices: &[(usize, usize, usize)],
) {
// Layout: [0..N] = incoming inlet edges, [N] = outgoing outlet edge.
// Triple: (m_idx, p_idx, h_idx) — extract (p, h) pairs for residuals.
let n = self.inlets.len();
if external_edge_state_indices.len() >= n {
self.inlet_idx = external_edge_state_indices[0..n]
.iter()
.map(|&(_, p, h)| (p, h))
.collect();
}
if external_edge_state_indices.len() >= n + 1 {
self.outlet_idx = Some((
external_edge_state_indices[n].1,
external_edge_state_indices[n].2,
));
}
}
fn compute_residuals(
&self,
_state: &StateSlice,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
let n_eqs = self.n_equations();
if residuals.len() < n_eqs {
if residuals.len() < 2 {
return Err(ComponentError::InvalidResidualDimensions {
expected: n_eqs,
expected: 2,
actual: residuals.len(),
});
}
let p_ref = self.inlets[0].pressure().to_pascals();
let mut r_idx = 0;
let (out_p, out_h) = match self.outlet_idx {
Some(idx) if self.inlet_idx.len() == self.inlets.len() => idx,
_ => {
return Err(ComponentError::InvalidState(
"FlowMerger requires all live inlet edges and one live outlet edge".to_string(),
));
}
};
// --- Pressure equalisation: inlets 1..N must match inlet 0 ---
for k in 1..self.inlets.len() {
let p_k = self.inlets[k].pressure().to_pascals();
residuals[r_idx] = p_k - p_ref;
r_idx += 1;
}
// r0: outlet pressure tracks the (reference) first inlet pressure.
let p_ref = state[self.inlet_idx[0].0];
residuals[0] = state[out_p] - p_ref;
// --- Outlet pressure = reference inlet pressure ---
let p_out = self.outlet.pressure().to_pascals();
residuals[r_idx] = p_out - p_ref;
r_idx += 1;
// --- Mixing enthalpy ---
let h_mixed = self.mixed_enthalpy();
let h_out = self.outlet.enthalpy().to_joules_per_kg();
residuals[r_idx] = h_out - h_mixed;
// r1: mass-weighted enthalpy mixing.
let weights = self.normalized_weights();
let h_mix: f64 = self
.inlet_idx
.iter()
.zip(weights.iter())
.map(|(&(_, h_idx), &w)| w * state[h_idx])
.sum();
residuals[1] = state[out_h] - h_mix;
Ok(())
}
@@ -620,11 +653,25 @@ impl Component for FlowMerger {
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// Diagonal approximation — the full coupling is resolved by the System
// assembler through the edge state indices.
let n_eqs = self.n_equations();
for i in 0..n_eqs {
jacobian.add_entry(i, i, 1.0);
let (out_p, out_h) = match self.outlet_idx {
Some(idx) if self.inlet_idx.len() == self.inlets.len() => idx,
_ => {
return Err(ComponentError::InvalidState(
"FlowMerger Jacobian requires all live inlet edges and one live outlet edge"
.to_string(),
));
}
};
// r0 = P_out - P_in_0
jacobian.add_entry(0, out_p, 1.0);
jacobian.add_entry(0, self.inlet_idx[0].0, -1.0);
// r1 = h_out - Σ wₖ·h_in_k
jacobian.add_entry(1, out_h, 1.0);
let weights = self.normalized_weights();
for (&(_, h_idx), &w) in self.inlet_idx.iter().zip(weights.iter()) {
jacobian.add_entry(1, h_idx, -w);
}
Ok(())
}
@@ -693,7 +740,11 @@ impl Component for FlowMerger {
}
fn signature(&self) -> String {
format!("FlowMerger(fluid={}, inlets={})", self.fluid_id, self.inlets.len())
format!(
"FlowMerger(fluid={}, inlets={})",
self.fluid_id,
self.inlets.len()
)
}
fn to_params(&self) -> crate::ComponentParams {
@@ -762,8 +813,8 @@ mod tests {
let s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
assert_eq!(s.n_outlets(), 2);
assert_eq!(s.fluid_kind(), FluidKind::Incompressible);
// n_equations = 2*2 - 1 = 3
assert_eq!(s.n_equations(), 3);
// n_equations = 2*N = 4 (owns 2 outgoing edges, P+h each)
assert_eq!(s.n_equations(), 4);
}
#[test]
@@ -776,8 +827,8 @@ mod tests {
let s = FlowSplitter::compressible("R410A", inlet, vec![out_a, out_b, out_c]).unwrap();
assert_eq!(s.n_outlets(), 3);
assert_eq!(s.fluid_kind(), FluidKind::Compressible);
// n_equations = 2*3 - 1 = 5
assert_eq!(s.n_equations(), 5);
// n_equations = 2*N = 6
assert_eq!(s.n_equations(), 6);
}
#[test]
@@ -802,13 +853,17 @@ mod tests {
#[test]
fn test_splitter_residuals_zero_at_consistent_state() {
// Consistent state: all pressures and enthalpies equal
// Consistent state: all branch pressures/enthalpies equal the inlet.
let inlet = make_port("Water", 3.0e5, 2.0e5);
let out_a = make_port("Water", 3.0e5, 2.0e5);
let out_b = make_port("Water", 3.0e5, 2.0e5);
let s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
let state = vec![0.0; 6]; // dummy, not used by current impl
let mut s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
// Edges (CM1.3 stride-3): inlet=(ṁ@0,P@1,h@2), out_a=(ṁ@3,P@4,h@5), out_b=(ṁ@6,P@7,h@8)
s.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);
// State: ṁ slots are don't-care (splitter ignores them); P and h are consistent.
let state = vec![0.0, 3.0e5, 2.0e5, 0.0, 3.0e5, 2.0e5, 0.0, 3.0e5, 2.0e5];
let mut res = vec![0.0; s.n_equations()];
s.compute_residuals(&state, &mut res).unwrap();
@@ -825,11 +880,14 @@ mod tests {
#[test]
fn test_splitter_residuals_nonzero_on_pressure_mismatch() {
let inlet = make_port("Water", 3.0e5, 2.0e5);
let out_a = make_port("Water", 2.5e5, 2.0e5); // lower pressure!
let out_a = make_port("Water", 3.0e5, 2.0e5);
let out_b = make_port("Water", 3.0e5, 2.0e5);
let s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
let state = vec![0.0; 6];
let mut s = FlowSplitter::incompressible("Water", inlet, vec![out_a, out_b]).unwrap();
s.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);
// out_a pressure lower than inlet by 0.5e5; ṁ slots are don't-care.
let state = vec![0.0, 3.0e5, 2.0e5, 0.0, 2.5e5, 2.0e5, 0.0, 3.0e5, 2.0e5];
let mut res = vec![0.0; s.n_equations()];
s.compute_residuals(&state, &mut res).unwrap();
@@ -846,8 +904,8 @@ mod tests {
let inlet = make_port("Water", 3.0e5, 2.0e5);
let outlets: Vec<_> = (0..3).map(|_| make_port("Water", 3.0e5, 2.0e5)).collect();
let s = FlowSplitter::incompressible("Water", inlet, outlets).unwrap();
// N=3 → 2*3-1 = 5
assert_eq!(s.n_equations(), 5);
// N=3 → 2*N = 6
assert_eq!(s.n_equations(), 6);
}
#[test]
@@ -872,8 +930,8 @@ mod tests {
let m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
assert_eq!(m.n_inlets(), 2);
assert_eq!(m.fluid_kind(), FluidKind::Incompressible);
// N=2 → N+1 = 3
assert_eq!(m.n_equations(), 3);
// Merger owns its single outgoing edge → 2 equations (P + h)
assert_eq!(m.n_equations(), 2);
}
#[test]
@@ -886,8 +944,8 @@ mod tests {
let m = FlowMerger::compressible("R134a", vec![in_a, in_b, in_c], outlet).unwrap();
assert_eq!(m.n_inlets(), 3);
assert_eq!(m.fluid_kind(), FluidKind::Compressible);
// N=3 → N+1 = 4
assert_eq!(m.n_equations(), 4);
// Merger owns its single outgoing edge → 2 equations (P + h)
assert_eq!(m.n_equations(), 2);
}
#[test]
@@ -907,8 +965,11 @@ mod tests {
let in_b = make_port("Water", p, h);
let outlet = make_port("Water", p, h); // h_mixed = (h+h)/2 = h
let m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
let state = vec![0.0; 6];
let mut m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
// Edges (CM1.3 stride-3): in_a=(ṁ@0,P@1,h@2), in_b=(ṁ@3,P@4,h@5), outlet=(ṁ@6,P@7,h@8)
m.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);
let state = vec![0.0, p, h, 0.0, p, h, 0.0, p, h];
let mut res = vec![0.0; m.n_equations()];
m.compute_residuals(&state, &mut res).unwrap();
@@ -928,8 +989,10 @@ mod tests {
let in_b = make_port("Water", p, h_b);
let outlet = make_port("Water", p, h_expected);
let m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
let state = vec![0.0; 6];
let mut m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap();
m.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);
let state = vec![0.0, p, h_a, 0.0, p, h_b, 0.0, p, h_expected];
let mut res = vec![0.0; m.n_equations()];
m.compute_residuals(&state, &mut res).unwrap();
@@ -948,12 +1011,13 @@ mod tests {
let in_b = make_port("Water", p, 3.0e5);
let outlet = make_port("Water", p, 2.7e5);
let m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet)
let mut m = FlowMerger::incompressible("Water", vec![in_a, in_b], outlet)
.unwrap()
.with_mass_flows(vec![0.3, 0.7])
.unwrap();
m.set_system_context(0, &[(0, 1, 2), (3, 4, 5), (6, 7, 8)]);
let state = vec![0.0; 6];
let state = vec![0.0, p, 2.0e5, 0.0, p, 3.0e5, 0.0, p, 2.7e5];
let mut res = vec![0.0; m.n_equations()];
m.compute_residuals(&state, &mut res).unwrap();
@@ -973,7 +1037,7 @@ mod tests {
let merger: Box<dyn Component> =
Box::new(FlowMerger::incompressible("Water", vec![in_a, in_b], outlet).unwrap());
assert_eq!(merger.n_equations(), 3);
assert_eq!(merger.n_equations(), 2);
}
#[test]
@@ -984,7 +1048,7 @@ mod tests {
let splitter: Box<dyn Component> =
Box::new(FlowSplitter::compressible("R410A", inlet, vec![out_a, out_b]).unwrap());
assert_eq!(splitter.n_equations(), 3);
assert_eq!(splitter.n_equations(), 4);
}
// ── energy_transfers tests ─────────────────────────────────────────────────