feat(components): add ThermoState generators and Eurovent backend demo

This commit is contained in:
Sepehr
2026-02-20 22:01:38 +01:00
parent 375d288950
commit 4a40fddfe3
271 changed files with 28614 additions and 447 deletions

View File

@@ -0,0 +1,672 @@
//! Integration tests for Story 4.4: Intelligent Fallback Strategy
//!
//! Tests the FallbackSolver behavior:
//! - Newton diverges → Picard converges
//! - Newton diverges → Picard stabilizes → Newton returns
//! - Oscillation prevention (max switches reached)
//! - Fallback disabled (pure Newton behavior)
//! - Timeout applies across switches
//! - No heap allocation during switches
use entropyk_components::{
Component, ComponentError, JacobianBuilder, ResidualVector, SystemState,
};
use entropyk_solver::solver::{
ConvergenceStatus, FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig, Solver,
SolverError, SolverStrategy,
};
use entropyk_solver::system::System;
use std::time::Duration;
// ─────────────────────────────────────────────────────────────────────────────
// Mock Components for Testing
// ─────────────────────────────────────────────────────────────────────────────
/// A simple linear system: r = A * x - b
/// Converges in one Newton step, but can be made to diverge.
struct LinearSystem {
/// System matrix (n x n)
a: Vec<Vec<f64>>,
/// Right-hand side
b: Vec<f64>,
/// Number of equations
n: usize,
}
impl LinearSystem {
fn new(a: Vec<Vec<f64>>, b: Vec<f64>) -> Self {
let n = b.len();
Self { a, b, n }
}
/// Creates a well-conditioned 2x2 system that converges easily.
fn well_conditioned() -> Self {
// A = [[2, 1], [1, 2]], b = [3, 3]
// Solution: x = [1, 1]
Self::new(vec![vec![2.0, 1.0], vec![1.0, 2.0]], vec![3.0, 3.0])
}
}
impl Component for LinearSystem {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// r = A * x - b
for i in 0..self.n {
let mut ax_i = 0.0;
for j in 0..self.n {
ax_i += self.a[i][j] * state[j];
}
residuals[i] = ax_i - self.b[i];
}
Ok(())
}
fn jacobian_entries(
&self,
_state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// J = A (constant Jacobian)
for i in 0..self.n {
for j in 0..self.n {
jacobian.add_entry(i, j, self.a[i][j]);
}
}
Ok(())
}
fn n_equations(&self) -> usize {
self.n
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
/// A non-linear system that causes Newton to diverge but Picard to converge.
/// Uses a highly non-linear residual function.
struct StiffNonlinearSystem {
/// Non-linearity factor (higher = more stiff)
alpha: f64,
/// Number of equations
n: usize,
}
impl StiffNonlinearSystem {
fn new(alpha: f64, n: usize) -> Self {
Self { alpha, n }
}
}
impl Component for StiffNonlinearSystem {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// Non-linear residual: r_i = x_i^3 - alpha * x_i - 1
// This creates a cubic equation that can have multiple roots
for i in 0..self.n {
let x = state[i];
residuals[i] = x * x * x - self.alpha * x - 1.0;
}
Ok(())
}
fn jacobian_entries(
&self,
state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
// J_ii = 3 * x_i^2 - alpha
for i in 0..self.n {
let x = state[i];
jacobian.add_entry(i, i, 3.0 * x * x - self.alpha);
}
Ok(())
}
fn n_equations(&self) -> usize {
self.n
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
/// A system that converges slowly with Picard but diverges with Newton
/// from certain initial conditions.
struct SlowConvergingSystem {
/// Convergence rate (0 < rate < 1)
rate: f64,
/// Target value
target: f64,
}
impl SlowConvergingSystem {
fn new(rate: f64, target: f64) -> Self {
Self { rate, target }
}
}
impl Component for SlowConvergingSystem {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// r = x - target (simple, but Newton can overshoot)
residuals[0] = state[0] - self.target;
Ok(())
}
fn jacobian_entries(
&self,
_state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
jacobian.add_entry(0, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize {
1
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Helper Functions
// ─────────────────────────────────────────────────────────────────────────────
/// Creates a minimal system with a single component for testing.
fn create_test_system(component: Box<dyn Component>) -> System {
let mut system = System::new();
let n0 = system.add_component(component);
// Add a self-loop edge to satisfy topology requirements
system.add_edge(n0, n0).unwrap();
system.finalize().unwrap();
system
}
// ─────────────────────────────────────────────────────────────────────────────
// Integration Tests
// ─────────────────────────────────────────────────────────────────────────────
/// Test that FallbackSolver converges on a well-conditioned linear system.
#[test]
fn test_fallback_solver_converges_linear_system() {
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
let mut solver = FallbackSolver::default_solver();
let result = solver.solve(&mut system);
assert!(result.is_ok(), "Should converge on well-conditioned system");
let converged = result.unwrap();
assert!(converged.is_converged());
assert!(converged.final_residual < 1e-6);
}
/// Test that FallbackSolver with fallback disabled behaves like pure Newton.
#[test]
fn test_fallback_disabled_pure_newton() {
let config = FallbackConfig {
fallback_enabled: false,
..Default::default()
};
let mut solver = FallbackSolver::new(config);
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
let result = solver.solve(&mut system);
assert!(
result.is_ok(),
"Should converge with Newton on well-conditioned system"
);
}
/// Test that FallbackSolver handles empty system correctly.
#[test]
fn test_fallback_solver_empty_system() {
let mut system = System::new();
system.finalize().unwrap();
let mut solver = FallbackSolver::default_solver();
let result = solver.solve(&mut system);
assert!(result.is_err());
match result {
Err(SolverError::InvalidSystem { ref message }) => {
assert!(message.contains("Empty") || message.contains("no state"));
}
other => panic!("Expected InvalidSystem, got {:?}", other),
}
}
/// Test timeout enforcement across solver switches.
#[test]
fn test_fallback_solver_timeout() {
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
// Very short timeout that should trigger
let timeout = Duration::from_micros(1);
let mut solver = FallbackSolver::default_solver()
.with_timeout(timeout)
.with_newton_config(NewtonConfig {
max_iterations: 10000,
..Default::default()
});
// The system should either converge very quickly or timeout
// Given the simple linear system, it will likely converge before timeout
let result = solver.solve(&mut system);
// Either convergence or timeout is acceptable
match result {
Ok(_) => {} // Converged before timeout
Err(SolverError::Timeout { .. }) => {} // Timed out as expected
Err(other) => panic!("Unexpected error: {:?}", other),
}
}
/// Test that FallbackSolver can be used as a trait object.
#[test]
fn test_fallback_solver_as_trait_object() {
let mut boxed: Box<dyn Solver> = Box::new(FallbackSolver::default_solver());
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
let result = boxed.solve(&mut system);
assert!(result.is_ok());
}
/// Test FallbackConfig customization.
#[test]
fn test_fallback_config_customization() {
let config = FallbackConfig {
fallback_enabled: true,
return_to_newton_threshold: 5e-4,
max_fallback_switches: 3,
};
let solver = FallbackSolver::new(config.clone());
assert_eq!(solver.config, config);
assert_eq!(solver.config.return_to_newton_threshold, 5e-4);
assert_eq!(solver.config.max_fallback_switches, 3);
}
/// Test that FallbackSolver with custom Newton config uses that config.
#[test]
fn test_fallback_solver_custom_newton_config() {
let newton_config = NewtonConfig {
max_iterations: 50,
tolerance: 1e-8,
..Default::default()
};
let solver = FallbackSolver::default_solver().with_newton_config(newton_config.clone());
assert_eq!(solver.newton_config.max_iterations, 50);
assert!((solver.newton_config.tolerance - 1e-8).abs() < 1e-15);
}
/// Test that FallbackSolver with custom Picard config uses that config.
#[test]
fn test_fallback_solver_custom_picard_config() {
let picard_config = PicardConfig {
relaxation_factor: 0.3,
max_iterations: 200,
..Default::default()
};
let solver = FallbackSolver::default_solver().with_picard_config(picard_config.clone());
assert!((solver.picard_config.relaxation_factor - 0.3).abs() < 1e-15);
assert_eq!(solver.picard_config.max_iterations, 200);
}
/// Test that max_fallback_switches = 0 prevents any switching.
#[test]
fn test_fallback_zero_switches() {
let config = FallbackConfig {
fallback_enabled: true,
max_fallback_switches: 0,
..Default::default()
};
let solver = FallbackSolver::new(config);
// With 0 switches, Newton should be the only solver used
assert_eq!(solver.config.max_fallback_switches, 0);
}
/// Test that FallbackSolver converges on a simple system with both solvers.
#[test]
fn test_fallback_both_solvers_can_converge() {
// Create a system that both Newton and Picard can solve
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
// Test with Newton directly
let mut newton = NewtonConfig::default();
let newton_result = newton.solve(&mut system);
assert!(newton_result.is_ok(), "Newton should converge");
// Reset system
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
// Test with Picard directly
let mut picard = PicardConfig::default();
let picard_result = picard.solve(&mut system);
assert!(picard_result.is_ok(), "Picard should converge");
// Reset system
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
// Test with FallbackSolver
let mut fallback = FallbackSolver::default_solver();
let fallback_result = fallback.solve(&mut system);
assert!(fallback_result.is_ok(), "FallbackSolver should converge");
}
/// Test return_to_newton_threshold configuration.
#[test]
fn test_return_to_newton_threshold() {
let config = FallbackConfig {
return_to_newton_threshold: 1e-2, // Higher threshold
..Default::default()
};
let solver = FallbackSolver::new(config);
// Higher threshold means Newton return happens earlier
assert!((solver.config.return_to_newton_threshold - 1e-2).abs() < 1e-15);
}
/// Test that FallbackSolver handles a stiff non-linear system with graceful degradation.
#[test]
fn test_fallback_stiff_nonlinear() {
// Create a stiff non-linear system that challenges both solvers
let mut system = create_test_system(Box::new(StiffNonlinearSystem::new(10.0, 2)));
let mut solver = FallbackSolver::default_solver()
.with_newton_config(NewtonConfig {
max_iterations: 50,
tolerance: 1e-6,
..Default::default()
})
.with_picard_config(PicardConfig {
relaxation_factor: 0.3,
max_iterations: 200,
tolerance: 1e-6,
..Default::default()
});
let result = solver.solve(&mut system);
// Verify expected behavior:
// 1. Should converge (fallback strategy succeeds)
// 2. Or should fail with NonConvergence (didn't converge within iterations)
// 3. Or should fail with Divergence (solver diverged)
// Should NEVER panic or infinite loop
match result {
Ok(converged) => {
// SUCCESS CASE: Fallback strategy worked
// Verify convergence is actually valid
assert!(
converged.final_residual < 1.0,
"Converged residual {} should be reasonable (< 1.0)",
converged.final_residual
);
if converged.is_converged() {
assert!(
converged.final_residual < 1e-6,
"Converged state should have residual below tolerance"
);
}
}
Err(SolverError::NonConvergence {
iterations,
final_residual,
}) => {
// EXPECTED FAILURE: Hit iteration limit without converging
// Verify we actually tried to solve (not an immediate failure)
assert!(
iterations > 0,
"NonConvergence should occur after some iterations, not immediately"
);
// Verify residual is finite (didn't explode)
assert!(
final_residual.is_finite(),
"Non-converged residual should be finite, got {}",
final_residual
);
}
Err(SolverError::Divergence { reason }) => {
// EXPECTED FAILURE: Solver detected divergence
// Verify we have a meaningful reason
assert!(!reason.is_empty(), "Divergence error should have a reason");
assert!(
reason.contains("diverg")
|| reason.contains("exceed")
|| reason.contains("increas"),
"Divergence reason should explain what happened: {}",
reason
);
}
Err(other) => {
// UNEXPECTED: Any other error type is a problem
panic!("Unexpected error type for stiff system: {:?}", other);
}
}
}
/// Test that timeout is enforced across solver switches.
#[test]
fn test_timeout_across_switches() {
let mut system = create_test_system(Box::new(StiffNonlinearSystem::new(5.0, 2)));
// Very short timeout
let timeout = Duration::from_millis(10);
let mut solver = FallbackSolver::default_solver()
.with_timeout(timeout)
.with_newton_config(NewtonConfig {
max_iterations: 1000,
..Default::default()
})
.with_picard_config(PicardConfig {
max_iterations: 1000,
..Default::default()
});
let result = solver.solve(&mut system);
// Should either converge quickly or timeout
match result {
Ok(_) => {} // Converged
Err(SolverError::Timeout { .. }) => {} // Timed out
Err(SolverError::NonConvergence { .. }) => {} // Didn't converge in time
Err(SolverError::Divergence { .. }) => {} // Diverged
Err(other) => panic!("Unexpected error: {:?}", other),
}
}
/// Test that max_fallback_switches config value is respected.
#[test]
fn test_max_fallback_switches_config() {
let config = FallbackConfig {
fallback_enabled: true,
max_fallback_switches: 1, // Only one switch allowed
..Default::default()
};
let solver = FallbackSolver::new(config);
// With max 1 switch, oscillation is prevented
assert_eq!(solver.config.max_fallback_switches, 1);
}
/// Test oscillation prevention - Newton diverges, switches to Picard, stays on Picard.
#[test]
fn test_oscillation_prevention_newton_to_picard_stays() {
use entropyk_solver::solver::{
FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig, Solver,
};
// Create a system where Newton diverges but Picard converges
// Use StiffNonlinearSystem with high alpha to cause Newton divergence
let mut system = create_test_system(Box::new(StiffNonlinearSystem::new(100.0, 2)));
// Configure with max 1 switch - Newton diverges → Picard, should stay on Picard
let config = FallbackConfig {
fallback_enabled: true,
max_fallback_switches: 1,
return_to_newton_threshold: 1e-6, // Very low threshold so Newton return won't trigger easily
..Default::default()
};
let mut solver = FallbackSolver::new(config)
.with_newton_config(NewtonConfig {
max_iterations: 20,
tolerance: 1e-6,
..Default::default()
})
.with_picard_config(PicardConfig {
relaxation_factor: 0.2,
max_iterations: 500,
..Default::default()
});
// Should either converge (Picard succeeds) or non-converge (but NOT oscillate)
let result = solver.solve(&mut system);
match result {
Ok(converged) => {
// Success - Picard converged after Newton divergence
assert!(converged.is_converged() || converged.final_residual < 1.0);
}
Err(SolverError::NonConvergence { .. }) => {
// Acceptable - didn't converge, but shouldn't have oscillated
}
Err(SolverError::Divergence { .. }) => {
// Picard diverged - acceptable for stiff system
}
Err(other) => panic!("Unexpected error type: {:?}", other),
}
}
/// Test that Newton re-divergence causes permanent commit to Picard.
#[test]
fn test_newton_redivergence_commits_to_picard() {
// Create a system that's borderline - Newton might diverge, Picard converges slowly
let mut system = create_test_system(Box::new(StiffNonlinearSystem::new(50.0, 2)));
let config = FallbackConfig {
fallback_enabled: true,
max_fallback_switches: 3, // Allow multiple switches to test re-divergence
return_to_newton_threshold: 1e-2, // Relatively high threshold for return
..Default::default()
};
let mut solver = FallbackSolver::new(config)
.with_newton_config(NewtonConfig {
max_iterations: 30,
tolerance: 1e-8,
..Default::default()
})
.with_picard_config(PicardConfig {
relaxation_factor: 0.25,
max_iterations: 300,
..Default::default()
});
let result = solver.solve(&mut system);
// Should complete without infinite oscillation
match result {
Ok(converged) => {
assert!(converged.final_residual < 1.0 || converged.is_converged());
}
Err(SolverError::NonConvergence {
iterations,
final_residual,
}) => {
// Verify we didn't iterate forever (oscillation would cause excessive iterations)
assert!(
iterations < 1000,
"Too many iterations - possible oscillation"
);
assert!(final_residual < 1e10, "Residual diverged excessively");
}
Err(SolverError::Divergence { .. }) => {
// Acceptable - system is stiff
}
Err(other) => panic!("Unexpected error: {:?}", other),
}
}
/// Test that FallbackSolver works with SolverStrategy pattern.
#[test]
fn test_fallback_solver_integration() {
// Verify FallbackSolver can be used alongside other solvers
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
// Test with SolverStrategy::NewtonRaphson
let mut strategy = SolverStrategy::default();
let result1 = strategy.solve(&mut system);
assert!(result1.is_ok());
// Reset and test with FallbackSolver
let mut system = create_test_system(Box::new(LinearSystem::well_conditioned()));
let mut fallback = FallbackSolver::default_solver();
let result2 = fallback.solve(&mut system);
assert!(result2.is_ok());
// Both should converge to similar residuals
let r1 = result1.unwrap();
let r2 = result2.unwrap();
assert!((r1.final_residual - r2.final_residual).abs() < 1e-6);
}
/// Test that FallbackSolver handles convergence at initial state.
#[test]
fn test_fallback_already_converged() {
// Create a system that's already at solution
struct ZeroResidualComponent;
impl Component for ZeroResidualComponent {
fn compute_residuals(
&self,
_state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
residuals[0] = 0.0; // Already zero
Ok(())
}
fn jacobian_entries(
&self,
_state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
jacobian.add_entry(0, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize {
1
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
let mut system = create_test_system(Box::new(ZeroResidualComponent));
let mut solver = FallbackSolver::default_solver();
let result = solver.solve(&mut system);
assert!(result.is_ok());
let converged = result.unwrap();
assert_eq!(converged.iterations, 0); // Should converge immediately
assert!(converged.is_converged());
}

View File

@@ -0,0 +1,239 @@
//! Integration tests for multi-circuit machine definition (Story 3.3, FR9).
//!
//! Verifies multi-circuit heat pump topology (refrigerant + water) without thermal coupling.
//! Tests circuits from 2 up to the maximum of 5 circuits (circuit IDs 0-4).
use entropyk_components::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
};
use entropyk_solver::{CircuitId, System, ThermalCoupling, TopologyError};
use entropyk_core::ThermalConductance;
/// Mock refrigerant component (e.g. compressor, condenser refrigerant side).
struct RefrigerantMock {
n_equations: usize,
}
impl Component for RefrigerantMock {
fn compute_residuals(
&self,
_state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
for r in residuals.iter_mut().take(self.n_equations) {
*r = 0.0;
}
Ok(())
}
fn jacobian_entries(
&self,
_state: &SystemState,
_jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
Ok(())
}
fn n_equations(&self) -> usize {
self.n_equations
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
}
#[test]
fn test_two_circuit_heat_pump_topology() {
let mut sys = System::new();
// Circuit 0: refrigerant (compressor -> condenser -> valve -> evaporator)
let comp = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 2 }),
CircuitId::ZERO,
)
.unwrap();
let cond = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 2 }),
CircuitId::ZERO,
)
.unwrap();
let valve = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 2 }),
CircuitId::ZERO,
)
.unwrap();
let evap = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 2 }),
CircuitId::ZERO,
)
.unwrap();
sys.add_edge(comp, cond).unwrap();
sys.add_edge(cond, valve).unwrap();
sys.add_edge(valve, evap).unwrap();
sys.add_edge(evap, comp).unwrap();
// Circuit 1: water (pump -> condenser water side -> evaporator water side)
let pump = sys
.add_component_to_circuit(Box::new(RefrigerantMock { n_equations: 1 }), CircuitId(1))
.unwrap();
let cond_w = sys
.add_component_to_circuit(Box::new(RefrigerantMock { n_equations: 1 }), CircuitId(1))
.unwrap();
let evap_w = sys
.add_component_to_circuit(Box::new(RefrigerantMock { n_equations: 1 }), CircuitId(1))
.unwrap();
sys.add_edge(pump, cond_w).unwrap();
sys.add_edge(cond_w, evap_w).unwrap();
sys.add_edge(evap_w, pump).unwrap();
assert_eq!(sys.circuit_count(), 2);
assert_eq!(sys.circuit_nodes(CircuitId::ZERO).count(), 4);
assert_eq!(sys.circuit_nodes(CircuitId(1)).count(), 3);
assert_eq!(sys.circuit_edges(CircuitId::ZERO).count(), 4);
assert_eq!(sys.circuit_edges(CircuitId(1)).count(), 3);
let result = sys.finalize();
assert!(
result.is_ok(),
"finalize should succeed: {:?}",
result.err()
);
}
#[test]
fn test_cross_circuit_rejected_integration() {
let mut sys = System::new();
let n0 = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 0 }),
CircuitId::ZERO,
)
.unwrap();
let n1 = sys
.add_component_to_circuit(Box::new(RefrigerantMock { n_equations: 0 }), CircuitId(1))
.unwrap();
let result = sys.add_edge(n0, n1);
assert!(result.is_err());
assert!(matches!(
result,
Err(TopologyError::CrossCircuitConnection { .. })
));
}
#[test]
fn test_maximum_five_circuits_integration() {
// Integration test: Verify maximum of 5 circuits (IDs 0-4) is supported
let mut sys = System::new();
// Create 5 separate circuits, each with 2 nodes forming a cycle
for circuit_id in 0..=4 {
let n0 = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 1 }),
CircuitId(circuit_id),
)
.unwrap();
let n1 = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 1 }),
CircuitId(circuit_id),
)
.unwrap();
sys.add_edge(n0, n1).unwrap();
sys.add_edge(n1, n0).unwrap();
}
assert_eq!(sys.circuit_count(), 5, "should have exactly 5 circuits");
// Verify each circuit has its own nodes and edges
for circuit_id in 0..=4 {
assert_eq!(
sys.circuit_nodes(CircuitId(circuit_id)).count(),
2,
"circuit {} should have 2 nodes",
circuit_id
);
assert_eq!(
sys.circuit_edges(CircuitId(circuit_id)).count(),
2,
"circuit {} should have 2 edges",
circuit_id
);
}
// Verify 6th circuit is rejected
let result =
sys.add_component_to_circuit(Box::new(RefrigerantMock { n_equations: 1 }), CircuitId(5));
assert!(
result.is_err(),
"circuit 5 should be rejected (exceeds max of 4)"
);
assert!(matches!(
result,
Err(TopologyError::TooManyCircuits { requested: 5 })
));
// Verify system can still be finalized with 5 circuits
sys.finalize().unwrap();
}
#[test]
fn test_coupling_residuals_basic() {
// Two circuits with one thermal coupling; verify coupling_residual_count and coupling_residuals.
let mut sys = System::new();
let n0 = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 1 }),
CircuitId::ZERO,
)
.unwrap();
let n1 = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 1 }),
CircuitId::ZERO,
)
.unwrap();
sys.add_edge(n0, n1).unwrap();
sys.add_edge(n1, n0).unwrap();
let n2 = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 1 }),
CircuitId(1),
)
.unwrap();
let n3 = sys
.add_component_to_circuit(
Box::new(RefrigerantMock { n_equations: 1 }),
CircuitId(1),
)
.unwrap();
sys.add_edge(n2, n3).unwrap();
sys.add_edge(n3, n2).unwrap();
let coupling = ThermalCoupling::new(
CircuitId::ZERO,
CircuitId(1),
ThermalConductance::from_watts_per_kelvin(1000.0),
);
sys.add_thermal_coupling(coupling).unwrap();
sys.finalize().unwrap();
assert_eq!(sys.coupling_residual_count(), 1);
let temperatures = [(350.0_f64, 300.0_f64)]; // T_hot, T_cold in K
let mut out = [0.0_f64; 4];
sys.coupling_residuals(&temperatures, &mut out);
// Q = UA * (T_hot - T_cold) = 1000 * 50 = 50000 W into cold circuit
assert!(out[0] > 0.0);
assert!((out[0] - 50000.0).abs() < 1.0);
}

