313 lines
12 KiB
Rust
313 lines
12 KiB
Rust
//! Integration tests for Story 4.7: Convergence Criteria & Validation.
|
|
//!
|
|
//! Tests cover all behaviour-level Acceptance Criteria:
|
|
//! - AC #7: ConvergenceCriteria integrates with Newton/Picard solvers
|
|
//! - AC #8: `convergence_report` field in `ConvergedState` (Some when criteria set, None by default)
|
|
//! - Backward compatibility: existing raw-tolerance workflow unchanged
|
|
|
|
use approx::assert_relative_eq;
|
|
use entropyk_solver::{
|
|
CircuitConvergence, ConvergedState, ConvergenceCriteria, ConvergenceReport, ConvergenceStatus,
|
|
FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig, Solver, System,
|
|
};
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// AC #8: ConvergenceReport in ConvergedState
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Test that `ConvergedState::new` does NOT attach a report (backward-compat).
|
|
#[test]
|
|
fn test_converged_state_new_no_report() {
|
|
let state = ConvergedState::new(vec![1.0, 2.0], 10, 1e-8, ConvergenceStatus::Converged, entropyk_solver::SimulationMetadata::new("".to_string()));
|
|
assert!(
|
|
state.convergence_report.is_none(),
|
|
"ConvergedState::new should not attach a report"
|
|
);
|
|
}
|
|
|
|
/// Test that `ConvergedState::with_report` attaches a report.
|
|
#[test]
|
|
fn test_converged_state_with_report_attaches_report() {
|
|
let report = ConvergenceReport {
|
|
per_circuit: vec![CircuitConvergence {
|
|
circuit_id: 0,
|
|
pressure_ok: true,
|
|
mass_ok: true,
|
|
energy_ok: true,
|
|
converged: true,
|
|
}],
|
|
globally_converged: true,
|
|
};
|
|
|
|
let state = ConvergedState::with_report(
|
|
vec![1.0, 2.0],
|
|
10,
|
|
1e-8,
|
|
ConvergenceStatus::Converged,
|
|
report,
|
|
entropyk_solver::SimulationMetadata::new("".to_string()),
|
|
);
|
|
|
|
assert!(
|
|
state.convergence_report.is_some(),
|
|
"with_report should attach a report"
|
|
);
|
|
assert!(state.convergence_report.unwrap().is_globally_converged());
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// AC #7: ConvergenceCriteria builder methods
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Test that `NewtonConfig::with_convergence_criteria` stores the criteria.
|
|
#[test]
|
|
fn test_newton_with_convergence_criteria_builder() {
|
|
let criteria = ConvergenceCriteria::default();
|
|
let cfg = NewtonConfig::default().with_convergence_criteria(criteria.clone());
|
|
|
|
assert!(cfg.convergence_criteria.is_some());
|
|
let stored = cfg.convergence_criteria.unwrap();
|
|
assert_relative_eq!(stored.pressure_tolerance_pa, criteria.pressure_tolerance_pa);
|
|
}
|
|
|
|
/// Test that `PicardConfig::with_convergence_criteria` stores the criteria.
|
|
#[test]
|
|
fn test_picard_with_convergence_criteria_builder() {
|
|
let criteria = ConvergenceCriteria {
|
|
pressure_tolerance_pa: 0.5,
|
|
mass_balance_tolerance_kgs: 1e-10,
|
|
energy_balance_tolerance_w: 1e-4,
|
|
};
|
|
let cfg = PicardConfig::default().with_convergence_criteria(criteria.clone());
|
|
|
|
assert!(cfg.convergence_criteria.is_some());
|
|
let stored = cfg.convergence_criteria.unwrap();
|
|
assert_relative_eq!(stored.pressure_tolerance_pa, 0.5);
|
|
assert_relative_eq!(stored.mass_balance_tolerance_kgs, 1e-10);
|
|
}
|
|
|
|
/// Test that `FallbackSolver::with_convergence_criteria` delegates to both sub-solvers.
|
|
#[test]
|
|
fn test_fallback_with_convergence_criteria_delegates() {
|
|
let criteria = ConvergenceCriteria::default();
|
|
let solver = FallbackSolver::default_solver().with_convergence_criteria(criteria.clone());
|
|
|
|
assert!(solver.newton_config.convergence_criteria.is_some());
|
|
assert!(solver.picard_config.convergence_criteria.is_some());
|
|
|
|
let newton_c = solver.newton_config.convergence_criteria.unwrap();
|
|
let picard_c = solver.picard_config.convergence_criteria.unwrap();
|
|
assert_relative_eq!(
|
|
newton_c.pressure_tolerance_pa,
|
|
criteria.pressure_tolerance_pa
|
|
);
|
|
assert_relative_eq!(
|
|
picard_c.pressure_tolerance_pa,
|
|
criteria.pressure_tolerance_pa
|
|
);
|
|
}
|
|
|
|
/// Test backward-compat: Newton without criteria → `convergence_criteria` is `None`.
|
|
#[test]
|
|
fn test_newton_without_criteria_is_none() {
|
|
let cfg = NewtonConfig::default();
|
|
assert!(
|
|
cfg.convergence_criteria.is_none(),
|
|
"Default Newton should have no criteria"
|
|
);
|
|
}
|
|
|
|
/// Test backward-compat: Picard without criteria → `convergence_criteria` is `None`.
|
|
#[test]
|
|
fn test_picard_without_criteria_is_none() {
|
|
let cfg = PicardConfig::default();
|
|
assert!(
|
|
cfg.convergence_criteria.is_none(),
|
|
"Default Picard should have no criteria"
|
|
);
|
|
}
|
|
|
|
/// Test that Newton with empty system returns Err (no panic when criteria set).
|
|
#[test]
|
|
fn test_newton_with_criteria_empty_system_no_panic() {
|
|
let mut sys = System::new();
|
|
sys.finalize().unwrap();
|
|
|
|
let mut solver =
|
|
NewtonConfig::default().with_convergence_criteria(ConvergenceCriteria::default());
|
|
|
|
// Empty system → wrapped error, no panic
|
|
let result = solver.solve(&mut sys);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
/// Test that Picard with empty system returns Err (no panic when criteria set).
|
|
#[test]
|
|
fn test_picard_with_criteria_empty_system_no_panic() {
|
|
let mut sys = System::new();
|
|
sys.finalize().unwrap();
|
|
|
|
let mut solver =
|
|
PicardConfig::default().with_convergence_criteria(ConvergenceCriteria::default());
|
|
|
|
let result = solver.solve(&mut sys);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// ConvergenceCriteria type tests
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// AC #1: Default pressure tolerance is 1.0 Pa.
|
|
#[test]
|
|
fn test_criteria_default_pressure_tolerance() {
|
|
let c = ConvergenceCriteria::default();
|
|
assert_relative_eq!(c.pressure_tolerance_pa, 1.0);
|
|
}
|
|
|
|
/// AC #2: Default mass balance tolerance is 1e-9 kg/s.
|
|
#[test]
|
|
fn test_criteria_default_mass_tolerance() {
|
|
let c = ConvergenceCriteria::default();
|
|
assert_relative_eq!(c.mass_balance_tolerance_kgs, 1e-9);
|
|
}
|
|
|
|
/// AC #3: Default energy balance tolerance is 1e-3 W (= 1e-6 kW).
|
|
#[test]
|
|
fn test_criteria_default_energy_tolerance() {
|
|
let c = ConvergenceCriteria::default();
|
|
assert_relative_eq!(c.energy_balance_tolerance_w, 1e-3);
|
|
}
|
|
|
|
/// AC #5: Global convergence only when ALL circuits converge.
|
|
#[test]
|
|
fn test_global_convergence_requires_all_circuits() {
|
|
// 3 circuits, one fails → not globally converged
|
|
let report = ConvergenceReport {
|
|
per_circuit: vec![
|
|
CircuitConvergence {
|
|
circuit_id: 0,
|
|
pressure_ok: true,
|
|
mass_ok: true,
|
|
energy_ok: true,
|
|
converged: true,
|
|
},
|
|
CircuitConvergence {
|
|
circuit_id: 1,
|
|
pressure_ok: true,
|
|
mass_ok: true,
|
|
energy_ok: true,
|
|
converged: true,
|
|
},
|
|
CircuitConvergence {
|
|
circuit_id: 2,
|
|
pressure_ok: false,
|
|
mass_ok: true,
|
|
energy_ok: true,
|
|
converged: false,
|
|
},
|
|
],
|
|
globally_converged: false,
|
|
};
|
|
assert!(!report.is_globally_converged());
|
|
}
|
|
|
|
/// AC #5: Single-circuit system is a degenerate case of global convergence.
|
|
#[test]
|
|
fn test_single_circuit_global_convergence() {
|
|
let report = ConvergenceReport {
|
|
per_circuit: vec![CircuitConvergence {
|
|
circuit_id: 0,
|
|
pressure_ok: true,
|
|
mass_ok: true,
|
|
energy_ok: true,
|
|
converged: true,
|
|
}],
|
|
globally_converged: true,
|
|
};
|
|
assert!(report.is_globally_converged());
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// AC #7: Integration Validation (Actual Solve)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
use entropyk_components::port::ConnectedPort;
|
|
use entropyk_components::{
|
|
Component, ComponentError, JacobianBuilder, ResidualVector, StateSlice,
|
|
};
|
|
|
|
struct MockConvergingComponent;
|
|
|
|
impl Component for MockConvergingComponent {
|
|
fn compute_residuals(
|
|
&self,
|
|
state: &StateSlice,
|
|
residuals: &mut ResidualVector,
|
|
) -> Result<(), ComponentError> {
|
|
// Simple linear system will converge in 1 step
|
|
residuals[0] = state[0] - 5.0;
|
|
residuals[1] = state[1] - 10.0;
|
|
Ok(())
|
|
}
|
|
|
|
fn jacobian_entries(
|
|
&self,
|
|
_state: &StateSlice,
|
|
jacobian: &mut JacobianBuilder,
|
|
) -> Result<(), ComponentError> {
|
|
jacobian.add_entry(0, 0, 1.0);
|
|
jacobian.add_entry(1, 1, 1.0);
|
|
Ok(())
|
|
}
|
|
|
|
fn n_equations(&self) -> usize {
|
|
2
|
|
}
|
|
fn get_ports(&self) -> &[ConnectedPort] {
|
|
&[]
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_newton_with_criteria_single_circuit() {
|
|
let mut sys = System::new();
|
|
let node1 = sys.add_component(Box::new(MockConvergingComponent));
|
|
let node2 = sys.add_component(Box::new(MockConvergingComponent));
|
|
sys.add_edge(node1, node2).unwrap();
|
|
sys.finalize().unwrap();
|
|
|
|
let criteria = ConvergenceCriteria {
|
|
pressure_tolerance_pa: 1.0,
|
|
mass_balance_tolerance_kgs: 1e-1,
|
|
energy_balance_tolerance_w: 1e-1,
|
|
};
|
|
|
|
let mut solver = NewtonConfig::default().with_convergence_criteria(criteria);
|
|
let result = solver.solve(&mut sys).expect("Solver should converge");
|
|
|
|
// Check that we got a report back
|
|
assert!(result.convergence_report.is_some());
|
|
let report = result.convergence_report.unwrap();
|
|
assert!(report.is_globally_converged());
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// AC #7: Old tolerance field retained for backward-compat
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Test that old `tolerance` field is still accessible after setting criteria.
|
|
#[test]
|
|
fn test_backward_compat_tolerance_field_survives() {
|
|
let criteria = ConvergenceCriteria::default();
|
|
let cfg = NewtonConfig {
|
|
tolerance: 1e-8,
|
|
..Default::default()
|
|
}
|
|
.with_convergence_criteria(criteria);
|
|
|
|
// tolerance is still 1e-8 (not overwritten by criteria)
|
|
assert_relative_eq!(cfg.tolerance, 1e-8);
|
|
assert!(cfg.convergence_criteria.is_some());
|
|
}
|