//! 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 entropyk_solver::{ CircuitConvergence, ConvergenceCriteria, ConvergenceReport, ConvergedState, ConvergenceStatus, FallbackSolver, FallbackConfig, NewtonConfig, PicardConfig, Solver, System, }; use approx::assert_relative_eq; // ───────────────────────────────────────────────────────────────────────────── // 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, ); 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, ); 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::{Component, ComponentError, JacobianBuilder, ResidualVector, SystemState}; use entropyk_components::port::ConnectedPort; struct MockConvergingComponent; impl Component for MockConvergingComponent { fn compute_residuals(&self, state: &SystemState, 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: &SystemState, 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()); }