View File

@@ -0,0 +1,480 @@
//! Comprehensive integration tests for Newton-Raphson solver (Story 4.2).
//!
//! Tests cover all Acceptance Criteria:
//! - AC #1: Quadratic convergence near solution
//! - AC #2: Line search prevents overshooting
//! - AC #3: Analytical and numerical Jacobian support
//! - AC #4: Timeout enforcement
//! - AC #5: Divergence detection
//! - AC #6: Pre-allocated buffers
use entropyk_solver::{ConvergenceStatus, JacobianMatrix, NewtonConfig, Solver, SolverError, System};
use approx::assert_relative_eq;
use std::time::Duration;
// ─────────────────────────────────────────────────────────────────────────────
// AC #1: Quadratic Convergence Near Solution
// ─────────────────────────────────────────────────────────────────────────────
/// Test that Newton-Raphson exhibits quadratic convergence on a simple system.
///
/// For a well-conditioned system near the solution, the residual norm should
/// decrease quadratically (roughly square each iteration).
#[test]
fn test_quadratic_convergence_simple_system() {
// We'll test the Jacobian solve directly since we need a mock system
// For J = [[2, 0], [0, 3]] and r = [2, 3], solution is x = [-1, -1]
let entries = vec![(0, 0, 2.0), (1, 1, 3.0)];
let jacobian = JacobianMatrix::from_builder(&entries, 2, 2);
let residuals = vec![2.0, 3.0];
let delta = jacobian.solve(&residuals).expect("non-singular");
// J·Δx = -r => Δx = -J^{-1}·r
assert_relative_eq!(delta[0], -1.0, epsilon = 1e-10);
assert_relative_eq!(delta[1], -1.0, epsilon = 1e-10);
}
/// Test convergence on a 2x2 linear system.
#[test]
fn test_solve_2x2_linear_system() {
// J = [[4, 1], [1, 3]], r = [1, 2]
// Solution: Δx = -J^{-1}·r
let entries = vec![(0, 0, 4.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 3.0)];
let jacobian = JacobianMatrix::from_builder(&entries, 2, 2);
let residuals = vec![1.0, 2.0];
let delta = jacobian.solve(&residuals).expect("non-singular");
// Verify: J·Δx = -r
let j00 = 4.0;
let j01 = 1.0;
let j10 = 1.0;
let j11 = 3.0;
let computed_r0 = j00 * delta[0] + j01 * delta[1];
let computed_r1 = j10 * delta[0] + j11 * delta[1];
assert_relative_eq!(computed_r0, -1.0, epsilon = 1e-10);
assert_relative_eq!(computed_r1, -2.0, epsilon = 1e-10);
}
/// Test that a diagonal system converges in one Newton iteration.
#[test]
fn test_diagonal_system_one_iteration() {
// For a diagonal Jacobian, Newton should converge in 1 iteration
// J = [[a, 0], [0, b]], r = [c, d]
// Δx = [-c/a, -d/b]
let entries = vec![(0, 0, 5.0), (1, 1, 7.0)];
let jacobian = JacobianMatrix::from_builder(&entries, 2, 2);
let residuals = vec![10.0, 21.0];
let delta = jacobian.solve(&residuals).expect("non-singular");
assert_relative_eq!(delta[0], -2.0, epsilon = 1e-10);
assert_relative_eq!(delta[1], -3.0, epsilon = 1e-10);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #2: Line Search Prevents Overshooting
// ─────────────────────────────────────────────────────────────────────────────
/// Test that line search is configured correctly.
#[test]
fn test_line_search_configuration() {
let cfg = NewtonConfig {
line_search: true,
line_search_armijo_c: 1e-4,
line_search_max_backtracks: 20,
..Default::default()
};
assert!(cfg.line_search);
assert_relative_eq!(cfg.line_search_armijo_c, 1e-4);
assert_eq!(cfg.line_search_max_backtracks, 20);
}
/// Test that line search can be disabled.
#[test]
fn test_line_search_disabled_by_default() {
let cfg = NewtonConfig::default();
assert!(!cfg.line_search);
}
/// Test Armijo condition constants are sensible.
#[test]
fn test_armijo_constant_range() {
let cfg = NewtonConfig::default();
// Armijo constant should be in (0, 0.5) for typical line search
assert!(cfg.line_search_armijo_c > 0.0);
assert!(cfg.line_search_armijo_c < 0.5);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #3: Analytical and Numerical Jacobian Support
// ─────────────────────────────────────────────────────────────────────────────
/// Test that numerical Jacobian can be enabled.
#[test]
fn test_numerical_jacobian_configuration() {
let cfg = NewtonConfig {
use_numerical_jacobian: true,
..Default::default()
};
assert!(cfg.use_numerical_jacobian);
}
/// Test that analytical Jacobian is the default.
#[test]
fn test_analytical_jacobian_default() {
let cfg = NewtonConfig::default();
assert!(!cfg.use_numerical_jacobian);
}
/// Test numerical Jacobian computation matches analytical for linear function.
#[test]
fn test_numerical_jacobian_linear_function() {
// r[0] = 2*x0 + 3*x1
// r[1] = x0 - 2*x1
// J = [[2, 3], [1, -2]]
let state = vec![1.0, 2.0];
let residuals = vec![2.0 * state[0] + 3.0 * state[1], state[0] - 2.0 * state[1]];
let compute_residuals = |s: &[f64], r: &mut [f64]| {
r[0] = 2.0 * s[0] + 3.0 * s[1];
r[1] = s[0] - 2.0 * s[1];
Ok(())
};
let j_num = JacobianMatrix::numerical(compute_residuals, &state, &residuals, 1e-8).unwrap();
// Check against analytical Jacobian
assert_relative_eq!(j_num.get(0, 0).unwrap(), 2.0, epsilon = 1e-5);
assert_relative_eq!(j_num.get(0, 1).unwrap(), 3.0, epsilon = 1e-5);
assert_relative_eq!(j_num.get(1, 0).unwrap(), 1.0, epsilon = 1e-5);
assert_relative_eq!(j_num.get(1, 1).unwrap(), -2.0, epsilon = 1e-5);
}
/// Test numerical Jacobian for non-linear function.
#[test]
fn test_numerical_jacobian_nonlinear_function() {
// r[0] = x0^2 + x1
// r[1] = sin(x0) + cos(x1)
// J = [[2*x0, 1], [cos(x0), -sin(x1)]]
let state = vec![0.5_f64, 1.0_f64];
let residuals = vec![state[0].powi(2) + state[1], state[0].sin() + state[1].cos()];
let compute_residuals = |s: &[f64], r: &mut [f64]| {
r[0] = s[0].powi(2) + s[1];
r[1] = s[0].sin() + s[1].cos();
Ok(())
};
let j_num = JacobianMatrix::numerical(compute_residuals, &state, &residuals, 1e-8).unwrap();
// Analytical values
let j00 = 2.0 * state[0]; // 1.0
let j01 = 1.0;
let j10 = state[0].cos();
let j11 = -state[1].sin();
assert_relative_eq!(j_num.get(0, 0).unwrap(), j00, epsilon = 1e-5);
assert_relative_eq!(j_num.get(0, 1).unwrap(), j01, epsilon = 1e-5);
assert_relative_eq!(j_num.get(1, 0).unwrap(), j10, epsilon = 1e-5);
assert_relative_eq!(j_num.get(1, 1).unwrap(), j11, epsilon = 1e-5);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #4: Timeout Enforcement
// ─────────────────────────────────────────────────────────────────────────────
/// Test timeout configuration.
#[test]
fn test_timeout_configuration() {
let timeout = Duration::from_millis(500);
let cfg = NewtonConfig::default().with_timeout(timeout);
assert_eq!(cfg.timeout, Some(timeout));
}
/// Test timeout is None by default.
#[test]
fn test_no_timeout_by_default() {
let cfg = NewtonConfig::default();
assert!(cfg.timeout.is_none());
}
/// Test timeout error contains correct duration.
#[test]
fn test_timeout_error_contains_duration() {
let err = SolverError::Timeout { timeout_ms: 1234 };
let msg = err.to_string();
assert!(msg.contains("1234"));
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #5: Divergence Detection
// ─────────────────────────────────────────────────────────────────────────────
/// Test divergence threshold configuration.
#[test]
fn test_divergence_threshold_configuration() {
let cfg = NewtonConfig {
divergence_threshold: 1e8,
..Default::default()
};
assert_relative_eq!(cfg.divergence_threshold, 1e8);
}
/// Test default divergence threshold.
#[test]
fn test_default_divergence_threshold() {
let cfg = NewtonConfig::default();
assert_relative_eq!(cfg.divergence_threshold, 1e10);
}
/// Test divergence error contains reason.
#[test]
fn test_divergence_error_contains_reason() {
let err = SolverError::Divergence {
reason: "Residual increased for 3 consecutive iterations".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("Residual increased"));
assert!(msg.contains("3 consecutive"));
}
/// Test divergence error for threshold exceeded.
#[test]
fn test_divergence_error_threshold_exceeded() {
let err = SolverError::Divergence {
reason: "Residual norm 1e12 exceeds threshold 1e10".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("exceeds threshold"));
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #6: Pre-Allocated Buffers
// ─────────────────────────────────────────────────────────────────────────────
/// Test that solver handles empty system gracefully (pre-allocated buffers work).
#[test]
fn test_preallocated_buffers_empty_system() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = NewtonConfig::default();
let result = solver.solve(&mut sys);
// Should return error without panic
assert!(result.is_err());
}
/// Test that solver handles configuration variations without panic.
#[test]
fn test_preallocated_buffers_all_configs() {
let mut sys = System::new();
sys.finalize().unwrap();
// Test with all features enabled
let mut solver = NewtonConfig {
max_iterations: 50,
tolerance: 1e-8,
line_search: true,
timeout: Some(Duration::from_millis(100)),
use_numerical_jacobian: true,
line_search_armijo_c: 1e-3,
line_search_max_backtracks: 10,
divergence_threshold: 1e8,
..Default::default()
};
let result = solver.solve(&mut sys);
assert!(result.is_err()); // Empty system, but no panic
}
// ─────────────────────────────────────────────────────────────────────────────
// Jacobian Matrix Tests
// ─────────────────────────────────────────────────────────────────────────────
/// Test singular Jacobian returns None.
#[test]
fn test_singular_jacobian_returns_none() {
// Singular matrix: [[1, 1], [1, 1]]
let entries = vec![(0, 0, 1.0), (0, 1, 1.0), (1, 0, 1.0), (1, 1, 1.0)];
let jacobian = JacobianMatrix::from_builder(&entries, 2, 2);
let residuals = vec![1.0, 2.0];
let result = jacobian.solve(&residuals);
assert!(result.is_none(), "Singular matrix should return None");
}
/// Test zero Jacobian returns None.
#[test]
fn test_zero_jacobian_returns_none() {
let jacobian = JacobianMatrix::zeros(2, 2);
let residuals = vec![1.0, 2.0];
let result = jacobian.solve(&residuals);
assert!(result.is_none(), "Zero matrix should return None");
}
/// Test Jacobian condition number for well-conditioned matrix.
#[test]
fn test_jacobian_condition_number_well_conditioned() {
let entries = vec![(0, 0, 1.0), (1, 1, 1.0)];
let jacobian = JacobianMatrix::from_builder(&entries, 2, 2);
let cond = jacobian.condition_number().unwrap();
assert_relative_eq!(cond, 1.0, epsilon = 1e-10);
}
/// Test Jacobian condition number for ill-conditioned matrix.
#[test]
fn test_jacobian_condition_number_ill_conditioned() {
// Nearly singular matrix
let entries = vec![
(0, 0, 1.0),
(0, 1, 1.0),
(1, 0, 1.0),
(1, 1, 1.0 + 1e-12),
];
let jacobian = JacobianMatrix::from_builder(&entries, 2, 2);
let cond = jacobian.condition_number();
assert!(cond.unwrap() > 1e10, "Should be ill-conditioned");
}
/// Test Jacobian for non-square (overdetermined) system uses least-squares.
#[test]
fn test_jacobian_non_square_overdetermined() {
// 3 equations, 2 unknowns (overdetermined)
let entries = vec![
(0, 0, 1.0),
(0, 1, 1.0),
(1, 0, 1.0),
(1, 1, 2.0),
(2, 0, 1.0),
(2, 1, 3.0),
];
let jacobian = JacobianMatrix::from_builder(&entries, 3, 2);
let residuals = vec![1.0, 2.0, 3.0];
let result = jacobian.solve(&residuals);
// Should return a least-squares solution
assert!(result.is_some(), "Non-square system should return least-squares solution");
}
// ─────────────────────────────────────────────────────────────────────────────
// ConvergenceStatus Tests
// ─────────────────────────────────────────────────────────────────────────────
/// Test ConvergenceStatus::Converged.
#[test]
fn test_convergence_status_converged() {
use entropyk_solver::ConvergedState;
let state = ConvergedState::new(
vec![1.0, 2.0],
10,
1e-8,
ConvergenceStatus::Converged,
);
assert!(state.is_converged());
assert_eq!(state.status, ConvergenceStatus::Converged);
}
/// Test ConvergenceStatus::TimedOutWithBestState.
#[test]
fn test_convergence_status_timed_out() {
use entropyk_solver::ConvergedState;
let state = ConvergedState::new(
vec![1.0],
50,
1e-3,
ConvergenceStatus::TimedOutWithBestState,
);
assert!(!state.is_converged());
assert_eq!(state.status, ConvergenceStatus::TimedOutWithBestState);
}
// ─────────────────────────────────────────────────────────────────────────────
// Error Display Tests
// ─────────────────────────────────────────────────────────────────────────────
/// Test NonConvergence error display.
#[test]
fn test_non_convergence_display() {
let err = SolverError::NonConvergence {
iterations: 100,
final_residual: 1.23e-4,
};
let msg = err.to_string();
assert!(msg.contains("100"));
assert!(msg.contains("1.23"));
}
/// Test InvalidSystem error display.
#[test]
fn test_invalid_system_display() {
let err = SolverError::InvalidSystem {
message: "Empty system has no equations".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("Empty system"));
}
// ─────────────────────────────────────────────────────────────────────────────
// Configuration Validation Tests
// ─────────────────────────────────────────────────────────────────────────────
/// Test that max_iterations must be positive.
#[test]
fn test_max_iterations_positive() {
let cfg = NewtonConfig::default();
assert!(cfg.max_iterations > 0);
}
/// Test that tolerance must be positive.
#[test]
fn test_tolerance_positive() {
let cfg = NewtonConfig::default();
assert!(cfg.tolerance > 0.0);
}
/// Test that relaxation factor for Picard is in valid range.
#[test]
fn test_picard_relaxation_factor_range() {
use entropyk_solver::PicardConfig;
let cfg = PicardConfig::default();
assert!(cfg.relaxation_factor > 0.0);
assert!(cfg.relaxation_factor <= 1.0);
}
/// Test line search max backtracks is reasonable.
#[test]
fn test_line_search_max_backtracks_reasonable() {
let cfg = NewtonConfig::default();
assert!(cfg.line_search_max_backtracks > 0);
assert!(cfg.line_search_max_backtracks <= 100);
}

View File

@@ -0,0 +1,254 @@
//! Integration tests for Newton-Raphson solver (Story 4.2).
//!
//! Tests cover:
//! - AC #1: Solver trait and strategy dispatch
//! - AC #2: Configuration options
//! - AC #3: Timeout enforcement
//! - AC #4: Error handling for empty/invalid systems
//! - AC #5: Pre-allocated buffers (no panic)
use entropyk_solver::{NewtonConfig, Solver, SolverError, System};
use approx::assert_relative_eq;
use std::time::Duration;
// ─────────────────────────────────────────────────────────────────────────────
// AC #1: Solver Trait and Strategy Dispatch
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_newton_config_default() {
let cfg = NewtonConfig::default();
assert_eq!(cfg.max_iterations, 100);
assert_relative_eq!(cfg.tolerance, 1e-6);
assert!(!cfg.line_search);
assert!(cfg.timeout.is_none());
assert!(!cfg.use_numerical_jacobian);
assert_relative_eq!(cfg.line_search_armijo_c, 1e-4);
assert_eq!(cfg.line_search_max_backtracks, 20);
assert_relative_eq!(cfg.divergence_threshold, 1e10);
}
#[test]
fn test_newton_config_with_timeout() {
let timeout = Duration::from_millis(500);
let cfg = NewtonConfig::default().with_timeout(timeout);
assert_eq!(cfg.timeout, Some(timeout));
}
#[test]
fn test_newton_config_custom_values() {
let cfg = NewtonConfig {
max_iterations: 50,
tolerance: 1e-8,
line_search: true,
timeout: Some(Duration::from_millis(500)),
use_numerical_jacobian: true,
line_search_armijo_c: 1e-3,
line_search_max_backtracks: 10,
divergence_threshold: 1e8,
..Default::default()
};
assert_eq!(cfg.max_iterations, 50);
assert_relative_eq!(cfg.tolerance, 1e-8);
assert!(cfg.line_search);
assert_eq!(cfg.timeout, Some(Duration::from_millis(500)));
assert!(cfg.use_numerical_jacobian);
assert_relative_eq!(cfg.line_search_armijo_c, 1e-3);
assert_eq!(cfg.line_search_max_backtracks, 10);
assert_relative_eq!(cfg.divergence_threshold, 1e8);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #2: Empty System Handling
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_empty_system_returns_invalid() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = NewtonConfig::default();
let result = solver.solve(&mut sys);
assert!(result.is_err());
match result {
Err(SolverError::InvalidSystem { message }) => {
assert!(message.contains("Empty") || message.contains("no state"));
}
other => panic!("Expected InvalidSystem, got {:?}", other),
}
}
#[test]
#[should_panic(expected = "finalize")]
fn test_empty_system_without_finalize_panics() {
// System panics if solve() is called without finalize()
// This is expected behavior - the solver requires a finalized system
let mut sys = System::new();
// Don't call finalize
let mut solver = NewtonConfig::default();
let _ = solver.solve(&mut sys);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #3: Timeout Enforcement
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_timeout_value_in_error() {
let mut sys = System::new();
sys.finalize().unwrap();
let timeout_ms = 10u64;
let mut solver = NewtonConfig {
timeout: Some(Duration::from_millis(timeout_ms)),
..Default::default()
};
let result = solver.solve(&mut sys);
// Empty system returns InvalidSystem immediately (before timeout check)
assert!(result.is_err());
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #4: Error Types
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_error_display_non_convergence() {
let err = SolverError::NonConvergence {
iterations: 42,
final_residual: 1.23e-3,
};
let msg = err.to_string();
assert!(msg.contains("42"));
assert!(msg.contains("1.23"));
}
#[test]
fn test_error_display_timeout() {
let err = SolverError::Timeout { timeout_ms: 500 };
let msg = err.to_string();
assert!(msg.contains("500"));
}
#[test]
fn test_error_display_divergence() {
let err = SolverError::Divergence {
reason: "test reason".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("test reason"));
}
#[test]
fn test_error_display_invalid_system() {
let err = SolverError::InvalidSystem {
message: "test message".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("test message"));
}
#[test]
fn test_error_equality() {
let e1 = SolverError::NonConvergence {
iterations: 10,
final_residual: 1e-3,
};
let e2 = SolverError::NonConvergence {
iterations: 10,
final_residual: 1e-3,
};
assert_eq!(e1, e2);
let e3 = SolverError::Timeout { timeout_ms: 100 };
assert_ne!(e1, e3);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #5: Pre-Allocated Buffers (No Panic)
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_solver_does_not_panic_on_empty_system() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = NewtonConfig::default();
// Should complete without panic
let result = solver.solve(&mut sys);
assert!(result.is_err());
}
#[test]
fn test_solver_does_not_panic_with_line_search() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = NewtonConfig {
line_search: true,
..Default::default()
};
// Should complete without panic
let result = solver.solve(&mut sys);
assert!(result.is_err());
}
#[test]
fn test_solver_does_not_panic_with_numerical_jacobian() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = NewtonConfig {
use_numerical_jacobian: true,
..Default::default()
};
// Should complete without panic
let result = solver.solve(&mut sys);
assert!(result.is_err());
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #6: ConvergedState
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_converged_state_is_converged() {
use entropyk_solver::ConvergenceStatus;
use entropyk_solver::ConvergedState;
let state = ConvergedState::new(
vec![1.0, 2.0, 3.0],
10,
1e-8,
ConvergenceStatus::Converged,
);
assert!(state.is_converged());
assert_eq!(state.iterations, 10);
assert_eq!(state.state, vec![1.0, 2.0, 3.0]);
}
#[test]
fn test_converged_state_timed_out() {
use entropyk_solver::ConvergenceStatus;
use entropyk_solver::ConvergedState;
let state = ConvergedState::new(
vec![1.0],
50,
1e-3,
ConvergenceStatus::TimedOutWithBestState,
);
assert!(!state.is_converged());
}

View File

@@ -0,0 +1,410 @@
//! Integration tests for Sequential Substitution (Picard) solver (Story 4.3).
//!
//! Tests cover:
//! - AC #1: Reliable convergence when Newton diverges
//! - AC #2: Sequential variable update
//! - AC #3: Configurable relaxation factors
//! - AC #4: Timeout enforcement
//! - AC #5: Divergence detection
//! - AC #6: Pre-allocated buffers
use entropyk_solver::{PicardConfig, Solver, SolverError, System};
use approx::assert_relative_eq;
use std::time::Duration;
// ─────────────────────────────────────────────────────────────────────────────
// AC #1: Solver Trait and Configuration
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_picard_config_default() {
let cfg = PicardConfig::default();
assert_eq!(cfg.max_iterations, 100);
assert_relative_eq!(cfg.tolerance, 1e-6);
assert_relative_eq!(cfg.relaxation_factor, 0.5);
assert!(cfg.timeout.is_none());
assert_relative_eq!(cfg.divergence_threshold, 1e10);
assert_eq!(cfg.divergence_patience, 5);
}
#[test]
fn test_picard_config_with_timeout() {
let timeout = Duration::from_millis(500);
let cfg = PicardConfig::default().with_timeout(timeout);
assert_eq!(cfg.timeout, Some(timeout));
}
#[test]
fn test_picard_config_custom_values() {
let cfg = PicardConfig {
max_iterations: 200,
tolerance: 1e-8,
relaxation_factor: 0.3,
timeout: Some(Duration::from_millis(1000)),
divergence_threshold: 1e8,
divergence_patience: 7,
..Default::default()
};
assert_eq!(cfg.max_iterations, 200);
assert_relative_eq!(cfg.tolerance, 1e-8);
assert_relative_eq!(cfg.relaxation_factor, 0.3);
assert_eq!(cfg.timeout, Some(Duration::from_millis(1000)));
assert_relative_eq!(cfg.divergence_threshold, 1e8);
assert_eq!(cfg.divergence_patience, 7);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #2: Empty System Handling
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_empty_system_returns_invalid() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = PicardConfig::default();
let result = solver.solve(&mut sys);
assert!(result.is_err());
match result {
Err(SolverError::InvalidSystem { message }) => {
assert!(
message.contains("Empty") || message.contains("no state"),
"Expected empty system message, got: {}",
message
);
}
other => panic!("Expected InvalidSystem, got {:?}", other),
}
}
#[test]
#[should_panic(expected = "finalize")]
fn test_picard_empty_system_without_finalize_panics() {
// System panics if solve() is called without finalize()
// This is expected behavior - the solver requires a finalized system
let mut sys = System::new();
// Don't call finalize
let mut solver = PicardConfig::default();
let _ = solver.solve(&mut sys);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #3: Relaxation Factor Configuration
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_relaxation_factor_default() {
let cfg = PicardConfig::default();
assert_relative_eq!(cfg.relaxation_factor, 0.5);
}
#[test]
fn test_relaxation_factor_full_update() {
// omega = 1.0: Full update (fastest, may oscillate)
let cfg = PicardConfig {
relaxation_factor: 1.0,
..Default::default()
};
assert_relative_eq!(cfg.relaxation_factor, 1.0);
}
#[test]
fn test_relaxation_factor_heavy_damping() {
// omega = 0.1: Heavy damping (slow but very stable)
let cfg = PicardConfig {
relaxation_factor: 0.1,
..Default::default()
};
assert_relative_eq!(cfg.relaxation_factor, 0.1);
}
#[test]
fn test_relaxation_factor_moderate() {
// omega = 0.5: Moderate damping (default, good balance)
let cfg = PicardConfig {
relaxation_factor: 0.5,
..Default::default()
};
assert_relative_eq!(cfg.relaxation_factor, 0.5);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #4: Timeout Enforcement
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_timeout_value_stored() {
let timeout = Duration::from_millis(250);
let cfg = PicardConfig::default().with_timeout(timeout);
assert_eq!(cfg.timeout, Some(timeout));
}
#[test]
fn test_timeout_preserves_other_fields() {
let cfg = PicardConfig {
max_iterations: 150,
tolerance: 1e-7,
relaxation_factor: 0.25,
timeout: None,
divergence_threshold: 1e9,
divergence_patience: 8,
..Default::default()
}
.with_timeout(Duration::from_millis(300));
assert_eq!(cfg.max_iterations, 150);
assert_relative_eq!(cfg.tolerance, 1e-7);
assert_relative_eq!(cfg.relaxation_factor, 0.25);
assert_eq!(cfg.timeout, Some(Duration::from_millis(300)));
assert_relative_eq!(cfg.divergence_threshold, 1e9);
assert_eq!(cfg.divergence_patience, 8);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #5: Divergence Detection Configuration
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_divergence_threshold_default() {
let cfg = PicardConfig::default();
assert_relative_eq!(cfg.divergence_threshold, 1e10);
}
#[test]
fn test_divergence_patience_default() {
let cfg = PicardConfig::default();
assert_eq!(cfg.divergence_patience, 5);
}
#[test]
fn test_divergence_patience_higher_than_newton() {
// Newton uses hardcoded patience of 3
// Picard should be more tolerant (5 by default)
let cfg = PicardConfig::default();
assert!(
cfg.divergence_patience >= 5,
"Picard divergence_patience ({}) should be >= 5 (more tolerant than Newton's 3)",
cfg.divergence_patience
);
}
#[test]
fn test_divergence_threshold_custom() {
let cfg = PicardConfig {
divergence_threshold: 1e6,
..Default::default()
};
assert_relative_eq!(cfg.divergence_threshold, 1e6);
}
#[test]
fn test_divergence_patience_custom() {
let cfg = PicardConfig {
divergence_patience: 10,
..Default::default()
};
assert_eq!(cfg.divergence_patience, 10);
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #6: Pre-Allocated Buffers (No Panic)
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_solver_does_not_panic_on_empty_system() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = PicardConfig::default();
// Should complete without panic
let result = solver.solve(&mut sys);
assert!(result.is_err());
}
#[test]
fn test_solver_does_not_panic_with_small_relaxation() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = PicardConfig {
relaxation_factor: 0.1,
..Default::default()
};
// Should complete without panic
let result = solver.solve(&mut sys);
assert!(result.is_err());
}
#[test]
fn test_solver_does_not_panic_with_full_relaxation() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = PicardConfig {
relaxation_factor: 1.0,
..Default::default()
};
// Should complete without panic
let result = solver.solve(&mut sys);
assert!(result.is_err());
}
#[test]
fn test_solver_does_not_panic_with_timeout() {
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = PicardConfig {
timeout: Some(Duration::from_millis(10)),
..Default::default()
};
// Should complete without panic
let result = solver.solve(&mut sys);
assert!(result.is_err());
}
// ─────────────────────────────────────────────────────────────────────────────
// Error Types
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_error_display_non_convergence() {
let err = SolverError::NonConvergence {
iterations: 100,
final_residual: 5.67e-4,
};
let msg = err.to_string();
assert!(msg.contains("100"));
assert!(msg.contains("5.67"));
}
#[test]
fn test_error_display_timeout() {
let err = SolverError::Timeout { timeout_ms: 250 };
let msg = err.to_string();
assert!(msg.contains("250"));
}
#[test]
fn test_error_display_divergence() {
let err = SolverError::Divergence {
reason: "residual increased for 5 consecutive iterations".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("residual increased"));
}
#[test]
fn test_error_display_invalid_system() {
let err = SolverError::InvalidSystem {
message: "State dimension does not match equation count".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("State dimension"));
}
// ─────────────────────────────────────────────────────────────────────────────
// ConvergedState
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_converged_state_is_converged() {
use entropyk_solver::{ConvergedState, ConvergenceStatus};
let state = ConvergedState::new(
vec![1.0, 2.0, 3.0],
25,
1e-7,
ConvergenceStatus::Converged,
);
assert!(state.is_converged());
assert_eq!(state.iterations, 25);
assert_eq!(state.state, vec![1.0, 2.0, 3.0]);
assert_relative_eq!(state.final_residual, 1e-7);
}
#[test]
fn test_converged_state_timed_out() {
use entropyk_solver::{ConvergedState, ConvergenceStatus};
let state = ConvergedState::new(
vec![0.5],
75,
1e-2,
ConvergenceStatus::TimedOutWithBestState,
);
assert!(!state.is_converged());
assert_eq!(state.status, ConvergenceStatus::TimedOutWithBestState);
}
// ─────────────────────────────────────────────────────────────────────────────
// SolverStrategy Integration
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_solver_strategy_picard_dispatch() {
use entropyk_solver::SolverStrategy;
let mut strategy = SolverStrategy::SequentialSubstitution(PicardConfig::default());
let mut system = System::new();
system.finalize().unwrap();
let result = strategy.solve(&mut system);
assert!(result.is_err());
}
#[test]
fn test_solver_strategy_picard_with_timeout() {
use entropyk_solver::SolverStrategy;
let strategy =
SolverStrategy::SequentialSubstitution(PicardConfig::default())
.with_timeout(Duration::from_millis(100));
match strategy {
SolverStrategy::SequentialSubstitution(cfg) => {
assert_eq!(cfg.timeout, Some(Duration::from_millis(100)));
}
other => panic!("Expected SequentialSubstitution, got {:?}", other),
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Dimension Mismatch Handling
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn test_picard_dimension_mismatch_returns_error() {
// Picard requires state dimension == equation count
// This is validated in solve() before iteration begins
let mut sys = System::new();
sys.finalize().unwrap();
let mut solver = PicardConfig::default();
let result = solver.solve(&mut sys);
// Empty system should return InvalidSystem
assert!(result.is_err());
match result {
Err(SolverError::InvalidSystem { message }) => {
assert!(
message.contains("Empty") || message.contains("no state"),
"Expected empty system message, got: {}",
message
);
}
other => panic!("Expected InvalidSystem, got {:?}", other),
}
}

View File

@@ -0,0 +1,267 @@
//! Integration tests for Story 4.6: Smart Initialization Heuristic (AC: #8)
//!
//! Tests cover:
//! - AC #8: Integration with FallbackSolver via `with_initial_state`
//! - Cold-start convergence: SmartInitializer → FallbackSolver
//! - `initial_state` respected by NewtonConfig and PicardConfig
//! - `with_initial_state` builder on FallbackSolver delegates to both sub-solvers
use entropyk_components::{Component, ComponentError, JacobianBuilder, ResidualVector, SystemState};
use entropyk_core::{Enthalpy, Pressure, Temperature};
use entropyk_solver::{
solver::{FallbackSolver, NewtonConfig, PicardConfig, Solver},
InitializerConfig, SmartInitializer, System,
};
use approx::assert_relative_eq;
// ─────────────────────────────────────────────────────────────────────────────
// Mock Components for Testing
// ─────────────────────────────────────────────────────────────────────────────
/// A simple linear component whose residual is r_i = x_i - target_i.
/// The solution is x = target. Used to verify initial_state is copied correctly.
struct LinearTargetSystem {
/// Target values (solution)
targets: Vec<f64>,
}
impl LinearTargetSystem {
fn new(targets: Vec<f64>) -> Self {
Self { targets }
}
}
impl Component for LinearTargetSystem {
fn compute_residuals(
&self,
state: &SystemState,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
for (i, &t) in self.targets.iter().enumerate() {
residuals[i] = state[i] - t;
}
Ok(())
}
fn jacobian_entries(
&self,
_state: &SystemState,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
for i in 0..self.targets.len() {
jacobian.add_entry(i, i, 1.0);
}
Ok(())
}
fn n_equations(&self) -> usize {
self.targets.len()
}
fn get_ports(&self) -> &[entropyk_components::ConnectedPort] {
&[]
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
fn build_system_with_targets(targets: Vec<f64>) -> System {
let mut sys = System::new();
let n0 = sys.add_component(Box::new(LinearTargetSystem::new(targets)));
sys.add_edge(n0, n0).unwrap();
sys.finalize().unwrap();
sys
}
// ─────────────────────────────────────────────────────────────────────────────
// AC #8: Integration with Solver — initial_state accepted via builders
// ─────────────────────────────────────────────────────────────────────────────
/// AC #8 — `NewtonConfig::with_initial_state` starts from provided state.
///
/// We build a 2-entry system where target = [3e5, 4e5].
/// Starting from zeros → needs to close the gap.
/// Starting from the exact solution → should converge in 0 additional iterations
/// (already converged at initial check).
#[test]
fn test_newton_with_initial_state_converges_at_target() {
// 2-entry state (1 edge × 2 entries: P, h)
let targets = vec![300_000.0, 400_000.0];
let mut sys = build_system_with_targets(targets.clone());
let mut solver = NewtonConfig::default().with_initial_state(targets.clone());
let result = solver.solve(&mut sys);
assert!(result.is_ok(), "Should converge: {:?}", result.err());
let converged = result.unwrap();
// Started exactly at solution → 0 iterations needed
assert_eq!(converged.iterations, 0, "Should converge at initial state (0 iterations)");
assert!(converged.final_residual < 1e-6);
}
/// AC #8 — `PicardConfig::with_initial_state` starts from provided state.
#[test]
fn test_picard_with_initial_state_converges_at_target() {
let targets = vec![300_000.0, 400_000.0];
let mut sys = build_system_with_targets(targets.clone());
let mut solver = PicardConfig::default().with_initial_state(targets.clone());
let result = solver.solve(&mut sys);
assert!(result.is_ok(), "Should converge: {:?}", result.err());
let converged = result.unwrap();
assert_eq!(converged.iterations, 0, "Should converge at initial state (0 iterations)");
assert!(converged.final_residual < 1e-6);
}
/// AC #8 — `FallbackSolver::with_initial_state` delegates to both newton and picard.
#[test]
fn test_fallback_solver_with_initial_state_delegates() {
let state = vec![300_000.0, 400_000.0];
let solver = FallbackSolver::default_solver().with_initial_state(state.clone());
// Verify both sub-solvers received the initial state
assert_eq!(
solver.newton_config.initial_state.as_deref(),
Some(state.as_slice()),
"NewtonConfig should have the initial state"
);
assert_eq!(
solver.picard_config.initial_state.as_deref(),
Some(state.as_slice()),
"PicardConfig should have the initial state"
);
}
/// AC #8 — `FallbackSolver::with_initial_state` causes early convergence at exact solution.
#[test]
fn test_fallback_solver_with_initial_state_at_solution() {
let targets = vec![300_000.0, 400_000.0];
let mut sys = build_system_with_targets(targets.clone());
let mut solver = FallbackSolver::default_solver().with_initial_state(targets.clone());
let result = solver.solve(&mut sys);
assert!(result.is_ok(), "Should converge: {:?}", result.err());
let converged = result.unwrap();
assert_eq!(converged.iterations, 0, "Should converge immediately at initial state");
}
/// AC #8 — Smart initial state reduces iterations vs. zero initial state.
///
/// We use a system where the solution is far from zero (large P, h values).
/// Newton from zero must close a large gap; Newton from SmartInitializer's output
/// starts close and should converge in fewer iterations.
#[test]
fn test_smart_initializer_reduces_iterations_vs_zero_start() {
// System solution: P = 300_000, h = 400_000
let targets = vec![300_000.0_f64, 400_000.0_f64];
// Run 1: from zeros
let mut sys_zero = build_system_with_targets(targets.clone());
let mut solver_zero = NewtonConfig::default();
let result_zero = solver_zero.solve(&mut sys_zero).expect("zero-start should converge");
// Run 2: from smart initial state (we directly provide the values as an approximation)
// Use 95% of target as "smart" initial — simulating a near-correct heuristic
let smart_state: Vec<f64> = targets.iter().map(|&t| t * 0.95).collect();
let mut sys_smart = build_system_with_targets(targets.clone());
let mut solver_smart = NewtonConfig::default().with_initial_state(smart_state);
let result_smart = solver_smart.solve(&mut sys_smart).expect("smart-start should converge");
// Smart start should converge at least as fast (same or fewer iterations)
// For a linear system, Newton always converges in 1 step regardless of start,
// so both should use ≤ 1 iteration and achieve tolerance
assert!(result_zero.final_residual < 1e-6, "Zero start should converge to tolerance");
assert!(result_smart.final_residual < 1e-6, "Smart start should converge to tolerance");
assert!(
result_smart.iterations <= result_zero.iterations,
"Smart start ({} iters) should not need more iterations than zero start ({} iters)",
result_smart.iterations,
result_zero.iterations
);
}
// ─────────────────────────────────────────────────────────────────────────────
// SmartInitializer API — cold-start pressure estimation
// ─────────────────────────────────────────────────────────────────────────────
/// AC #8 — SmartInitializer produces pressures and populate_state works end-to-end.
///
/// Full integration: estimate pressures → populate state → verify no allocation.
#[test]
fn test_cold_start_estimate_then_populate() {
let init = SmartInitializer::new(InitializerConfig {
fluid: entropyk_components::port::FluidId::new("R134a"),
dt_approach: 5.0,
});
let t_source = Temperature::from_celsius(5.0);
let t_sink = Temperature::from_celsius(40.0);
let (p_evap, p_cond) = init
.estimate_pressures(t_source, t_sink)
.expect("R134a estimation should succeed");
// Both pressures should be physically reasonable
assert!(p_evap.to_bar() > 0.5, "P_evap should be > 0.5 bar");
assert!(p_cond.to_bar() > p_evap.to_bar(), "P_cond should exceed P_evap");
assert!(p_cond.to_bar() < 50.0, "P_cond should be < 50 bar (not supercritical)");
// Build a 2-edge system and populate state
let mut sys = System::new();
let n0 = sys.add_component(Box::new(LinearTargetSystem::new(vec![1.0, 1.0])));
let n1 = sys.add_component(Box::new(LinearTargetSystem::new(vec![1.0, 1.0])));
let n2 = sys.add_component(Box::new(LinearTargetSystem::new(vec![1.0, 1.0])));
sys.add_edge(n0, n1).unwrap();
sys.add_edge(n1, n2).unwrap();
sys.finalize().unwrap();
let h_default = Enthalpy::from_joules_per_kg(420_000.0);
let mut state = vec![0.0f64; sys.state_vector_len()]; // pre-allocated, no allocation in populate_state
init.populate_state(&sys, p_evap, p_cond, h_default, &mut state)
.expect("populate_state should succeed");
assert_eq!(state.len(), 4); // 2 edges × [P, h]
// All edges in single circuit → P_evap used for all
assert_relative_eq!(state[0], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[1], h_default.to_joules_per_kg(), max_relative = 1e-9);
assert_relative_eq!(state[2], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[3], h_default.to_joules_per_kg(), max_relative = 1e-9);
}
/// AC #8 — Verify initial_state length mismatch falls back gracefully (doesn't panic).
///
/// In release mode the solver silently falls back to zeros; in debug mode
/// debug_assert fires but we can't test that here (it would abort). We verify
/// the release-mode behavior: a mismatched initial_state causes fallback to zeros
/// and the solver still converges.
#[test]
fn test_initial_state_length_mismatch_fallback() {
// System has 2 state entries (1 edge × 2)
let targets = vec![300_000.0, 400_000.0];
let mut sys = build_system_with_targets(targets.clone());
// Provide wrong-length initial state (3 instead of 2)
// In release mode: solver falls back to zeros, still converges
// In debug mode: debug_assert panics — we skip this test in debug
#[cfg(not(debug_assertions))]
{
let wrong_state = vec![1.0, 2.0, 3.0]; // length 3, system needs 2
let mut solver = NewtonConfig::default().with_initial_state(wrong_state);
let result = solver.solve(&mut sys);
// Should still converge (fell back to zeros)
assert!(result.is_ok(), "Should converge even with mismatched initial_state in release mode");
}
#[cfg(debug_assertions)]
{
// In debug mode, skip this test (debug_assert would abort)
let _ = (sys, targets); // suppress unused variable warnings
}
}