487 lines
18 KiB
Rust
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);
|
|
}
|
|
}
|