Entropyk/crates/solver/src/criteria.rs

487 lines
18 KiB
Rust

//! 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<CircuitConvergence>,
/// `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<usize> = 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<CircuitConvergence> = (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);
}
}