//! Convergence criteria for multi-circuit thermodynamic systems. //! //! This module implements multi-dimensional convergence checking with per-circuit //! granularity, as required by FR20 and FR21: //! //! - **FR20**: Convergence criterion: max |ΔP| < 1 Pa (1e-5 bar) //! - **FR21**: Global multi-circuit convergence: ALL circuits must converge //! //! # Proxy Approach (Story 4.7) //! //! Full mass and energy balance validation requires component-level metadata //! that does not exist until Epic 7 (Stories 7-1, 7-2). For Story 4.7, the //! mass and energy balance checks use the **per-circuit residual L2 norm** as //! a proxy: when all residual equations within a circuit satisfy the tolerance, //! the circuit is considered mass- and energy-balanced. This is a valid //! approximation because the residuals encode both pressure continuity and //! enthalpy balance equations simultaneously. //! //! # Example //! //! ```rust,no_run //! use entropyk_solver::criteria::{ConvergenceCriteria, ConvergenceReport}; //! use entropyk_solver::system::System; //! //! let criteria = ConvergenceCriteria::default(); //! // let report = criteria.check(&state, Some(&prev_state), &residuals, &system); //! // assert!(report.is_globally_converged()); //! ``` use crate::system::System; // ───────────────────────────────────────────────────────────────────────────── // Public types // ───────────────────────────────────────────────────────────────────────────── /// Configurable convergence thresholds for multi-circuit systems. /// /// Controls the three convergence dimensions checked per circuit: /// 1. **Pressure**: max |ΔP| across pressure state variables /// 2. **Mass balance**: per-circuit residual L2 norm (proxy for Story 4.7) /// 3. **Energy balance**: per-circuit residual L2 norm (proxy for Story 4.7) /// /// # Default values /// /// | Field | Default | Rationale | /// |-------|---------|-----------| /// | `pressure_tolerance_pa` | 1.0 Pa | FR20: 1 Pa = 1e-5 bar | /// | `mass_balance_tolerance_kgs` | 1e-9 kg/s | Architecture requirement | /// | `energy_balance_tolerance_w` | 1e-3 W | = 1e-6 kW architecture requirement | #[derive(Debug, Clone, PartialEq)] pub struct ConvergenceCriteria { /// Maximum allowed |ΔP| across any pressure state variable. /// /// Convergence requires: `max |state[p_idx] - prev_state[p_idx]| < pressure_tolerance_pa` /// /// Default: 1.0 Pa (FR20). pub pressure_tolerance_pa: f64, /// Mass balance tolerance per circuit (default: 1e-9 kg/s). /// /// **Story 4.7 proxy**: Uses per-circuit residual L2 norm instead of /// explicit mass flow balance. Full mass balance is implemented in Epic 7 (Story 7-1). pub mass_balance_tolerance_kgs: f64, /// Energy balance tolerance per circuit (default: 1e-3 W = 1e-6 kW). /// /// **Story 4.7 proxy**: Uses per-circuit residual L2 norm instead of /// explicit enthalpy balance. Full energy balance is implemented in Epic 7 (Story 7-2). pub energy_balance_tolerance_w: f64, } impl Default for ConvergenceCriteria { fn default() -> Self { Self { pressure_tolerance_pa: 1.0, mass_balance_tolerance_kgs: 1e-9, energy_balance_tolerance_w: 1e-3, } } } /// Per-circuit convergence breakdown. /// /// Each instance represents the convergence status of a single circuit /// in a multi-circuit system. All three sub-checks must pass for the /// circuit to be considered converged. #[derive(Debug, Clone, PartialEq)] pub struct CircuitConvergence { /// The circuit identifier (0-indexed). pub circuit_id: u16, /// Pressure convergence satisfied: `max |ΔP| < pressure_tolerance_pa`. pub pressure_ok: bool, /// Mass balance convergence satisfied (proxy: per-circuit residual norm). /// Full mass balance validation is deferred to Epic 7 (Story 7-1). pub mass_ok: bool, /// Energy balance convergence satisfied (proxy: per-circuit residual norm). /// Full energy balance validation is deferred to Epic 7 (Story 7-2). pub energy_ok: bool, /// `true` iff `pressure_ok && mass_ok && energy_ok`. pub converged: bool, } /// Aggregated convergence result for all circuits in the system. /// /// Contains one [`CircuitConvergence`] entry per active circuit, /// plus a cached global flag. #[derive(Debug, Clone, PartialEq)] pub struct ConvergenceReport { /// Per-circuit breakdown (one entry per circuit, ordered by circuit ID). pub per_circuit: Vec, /// `true` iff every circuit in `per_circuit` has `converged == true`. pub globally_converged: bool, } impl ConvergenceReport { /// Returns `true` if ALL circuits are converged. pub fn is_globally_converged(&self) -> bool { self.globally_converged } } // ───────────────────────────────────────────────────────────────────────────── // ConvergenceCriteria implementation // ───────────────────────────────────────────────────────────────────────────── impl ConvergenceCriteria { /// Evaluate convergence for all circuits in the system. /// /// # Arguments /// /// * `state` — Current full state vector (length = `system.state_vector_len()`). /// Layout: `[P_edge0, h_edge0, P_edge1, h_edge1, ...]` /// * `prev_state` — Previous iteration state (same length). Used to compute ΔP. /// When `None` (first call), the residuals at pressure indices are used as proxy. /// * `residuals` — Current residual vector from `system.compute_residuals()`. /// Used as mass/energy proxy and as ΔP fallback on first iteration. /// * `system` — Finalized `System` for circuit decomposition. /// /// # Panics /// /// Does not panic. Length mismatches trigger `debug_assert!` in debug builds /// and fall back to conservative (not-converged) results in release builds. pub fn check( &self, state: &[f64], prev_state: Option<&[f64]>, residuals: &[f64], system: &System, ) -> ConvergenceReport { debug_assert!( state.len() == system.state_vector_len(), "state length {} != system state length {}", state.len(), system.state_vector_len() ); if let Some(prev) = prev_state { debug_assert!( prev.len() == state.len(), "prev_state length {} != state length {}", prev.len(), state.len() ); } let n_circuits = system.circuit_count(); let mut per_circuit = Vec::with_capacity(n_circuits); // Build per-circuit equation index mapping. // The residual vector is ordered by traverse_for_jacobian(), which // visits components in circuit order. We track which residual equation // indices belong to which circuit by matching state indices. // // Equation ordering heuristic: residual equations are paired with // state variables — equation 2*i is the pressure equation for edge i, // equation 2*i+1 is the enthalpy equation for edge i. // This matches the state vector layout [P_edge0, h_edge0, ...]. for circuit_idx in 0..n_circuits { let circuit_id = circuit_idx as u16; // Collect pressure-variable indices for this circuit let pressure_indices: Vec = system .circuit_edges(crate::CircuitId(circuit_id.into())) .map(|edge| { let (p_idx, _h_idx) = system.edge_state_indices(edge); p_idx }) .collect(); if pressure_indices.is_empty() { // Empty circuit — conservatively mark as not converged tracing::debug!(circuit_id = circuit_id, "Empty circuit — skipping"); per_circuit.push(CircuitConvergence { circuit_id: circuit_id as u16, pressure_ok: false, mass_ok: false, energy_ok: false, converged: false, }); continue; } // ── Pressure check ──────────────────────────────────────────────── // max |ΔP| = max |state[p_idx] - prev[p_idx]| // Fallback on first iteration: use |residuals[p_idx]| as proxy for ΔP. let max_delta_p = pressure_indices .iter() .map(|&p_idx| { let p = if p_idx < state.len() { state[p_idx] } else { 0.0 }; if let Some(prev) = prev_state { let pp = if p_idx < prev.len() { prev[p_idx] } else { 0.0 }; (p - pp).abs() } else { // First-call fallback: residual at pressure index let r = if p_idx < residuals.len() { residuals[p_idx] } else { 0.0 }; r.abs() } }) .fold(0.0_f64, f64::max); let pressure_ok = max_delta_p < self.pressure_tolerance_pa; tracing::debug!( circuit_id = circuit_id, max_delta_p = max_delta_p, threshold = self.pressure_tolerance_pa, pressure_ok = pressure_ok, "Pressure convergence check" ); // ── Mass/Energy balance check (proxy: per-circuit residual L2 norm) ── // Partition residuals by circuit: residual equations are interleaved // with state variables. Pressure equation index = p_idx, enthalpy // equation index = h_idx (= p_idx + 1 by layout convention). let circuit_residual_norm_sq: f64 = system .circuit_edges(crate::CircuitId(circuit_id.into())) .map(|edge| { let (p_idx, h_idx) = system.edge_state_indices(edge); let rp = if p_idx < residuals.len() { residuals[p_idx] } else { 0.0 }; let rh = if h_idx < residuals.len() { residuals[h_idx] } else { 0.0 }; rp * rp + rh * rh }) .sum(); let circuit_residual_norm = circuit_residual_norm_sq.sqrt(); let mass_ok = circuit_residual_norm < self.mass_balance_tolerance_kgs; let energy_ok = circuit_residual_norm < self.energy_balance_tolerance_w; tracing::debug!( circuit_id = circuit_id, residual_norm = circuit_residual_norm, mass_threshold = self.mass_balance_tolerance_kgs, energy_threshold = self.energy_balance_tolerance_w, mass_ok = mass_ok, energy_ok = energy_ok, "Mass/Energy convergence check (proxy)" ); let converged = pressure_ok && mass_ok && energy_ok; per_circuit.push(CircuitConvergence { circuit_id, pressure_ok, mass_ok, energy_ok, converged, }); } let globally_converged = !per_circuit.is_empty() && per_circuit.iter().all(|c| c.converged); tracing::debug!( n_circuits = n_circuits, globally_converged = globally_converged, "Global convergence check complete" ); ConvergenceReport { per_circuit, globally_converged, } } } // ───────────────────────────────────────────────────────────────────────────── // Tests // ───────────────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use approx::assert_relative_eq; #[test] fn test_default_thresholds() { let c = ConvergenceCriteria::default(); assert_relative_eq!(c.pressure_tolerance_pa, 1.0); assert_relative_eq!(c.mass_balance_tolerance_kgs, 1e-9); assert_relative_eq!(c.energy_balance_tolerance_w, 1e-3); } #[test] fn test_convergence_report_is_globally_converged_all_true() { 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, }, ], globally_converged: true, }; assert!(report.is_globally_converged()); } #[test] fn test_convergence_report_is_globally_converged_one_fails() { 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: false, // fails mass_ok: true, energy_ok: true, converged: false, }, ], globally_converged: false, }; assert!(!report.is_globally_converged()); } #[test] fn test_convergence_report_empty_circuits_not_globally_converged() { // Empty per_circuit → not globally converged (no circuits = not proven converged) let report = ConvergenceReport { per_circuit: vec![], globally_converged: false, }; assert!(!report.is_globally_converged()); } #[test] fn test_circuit_convergence_converged_field() { // converged = pressure_ok && mass_ok && energy_ok let all_ok = CircuitConvergence { circuit_id: 0, pressure_ok: true, mass_ok: true, energy_ok: true, converged: true, }; assert!(all_ok.converged); let pressure_fail = CircuitConvergence { circuit_id: 0, pressure_ok: false, mass_ok: true, energy_ok: true, converged: false, }; assert!(!pressure_fail.converged); } #[test] fn test_custom_thresholds() { let criteria = ConvergenceCriteria { pressure_tolerance_pa: 0.1, mass_balance_tolerance_kgs: 1e-12, energy_balance_tolerance_w: 1e-6, }; assert_relative_eq!(criteria.pressure_tolerance_pa, 0.1); assert_relative_eq!(criteria.mass_balance_tolerance_kgs, 1e-12); assert_relative_eq!(criteria.energy_balance_tolerance_w, 1e-6); } #[test] fn test_multi_circuit_global_needs_all() { // 2 circuits, circuit 1 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: false, energy_ok: true, converged: false, }, ], globally_converged: false, }; assert!(!report.is_globally_converged()); } #[test] fn test_multi_circuit_all_converged() { // 2 circuits both converged → 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, }, ], globally_converged: true, }; assert!(report.is_globally_converged()); } #[test] fn test_report_per_circuit_count() { // N circuits → report has N entries let n = 5; let per_circuit: Vec = (0..n) .map(|i| CircuitConvergence { circuit_id: i as u16, pressure_ok: true, mass_ok: true, energy_ok: true, converged: true, }) .collect(); let report = ConvergenceReport { globally_converged: per_circuit.iter().all(|c| c.converged), per_circuit, }; assert_eq!(report.per_circuit.len(), n); } }