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>
110 lines
3.4 KiB
Rust
110 lines
3.4 KiB
Rust
//! System-wide DoF balance tests.
|
||
//!
|
||
//! Verifies that the ledger counts equations and unknowns consistently and that
|
||
//! `finalize` hard-fails on square-system violations.
|
||
|
||
use entropyk_components::{Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice};
|
||
use entropyk_solver::dof::SystemDofBalance;
|
||
use entropyk_solver::system::System;
|
||
use entropyk_solver::TopologyError;
|
||
|
||
/// Minimal mock: N residuals that are identically zero (topology bookkeeping only).
|
||
struct MockEq {
|
||
n: usize,
|
||
}
|
||
|
||
impl Component for MockEq {
|
||
fn compute_residuals(
|
||
&self,
|
||
_state: &StateSlice,
|
||
residuals: &mut ResidualVector,
|
||
) -> Result<(), ComponentError> {
|
||
for r in residuals.iter_mut().take(self.n) {
|
||
*r = 0.0;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn jacobian_entries(
|
||
&self,
|
||
_state: &StateSlice,
|
||
_jacobian: &mut JacobianBuilder,
|
||
) -> Result<(), ComponentError> {
|
||
Ok(())
|
||
}
|
||
|
||
fn n_equations(&self) -> usize {
|
||
self.n
|
||
}
|
||
|
||
fn get_ports(&self) -> &[ConnectedPort] {
|
||
&[]
|
||
}
|
||
|
||
fn flow_paths(&self) -> Vec<(usize, usize)> {
|
||
// Single-stream pass-through so the edge pair shares one ṁ branch.
|
||
vec![(0, 1)]
|
||
}
|
||
}
|
||
|
||
/// Two-node cycle with matching residual count → square after CM1.4.
|
||
///
|
||
/// Unknowns: 1 ṁ + 2 edges × (P,h) = 5
|
||
/// Equations: 3 + 2 = 5
|
||
#[test]
|
||
fn two_node_cycle_is_balanced() {
|
||
let mut sys = System::new();
|
||
let a = sys.add_component(Box::new(MockEq { n: 3 }));
|
||
let b = sys.add_component(Box::new(MockEq { n: 2 }));
|
||
sys.add_edge(a, b).unwrap();
|
||
sys.add_edge(b, a).unwrap();
|
||
sys.finalize().expect("balanced system must finalize");
|
||
|
||
let report = sys.dof_report();
|
||
assert_eq!(report.n_unknowns, 5);
|
||
assert_eq!(report.n_equations, 5);
|
||
assert_eq!(report.balance, SystemDofBalance::Balanced);
|
||
assert!(sys.validate_system_dof().is_ok());
|
||
}
|
||
|
||
/// One extra residual without a free unknown → over-constrained, finalize fails.
|
||
#[test]
|
||
fn overconstrained_finalize_fails() {
|
||
let mut sys = System::new();
|
||
let a = sys.add_component(Box::new(MockEq { n: 4 })); // +1 excess
|
||
let b = sys.add_component(Box::new(MockEq { n: 2 }));
|
||
sys.add_edge(a, b).unwrap();
|
||
sys.add_edge(b, a).unwrap();
|
||
// unknowns = 5, equations = 6
|
||
let err = sys.finalize().expect_err("must reject over-constrained system");
|
||
match err {
|
||
TopologyError::DofImbalance { message } => {
|
||
assert!(
|
||
message.contains("over-constrained") || message.contains("equations"),
|
||
"unexpected message: {message}"
|
||
);
|
||
}
|
||
other => panic!("expected DofImbalance, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// Missing residual → under-constrained: finalize warns but allows topology tests;
|
||
/// hard `validate_system_dof` still rejects (production path).
|
||
#[test]
|
||
fn underconstrained_detected_by_validate_system_dof() {
|
||
let mut sys = System::new();
|
||
let a = sys.add_component(Box::new(MockEq { n: 2 }));
|
||
let b = sys.add_component(Box::new(MockEq { n: 2 }));
|
||
sys.add_edge(a, b).unwrap();
|
||
sys.add_edge(b, a).unwrap();
|
||
// unknowns = 5, equations = 4
|
||
sys.finalize()
|
||
.expect("under-constrained allowed at finalize for topology mocks");
|
||
let report = sys.dof_report();
|
||
assert!(matches!(
|
||
report.balance,
|
||
SystemDofBalance::UnderConstrained { free_dofs: 1 }
|
||
));
|
||
assert!(sys.validate_system_dof().is_err());
|
||
}
|