chore: sync project state and current artifacts
This commit is contained in:
@@ -15,10 +15,12 @@ petgraph = "0.6"
|
||||
thiserror = "1.0"
|
||||
tracing = "0.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
||||
sha2 = "0.10"
|
||||
serde_json = "1.0"
|
||||
[dev-dependencies]
|
||||
approx = "0.5"
|
||||
serde_json = "1.0"
|
||||
tracing-subscriber = "0.3"
|
||||
|
||||
[lib]
|
||||
name = "entropyk_solver"
|
||||
|
||||
@@ -19,13 +19,11 @@
|
||||
//! Circular dependencies occur when circuits mutually heat each other (A→B and B→A).
|
||||
//! Circuits in circular dependencies must be solved simultaneously by the solver.
|
||||
|
||||
use entropyk_core::{Temperature, ThermalConductance};
|
||||
use entropyk_core::{CircuitId, Temperature, ThermalConductance};
|
||||
use petgraph::algo::{is_cyclic_directed, kosaraju_scc};
|
||||
use petgraph::graph::{DiGraph, NodeIndex};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::system::CircuitId;
|
||||
|
||||
/// Thermal coupling between two circuits via a heat exchanger.
|
||||
///
|
||||
/// Heat flows from `hot_circuit` to `cold_circuit` proportional to the
|
||||
@@ -232,7 +230,7 @@ mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn make_coupling(hot: u8, cold: u8, ua_w_per_k: f64) -> ThermalCoupling {
|
||||
fn make_coupling(hot: u16, cold: u16, ua_w_per_k: f64) -> ThermalCoupling {
|
||||
ThermalCoupling::new(
|
||||
CircuitId(hot),
|
||||
CircuitId(cold),
|
||||
|
||||
@@ -87,7 +87,7 @@ impl Default for ConvergenceCriteria {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CircuitConvergence {
|
||||
/// The circuit identifier (0-indexed).
|
||||
pub circuit_id: u8,
|
||||
pub circuit_id: u16,
|
||||
|
||||
/// Pressure convergence satisfied: `max |ΔP| < pressure_tolerance_pa`.
|
||||
pub pressure_ok: bool,
|
||||
@@ -182,11 +182,11 @@ impl ConvergenceCriteria {
|
||||
// This matches the state vector layout [P_edge0, h_edge0, ...].
|
||||
|
||||
for circuit_idx in 0..n_circuits {
|
||||
let circuit_id = circuit_idx as u8;
|
||||
let circuit_id = circuit_idx as u16;
|
||||
|
||||
// Collect pressure-variable indices for this circuit
|
||||
let pressure_indices: Vec<usize> = system
|
||||
.circuit_edges(crate::system::CircuitId(circuit_id))
|
||||
.circuit_edges(crate::CircuitId(circuit_id.into()))
|
||||
.map(|edge| {
|
||||
let (p_idx, _h_idx) = system.edge_state_indices(edge);
|
||||
p_idx
|
||||
@@ -197,7 +197,7 @@ impl ConvergenceCriteria {
|
||||
// Empty circuit — conservatively mark as not converged
|
||||
tracing::debug!(circuit_id = circuit_id, "Empty circuit — skipping");
|
||||
per_circuit.push(CircuitConvergence {
|
||||
circuit_id,
|
||||
circuit_id: circuit_id as u16,
|
||||
pressure_ok: false,
|
||||
mass_ok: false,
|
||||
energy_ok: false,
|
||||
@@ -247,7 +247,7 @@ impl ConvergenceCriteria {
|
||||
// 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::system::CircuitId(circuit_id))
|
||||
.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() {
|
||||
@@ -470,7 +470,7 @@ mod tests {
|
||||
let n = 5;
|
||||
let per_circuit: Vec<CircuitConvergence> = (0..n)
|
||||
.map(|i| CircuitConvergence {
|
||||
circuit_id: i as u8,
|
||||
circuit_id: i as u16,
|
||||
pressure_ok: true,
|
||||
mass_ok: true,
|
||||
energy_ok: true,
|
||||
|
||||
@@ -34,16 +34,16 @@ pub enum TopologyError {
|
||||
#[error("Cross-circuit connection not allowed: source circuit {source_circuit}, target circuit {target_circuit}. Flow edges connect only nodes within the same circuit")]
|
||||
CrossCircuitConnection {
|
||||
/// Circuit ID of the source node
|
||||
source_circuit: u8,
|
||||
source_circuit: u16,
|
||||
/// Circuit ID of the target node
|
||||
target_circuit: u8,
|
||||
target_circuit: u16,
|
||||
},
|
||||
|
||||
/// Too many circuits requested. Maximum is 5 (circuit IDs 0..=4).
|
||||
#[error("Too many circuits: requested {requested}, maximum is 5")]
|
||||
TooManyCircuits {
|
||||
/// The requested circuit ID that exceeded the limit
|
||||
requested: u8,
|
||||
requested: u16,
|
||||
},
|
||||
|
||||
/// Attempted to add thermal coupling with a circuit that doesn't exist.
|
||||
@@ -52,7 +52,7 @@ pub enum TopologyError {
|
||||
)]
|
||||
InvalidCircuitForCoupling {
|
||||
/// The circuit ID that was referenced but doesn't exist
|
||||
circuit_id: u8,
|
||||
circuit_id: u16,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -510,14 +510,14 @@ mod tests {
|
||||
fn test_populate_state_2_edges() {
|
||||
use crate::system::System;
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
|
||||
struct MockComp;
|
||||
impl Component for MockComp {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_s: &SystemState,
|
||||
_s: &StateSlice,
|
||||
r: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
for v in r.iter_mut() {
|
||||
@@ -527,7 +527,7 @@ mod tests {
|
||||
}
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_s: &SystemState,
|
||||
_s: &StateSlice,
|
||||
j: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
j.add_entry(0, 0, 1.0);
|
||||
@@ -570,16 +570,17 @@ mod tests {
|
||||
/// AC: #4 — populate_state uses P_cond for circuit 1 edges in multi-circuit system.
|
||||
#[test]
|
||||
fn test_populate_state_multi_circuit() {
|
||||
use crate::system::{CircuitId, System};
|
||||
use crate::system::System;
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::CircuitId;
|
||||
|
||||
struct MockComp;
|
||||
impl Component for MockComp {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_s: &SystemState,
|
||||
_s: &StateSlice,
|
||||
r: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
for v in r.iter_mut() {
|
||||
@@ -589,7 +590,7 @@ mod tests {
|
||||
}
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_s: &SystemState,
|
||||
_s: &StateSlice,
|
||||
j: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
j.add_entry(0, 0, 1.0);
|
||||
@@ -646,14 +647,14 @@ mod tests {
|
||||
fn test_populate_state_length_mismatch() {
|
||||
use crate::system::System;
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
|
||||
struct MockComp;
|
||||
impl Component for MockComp {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_s: &SystemState,
|
||||
_s: &StateSlice,
|
||||
r: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
for v in r.iter_mut() {
|
||||
@@ -663,7 +664,7 @@ mod tests {
|
||||
}
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_s: &SystemState,
|
||||
_s: &StateSlice,
|
||||
j: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
j.add_entry(0, 0, 1.0);
|
||||
|
||||
@@ -218,7 +218,7 @@ impl JacobianMatrix {
|
||||
compute_residuals: F,
|
||||
state: &[f64],
|
||||
residuals: &[f64],
|
||||
epsilon: f64,
|
||||
relative_epsilon: f64,
|
||||
) -> Result<Self, String>
|
||||
where
|
||||
F: Fn(&[f64], &mut [f64]) -> Result<(), String>,
|
||||
@@ -228,17 +228,21 @@ impl JacobianMatrix {
|
||||
let mut matrix = DMatrix::zeros(m, n);
|
||||
|
||||
for j in 0..n {
|
||||
// Perturb state[j]
|
||||
let mut state_perturbed = state.to_vec();
|
||||
state_perturbed[j] += epsilon;
|
||||
// Use relative epsilon scaled to the state variable magnitude.
|
||||
// For variables like P~350,000 Pa or h~400,000 J/kg, an absolute
|
||||
// epsilon of 1e-8 is below library numerical precision and gives zero
|
||||
// finite differences. A relative epsilon of ~1e-5 gives physically
|
||||
// meaningful perturbations across all thermodynamic property scales.
|
||||
let eps_j = state[j].abs().max(1.0) * relative_epsilon;
|
||||
|
||||
let mut state_perturbed = state.to_vec();
|
||||
state_perturbed[j] += eps_j;
|
||||
|
||||
// Compute perturbed residuals
|
||||
let mut residuals_perturbed = vec![0.0; m];
|
||||
compute_residuals(&state_perturbed, &mut residuals_perturbed)?;
|
||||
|
||||
// Compute finite difference
|
||||
for i in 0..m {
|
||||
matrix[(i, j)] = (residuals_perturbed[i] - residuals[i]) / epsilon;
|
||||
matrix[(i, j)] = (residuals_perturbed[i] - residuals[i]) / eps_j;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +328,7 @@ impl JacobianMatrix {
|
||||
// col p_idx = 2*i, col h_idx = 2*i+1.
|
||||
// The equation rows mirror the same layout, so row = col for square systems.
|
||||
let indices: Vec<usize> = system
|
||||
.circuit_edges(crate::system::CircuitId(circuit_id))
|
||||
.circuit_edges(crate::CircuitId(circuit_id.into()))
|
||||
.flat_map(|edge| {
|
||||
let (p_idx, h_idx) = system.edge_state_indices(edge);
|
||||
[p_idx, h_idx]
|
||||
|
||||
@@ -14,7 +14,9 @@ pub mod initializer;
|
||||
pub mod inverse;
|
||||
pub mod jacobian;
|
||||
pub mod macro_component;
|
||||
pub mod metadata;
|
||||
pub mod solver;
|
||||
pub mod strategies;
|
||||
pub mod system;
|
||||
|
||||
pub use coupling::{
|
||||
@@ -22,6 +24,7 @@ pub use coupling::{
|
||||
};
|
||||
pub use criteria::{CircuitConvergence, ConvergenceCriteria, ConvergenceReport};
|
||||
pub use entropyk_components::ConnectionError;
|
||||
pub use entropyk_core::CircuitId;
|
||||
pub use error::{AddEdgeError, TopologyError};
|
||||
pub use initializer::{
|
||||
antoine_pressure, AntoineCoefficients, InitializerConfig, InitializerError, SmartInitializer,
|
||||
@@ -29,8 +32,11 @@ pub use initializer::{
|
||||
pub use inverse::{ComponentOutput, Constraint, ConstraintError, ConstraintId};
|
||||
pub use jacobian::JacobianMatrix;
|
||||
pub use macro_component::{MacroComponent, MacroComponentSnapshot, PortMapping};
|
||||
pub use metadata::SimulationMetadata;
|
||||
pub use solver::{
|
||||
ConvergedState, ConvergenceStatus, FallbackConfig, FallbackSolver, JacobianFreezingConfig,
|
||||
NewtonConfig, PicardConfig, Solver, SolverError, SolverStrategy, TimeoutConfig,
|
||||
ConvergedState, ConvergenceStatus, JacobianFreezingConfig, Solver, SolverError, TimeoutConfig,
|
||||
};
|
||||
pub use system::{CircuitId, FlowEdge, System};
|
||||
pub use strategies::{
|
||||
FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig, SolverStrategy,
|
||||
};
|
||||
pub use system::{FlowEdge, System, MAX_CIRCUIT_ID};
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
use crate::system::System;
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -268,7 +268,7 @@ impl MacroComponent {
|
||||
/// than expected.
|
||||
pub fn to_snapshot(
|
||||
&self,
|
||||
global_state: &SystemState,
|
||||
global_state: &StateSlice,
|
||||
label: Option<String>,
|
||||
) -> Option<MacroComponentSnapshot> {
|
||||
let start = self.global_state_offset;
|
||||
@@ -312,7 +312,7 @@ impl Component for MacroComponent {
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &SystemState,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
let n_internal_vars = self.internal_state_len();
|
||||
@@ -338,7 +338,7 @@ impl Component for MacroComponent {
|
||||
}
|
||||
|
||||
// --- 1. Delegate internal residuals ----------------------------------
|
||||
let internal_state: SystemState = state[start..end].to_vec();
|
||||
let internal_state: Vec<f64> = state[start..end].to_vec();
|
||||
let mut internal_residuals = vec![0.0; n_int_eqs];
|
||||
self.internal
|
||||
.compute_residuals(&internal_state, &mut internal_residuals)?;
|
||||
@@ -373,7 +373,7 @@ impl Component for MacroComponent {
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
state: &SystemState,
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
let n_internal_vars = self.internal_state_len();
|
||||
@@ -390,7 +390,7 @@ impl Component for MacroComponent {
|
||||
let n_int_eqs = self.n_internal_equations();
|
||||
|
||||
// --- 1. Internal Jacobian entries ------------------------------------
|
||||
let internal_state: SystemState = state[start..end].to_vec();
|
||||
let internal_state: Vec<f64> = state[start..end].to_vec();
|
||||
|
||||
let mut internal_jac = JacobianBuilder::new();
|
||||
self.internal
|
||||
@@ -455,7 +455,7 @@ mod tests {
|
||||
impl Component for MockInternalComponent {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &SystemState,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
// Simple identity: residual[i] = state[i] (so zero when state is zero)
|
||||
@@ -467,7 +467,7 @@ mod tests {
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
for i in 0..self.n_equations {
|
||||
|
||||
23
crates/solver/src/metadata.rs
Normal file
23
crates/solver/src/metadata.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Traceability metadata for a simulation result.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SimulationMetadata {
|
||||
/// Version of the solver crate used.
|
||||
pub solver_version: String,
|
||||
/// Version of the fluid backend used.
|
||||
pub fluid_backend_version: String,
|
||||
/// SHA-256 hash of the input configuration uniquely identifying the system configuration.
|
||||
pub input_hash: String,
|
||||
}
|
||||
|
||||
impl SimulationMetadata {
|
||||
/// Create a new SimulationMetadata with the given input hash.
|
||||
pub fn new(input_hash: String) -> Self {
|
||||
Self {
|
||||
solver_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
fluid_backend_version: "0.1.0".to_string(), // In a real system, we might query entropyk_fluids or coolprop
|
||||
input_hash,
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
490
crates/solver/src/strategies/fallback.rs
Normal file
490
crates/solver/src/strategies/fallback.rs
Normal file
@@ -0,0 +1,490 @@
|
||||
//! Intelligent fallback solver implementation.
|
||||
//!
|
||||
//! This module provides the [`FallbackSolver`] which implements an intelligent
|
||||
//! fallback strategy between Newton-Raphson and Sequential Substitution (Picard).
|
||||
//!
|
||||
//! # Strategy
|
||||
//!
|
||||
//! The fallback solver implements the following algorithm:
|
||||
//!
|
||||
//! 1. Start with Newton-Raphson (quadratic convergence)
|
||||
//! 2. If Newton diverges, switch to Picard (more robust)
|
||||
//! 3. If Picard stabilizes (residual < threshold), try returning to Newton
|
||||
//! 4. If max switches reached, stay on current solver permanently
|
||||
//!
|
||||
//! # Features
|
||||
//!
|
||||
//! - Automatic fallback from Newton to Picard on divergence
|
||||
//! - Return to Newton when Picard stabilizes the solution
|
||||
//! - Maximum switch limit to prevent infinite oscillation
|
||||
//! - Time-budgeted solving with graceful degradation (Story 4.5)
|
||||
//! - Smart initialization support (Story 4.6)
|
||||
//! - Multi-circuit convergence criteria (Story 4.7)
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::criteria::ConvergenceCriteria;
|
||||
use crate::metadata::SimulationMetadata;
|
||||
use crate::solver::{ConvergedState, ConvergenceStatus, Solver, SolverError};
|
||||
use crate::system::System;
|
||||
|
||||
use super::{NewtonConfig, PicardConfig};
|
||||
|
||||
/// Configuration for the intelligent fallback solver.
|
||||
///
|
||||
/// The fallback solver starts with Newton-Raphson (quadratic convergence) and
|
||||
/// automatically switches to Sequential Substitution (Picard) if Newton diverges.
|
||||
/// It can return to Newton when Picard stabilizes the solution.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_solver::solver::{FallbackConfig, FallbackSolver, Solver};
|
||||
/// use std::time::Duration;
|
||||
///
|
||||
/// let config = FallbackConfig {
|
||||
/// fallback_enabled: true,
|
||||
/// return_to_newton_threshold: 1e-3,
|
||||
/// max_fallback_switches: 2,
|
||||
/// };
|
||||
///
|
||||
/// let solver = FallbackSolver::new(config)
|
||||
/// .with_timeout(Duration::from_secs(1));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FallbackConfig {
|
||||
/// Enable automatic fallback from Newton to Picard on divergence.
|
||||
///
|
||||
/// When `true` (default), the solver switches to Picard if Newton diverges.
|
||||
/// When `false`, the solver runs pure Newton or Picard without fallback.
|
||||
pub fallback_enabled: bool,
|
||||
|
||||
/// Residual norm threshold for returning to Newton from Picard.
|
||||
///
|
||||
/// When Picard reduces the residual below this threshold, the solver
|
||||
/// attempts to return to Newton for faster convergence.
|
||||
/// Default: $10^{-3}$.
|
||||
pub return_to_newton_threshold: f64,
|
||||
|
||||
/// Maximum number of solver switches before staying on current solver.
|
||||
///
|
||||
/// Prevents infinite oscillation between Newton and Picard.
|
||||
/// Default: 2.
|
||||
pub max_fallback_switches: usize,
|
||||
}
|
||||
|
||||
impl Default for FallbackConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fallback_enabled: true,
|
||||
return_to_newton_threshold: 1e-3,
|
||||
max_fallback_switches: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks which solver is currently active in the fallback loop.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CurrentSolver {
|
||||
Newton,
|
||||
Picard,
|
||||
}
|
||||
|
||||
/// Internal state for the fallback solver.
|
||||
struct FallbackState {
|
||||
current_solver: CurrentSolver,
|
||||
switch_count: usize,
|
||||
/// Whether we've permanently committed to Picard (after max switches or Newton re-divergence)
|
||||
committed_to_picard: bool,
|
||||
/// Best state encountered across all solver invocations (Story 4.5 - AC: #4)
|
||||
best_state: Option<Vec<f64>>,
|
||||
/// Best residual norm across all solver invocations (Story 4.5 - AC: #4)
|
||||
best_residual: Option<f64>,
|
||||
}
|
||||
|
||||
impl FallbackState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
current_solver: CurrentSolver::Newton,
|
||||
switch_count: 0,
|
||||
committed_to_picard: false,
|
||||
best_state: None,
|
||||
best_residual: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update best state if the given residual is better (Story 4.5 - AC: #4).
|
||||
fn update_best_state(&mut self, state: &[f64], residual: f64) {
|
||||
if self.best_residual.is_none() || residual < self.best_residual.unwrap() {
|
||||
self.best_state = Some(state.to_vec());
|
||||
self.best_residual = Some(residual);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Intelligent fallback solver that switches between Newton-Raphson and Picard.
|
||||
///
|
||||
/// The fallback solver implements the following algorithm:
|
||||
///
|
||||
/// 1. Start with Newton-Raphson (quadratic convergence)
|
||||
/// 2. If Newton diverges, switch to Picard (more robust)
|
||||
/// 3. If Picard stabilizes (residual < threshold), try returning to Newton
|
||||
/// 4. If max switches reached, stay on current solver permanently
|
||||
///
|
||||
/// # Timeout Handling
|
||||
///
|
||||
/// The timeout applies to the total solving time across all solver switches.
|
||||
/// Each solver inherits the remaining time budget.
|
||||
///
|
||||
/// # Pre-Allocated Buffers
|
||||
///
|
||||
/// All buffers are pre-allocated once before the fallback loop to avoid
|
||||
/// heap allocation during solver switches (NFR4).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FallbackSolver {
|
||||
/// Fallback behavior configuration.
|
||||
pub config: FallbackConfig,
|
||||
/// Newton-Raphson configuration.
|
||||
pub newton_config: NewtonConfig,
|
||||
/// Sequential Substitution (Picard) configuration.
|
||||
pub picard_config: PicardConfig,
|
||||
}
|
||||
|
||||
impl FallbackSolver {
|
||||
/// Creates a new fallback solver with the given configuration.
|
||||
pub fn new(config: FallbackConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
newton_config: NewtonConfig::default(),
|
||||
picard_config: PicardConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a fallback solver with default configuration.
|
||||
pub fn default_solver() -> Self {
|
||||
Self::new(FallbackConfig::default())
|
||||
}
|
||||
|
||||
/// Sets custom Newton-Raphson configuration.
|
||||
pub fn with_newton_config(mut self, config: NewtonConfig) -> Self {
|
||||
self.newton_config = config;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets custom Picard configuration.
|
||||
pub fn with_picard_config(mut self, config: PicardConfig) -> Self {
|
||||
self.picard_config = config;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the initial state for cold-start solving (Story 4.6 — builder pattern).
|
||||
///
|
||||
/// Delegates to both `newton_config` and `picard_config` so the initial state
|
||||
/// is used regardless of which solver is active in the fallback loop.
|
||||
pub fn with_initial_state(mut self, state: Vec<f64>) -> Self {
|
||||
self.newton_config.initial_state = Some(state.clone());
|
||||
self.picard_config.initial_state = Some(state);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets multi-circuit convergence criteria (Story 4.7 — builder pattern).
|
||||
///
|
||||
/// Delegates to both `newton_config` and `picard_config` so criteria are
|
||||
/// applied regardless of which solver is active in the fallback loop.
|
||||
pub fn with_convergence_criteria(mut self, criteria: ConvergenceCriteria) -> Self {
|
||||
self.newton_config.convergence_criteria = Some(criteria.clone());
|
||||
self.picard_config.convergence_criteria = Some(criteria);
|
||||
self
|
||||
}
|
||||
|
||||
/// Main fallback solving loop.
|
||||
///
|
||||
/// Implements the intelligent fallback algorithm:
|
||||
/// - Start with Newton-Raphson
|
||||
/// - Switch to Picard on Newton divergence
|
||||
/// - Return to Newton when Picard stabilizes (if under switch limit and residual below threshold)
|
||||
/// - Stay on Picard permanently after max switches or if Newton re-diverges
|
||||
fn solve_with_fallback(
|
||||
&mut self,
|
||||
system: &mut System,
|
||||
start_time: Instant,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<ConvergedState, SolverError> {
|
||||
let mut state = FallbackState::new();
|
||||
|
||||
// Pre-configure solver configs once
|
||||
let mut newton_cfg = self.newton_config.clone();
|
||||
let mut picard_cfg = self.picard_config.clone();
|
||||
|
||||
loop {
|
||||
// Check remaining time budget
|
||||
let remaining = timeout.map(|t| t.saturating_sub(start_time.elapsed()));
|
||||
|
||||
// Check for timeout before running solver
|
||||
if let Some(remaining_time) = remaining {
|
||||
if remaining_time.is_zero() {
|
||||
return Err(SolverError::Timeout {
|
||||
timeout_ms: timeout.unwrap().as_millis() as u64,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Run current solver with remaining time
|
||||
newton_cfg.timeout = remaining;
|
||||
picard_cfg.timeout = remaining;
|
||||
|
||||
let result = match state.current_solver {
|
||||
CurrentSolver::Newton => newton_cfg.solve(system),
|
||||
CurrentSolver::Picard => picard_cfg.solve(system),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(converged) => {
|
||||
// Update best state tracking (Story 4.5 - AC: #4)
|
||||
state.update_best_state(&converged.state, converged.final_residual);
|
||||
|
||||
tracing::info!(
|
||||
solver = match state.current_solver {
|
||||
CurrentSolver::Newton => "NewtonRaphson",
|
||||
CurrentSolver::Picard => "Picard",
|
||||
},
|
||||
iterations = converged.iterations,
|
||||
final_residual = converged.final_residual,
|
||||
switch_count = state.switch_count,
|
||||
"Fallback solver converged"
|
||||
);
|
||||
return Ok(converged);
|
||||
}
|
||||
Err(SolverError::Timeout { timeout_ms }) => {
|
||||
// Story 4.5 - AC: #4: Return best state on timeout if available
|
||||
if let (Some(best_state), Some(best_residual)) =
|
||||
(state.best_state.clone(), state.best_residual)
|
||||
{
|
||||
tracing::info!(
|
||||
best_residual = best_residual,
|
||||
"Returning best state across all solver invocations on timeout"
|
||||
);
|
||||
return Ok(ConvergedState::new(
|
||||
best_state,
|
||||
0, // iterations not tracked across switches
|
||||
best_residual,
|
||||
ConvergenceStatus::TimedOutWithBestState,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
return Err(SolverError::Timeout { timeout_ms });
|
||||
}
|
||||
Err(SolverError::Divergence { ref reason }) => {
|
||||
// Handle divergence based on current solver and state
|
||||
if !self.config.fallback_enabled {
|
||||
tracing::info!(
|
||||
solver = match state.current_solver {
|
||||
CurrentSolver::Newton => "NewtonRaphson",
|
||||
CurrentSolver::Picard => "Picard",
|
||||
},
|
||||
reason = reason,
|
||||
"Divergence detected, fallback disabled"
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
match state.current_solver {
|
||||
CurrentSolver::Newton => {
|
||||
// Newton diverged - switch to Picard (stay there permanently after max switches)
|
||||
if state.switch_count >= self.config.max_fallback_switches {
|
||||
// Max switches reached - commit to Picard permanently
|
||||
state.committed_to_picard = true;
|
||||
state.current_solver = CurrentSolver::Picard;
|
||||
tracing::info!(
|
||||
switch_count = state.switch_count,
|
||||
max_switches = self.config.max_fallback_switches,
|
||||
"Max switches reached, committing to Picard permanently"
|
||||
);
|
||||
} else {
|
||||
// Switch to Picard
|
||||
state.switch_count += 1;
|
||||
state.current_solver = CurrentSolver::Picard;
|
||||
tracing::warn!(
|
||||
switch_count = state.switch_count,
|
||||
reason = reason,
|
||||
"Newton diverged, switching to Picard"
|
||||
);
|
||||
}
|
||||
// Continue loop with Picard
|
||||
}
|
||||
CurrentSolver::Picard => {
|
||||
// Picard diverged - if we were trying Newton again, commit to Picard permanently
|
||||
if state.switch_count > 0 && !state.committed_to_picard {
|
||||
state.committed_to_picard = true;
|
||||
tracing::info!(
|
||||
switch_count = state.switch_count,
|
||||
reason = reason,
|
||||
"Newton re-diverged after return from Picard, staying on Picard permanently"
|
||||
);
|
||||
// Stay on Picard and try again
|
||||
} else {
|
||||
// Picard diverged with no return attempt - no more fallbacks available
|
||||
tracing::warn!(
|
||||
reason = reason,
|
||||
"Picard diverged, no more fallbacks available"
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(SolverError::NonConvergence {
|
||||
iterations,
|
||||
final_residual,
|
||||
}) => {
|
||||
// Non-convergence: check if we should try the other solver
|
||||
if !self.config.fallback_enabled {
|
||||
return Err(SolverError::NonConvergence {
|
||||
iterations,
|
||||
final_residual,
|
||||
});
|
||||
}
|
||||
|
||||
match state.current_solver {
|
||||
CurrentSolver::Newton => {
|
||||
// Newton didn't converge - try Picard
|
||||
if state.switch_count >= self.config.max_fallback_switches {
|
||||
// Max switches reached - commit to Picard permanently
|
||||
state.committed_to_picard = true;
|
||||
state.current_solver = CurrentSolver::Picard;
|
||||
tracing::info!(
|
||||
switch_count = state.switch_count,
|
||||
"Max switches reached, committing to Picard permanently"
|
||||
);
|
||||
} else {
|
||||
state.switch_count += 1;
|
||||
state.current_solver = CurrentSolver::Picard;
|
||||
tracing::info!(
|
||||
switch_count = state.switch_count,
|
||||
iterations = iterations,
|
||||
final_residual = final_residual,
|
||||
"Newton did not converge, switching to Picard"
|
||||
);
|
||||
}
|
||||
// Continue loop with Picard
|
||||
}
|
||||
CurrentSolver::Picard => {
|
||||
// Picard didn't converge - check if we should try Newton
|
||||
if state.committed_to_picard
|
||||
|| state.switch_count >= self.config.max_fallback_switches
|
||||
{
|
||||
tracing::info!(
|
||||
iterations = iterations,
|
||||
final_residual = final_residual,
|
||||
"Picard did not converge, no more fallbacks"
|
||||
);
|
||||
return Err(SolverError::NonConvergence {
|
||||
iterations,
|
||||
final_residual,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if residual is low enough to try Newton
|
||||
if final_residual < self.config.return_to_newton_threshold {
|
||||
state.switch_count += 1;
|
||||
state.current_solver = CurrentSolver::Newton;
|
||||
tracing::info!(
|
||||
switch_count = state.switch_count,
|
||||
final_residual = final_residual,
|
||||
threshold = self.config.return_to_newton_threshold,
|
||||
"Picard stabilized, attempting Newton return"
|
||||
);
|
||||
// Continue loop with Newton
|
||||
} else {
|
||||
// Stay on Picard and keep trying
|
||||
tracing::debug!(
|
||||
final_residual = final_residual,
|
||||
threshold = self.config.return_to_newton_threshold,
|
||||
"Picard not yet stabilized, aborting"
|
||||
);
|
||||
return Err(SolverError::NonConvergence {
|
||||
iterations,
|
||||
final_residual,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(other) => {
|
||||
// InvalidSystem or other errors - propagate immediately
|
||||
return Err(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Solver for FallbackSolver {
|
||||
fn solve(&mut self, system: &mut System) -> Result<ConvergedState, SolverError> {
|
||||
let start_time = Instant::now();
|
||||
let timeout = self.newton_config.timeout.or(self.picard_config.timeout);
|
||||
|
||||
tracing::info!(
|
||||
fallback_enabled = self.config.fallback_enabled,
|
||||
return_to_newton_threshold = self.config.return_to_newton_threshold,
|
||||
max_fallback_switches = self.config.max_fallback_switches,
|
||||
"Fallback solver starting"
|
||||
);
|
||||
|
||||
if self.config.fallback_enabled {
|
||||
self.solve_with_fallback(system, start_time, timeout)
|
||||
} else {
|
||||
// Fallback disabled - run pure Newton
|
||||
self.newton_config.solve(system)
|
||||
}
|
||||
}
|
||||
|
||||
fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.newton_config.timeout = Some(timeout);
|
||||
self.picard_config.timeout = Some(timeout);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::solver::Solver;
|
||||
use crate::system::System;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_fallback_config_defaults() {
|
||||
let cfg = FallbackConfig::default();
|
||||
assert!(cfg.fallback_enabled);
|
||||
assert!((cfg.return_to_newton_threshold - 1e-3).abs() < 1e-15);
|
||||
assert_eq!(cfg.max_fallback_switches, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_solver_new() {
|
||||
let config = FallbackConfig {
|
||||
fallback_enabled: false,
|
||||
return_to_newton_threshold: 5e-4,
|
||||
max_fallback_switches: 3,
|
||||
};
|
||||
let solver = FallbackSolver::new(config.clone());
|
||||
assert_eq!(solver.config, config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_solver_with_timeout() {
|
||||
let timeout = Duration::from_millis(500);
|
||||
let solver = FallbackSolver::default_solver().with_timeout(timeout);
|
||||
assert_eq!(solver.newton_config.timeout, Some(timeout));
|
||||
assert_eq!(solver.picard_config.timeout, Some(timeout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_solver_trait_object() {
|
||||
let mut boxed: Box<dyn Solver> = Box::new(FallbackSolver::default_solver());
|
||||
let mut system = System::new();
|
||||
system.finalize().unwrap();
|
||||
assert!(boxed.solve(&mut system).is_err());
|
||||
}
|
||||
}
|
||||
232
crates/solver/src/strategies/mod.rs
Normal file
232
crates/solver/src/strategies/mod.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
//! Solver strategy implementations for thermodynamic system solving.
|
||||
//!
|
||||
//! This module provides the concrete solver implementations that can be used
|
||||
//! via the [`Solver`] trait or the [`SolverStrategy`] enum for zero-cost
|
||||
//! static dispatch.
|
||||
//!
|
||||
//! # Available Strategies
|
||||
//!
|
||||
//! - [`NewtonRaphson`] — Newton-Raphson solver with quadratic convergence
|
||||
//! - [`SequentialSubstitution`] — Picard iteration solver, more robust for non-linear systems
|
||||
//! - [`FallbackSolver`] — Intelligent fallback between Newton and Picard
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust
|
||||
//! use entropyk_solver::solver::{Solver, SolverStrategy};
|
||||
//! use std::time::Duration;
|
||||
//!
|
||||
//! let solver = SolverStrategy::default()
|
||||
//! .with_timeout(Duration::from_millis(500));
|
||||
//! ```
|
||||
|
||||
mod fallback;
|
||||
mod newton_raphson;
|
||||
mod sequential_substitution;
|
||||
|
||||
pub use fallback::{FallbackConfig, FallbackSolver};
|
||||
pub use newton_raphson::NewtonConfig;
|
||||
pub use sequential_substitution::PicardConfig;
|
||||
|
||||
use crate::solver::{ConvergedState, Solver, SolverError};
|
||||
use crate::system::System;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Enum-based solver strategy dispatcher.
|
||||
///
|
||||
/// Provides zero-cost static dispatch to the selected solver strategy via
|
||||
/// `match` (monomorphization), avoiding vtable overhead while still allowing
|
||||
/// runtime strategy selection.
|
||||
///
|
||||
/// # Default
|
||||
///
|
||||
/// `SolverStrategy::default()` returns `NewtonRaphson(NewtonConfig::default())`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust
|
||||
/// use entropyk_solver::solver::{Solver, SolverStrategy, PicardConfig};
|
||||
/// use std::time::Duration;
|
||||
///
|
||||
/// let strategy = SolverStrategy::SequentialSubstitution(
|
||||
/// PicardConfig { relaxation_factor: 0.3, ..Default::default() }
|
||||
/// ).with_timeout(Duration::from_secs(1));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SolverStrategy {
|
||||
/// Newton-Raphson solver (quadratic convergence, requires Jacobian).
|
||||
NewtonRaphson(NewtonConfig),
|
||||
/// Sequential Substitution / Picard iteration (robust, no Jacobian needed).
|
||||
SequentialSubstitution(PicardConfig),
|
||||
}
|
||||
|
||||
impl Default for SolverStrategy {
|
||||
/// Returns `SolverStrategy::NewtonRaphson(NewtonConfig::default())`.
|
||||
fn default() -> Self {
|
||||
SolverStrategy::NewtonRaphson(NewtonConfig::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl Solver for SolverStrategy {
|
||||
fn solve(&mut self, system: &mut System) -> Result<ConvergedState, SolverError> {
|
||||
tracing::info!(
|
||||
strategy = match self {
|
||||
SolverStrategy::NewtonRaphson(_) => "NewtonRaphson",
|
||||
SolverStrategy::SequentialSubstitution(_) => "SequentialSubstitution",
|
||||
},
|
||||
"SolverStrategy::solve dispatching"
|
||||
);
|
||||
let result = match self {
|
||||
SolverStrategy::NewtonRaphson(cfg) => cfg.solve(system),
|
||||
SolverStrategy::SequentialSubstitution(cfg) => cfg.solve(system),
|
||||
};
|
||||
|
||||
if let Ok(state) = &result {
|
||||
if state.is_converged() {
|
||||
// Post-solve validation checks
|
||||
// Convert Vec<f64> to SystemState for validation methods
|
||||
let system_state: entropyk_components::SystemState = state.state.clone().into();
|
||||
system.check_mass_balance(&system_state)?;
|
||||
system.check_energy_balance(&system_state)?;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn with_timeout(self, timeout: Duration) -> Self {
|
||||
match self {
|
||||
SolverStrategy::NewtonRaphson(cfg) => {
|
||||
SolverStrategy::NewtonRaphson(cfg.with_timeout(timeout))
|
||||
}
|
||||
SolverStrategy::SequentialSubstitution(cfg) => {
|
||||
SolverStrategy::SequentialSubstitution(cfg.with_timeout(timeout))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::system::System;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Verify that `SolverStrategy::default()` returns Newton-Raphson.
|
||||
#[test]
|
||||
fn test_solver_strategy_default_is_newton_raphson() {
|
||||
let strategy = SolverStrategy::default();
|
||||
assert!(
|
||||
matches!(strategy, SolverStrategy::NewtonRaphson(_)),
|
||||
"Default strategy must be NewtonRaphson, got {:?}",
|
||||
strategy
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that the Newton-Raphson variant wraps a `NewtonConfig`.
|
||||
#[test]
|
||||
fn test_solver_strategy_newton_raphson_variant() {
|
||||
let strategy = SolverStrategy::NewtonRaphson(NewtonConfig::default());
|
||||
match strategy {
|
||||
SolverStrategy::NewtonRaphson(cfg) => {
|
||||
assert_eq!(cfg.max_iterations, 100);
|
||||
assert!((cfg.tolerance - 1e-6).abs() < 1e-15);
|
||||
assert!(!cfg.line_search);
|
||||
assert!(cfg.timeout.is_none());
|
||||
}
|
||||
other => panic!("Expected NewtonRaphson, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that the Sequential Substitution variant wraps a `PicardConfig`.
|
||||
#[test]
|
||||
fn test_solver_strategy_sequential_substitution_variant() {
|
||||
let strategy = SolverStrategy::SequentialSubstitution(PicardConfig::default());
|
||||
match strategy {
|
||||
SolverStrategy::SequentialSubstitution(cfg) => {
|
||||
assert_eq!(cfg.max_iterations, 100);
|
||||
assert!((cfg.tolerance - 1e-6).abs() < 1e-15);
|
||||
assert!((cfg.relaxation_factor - 0.5).abs() < 1e-15);
|
||||
assert!(cfg.timeout.is_none());
|
||||
}
|
||||
other => panic!("Expected SequentialSubstitution, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that `with_timeout` on `SolverStrategy::NewtonRaphson` propagates to inner config.
|
||||
#[test]
|
||||
fn test_solver_strategy_newton_with_timeout() {
|
||||
let timeout = Duration::from_millis(500);
|
||||
let strategy = SolverStrategy::default().with_timeout(timeout);
|
||||
match strategy {
|
||||
SolverStrategy::NewtonRaphson(cfg) => {
|
||||
assert_eq!(cfg.timeout, Some(timeout));
|
||||
}
|
||||
other => panic!("Expected NewtonRaphson after with_timeout, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that `with_timeout` on `SolverStrategy::SequentialSubstitution` propagates.
|
||||
#[test]
|
||||
fn test_solver_strategy_picard_with_timeout() {
|
||||
let timeout = Duration::from_secs(1);
|
||||
let strategy =
|
||||
SolverStrategy::SequentialSubstitution(PicardConfig::default()).with_timeout(timeout);
|
||||
match strategy {
|
||||
SolverStrategy::SequentialSubstitution(cfg) => {
|
||||
assert_eq!(cfg.timeout, Some(timeout));
|
||||
}
|
||||
other => panic!(
|
||||
"Expected SequentialSubstitution after with_timeout, got {:?}",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that `SolverStrategy::NewtonRaphson` dispatches to the Newton implementation.
|
||||
#[test]
|
||||
fn test_solver_strategy_newton_dispatch_reaches_stub() {
|
||||
let mut strategy = SolverStrategy::default(); // NewtonRaphson
|
||||
let mut system = System::new();
|
||||
system.finalize().unwrap();
|
||||
let result = strategy.solve(&mut system);
|
||||
// Empty system should return InvalidSystem
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Newton solver must return Err for empty system"
|
||||
);
|
||||
match result {
|
||||
Err(SolverError::InvalidSystem { ref message }) => {
|
||||
assert!(
|
||||
message.contains("Empty") || message.contains("no state"),
|
||||
"Newton dispatch must detect empty system, got: {}",
|
||||
message
|
||||
);
|
||||
}
|
||||
other => panic!("Expected InvalidSystem from Newton solver, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify that `SolverStrategy::SequentialSubstitution` dispatches to the Picard implementation.
|
||||
#[test]
|
||||
fn test_solver_strategy_picard_dispatch_reaches_implementation() {
|
||||
let mut strategy = SolverStrategy::SequentialSubstitution(PicardConfig::default());
|
||||
let mut system = System::new();
|
||||
system.finalize().unwrap();
|
||||
let result = strategy.solve(&mut system);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Picard solver must return Err for empty system"
|
||||
);
|
||||
match result {
|
||||
Err(SolverError::InvalidSystem { ref message }) => {
|
||||
assert!(
|
||||
message.contains("Empty") || message.contains("no state"),
|
||||
"Picard dispatch must detect empty system, got: {}",
|
||||
message
|
||||
);
|
||||
}
|
||||
other => panic!("Expected InvalidSystem from Picard solver, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
491
crates/solver/src/strategies/newton_raphson.rs
Normal file
491
crates/solver/src/strategies/newton_raphson.rs
Normal file
@@ -0,0 +1,491 @@
|
||||
//! Newton-Raphson solver implementation.
|
||||
//!
|
||||
//! Provides [`NewtonConfig`] which implements the Newton-Raphson method for
|
||||
//! solving systems of non-linear equations with quadratic convergence.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::criteria::ConvergenceCriteria;
|
||||
use crate::jacobian::JacobianMatrix;
|
||||
use crate::metadata::SimulationMetadata;
|
||||
use crate::solver::{
|
||||
apply_newton_step, ConvergedState, ConvergenceStatus, JacobianFreezingConfig, Solver,
|
||||
SolverError, TimeoutConfig,
|
||||
};
|
||||
use crate::system::System;
|
||||
use entropyk_components::JacobianBuilder;
|
||||
|
||||
/// Configuration for the Newton-Raphson solver.
|
||||
///
|
||||
/// Solves F(x) = 0 by iterating: x_{k+1} = x_k - α·J^{-1}·r(x_k)
|
||||
/// where J is the Jacobian matrix and α is the step length.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct NewtonConfig {
|
||||
/// Maximum iterations before declaring non-convergence. Default: 100.
|
||||
pub max_iterations: usize,
|
||||
/// Convergence tolerance (L2 norm). Default: 1e-6.
|
||||
pub tolerance: f64,
|
||||
/// Enable Armijo line-search. Default: false.
|
||||
pub line_search: bool,
|
||||
/// Optional time budget.
|
||||
pub timeout: Option<Duration>,
|
||||
/// Use numerical Jacobian (finite differences). Default: false.
|
||||
pub use_numerical_jacobian: bool,
|
||||
/// Armijo condition constant. Default: 1e-4.
|
||||
pub line_search_armijo_c: f64,
|
||||
/// Max backtracking iterations. Default: 20.
|
||||
pub line_search_max_backtracks: usize,
|
||||
/// Divergence threshold. Default: 1e10.
|
||||
pub divergence_threshold: f64,
|
||||
/// Timeout behavior configuration.
|
||||
pub timeout_config: TimeoutConfig,
|
||||
/// Previous state for ZOH fallback.
|
||||
pub previous_state: Option<Vec<f64>>,
|
||||
/// Residual for previous_state.
|
||||
pub previous_residual: Option<f64>,
|
||||
/// Smart initial state for cold-start.
|
||||
pub initial_state: Option<Vec<f64>>,
|
||||
/// Multi-circuit convergence criteria.
|
||||
pub convergence_criteria: Option<ConvergenceCriteria>,
|
||||
/// Jacobian-freezing optimization.
|
||||
pub jacobian_freezing: Option<JacobianFreezingConfig>,
|
||||
}
|
||||
|
||||
impl Default for NewtonConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_iterations: 100,
|
||||
tolerance: 1e-6,
|
||||
line_search: false,
|
||||
timeout: None,
|
||||
use_numerical_jacobian: false,
|
||||
line_search_armijo_c: 1e-4,
|
||||
line_search_max_backtracks: 20,
|
||||
divergence_threshold: 1e10,
|
||||
timeout_config: TimeoutConfig::default(),
|
||||
previous_state: None,
|
||||
previous_residual: None,
|
||||
initial_state: None,
|
||||
convergence_criteria: None,
|
||||
jacobian_freezing: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NewtonConfig {
|
||||
/// Sets the initial state for cold-start solving.
|
||||
pub fn with_initial_state(mut self, state: Vec<f64>) -> Self {
|
||||
self.initial_state = Some(state);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets multi-circuit convergence criteria.
|
||||
pub fn with_convergence_criteria(mut self, criteria: ConvergenceCriteria) -> Self {
|
||||
self.convergence_criteria = Some(criteria);
|
||||
self
|
||||
}
|
||||
|
||||
/// Enables Jacobian-freezing optimization.
|
||||
pub fn with_jacobian_freezing(mut self, config: JacobianFreezingConfig) -> Self {
|
||||
self.jacobian_freezing = Some(config);
|
||||
self
|
||||
}
|
||||
|
||||
/// Computes the L2 norm of the residual vector.
|
||||
fn residual_norm(residuals: &[f64]) -> f64 {
|
||||
residuals.iter().map(|r| r * r).sum::<f64>().sqrt()
|
||||
}
|
||||
|
||||
/// Handles timeout based on configuration.
|
||||
fn handle_timeout(
|
||||
&self,
|
||||
best_state: &[f64],
|
||||
best_residual: f64,
|
||||
iterations: usize,
|
||||
timeout: Duration,
|
||||
system: &System,
|
||||
) -> Result<ConvergedState, SolverError> {
|
||||
if !self.timeout_config.return_best_state_on_timeout {
|
||||
return Err(SolverError::Timeout {
|
||||
timeout_ms: timeout.as_millis() as u64,
|
||||
});
|
||||
}
|
||||
|
||||
if self.timeout_config.zoh_fallback {
|
||||
if let Some(ref prev_state) = self.previous_state {
|
||||
let residual = self.previous_residual.unwrap_or(best_residual);
|
||||
tracing::info!(iterations, residual, "ZOH fallback");
|
||||
return Ok(ConvergedState::new(
|
||||
prev_state.clone(),
|
||||
iterations,
|
||||
residual,
|
||||
ConvergenceStatus::TimedOutWithBestState,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(iterations, best_residual, "Returning best state on timeout");
|
||||
Ok(ConvergedState::new(
|
||||
best_state.to_vec(),
|
||||
iterations,
|
||||
best_residual,
|
||||
ConvergenceStatus::TimedOutWithBestState,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Checks for divergence based on residual growth.
|
||||
fn check_divergence(
|
||||
&self,
|
||||
current_norm: f64,
|
||||
previous_norm: f64,
|
||||
divergence_count: &mut usize,
|
||||
) -> Option<SolverError> {
|
||||
if current_norm > self.divergence_threshold {
|
||||
return Some(SolverError::Divergence {
|
||||
reason: format!("Residual {} exceeds threshold {}", current_norm, self.divergence_threshold),
|
||||
});
|
||||
}
|
||||
|
||||
if current_norm > previous_norm {
|
||||
*divergence_count += 1;
|
||||
if *divergence_count >= 3 {
|
||||
return Some(SolverError::Divergence {
|
||||
reason: format!("Residual increased 3x: {:.6e} → {:.6e}", previous_norm, current_norm),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
*divergence_count = 0;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Performs Armijo line search. Returns Some(alpha) if valid step found.
|
||||
/// hot path. `state_copy` and `new_residuals` must have appropriate lengths.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn line_search(
|
||||
&self,
|
||||
system: &System,
|
||||
state: &mut Vec<f64>,
|
||||
delta: &[f64],
|
||||
_residuals: &[f64],
|
||||
current_norm: f64,
|
||||
state_copy: &mut [f64],
|
||||
new_residuals: &mut Vec<f64>,
|
||||
clipping_mask: &[Option<(f64, f64)>],
|
||||
) -> Option<f64> {
|
||||
let mut alpha: f64 = 1.0;
|
||||
state_copy.copy_from_slice(state);
|
||||
let gradient_dot_delta = -current_norm;
|
||||
|
||||
for _backtrack in 0..self.line_search_max_backtracks {
|
||||
apply_newton_step(state, delta, clipping_mask, alpha);
|
||||
|
||||
if system.compute_residuals(state, new_residuals).is_err() {
|
||||
state.copy_from_slice(state_copy);
|
||||
alpha *= 0.5;
|
||||
continue;
|
||||
}
|
||||
|
||||
let new_norm = Self::residual_norm(new_residuals);
|
||||
if new_norm <= current_norm + self.line_search_armijo_c * alpha * gradient_dot_delta {
|
||||
tracing::debug!(alpha, old_norm = current_norm, new_norm, "Line search accepted");
|
||||
return Some(alpha);
|
||||
}
|
||||
|
||||
state.copy_from_slice(state_copy);
|
||||
alpha *= 0.5;
|
||||
}
|
||||
|
||||
tracing::warn!("Line search failed after {} backtracks", self.line_search_max_backtracks);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Solver for NewtonConfig {
|
||||
fn solve(&mut self, system: &mut System) -> Result<ConvergedState, SolverError> {
|
||||
let start_time = Instant::now();
|
||||
|
||||
tracing::info!(
|
||||
max_iterations = self.max_iterations,
|
||||
tolerance = self.tolerance,
|
||||
line_search = self.line_search,
|
||||
"Newton-Raphson solver starting"
|
||||
);
|
||||
|
||||
let n_state = system.full_state_vector_len();
|
||||
let n_equations: usize = system
|
||||
.traverse_for_jacobian()
|
||||
.map(|(_, c, _)| c.n_equations())
|
||||
.sum::<usize>()
|
||||
+ system.constraints().count()
|
||||
+ system.coupling_residual_count();
|
||||
|
||||
if n_state == 0 || n_equations == 0 {
|
||||
return Err(SolverError::InvalidSystem {
|
||||
message: "Empty system has no state variables or equations".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-allocate all buffers
|
||||
let mut state: Vec<f64> = self
|
||||
.initial_state
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
debug_assert_eq!(s.len(), n_state, "initial_state length mismatch");
|
||||
if s.len() == n_state { s.clone() } else { vec![0.0; n_state] }
|
||||
})
|
||||
.unwrap_or_else(|| vec![0.0; n_state]);
|
||||
let mut residuals: Vec<f64> = vec![0.0; n_equations];
|
||||
let mut jacobian_builder = JacobianBuilder::new();
|
||||
let mut divergence_count: usize = 0;
|
||||
let mut previous_norm: f64;
|
||||
let mut state_copy: Vec<f64> = vec![0.0; n_state]; // Pre-allocated for line search
|
||||
let mut new_residuals: Vec<f64> = vec![0.0; n_equations]; // Pre-allocated for line search
|
||||
let mut prev_iteration_state: Vec<f64> = vec![0.0; n_state]; // For convergence delta check
|
||||
|
||||
// Pre-allocate best-state tracking buffer (Story 4.5 - AC: #5)
|
||||
let mut best_state: Vec<f64> = vec![0.0; n_state];
|
||||
let mut best_residual: f64;
|
||||
|
||||
// Jacobian-freezing tracking state
|
||||
let mut jacobian_matrix = JacobianMatrix::zeros(n_equations, n_state);
|
||||
let mut frozen_count: usize = 0;
|
||||
let mut force_recompute: bool = true;
|
||||
|
||||
// Pre-compute clipping mask
|
||||
let clipping_mask: Vec<Option<(f64, f64)>> = (0..n_state)
|
||||
.map(|i| system.get_bounds_for_state_index(i))
|
||||
.collect();
|
||||
|
||||
// Initial residual computation
|
||||
system
|
||||
.compute_residuals(&state, &mut residuals)
|
||||
.map_err(|e| SolverError::InvalidSystem {
|
||||
message: format!("Failed to compute initial residuals: {:?}", e),
|
||||
})?;
|
||||
|
||||
let mut current_norm = Self::residual_norm(&residuals);
|
||||
best_state.copy_from_slice(&state);
|
||||
best_residual = current_norm;
|
||||
|
||||
tracing::debug!(iteration = 0, residual_norm = current_norm, "Initial state");
|
||||
|
||||
// Check if already converged
|
||||
if current_norm < self.tolerance {
|
||||
let status = if !system.saturated_variables().is_empty() {
|
||||
ConvergenceStatus::ControlSaturation
|
||||
} else {
|
||||
ConvergenceStatus::Converged
|
||||
};
|
||||
|
||||
if let Some(ref criteria) = self.convergence_criteria {
|
||||
let report = criteria.check(&state, None, &residuals, system);
|
||||
if report.is_globally_converged() {
|
||||
tracing::info!(iterations = 0, final_residual = current_norm, "Converged at initial state (criteria)");
|
||||
return Ok(ConvergedState::with_report(
|
||||
state, 0, current_norm, status, report, SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
tracing::info!(iterations = 0, final_residual = current_norm, "Converged at initial state");
|
||||
return Ok(ConvergedState::new(
|
||||
state, 0, current_norm, status, SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Main Newton-Raphson iteration loop
|
||||
for iteration in 1..=self.max_iterations {
|
||||
prev_iteration_state.copy_from_slice(&state);
|
||||
|
||||
// Check timeout
|
||||
if let Some(timeout) = self.timeout {
|
||||
if start_time.elapsed() > timeout {
|
||||
tracing::info!(iteration, elapsed_ms = ?start_time.elapsed(), best_residual, "Solver timed out");
|
||||
return self.handle_timeout(&best_state, best_residual, iteration - 1, timeout, system);
|
||||
}
|
||||
}
|
||||
|
||||
// Jacobian Assembly / Freeze Decision
|
||||
let should_recompute = if let Some(ref freeze_cfg) = self.jacobian_freezing {
|
||||
if force_recompute {
|
||||
true
|
||||
} else if frozen_count >= freeze_cfg.max_frozen_iters {
|
||||
tracing::debug!(iteration, frozen_count, "Jacobian freeze limit reached");
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
if should_recompute {
|
||||
// Fresh Jacobian assembly (in-place update)
|
||||
jacobian_builder.clear();
|
||||
if self.use_numerical_jacobian {
|
||||
// Numerical Jacobian via finite differences
|
||||
let compute_residuals_fn = |s: &[f64], r: &mut [f64]| {
|
||||
let s_vec = s.to_vec();
|
||||
let mut r_vec = vec![0.0; r.len()];
|
||||
let result = system.compute_residuals(&s_vec, &mut r_vec);
|
||||
r.copy_from_slice(&r_vec);
|
||||
result.map(|_| ()).map_err(|e| format!("{:?}", e))
|
||||
};
|
||||
let jm = JacobianMatrix::numerical(compute_residuals_fn, &state, &residuals, 1e-5)
|
||||
.map_err(|e| SolverError::InvalidSystem {
|
||||
message: format!("Failed to compute numerical Jacobian: {}", e),
|
||||
})?;
|
||||
jacobian_matrix.as_matrix_mut().copy_from(jm.as_matrix());
|
||||
} else {
|
||||
system.assemble_jacobian(&state, &mut jacobian_builder)
|
||||
.map_err(|e| SolverError::InvalidSystem {
|
||||
message: format!("Failed to assemble Jacobian: {:?}", e),
|
||||
})?;
|
||||
jacobian_matrix.update_from_builder(jacobian_builder.entries());
|
||||
};
|
||||
|
||||
frozen_count = 0;
|
||||
force_recompute = false;
|
||||
tracing::debug!(iteration, "Fresh Jacobian computed");
|
||||
} else {
|
||||
frozen_count += 1;
|
||||
tracing::debug!(iteration, frozen_count, "Reusing frozen Jacobian");
|
||||
}
|
||||
|
||||
// Solve J·Δx = -r
|
||||
let delta = match jacobian_matrix.solve(&residuals) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
return Err(SolverError::Divergence {
|
||||
reason: "Jacobian is singular".to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Apply step with optional line search
|
||||
let alpha = if self.line_search {
|
||||
match self.line_search(
|
||||
system, &mut state, &delta, &residuals, current_norm,
|
||||
&mut state_copy, &mut new_residuals, &clipping_mask,
|
||||
) {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
return Err(SolverError::Divergence {
|
||||
reason: "Line search failed".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
apply_newton_step(&mut state, &delta, &clipping_mask, 1.0);
|
||||
1.0
|
||||
};
|
||||
|
||||
system.compute_residuals(&state, &mut residuals)
|
||||
.map_err(|e| SolverError::InvalidSystem {
|
||||
message: format!("Failed to compute residuals: {:?}", e),
|
||||
})?;
|
||||
|
||||
previous_norm = current_norm;
|
||||
current_norm = Self::residual_norm(&residuals);
|
||||
|
||||
if current_norm < best_residual {
|
||||
best_state.copy_from_slice(&state);
|
||||
best_residual = current_norm;
|
||||
tracing::debug!(iteration, best_residual, "Best state updated");
|
||||
}
|
||||
|
||||
// Jacobian-freeze feedback
|
||||
if let Some(ref freeze_cfg) = self.jacobian_freezing {
|
||||
if previous_norm > 0.0 && current_norm / previous_norm >= (1.0 - freeze_cfg.threshold) {
|
||||
if frozen_count > 0 || !force_recompute {
|
||||
tracing::debug!(iteration, current_norm, previous_norm, "Unfreezing Jacobian");
|
||||
}
|
||||
force_recompute = true;
|
||||
frozen_count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(iteration, residual_norm = current_norm, alpha, "Newton iteration complete");
|
||||
|
||||
// Check convergence
|
||||
let converged = if let Some(ref criteria) = self.convergence_criteria {
|
||||
let report = criteria.check(&state, Some(&prev_iteration_state), &residuals, system);
|
||||
if report.is_globally_converged() {
|
||||
let status = if !system.saturated_variables().is_empty() {
|
||||
ConvergenceStatus::ControlSaturation
|
||||
} else {
|
||||
ConvergenceStatus::Converged
|
||||
};
|
||||
tracing::info!(iterations = iteration, final_residual = current_norm, "Converged (criteria)");
|
||||
return Ok(ConvergedState::with_report(
|
||||
state, iteration, current_norm, status, report, SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
false
|
||||
} else {
|
||||
current_norm < self.tolerance
|
||||
};
|
||||
|
||||
if converged {
|
||||
let status = if !system.saturated_variables().is_empty() {
|
||||
ConvergenceStatus::ControlSaturation
|
||||
} else {
|
||||
ConvergenceStatus::Converged
|
||||
};
|
||||
tracing::info!(iterations = iteration, final_residual = current_norm, "Converged");
|
||||
return Ok(ConvergedState::new(
|
||||
state, iteration, current_norm, status, SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(err) = self.check_divergence(current_norm, previous_norm, &mut divergence_count) {
|
||||
tracing::warn!(iteration, residual_norm = current_norm, "Divergence detected");
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::warn!(max_iterations = self.max_iterations, final_residual = current_norm, "Did not converge");
|
||||
Err(SolverError::NonConvergence {
|
||||
iterations: self.max_iterations,
|
||||
final_residual: current_norm,
|
||||
})
|
||||
}
|
||||
|
||||
fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = Some(timeout);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::solver::Solver;
|
||||
use crate::system::System;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_newton_config_with_timeout() {
|
||||
let cfg = NewtonConfig::default().with_timeout(Duration::from_millis(100));
|
||||
assert_eq!(cfg.timeout, Some(Duration::from_millis(100)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_newton_config_default() {
|
||||
let cfg = NewtonConfig::default();
|
||||
assert_eq!(cfg.max_iterations, 100);
|
||||
assert!(cfg.tolerance > 0.0 && cfg.tolerance < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_newton_solver_trait_object() {
|
||||
let mut boxed: Box<dyn Solver> = Box::new(NewtonConfig::default());
|
||||
let mut system = System::new();
|
||||
system.finalize().unwrap();
|
||||
assert!(boxed.solve(&mut system).is_err());
|
||||
}
|
||||
}
|
||||
467
crates/solver/src/strategies/sequential_substitution.rs
Normal file
467
crates/solver/src/strategies/sequential_substitution.rs
Normal file
@@ -0,0 +1,467 @@
|
||||
//! Sequential Substitution (Picard iteration) solver implementation.
|
||||
//!
|
||||
//! Provides [`PicardConfig`] which implements Picard iteration for solving
|
||||
//! systems of non-linear equations. Slower than Newton-Raphson but more robust.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::criteria::ConvergenceCriteria;
|
||||
use crate::metadata::SimulationMetadata;
|
||||
use crate::solver::{ConvergedState, ConvergenceStatus, Solver, SolverError, TimeoutConfig};
|
||||
use crate::system::System;
|
||||
|
||||
/// Configuration for the Sequential Substitution (Picard iteration) solver.
|
||||
///
|
||||
/// Solves x = G(x) by iterating: x_{k+1} = (1-ω)·x_k + ω·G(x_k)
|
||||
/// where ω ∈ (0,1] is the relaxation factor.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PicardConfig {
|
||||
/// Maximum iterations. Default: 100.
|
||||
pub max_iterations: usize,
|
||||
/// Convergence tolerance (L2 norm). Default: 1e-6.
|
||||
pub tolerance: f64,
|
||||
/// Relaxation factor ω ∈ (0,1]. Default: 0.5.
|
||||
pub relaxation_factor: f64,
|
||||
/// Optional time budget.
|
||||
pub timeout: Option<Duration>,
|
||||
/// Divergence threshold. Default: 1e10.
|
||||
pub divergence_threshold: f64,
|
||||
/// Consecutive increases before divergence. Default: 5.
|
||||
pub divergence_patience: usize,
|
||||
/// Timeout behavior configuration.
|
||||
pub timeout_config: TimeoutConfig,
|
||||
/// Previous state for ZOH fallback.
|
||||
pub previous_state: Option<Vec<f64>>,
|
||||
/// Residual for previous_state.
|
||||
pub previous_residual: Option<f64>,
|
||||
/// Smart initial state for cold-start.
|
||||
pub initial_state: Option<Vec<f64>>,
|
||||
/// Multi-circuit convergence criteria.
|
||||
pub convergence_criteria: Option<ConvergenceCriteria>,
|
||||
}
|
||||
|
||||
impl Default for PicardConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_iterations: 100,
|
||||
tolerance: 1e-6,
|
||||
relaxation_factor: 0.5,
|
||||
timeout: None,
|
||||
divergence_threshold: 1e10,
|
||||
divergence_patience: 5,
|
||||
timeout_config: TimeoutConfig::default(),
|
||||
previous_state: None,
|
||||
previous_residual: None,
|
||||
initial_state: None,
|
||||
convergence_criteria: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PicardConfig {
|
||||
/// Sets the initial state for cold-start solving (Story 4.6 — builder pattern).
|
||||
///
|
||||
/// The solver will start from `state` instead of the zero vector.
|
||||
/// Use [`SmartInitializer::populate_state`] to generate a physically reasonable
|
||||
/// initial guess.
|
||||
pub fn with_initial_state(mut self, state: Vec<f64>) -> Self {
|
||||
self.initial_state = Some(state);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets multi-circuit convergence criteria (Story 4.7 — builder pattern).
|
||||
///
|
||||
/// When set, the solver uses [`ConvergenceCriteria::check()`] instead of the
|
||||
/// raw L2-norm `tolerance` check.
|
||||
pub fn with_convergence_criteria(mut self, criteria: ConvergenceCriteria) -> Self {
|
||||
self.convergence_criteria = Some(criteria);
|
||||
self
|
||||
}
|
||||
|
||||
/// Computes the residual norm (L2 norm of the residual vector).
|
||||
fn residual_norm(residuals: &[f64]) -> f64 {
|
||||
residuals.iter().map(|r| r * r).sum::<f64>().sqrt()
|
||||
}
|
||||
|
||||
/// Handles timeout based on configuration (Story 4.5).
|
||||
///
|
||||
/// Returns either:
|
||||
/// - `Ok(ConvergedState)` with `TimedOutWithBestState` status (default)
|
||||
/// - `Err(SolverError::Timeout)` if `return_best_state_on_timeout` is false
|
||||
/// - Previous state (ZOH) if `zoh_fallback` is true and previous state available
|
||||
fn handle_timeout(
|
||||
&self,
|
||||
best_state: &[f64],
|
||||
best_residual: f64,
|
||||
iterations: usize,
|
||||
timeout: Duration,
|
||||
system: &System,
|
||||
) -> Result<ConvergedState, SolverError> {
|
||||
// If configured to return error on timeout
|
||||
if !self.timeout_config.return_best_state_on_timeout {
|
||||
return Err(SolverError::Timeout {
|
||||
timeout_ms: timeout.as_millis() as u64,
|
||||
});
|
||||
}
|
||||
|
||||
// If ZOH fallback is enabled and previous state is available
|
||||
if self.timeout_config.zoh_fallback {
|
||||
if let Some(ref prev_state) = self.previous_state {
|
||||
let residual = self.previous_residual.unwrap_or(best_residual);
|
||||
tracing::info!(
|
||||
iterations = iterations,
|
||||
residual = residual,
|
||||
"Returning previous state (ZOH fallback) on timeout"
|
||||
);
|
||||
return Ok(ConvergedState::new(
|
||||
prev_state.clone(),
|
||||
iterations,
|
||||
residual,
|
||||
ConvergenceStatus::TimedOutWithBestState,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Default: return best state encountered during iteration
|
||||
tracing::info!(
|
||||
iterations = iterations,
|
||||
best_residual = best_residual,
|
||||
"Returning best state on timeout"
|
||||
);
|
||||
Ok(ConvergedState::new(
|
||||
best_state.to_vec(),
|
||||
iterations,
|
||||
best_residual,
|
||||
ConvergenceStatus::TimedOutWithBestState,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
))
|
||||
}
|
||||
|
||||
/// Checks for divergence based on residual growth pattern.
|
||||
///
|
||||
/// Returns `Some(SolverError::Divergence)` if:
|
||||
/// - Residual norm exceeds `divergence_threshold`, or
|
||||
/// - Residual has increased for `divergence_patience`+ consecutive iterations
|
||||
fn check_divergence(
|
||||
&self,
|
||||
current_norm: f64,
|
||||
previous_norm: f64,
|
||||
divergence_count: &mut usize,
|
||||
) -> Option<SolverError> {
|
||||
// Check absolute threshold
|
||||
if current_norm > self.divergence_threshold {
|
||||
return Some(SolverError::Divergence {
|
||||
reason: format!(
|
||||
"Residual norm {} exceeds threshold {}",
|
||||
current_norm, self.divergence_threshold
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Check consecutive increases
|
||||
if current_norm > previous_norm {
|
||||
*divergence_count += 1;
|
||||
if *divergence_count >= self.divergence_patience {
|
||||
return Some(SolverError::Divergence {
|
||||
reason: format!(
|
||||
"Residual increased for {} consecutive iterations: {:.6e} → {:.6e}",
|
||||
self.divergence_patience, previous_norm, current_norm
|
||||
),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
*divergence_count = 0;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Applies relaxation to the state update.
|
||||
///
|
||||
/// Update formula: x_new = x_old - omega * residual
|
||||
/// where residual = F(x_k) represents the equation residuals.
|
||||
///
|
||||
/// This is the standard Picard iteration: x_{k+1} = x_k - ω·F(x_k)
|
||||
fn apply_relaxation(state: &mut [f64], residuals: &[f64], omega: f64) {
|
||||
for (x, &r) in state.iter_mut().zip(residuals.iter()) {
|
||||
*x -= omega * r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Solver for PicardConfig {
|
||||
fn solve(&mut self, system: &mut System) -> Result<ConvergedState, SolverError> {
|
||||
let start_time = Instant::now();
|
||||
|
||||
tracing::info!(
|
||||
max_iterations = self.max_iterations,
|
||||
tolerance = self.tolerance,
|
||||
relaxation_factor = self.relaxation_factor,
|
||||
divergence_threshold = self.divergence_threshold,
|
||||
divergence_patience = self.divergence_patience,
|
||||
"Sequential Substitution (Picard) solver starting"
|
||||
);
|
||||
|
||||
// Get system dimensions
|
||||
let n_state = system.full_state_vector_len();
|
||||
let n_equations: usize = system
|
||||
.traverse_for_jacobian()
|
||||
.map(|(_, c, _)| c.n_equations())
|
||||
.sum::<usize>()
|
||||
+ system.constraints().count()
|
||||
+ system.coupling_residual_count();
|
||||
|
||||
// Validate system
|
||||
if n_state == 0 || n_equations == 0 {
|
||||
return Err(SolverError::InvalidSystem {
|
||||
message: "Empty system has no state variables or equations".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate state/equation dimensions
|
||||
if n_state != n_equations {
|
||||
return Err(SolverError::InvalidSystem {
|
||||
message: format!(
|
||||
"State dimension ({}) does not match equation count ({})",
|
||||
n_state, n_equations
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Pre-allocate all buffers (AC: #6 - no heap allocation in iteration loop)
|
||||
// Story 4.6 - AC: #8: Use initial_state if provided, else start from zeros
|
||||
let mut state: Vec<f64> = self
|
||||
.initial_state
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
debug_assert_eq!(
|
||||
s.len(),
|
||||
n_state,
|
||||
"initial_state length mismatch: expected {}, got {}",
|
||||
n_state,
|
||||
s.len()
|
||||
);
|
||||
if s.len() == n_state {
|
||||
s.clone()
|
||||
} else {
|
||||
vec![0.0; n_state]
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| vec![0.0; n_state]);
|
||||
let mut prev_iteration_state: Vec<f64> = vec![0.0; n_state]; // For convergence delta check
|
||||
let mut residuals: Vec<f64> = vec![0.0; n_equations];
|
||||
let mut divergence_count: usize = 0;
|
||||
let mut previous_norm: f64;
|
||||
|
||||
// Pre-allocate best-state tracking buffer (Story 4.5 - AC: #5)
|
||||
let mut best_state: Vec<f64> = vec![0.0; n_state];
|
||||
let mut best_residual: f64;
|
||||
|
||||
// Initial residual computation
|
||||
system
|
||||
.compute_residuals(&state, &mut residuals)
|
||||
.map_err(|e| SolverError::InvalidSystem {
|
||||
message: format!("Failed to compute initial residuals: {:?}", e),
|
||||
})?;
|
||||
|
||||
let mut current_norm = Self::residual_norm(&residuals);
|
||||
|
||||
// Initialize best state tracking with initial state
|
||||
best_state.copy_from_slice(&state);
|
||||
best_residual = current_norm;
|
||||
|
||||
tracing::debug!(iteration = 0, residual_norm = current_norm, "Initial state");
|
||||
|
||||
// Check if already converged
|
||||
if current_norm < self.tolerance {
|
||||
tracing::info!(
|
||||
iterations = 0,
|
||||
final_residual = current_norm,
|
||||
"System already converged at initial state"
|
||||
);
|
||||
return Ok(ConvergedState::new(
|
||||
state,
|
||||
0,
|
||||
current_norm,
|
||||
ConvergenceStatus::Converged,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
|
||||
// Main Picard iteration loop
|
||||
for iteration in 1..=self.max_iterations {
|
||||
// Save state before step for convergence criteria delta checks
|
||||
prev_iteration_state.copy_from_slice(&state);
|
||||
|
||||
// Check timeout at iteration start (Story 4.5 - AC: #1)
|
||||
if let Some(timeout) = self.timeout {
|
||||
if start_time.elapsed() > timeout {
|
||||
tracing::info!(
|
||||
iteration = iteration,
|
||||
elapsed_ms = start_time.elapsed().as_millis(),
|
||||
timeout_ms = timeout.as_millis(),
|
||||
best_residual = best_residual,
|
||||
"Solver timed out"
|
||||
);
|
||||
|
||||
// Story 4.5 - AC: #2, #6: Return best state or error based on config
|
||||
return self.handle_timeout(
|
||||
&best_state,
|
||||
best_residual,
|
||||
iteration - 1,
|
||||
timeout,
|
||||
system,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply relaxed update: x_new = x_old - omega * residual (AC: #2, #3)
|
||||
Self::apply_relaxation(&mut state, &residuals, self.relaxation_factor);
|
||||
|
||||
// Compute new residuals
|
||||
system
|
||||
.compute_residuals(&state, &mut residuals)
|
||||
.map_err(|e| SolverError::InvalidSystem {
|
||||
message: format!("Failed to compute residuals: {:?}", e),
|
||||
})?;
|
||||
|
||||
previous_norm = current_norm;
|
||||
current_norm = Self::residual_norm(&residuals);
|
||||
|
||||
// Update best state if residual improved (Story 4.5 - AC: #2)
|
||||
if current_norm < best_residual {
|
||||
best_state.copy_from_slice(&state);
|
||||
best_residual = current_norm;
|
||||
tracing::debug!(
|
||||
iteration = iteration,
|
||||
best_residual = best_residual,
|
||||
"Best state updated"
|
||||
);
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
iteration = iteration,
|
||||
residual_norm = current_norm,
|
||||
relaxation_factor = self.relaxation_factor,
|
||||
"Picard iteration complete"
|
||||
);
|
||||
|
||||
// Check convergence (AC: #1, Story 4.7 — criteria-aware)
|
||||
let converged = if let Some(ref criteria) = self.convergence_criteria {
|
||||
let report =
|
||||
criteria.check(&state, Some(&prev_iteration_state), &residuals, system);
|
||||
if report.is_globally_converged() {
|
||||
tracing::info!(
|
||||
iterations = iteration,
|
||||
final_residual = current_norm,
|
||||
relaxation_factor = self.relaxation_factor,
|
||||
"Sequential Substitution converged (criteria)"
|
||||
);
|
||||
return Ok(ConvergedState::with_report(
|
||||
state,
|
||||
iteration,
|
||||
current_norm,
|
||||
ConvergenceStatus::Converged,
|
||||
report,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
false
|
||||
} else {
|
||||
current_norm < self.tolerance
|
||||
};
|
||||
|
||||
if converged {
|
||||
tracing::info!(
|
||||
iterations = iteration,
|
||||
final_residual = current_norm,
|
||||
relaxation_factor = self.relaxation_factor,
|
||||
"Sequential Substitution converged"
|
||||
);
|
||||
return Ok(ConvergedState::new(
|
||||
state,
|
||||
iteration,
|
||||
current_norm,
|
||||
ConvergenceStatus::Converged,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
|
||||
// Check divergence (AC: #5)
|
||||
if let Some(err) =
|
||||
self.check_divergence(current_norm, previous_norm, &mut divergence_count)
|
||||
{
|
||||
tracing::warn!(
|
||||
iteration = iteration,
|
||||
residual_norm = current_norm,
|
||||
"Divergence detected"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Max iterations exceeded
|
||||
tracing::warn!(
|
||||
max_iterations = self.max_iterations,
|
||||
final_residual = current_norm,
|
||||
"Sequential Substitution did not converge"
|
||||
);
|
||||
Err(SolverError::NonConvergence {
|
||||
iterations: self.max_iterations,
|
||||
final_residual: current_norm,
|
||||
})
|
||||
}
|
||||
|
||||
fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = Some(timeout);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::solver::Solver;
|
||||
use crate::system::System;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_picard_config_with_timeout() {
|
||||
let timeout = Duration::from_millis(250);
|
||||
let cfg = PicardConfig::default().with_timeout(timeout);
|
||||
assert_eq!(cfg.timeout, Some(timeout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_picard_config_default_sensible() {
|
||||
let cfg = PicardConfig::default();
|
||||
assert_eq!(cfg.max_iterations, 100);
|
||||
assert!(cfg.tolerance > 0.0 && cfg.tolerance < 1e-3);
|
||||
assert!(cfg.relaxation_factor > 0.0 && cfg.relaxation_factor <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_picard_apply_relaxation_formula() {
|
||||
let mut state = vec![10.0, 20.0];
|
||||
let residuals = vec![1.0, 2.0];
|
||||
PicardConfig::apply_relaxation(&mut state, &residuals, 0.5);
|
||||
assert!((state[0] - 9.5).abs() < 1e-15);
|
||||
assert!((state[1] - 19.0).abs() < 1e-15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_picard_residual_norm() {
|
||||
let residuals = vec![3.0, 4.0];
|
||||
let norm = PicardConfig::residual_norm(&residuals);
|
||||
assert!((norm - 5.0).abs() < 1e-15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_picard_solver_trait_object() {
|
||||
let mut boxed: Box<dyn Solver> = Box::new(PicardConfig::default());
|
||||
let mut system = System::new();
|
||||
system.finalize().unwrap();
|
||||
assert!(boxed.solve(&mut system).is_err());
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
use entropyk_components::{
|
||||
validate_port_continuity, Component, ComponentError, ConnectionError, JacobianBuilder,
|
||||
ResidualVector, SystemState as StateSlice,
|
||||
ResidualVector, StateSlice,
|
||||
};
|
||||
use petgraph::algo;
|
||||
use petgraph::graph::{EdgeIndex, Graph, NodeIndex};
|
||||
@@ -24,32 +24,10 @@ use crate::inverse::{
|
||||
BoundedVariable, BoundedVariableError, BoundedVariableId, Constraint, ConstraintError,
|
||||
ConstraintId, DoFError, InverseControlConfig,
|
||||
};
|
||||
use entropyk_core::Temperature;
|
||||
use entropyk_core::{CircuitId, Temperature};
|
||||
|
||||
/// Circuit identifier. Valid range 0..=4 (max 5 circuits per machine).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct CircuitId(pub u8);
|
||||
|
||||
impl CircuitId {
|
||||
/// Maximum circuit ID (inclusive). Machine supports up to 5 circuits.
|
||||
pub const MAX: u8 = 4;
|
||||
|
||||
/// Creates a new CircuitId if within valid range.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `TopologyError::TooManyCircuits` if `id > 4`.
|
||||
pub fn new(id: u8) -> Result<Self, TopologyError> {
|
||||
if id <= Self::MAX {
|
||||
Ok(CircuitId(id))
|
||||
} else {
|
||||
Err(TopologyError::TooManyCircuits { requested: id })
|
||||
}
|
||||
}
|
||||
|
||||
/// Circuit 0 (default for single-circuit systems).
|
||||
pub const ZERO: CircuitId = CircuitId(0);
|
||||
}
|
||||
/// Maximum circuit ID (inclusive). Machine supports up to 5 circuits.
|
||||
pub const MAX_CIRCUIT_ID: u16 = 4;
|
||||
|
||||
/// Weight for flow edges in the system graph.
|
||||
///
|
||||
@@ -130,7 +108,11 @@ impl System {
|
||||
component: Box<dyn Component>,
|
||||
circuit_id: CircuitId,
|
||||
) -> Result<NodeIndex, TopologyError> {
|
||||
CircuitId::new(circuit_id.0)?;
|
||||
if circuit_id.0 > MAX_CIRCUIT_ID {
|
||||
return Err(TopologyError::TooManyCircuits {
|
||||
requested: circuit_id.0,
|
||||
});
|
||||
}
|
||||
self.finalized = false;
|
||||
let node_idx = self.graph.add_node(component);
|
||||
self.node_to_circuit.insert(node_idx, circuit_id);
|
||||
@@ -577,7 +559,7 @@ impl System {
|
||||
if self.graph.node_count() == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut ids: Vec<u8> = self.node_to_circuit.values().map(|c| c.0).collect();
|
||||
let mut ids: Vec<u16> = self.node_to_circuit.values().map(|c| c.0).collect();
|
||||
if ids.is_empty() {
|
||||
// This shouldn't happen since add_component adds to node_to_circuit,
|
||||
// but handle defensively
|
||||
@@ -1761,25 +1743,69 @@ impl System {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tolerance for mass balance validation [kg/s].
|
||||
///
|
||||
/// This value (1e-9 kg/s) is tight enough to catch numerical issues
|
||||
/// while allowing for floating-point rounding errors.
|
||||
pub const MASS_BALANCE_TOLERANCE_KG_S: f64 = 1e-9;
|
||||
|
||||
/// Tolerance for energy balance validation in Watts (1e-6 kW)
|
||||
pub const ENERGY_BALANCE_TOLERANCE_W: f64 = 1e-3;
|
||||
|
||||
/// Verifies that global mass balance is conserved.
|
||||
///
|
||||
/// Sums the mass flow rates at the ports of each component and ensures they
|
||||
/// sum to zero within a tight tolerance (1e-9 kg/s).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `Ok(())` if all components pass mass balance validation
|
||||
/// * `Err(SolverError::Validation)` if any component violates mass conservation
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Components without `port_mass_flows` implementation are logged as warnings
|
||||
/// and skipped. This ensures visibility of incomplete implementations without
|
||||
/// failing the validation.
|
||||
pub fn check_mass_balance(&self, state: &StateSlice) -> Result<(), crate::SolverError> {
|
||||
let tolerance = 1e-9;
|
||||
let mut total_mass_error = 0.0;
|
||||
let mut has_violation = false;
|
||||
let mut components_checked = 0usize;
|
||||
let mut components_skipped = 0usize;
|
||||
|
||||
for (_node_idx, component, _edge_indices) in self.traverse_for_jacobian() {
|
||||
if let Ok(mass_flows) = component.port_mass_flows(state) {
|
||||
let sum: f64 = mass_flows.iter().map(|m| m.to_kg_per_s()).sum();
|
||||
if sum.abs() > tolerance {
|
||||
has_violation = true;
|
||||
total_mass_error += sum.abs();
|
||||
for (node_idx, component, _edge_indices) in self.traverse_for_jacobian() {
|
||||
match component.port_mass_flows(state) {
|
||||
Ok(mass_flows) => {
|
||||
let sum: f64 = mass_flows.iter().map(|m| m.to_kg_per_s()).sum();
|
||||
if sum.abs() > Self::MASS_BALANCE_TOLERANCE_KG_S {
|
||||
has_violation = true;
|
||||
total_mass_error += sum.abs();
|
||||
tracing::warn!(
|
||||
node_index = node_idx.index(),
|
||||
mass_imbalance_kg_s = sum,
|
||||
"Mass balance violation detected at component"
|
||||
);
|
||||
}
|
||||
components_checked += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
components_skipped += 1;
|
||||
tracing::warn!(
|
||||
node_index = node_idx.index(),
|
||||
error = %e,
|
||||
"Component does not implement port_mass_flows - skipping mass balance check"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
components_checked,
|
||||
components_skipped,
|
||||
total_mass_error_kg_s = total_mass_error,
|
||||
"Mass balance validation complete"
|
||||
);
|
||||
|
||||
if has_violation {
|
||||
return Err(crate::SolverError::Validation {
|
||||
mass_error: total_mass_error,
|
||||
@@ -1788,6 +1814,164 @@ impl System {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verifies the First Law of Thermodynamics for all components in the system.
|
||||
///
|
||||
/// Validates that ΣQ - ΣW + Σ(ṁ·h) = 0 for each component.
|
||||
/// Returns `SolverError::Validation` if any component violates the balance.
|
||||
pub fn check_energy_balance(&self, state: &StateSlice) -> Result<(), crate::SolverError> {
|
||||
let mut total_energy_error = 0.0;
|
||||
let mut has_violation = false;
|
||||
let mut components_checked = 0usize;
|
||||
let mut components_skipped = 0usize;
|
||||
let mut skipped_components: Vec<String> = Vec::new();
|
||||
|
||||
for (node_idx, component, _edge_indices) in self.traverse_for_jacobian() {
|
||||
let energy_transfers = component.energy_transfers(state);
|
||||
let mass_flows = component.port_mass_flows(state);
|
||||
let enthalpies = component.port_enthalpies(state);
|
||||
|
||||
match (energy_transfers, mass_flows, enthalpies) {
|
||||
(Some((heat, work)), Ok(m_flows), Ok(h_flows))
|
||||
if m_flows.len() == h_flows.len() =>
|
||||
{
|
||||
let mut net_energy_flow = 0.0;
|
||||
for (m, h) in m_flows.iter().zip(h_flows.iter()) {
|
||||
net_energy_flow += m.to_kg_per_s() * h.to_joules_per_kg();
|
||||
}
|
||||
|
||||
let balance = heat.to_watts() - work.to_watts() + net_energy_flow;
|
||||
|
||||
if balance.abs() > Self::ENERGY_BALANCE_TOLERANCE_W {
|
||||
has_violation = true;
|
||||
total_energy_error += balance.abs();
|
||||
tracing::warn!(
|
||||
node_index = node_idx.index(),
|
||||
energy_imbalance_w = balance,
|
||||
"Energy balance violation detected at component"
|
||||
);
|
||||
}
|
||||
components_checked += 1;
|
||||
}
|
||||
_ => {
|
||||
components_skipped += 1;
|
||||
let component_type = std::any::type_name_of_val(component)
|
||||
.split("::")
|
||||
.last()
|
||||
.unwrap_or("unknown");
|
||||
let component_info =
|
||||
format!("{} (type: {})", component.signature(), component_type);
|
||||
skipped_components.push(component_info.clone());
|
||||
|
||||
tracing::warn!(
|
||||
component = %component_info,
|
||||
node_index = node_idx.index(),
|
||||
"Component lacks energy_transfers() or port_enthalpies() - SKIPPED in energy balance validation"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Summary warning if components were skipped
|
||||
if components_skipped > 0 {
|
||||
tracing::warn!(
|
||||
components_checked = components_checked,
|
||||
components_skipped = components_skipped,
|
||||
skipped = ?skipped_components,
|
||||
"Energy balance validation incomplete: {} component(s) skipped. \
|
||||
Implement energy_transfers() and port_enthalpies() for full validation.",
|
||||
components_skipped
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
components_checked,
|
||||
components_skipped,
|
||||
total_energy_error_w = total_energy_error,
|
||||
"Energy balance validation complete"
|
||||
);
|
||||
}
|
||||
|
||||
if has_violation {
|
||||
return Err(crate::SolverError::Validation {
|
||||
mass_error: 0.0,
|
||||
energy_error: total_energy_error,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generates a deterministic byte representation of the system configuration.
|
||||
/// Used for simulation traceability logic.
|
||||
pub fn generate_canonical_bytes(&self) -> Vec<u8> {
|
||||
let mut repr = String::new();
|
||||
repr.push_str("Nodes:\n");
|
||||
// To be deterministic, we just iterate in graph order which is stable
|
||||
// as long as we don't delete nodes.
|
||||
for node in self.graph.node_indices() {
|
||||
let circuit_id = self.node_to_circuit.get(&node).map(|c| c.0).unwrap_or(0);
|
||||
repr.push_str(&format!(
|
||||
" Node({}): Circuit({})\n",
|
||||
node.index(),
|
||||
circuit_id
|
||||
));
|
||||
if let Some(comp) = self.graph.node_weight(node) {
|
||||
repr.push_str(&format!(" Signature: {}\n", comp.signature()));
|
||||
}
|
||||
}
|
||||
repr.push_str("Edges:\n");
|
||||
for edge_idx in self.graph.edge_indices() {
|
||||
if let Some((src, tgt)) = self.graph.edge_endpoints(edge_idx) {
|
||||
repr.push_str(&format!(" Edge: {} -> {}\n", src.index(), tgt.index()));
|
||||
}
|
||||
}
|
||||
repr.push_str("Thermal Couplings:\n");
|
||||
for coupling in &self.thermal_couplings {
|
||||
repr.push_str(&format!(
|
||||
" Hot: {}, Cold: {}, UA: {}\n",
|
||||
coupling.hot_circuit.0, coupling.cold_circuit.0, coupling.ua
|
||||
));
|
||||
}
|
||||
repr.push_str("Constraints:\n");
|
||||
let mut constraint_keys: Vec<_> = self.constraints.keys().collect();
|
||||
constraint_keys.sort_by_key(|k| k.as_str());
|
||||
for key in constraint_keys {
|
||||
let c = &self.constraints[key];
|
||||
repr.push_str(&format!(" {}: {}\n", c.id().as_str(), c.target_value()));
|
||||
}
|
||||
repr.push_str("Bounded Variables:\n");
|
||||
let mut bounded_keys: Vec<_> = self.bounded_variables.keys().collect();
|
||||
bounded_keys.sort_by_key(|k| k.as_str());
|
||||
for key in bounded_keys {
|
||||
let var = &self.bounded_variables[key];
|
||||
repr.push_str(&format!(
|
||||
" {}: [{}, {}]\n",
|
||||
var.id().as_str(),
|
||||
var.min(),
|
||||
var.max()
|
||||
));
|
||||
}
|
||||
|
||||
repr.push_str("Inverse Control Mappings:\n");
|
||||
// For inverse control mappings, they are ordered internally. We'll just iterate linked controls.
|
||||
for (i, (constraint, bounded_var)) in self.inverse_control.mappings().enumerate() {
|
||||
repr.push_str(&format!(
|
||||
" Mapping {}: {} -> {}\n",
|
||||
i,
|
||||
constraint.as_str(),
|
||||
bounded_var.as_str()
|
||||
));
|
||||
}
|
||||
|
||||
repr.into_bytes()
|
||||
}
|
||||
|
||||
/// Computes the SHA-256 hash uniquely identifying the input configuration.
|
||||
pub fn input_hash(&self) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(self.generate_canonical_bytes());
|
||||
format!("{:064x}", hasher.finalize())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for System {
|
||||
@@ -1801,7 +1985,7 @@ mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
use entropyk_components::port::{FluidId, Port};
|
||||
use entropyk_components::{ConnectedPort, SystemState};
|
||||
use entropyk_components::{ConnectedPort, StateSlice};
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
|
||||
/// Minimal mock component for testing.
|
||||
@@ -1812,7 +1996,7 @@ mod tests {
|
||||
impl Component for MockComponent {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
residuals: &mut entropyk_components::ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
for r in residuals.iter_mut().take(self.n_equations) {
|
||||
@@ -1823,7 +2007,7 @@ mod tests {
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
for i in 0..self.n_equations {
|
||||
@@ -1869,7 +2053,7 @@ mod tests {
|
||||
impl Component for PortedMockComponent {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
residuals: &mut entropyk_components::ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
for r in residuals.iter_mut() {
|
||||
@@ -1880,7 +2064,7 @@ mod tests {
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
@@ -2579,7 +2763,7 @@ mod tests {
|
||||
impl Component for ZeroFlowMock {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &SystemState,
|
||||
state: &StateSlice,
|
||||
residuals: &mut entropyk_components::ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if !state.is_empty() {
|
||||
@@ -2590,7 +2774,7 @@ mod tests {
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
jacobian.add_entry(0, 0, 1.0);
|
||||
@@ -3565,7 +3749,7 @@ mod tests {
|
||||
impl Component for BadMassFlowComponent {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
_residuals: &mut entropyk_components::ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
@@ -3573,7 +3757,7 @@ mod tests {
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
@@ -3587,7 +3771,10 @@ mod tests {
|
||||
&self.ports
|
||||
}
|
||||
|
||||
fn port_mass_flows(&self, _state: &SystemState) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||||
fn port_mass_flows(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||||
Ok(vec![
|
||||
entropyk_core::MassFlow::from_kg_per_s(1.0),
|
||||
entropyk_core::MassFlow::from_kg_per_s(-0.5), // Intentionally unbalanced
|
||||
@@ -3595,10 +3782,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Component with balanced mass flow (inlet = outlet)
|
||||
struct BalancedMassFlowComponent {
|
||||
ports: Vec<ConnectedPort>,
|
||||
}
|
||||
|
||||
impl Component for BalancedMassFlowComponent {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_residuals: &mut entropyk_components::ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&self.ports
|
||||
}
|
||||
|
||||
fn port_mass_flows(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
|
||||
// Balanced: inlet = 1.0 kg/s, outlet = -1.0 kg/s (sum = 0)
|
||||
Ok(vec![
|
||||
entropyk_core::MassFlow::from_kg_per_s(1.0),
|
||||
entropyk_core::MassFlow::from_kg_per_s(-1.0),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mass_balance_violation() {
|
||||
fn test_mass_balance_passes_for_balanced_component() {
|
||||
let mut system = System::new();
|
||||
|
||||
|
||||
let inlet = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_bar(1.0),
|
||||
@@ -3610,20 +3839,295 @@ mod tests {
|
||||
Enthalpy::from_joules_per_kg(400000.0),
|
||||
);
|
||||
let (c1, c2) = inlet.connect(outlet).unwrap();
|
||||
|
||||
|
||||
let comp = Box::new(BalancedMassFlowComponent {
|
||||
ports: vec![c1, c2],
|
||||
});
|
||||
|
||||
let n0 = system.add_component(comp);
|
||||
system.add_edge(n0, n0).unwrap(); // Self-edge to avoid isolated node
|
||||
|
||||
system.finalize().unwrap();
|
||||
|
||||
let state = vec![0.0; system.full_state_vector_len()];
|
||||
let result = system.check_mass_balance(&state);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Expected mass balance to pass for balanced component"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mass_balance_violation() {
|
||||
let mut system = System::new();
|
||||
|
||||
let inlet = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_bar(1.0),
|
||||
Enthalpy::from_joules_per_kg(400000.0),
|
||||
);
|
||||
let outlet = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_bar(1.0),
|
||||
Enthalpy::from_joules_per_kg(400000.0),
|
||||
);
|
||||
let (c1, c2) = inlet.connect(outlet).unwrap();
|
||||
|
||||
let comp = Box::new(BadMassFlowComponent {
|
||||
ports: vec![c1, c2], // Just to have ports
|
||||
});
|
||||
|
||||
|
||||
let n0 = system.add_component(comp);
|
||||
system.add_edge(n0, n0).unwrap(); // Self-edge to avoid isolated node
|
||||
|
||||
|
||||
system.finalize().unwrap();
|
||||
|
||||
|
||||
// Ensure state is appropriately sized for finalize
|
||||
let state = vec![0.0; system.full_state_vector_len()];
|
||||
let result = system.check_mass_balance(&state);
|
||||
|
||||
|
||||
assert!(result.is_err());
|
||||
|
||||
// Verify error contains mass error information
|
||||
if let Err(crate::SolverError::Validation {
|
||||
mass_error,
|
||||
energy_error,
|
||||
}) = result
|
||||
{
|
||||
assert!(mass_error > 0.0, "Mass error should be positive");
|
||||
assert_eq!(
|
||||
energy_error, 0.0,
|
||||
"Energy error should be zero for mass-only validation"
|
||||
);
|
||||
} else {
|
||||
panic!("Expected Validation error, got {:?}", result);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mass_balance_tolerance_constant() {
|
||||
// Verify the tolerance constant is accessible and has expected value
|
||||
assert_eq!(System::MASS_BALANCE_TOLERANCE_KG_S, 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_canonical_bytes() {
|
||||
let mut sys = System::new();
|
||||
let n0 = sys.add_component(make_mock(0));
|
||||
let n1 = sys.add_component(make_mock(0));
|
||||
sys.add_edge(n0, n1).unwrap();
|
||||
|
||||
let bytes1 = sys.generate_canonical_bytes();
|
||||
let bytes2 = sys.generate_canonical_bytes();
|
||||
|
||||
// Exact same graph state should produce same bytes
|
||||
assert_eq!(bytes1, bytes2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_input_hash_deterministic() {
|
||||
let mut sys1 = System::new();
|
||||
let n0_1 = sys1.add_component(make_mock(0));
|
||||
let n1_1 = sys1.add_component(make_mock(0));
|
||||
sys1.add_edge(n0_1, n1_1).unwrap();
|
||||
|
||||
let mut sys2 = System::new();
|
||||
let n0_2 = sys2.add_component(make_mock(0));
|
||||
let n1_2 = sys2.add_component(make_mock(0));
|
||||
sys2.add_edge(n0_2, n1_2).unwrap();
|
||||
|
||||
// Two identically constructed systems should have same hash
|
||||
assert_eq!(sys1.input_hash(), sys2.input_hash());
|
||||
|
||||
// Now mutate one system by adding an edge
|
||||
sys1.add_edge(n1_1, n0_1).unwrap();
|
||||
|
||||
// Hash should be different now
|
||||
assert_ne!(sys1.input_hash(), sys2.input_hash());
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Story 9.6: Energy Validation Logging Improvement Tests
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Story 9.6: Energy Validation Logging Improvement Tests
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Test that check_energy_balance emits warnings for components without energy methods.
|
||||
/// This test verifies the logging improvement from Story 9.6.
|
||||
#[test]
|
||||
fn test_energy_balance_warns_for_skipped_components() {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
// Create a system with mock components that don't implement energy_transfers()
|
||||
let mut sys = System::new();
|
||||
let n0 = sys.add_component(make_mock(0));
|
||||
let n1 = sys.add_component(make_mock(0));
|
||||
sys.add_edge(n0, n1).unwrap();
|
||||
sys.add_edge(n1, n0).unwrap();
|
||||
sys.finalize().unwrap();
|
||||
|
||||
let state = vec![0.0; sys.state_vector_len()];
|
||||
|
||||
// Capture log output using tracing_subscriber
|
||||
let log_buffer = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
|
||||
let buffer_clone = log_buffer.clone();
|
||||
let layer = tracing_subscriber::fmt::layer()
|
||||
.with_writer(move || {
|
||||
use std::io::Write;
|
||||
struct BufWriter {
|
||||
buf: std::sync::Arc<std::sync::Mutex<String>>,
|
||||
}
|
||||
impl Write for BufWriter {
|
||||
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
||||
let mut buf = self.buf.lock().unwrap();
|
||||
buf.push_str(&String::from_utf8_lossy(data));
|
||||
Ok(data.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
BufWriter {
|
||||
buf: buffer_clone.clone(),
|
||||
}
|
||||
})
|
||||
.without_time();
|
||||
|
||||
let _guard = tracing_subscriber::registry().with(layer).set_default();
|
||||
|
||||
// check_energy_balance should succeed (no violations) but will emit warnings
|
||||
// for components that lack energy_transfers() and port_enthalpies()
|
||||
let result = sys.check_energy_balance(&state);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"check_energy_balance should succeed even with skipped components"
|
||||
);
|
||||
|
||||
// Verify warning was emitted
|
||||
let log_output = log_buffer.lock().unwrap();
|
||||
assert!(
|
||||
log_output.contains("SKIPPED in energy balance validation"),
|
||||
"Expected warning message not found in logs. Actual output: {}",
|
||||
*log_output
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that check_energy_balance includes component type in warning message.
|
||||
#[test]
|
||||
fn test_energy_balance_includes_component_type_in_warning() {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
// Create a system with mock components (need at least 2 nodes with edges to avoid isolated node error)
|
||||
let mut sys = System::new();
|
||||
let n0 = sys.add_component(make_mock(0));
|
||||
let n1 = sys.add_component(make_mock(0));
|
||||
sys.add_edge(n0, n1).unwrap();
|
||||
sys.add_edge(n1, n0).unwrap();
|
||||
sys.finalize().unwrap();
|
||||
|
||||
let state = vec![0.0; sys.state_vector_len()];
|
||||
|
||||
// Capture log output using tracing_subscriber
|
||||
let log_buffer = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
|
||||
let buffer_clone = log_buffer.clone();
|
||||
let layer = tracing_subscriber::fmt::layer()
|
||||
.with_writer(move || {
|
||||
use std::io::Write;
|
||||
struct BufWriter {
|
||||
buf: std::sync::Arc<std::sync::Mutex<String>>,
|
||||
}
|
||||
impl Write for BufWriter {
|
||||
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
||||
let mut buf = self.buf.lock().unwrap();
|
||||
buf.push_str(&String::from_utf8_lossy(data));
|
||||
Ok(data.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
BufWriter {
|
||||
buf: buffer_clone.clone(),
|
||||
}
|
||||
})
|
||||
.without_time();
|
||||
|
||||
let _guard = tracing_subscriber::registry().with(layer).set_default();
|
||||
|
||||
let result = sys.check_energy_balance(&state);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify warning message includes component type information
|
||||
// Note: type_name_of_val on a trait object returns the trait name ("Component"),
|
||||
// not the concrete type. This is a known Rust limitation.
|
||||
let log_output = log_buffer.lock().unwrap();
|
||||
assert!(
|
||||
log_output.contains("type: Component"),
|
||||
"Expected component type information not found in logs. Actual output: {}",
|
||||
*log_output
|
||||
);
|
||||
}
|
||||
|
||||
/// Test that check_energy_balance emits a summary warning with skipped component count.
|
||||
#[test]
|
||||
fn test_energy_balance_summary_warning() {
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
// Create a system with mock components
|
||||
let mut sys = System::new();
|
||||
let n0 = sys.add_component(make_mock(0));
|
||||
let n1 = sys.add_component(make_mock(0));
|
||||
sys.add_edge(n0, n1).unwrap();
|
||||
sys.add_edge(n1, n0).unwrap();
|
||||
sys.finalize().unwrap();
|
||||
|
||||
let state = vec![0.0; sys.state_vector_len()];
|
||||
|
||||
// Capture log output
|
||||
let log_buffer = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
|
||||
let buffer_clone = log_buffer.clone();
|
||||
let layer = tracing_subscriber::fmt::layer()
|
||||
.with_writer(move || {
|
||||
use std::io::Write;
|
||||
struct BufWriter {
|
||||
buf: std::sync::Arc<std::sync::Mutex<String>>,
|
||||
}
|
||||
impl Write for BufWriter {
|
||||
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
||||
let mut buf = self.buf.lock().unwrap();
|
||||
buf.push_str(&String::from_utf8_lossy(data));
|
||||
Ok(data.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
BufWriter {
|
||||
buf: buffer_clone.clone(),
|
||||
}
|
||||
})
|
||||
.without_time();
|
||||
|
||||
let _guard = tracing_subscriber::registry().with(layer).set_default();
|
||||
|
||||
let result = sys.check_energy_balance(&state);
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify summary warning was emitted
|
||||
let log_output = log_buffer.lock().unwrap();
|
||||
assert!(
|
||||
log_output.contains("Energy balance validation incomplete"),
|
||||
"Expected summary warning not found in logs. Actual output: {}",
|
||||
*log_output
|
||||
);
|
||||
assert!(
|
||||
log_output.contains("component(s) skipped"),
|
||||
"Expected 'component(s) skipped' not found in logs. Actual output: {}",
|
||||
*log_output
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use entropyk_solver::{
|
||||
/// 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);
|
||||
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"
|
||||
@@ -45,6 +45,7 @@ fn test_converged_state_with_report_attaches_report() {
|
||||
1e-8,
|
||||
ConvergenceStatus::Converged,
|
||||
report,
|
||||
entropyk_solver::SimulationMetadata::new("".to_string()),
|
||||
);
|
||||
|
||||
assert!(
|
||||
@@ -233,7 +234,7 @@ fn test_single_circuit_global_convergence() {
|
||||
|
||||
use entropyk_components::port::ConnectedPort;
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, JacobianBuilder, ResidualVector, SystemState,
|
||||
Component, ComponentError, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
|
||||
struct MockConvergingComponent;
|
||||
@@ -241,7 +242,7 @@ struct MockConvergingComponent;
|
||||
impl Component for MockConvergingComponent {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &SystemState,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
// Simple linear system will converge in 1 step
|
||||
@@ -252,7 +253,7 @@ impl Component for MockConvergingComponent {
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
jacobian.add_entry(0, 0, 1.0);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! - No heap allocation during switches
|
||||
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, JacobianBuilder, ResidualVector, SystemState,
|
||||
Component, ComponentError, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_solver::solver::{
|
||||
ConvergenceStatus, FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig, Solver,
|
||||
@@ -50,7 +50,7 @@ impl LinearSystem {
|
||||
impl Component for LinearSystem {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &SystemState,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
// r = A * x - b
|
||||
@@ -66,7 +66,7 @@ impl Component for LinearSystem {
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
// J = A (constant Jacobian)
|
||||
@@ -105,7 +105,7 @@ impl StiffNonlinearSystem {
|
||||
impl Component for StiffNonlinearSystem {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &SystemState,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
// Non-linear residual: r_i = x_i^3 - alpha * x_i - 1
|
||||
@@ -119,7 +119,7 @@ impl Component for StiffNonlinearSystem {
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
state: &SystemState,
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
// J_ii = 3 * x_i^2 - alpha
|
||||
@@ -157,7 +157,7 @@ impl SlowConvergingSystem {
|
||||
impl Component for SlowConvergingSystem {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &SystemState,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
// r = x - target (simple, but Newton can overshoot)
|
||||
@@ -167,7 +167,7 @@ impl Component for SlowConvergingSystem {
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
jacobian.add_entry(0, 0, 1.0);
|
||||
@@ -635,7 +635,7 @@ fn test_fallback_already_converged() {
|
||||
impl Component for ZeroResidualComponent {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
residuals[0] = 0.0; // Already zero
|
||||
@@ -644,7 +644,7 @@ fn test_fallback_already_converged() {
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
jacobian.add_entry(0, 0, 1.0);
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
//! - AC: Components can dynamically read calibration factors (e.g. f_m, f_ua) from SystemState.
|
||||
//! - AC: The solver successfully optimizes these calibration factors to meet constraints.
|
||||
|
||||
use entropyk_components::{Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState};
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::CalibIndices;
|
||||
use entropyk_solver::{
|
||||
System, NewtonConfig, Solver,
|
||||
inverse::{
|
||||
BoundedVariable, BoundedVariableId, Constraint, ConstraintId, ComponentOutput,
|
||||
},
|
||||
inverse::{BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId},
|
||||
NewtonConfig, Solver, System,
|
||||
};
|
||||
|
||||
/// A mock component that simulates a heat exchanger whose capacity depends on `f_ua`.
|
||||
@@ -21,28 +21,28 @@ struct MockCalibratedComponent {
|
||||
impl Component for MockCalibratedComponent {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &SystemState,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
// Fix the edge states to a known value
|
||||
residuals[0] = state[0] - 300.0;
|
||||
residuals[1] = state[1] - 400.0;
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
// d(r0)/d(state[0]) = 1.0
|
||||
jacobian.add_entry(0, 0, 1.0);
|
||||
// d(r1)/d(state[1]) = 1.0
|
||||
jacobian.add_entry(1, 1, 1.0);
|
||||
|
||||
|
||||
// No dependence of physical equations on f_ua
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -62,17 +62,17 @@ impl Component for MockCalibratedComponent {
|
||||
#[test]
|
||||
fn test_inverse_calibration_f_ua() {
|
||||
let mut sys = System::new();
|
||||
|
||||
|
||||
// Create a mock component
|
||||
let mock = Box::new(MockCalibratedComponent {
|
||||
calib_indices: CalibIndices::default(),
|
||||
});
|
||||
let comp_id = sys.add_component(mock);
|
||||
sys.register_component_name("evaporator", comp_id);
|
||||
|
||||
|
||||
// Add a self-edge just to simulate some connections
|
||||
sys.add_edge(comp_id, comp_id).unwrap();
|
||||
|
||||
|
||||
// We want the capacity to be exactly 4015 W.
|
||||
// The mocked math in System::extract_constraint_values_with_controls:
|
||||
// Capacity = state[1] * 10.0 + f_ua * 10.0 (primary effect)
|
||||
@@ -87,54 +87,61 @@ fn test_inverse_calibration_f_ua() {
|
||||
component_id: "evaporator".to_string(),
|
||||
},
|
||||
4015.0,
|
||||
)).unwrap();
|
||||
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// Bounded variable (the calibration factor f_ua)
|
||||
let bv = BoundedVariable::with_component(
|
||||
BoundedVariableId::new("f_ua"),
|
||||
"evaporator",
|
||||
1.0, // initial
|
||||
0.1, // min
|
||||
10.0 // max
|
||||
).unwrap();
|
||||
1.0, // initial
|
||||
0.1, // min
|
||||
10.0, // max
|
||||
)
|
||||
.unwrap();
|
||||
sys.add_bounded_variable(bv).unwrap();
|
||||
|
||||
|
||||
// Link constraint to control
|
||||
sys.link_constraint_to_control(
|
||||
&ConstraintId::new("capacity_control"),
|
||||
&BoundedVariableId::new("f_ua")
|
||||
).unwrap();
|
||||
|
||||
&BoundedVariableId::new("f_ua"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
sys.finalize().unwrap();
|
||||
|
||||
|
||||
// Verify that the validation passes
|
||||
assert!(sys.validate_inverse_control_dof().is_ok());
|
||||
|
||||
let initial_state = vec![0.0; sys.full_state_vector_len()];
|
||||
|
||||
|
||||
// Use NewtonRaphson
|
||||
let mut solver = NewtonConfig::default().with_initial_state(initial_state);
|
||||
|
||||
|
||||
let result = solver.solve(&mut sys);
|
||||
|
||||
|
||||
// Should converge quickly
|
||||
assert!(dbg!(&result).is_ok());
|
||||
let converged = result.unwrap();
|
||||
|
||||
|
||||
// The control variable `f_ua` is at the end of the state vector
|
||||
let f_ua_idx = sys.full_state_vector_len() - 1;
|
||||
let final_f_ua: f64 = converged.state[f_ua_idx];
|
||||
|
||||
|
||||
// Target f_ua = 1.5
|
||||
let abs_diff = (final_f_ua - 1.5_f64).abs();
|
||||
assert!(abs_diff < 1e-4, "f_ua should converge to 1.5, got {}", final_f_ua);
|
||||
assert!(
|
||||
abs_diff < 1e-4,
|
||||
"f_ua should converge to 1.5, got {}",
|
||||
final_f_ua
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inverse_expansion_valve_calibration() {
|
||||
use entropyk_components::expansion_valve::ExpansionValve;
|
||||
use entropyk_components::port::{FluidId, Port};
|
||||
use entropyk_core::{Pressure, Enthalpy};
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
|
||||
let mut sys = System::new();
|
||||
|
||||
@@ -149,7 +156,7 @@ fn test_inverse_expansion_valve_calibration() {
|
||||
Pressure::from_bar(10.0),
|
||||
Enthalpy::from_joules_per_kg(250000.0),
|
||||
);
|
||||
|
||||
|
||||
let inlet_target = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_bar(10.0),
|
||||
@@ -160,9 +167,13 @@ fn test_inverse_expansion_valve_calibration() {
|
||||
Pressure::from_bar(10.0),
|
||||
Enthalpy::from_joules_per_kg(250000.0),
|
||||
);
|
||||
|
||||
|
||||
let valve_disconnected = ExpansionValve::new(inlet, outlet, Some(1.0)).unwrap();
|
||||
let valve = Box::new(valve_disconnected.connect(inlet_target, outlet_target).unwrap());
|
||||
let valve = Box::new(
|
||||
valve_disconnected
|
||||
.connect(inlet_target, outlet_target)
|
||||
.unwrap(),
|
||||
);
|
||||
let comp_id = sys.add_component(valve);
|
||||
sys.register_component_name("valve", comp_id);
|
||||
|
||||
@@ -175,14 +186,16 @@ fn test_inverse_expansion_valve_calibration() {
|
||||
// Wait, let's look at ExpansionValve residuals:
|
||||
// residuals[1] = mass_flow_out - f_m * mass_flow_in;
|
||||
// state[0] = mass_flow_in, state[1] = mass_flow_out
|
||||
|
||||
|
||||
sys.add_constraint(Constraint::new(
|
||||
ConstraintId::new("flow_control"),
|
||||
ComponentOutput::Capacity { // Mocking output for test
|
||||
ComponentOutput::Capacity {
|
||||
// Mocking output for test
|
||||
component_id: "valve".to_string(),
|
||||
},
|
||||
0.5,
|
||||
)).unwrap();
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// Add a bounded variable for f_m
|
||||
let bv = BoundedVariable::with_component(
|
||||
@@ -190,14 +203,16 @@ fn test_inverse_expansion_valve_calibration() {
|
||||
"valve",
|
||||
1.0, // initial
|
||||
0.1, // min
|
||||
2.0 // max
|
||||
).unwrap();
|
||||
2.0, // max
|
||||
)
|
||||
.unwrap();
|
||||
sys.add_bounded_variable(bv).unwrap();
|
||||
|
||||
sys.link_constraint_to_control(
|
||||
&ConstraintId::new("flow_control"),
|
||||
&BoundedVariableId::new("f_m")
|
||||
).unwrap();
|
||||
&BoundedVariableId::new("f_m"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
sys.finalize().unwrap();
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! - AC #4: DoF validation correctly handles multiple linked variables
|
||||
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_solver::{
|
||||
inverse::{BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId},
|
||||
@@ -26,7 +26,7 @@ struct MockPassThrough {
|
||||
impl Component for MockPassThrough {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
for r in residuals.iter_mut().take(self.n_eq) {
|
||||
@@ -37,7 +37,7 @@ impl Component for MockPassThrough {
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
for i in 0..self.n_eq {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use approx::assert_relative_eq;
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, JacobianBuilder, ResidualVector, SystemState,
|
||||
Component, ComponentError, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_solver::{
|
||||
solver::{JacobianFreezingConfig, NewtonConfig, Solver},
|
||||
@@ -34,7 +34,7 @@ impl LinearTargetSystem {
|
||||
impl Component for LinearTargetSystem {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &SystemState,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
for (i, &t) in self.targets.iter().enumerate() {
|
||||
@@ -45,7 +45,7 @@ impl Component for LinearTargetSystem {
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
for i in 0..self.targets.len() {
|
||||
@@ -79,7 +79,7 @@ impl CubicTargetSystem {
|
||||
impl Component for CubicTargetSystem {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &SystemState,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
for (i, &t) in self.targets.iter().enumerate() {
|
||||
@@ -91,7 +91,7 @@ impl Component for CubicTargetSystem {
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
state: &SystemState,
|
||||
state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
for (i, &t) in self.targets.iter().enumerate() {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! - AC #4: Serialization snapshot round-trip
|
||||
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_solver::{MacroComponent, MacroComponentSnapshot, System};
|
||||
|
||||
@@ -23,7 +23,7 @@ struct PassThrough {
|
||||
impl Component for PassThrough {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
for r in residuals.iter_mut().take(self.n_eq) {
|
||||
@@ -34,7 +34,7 @@ impl Component for PassThrough {
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
for i in 0..self.n_eq {
|
||||
|
||||
271
crates/solver/tests/mass_balance_integration.rs
Normal file
271
crates/solver/tests/mass_balance_integration.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
//! Integration test for mass balance validation with multiple components.
|
||||
//!
|
||||
//! This test verifies that the mass balance validation works correctly
|
||||
//! across a multi-component system simulating a refrigeration cycle.
|
||||
|
||||
use entropyk_components::port::{FluidId, Port};
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::{Enthalpy, MassFlow, Pressure};
|
||||
use entropyk_solver::system::System;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Mock components for testing
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A mock component that simulates balanced mass flow (like a pipe or heat exchanger).
|
||||
struct BalancedComponent {
|
||||
ports: Vec<ConnectedPort>,
|
||||
mass_flow_in: f64,
|
||||
}
|
||||
|
||||
impl BalancedComponent {
|
||||
fn new(ports: Vec<ConnectedPort>, mass_flow: f64) -> Self {
|
||||
Self {
|
||||
ports,
|
||||
mass_flow_in: mass_flow,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for BalancedComponent {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
for r in residuals.iter_mut() {
|
||||
*r = 0.0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
for i in 0..self.n_equations() {
|
||||
jacobian.add_entry(i, i, 1.0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&self.ports
|
||||
}
|
||||
|
||||
fn port_mass_flows(&self, _state: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
|
||||
// Balanced: inlet positive, outlet negative
|
||||
Ok(vec![
|
||||
MassFlow::from_kg_per_s(self.mass_flow_in),
|
||||
MassFlow::from_kg_per_s(-self.mass_flow_in),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// A mock component with imbalanced mass flow (for testing violation detection).
|
||||
struct ImbalancedComponent {
|
||||
ports: Vec<ConnectedPort>,
|
||||
}
|
||||
|
||||
impl ImbalancedComponent {
|
||||
fn new(ports: Vec<ConnectedPort>) -> Self {
|
||||
Self { ports }
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for ImbalancedComponent {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
for r in residuals.iter_mut() {
|
||||
*r = 0.0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
for i in 0..self.n_equations() {
|
||||
jacobian.add_entry(i, i, 1.0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&self.ports
|
||||
}
|
||||
|
||||
fn port_mass_flows(&self, _state: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
|
||||
// Imbalanced: inlet 1.0 kg/s, outlet -0.5 kg/s (sum = 0.5 kg/s violation)
|
||||
Ok(vec![
|
||||
MassFlow::from_kg_per_s(1.0),
|
||||
MassFlow::from_kg_per_s(-0.5),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Test helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn make_connected_port_pair(
|
||||
fluid: &str,
|
||||
p_bar: f64,
|
||||
h_j_kg: f64,
|
||||
) -> (ConnectedPort, ConnectedPort) {
|
||||
let p1 = Port::new(
|
||||
FluidId::new(fluid),
|
||||
Pressure::from_bar(p_bar),
|
||||
Enthalpy::from_joules_per_kg(h_j_kg),
|
||||
);
|
||||
let p2 = Port::new(
|
||||
FluidId::new(fluid),
|
||||
Pressure::from_bar(p_bar),
|
||||
Enthalpy::from_joules_per_kg(h_j_kg),
|
||||
);
|
||||
let (c1, c2) = p1.connect(p2).unwrap();
|
||||
(c1, c2)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_mass_balance_4_component_cycle() {
|
||||
// Simulate a 4-component refrigeration cycle: Compressor → Condenser → Valve → Evaporator
|
||||
let mut system = System::new();
|
||||
|
||||
// Create 4 pairs of connected ports for 4 components
|
||||
let (p1a, p1b) = make_connected_port_pair("R134a", 5.0, 400_000.0);
|
||||
let (p2a, p2b) = make_connected_port_pair("R134a", 5.0, 400_000.0);
|
||||
let (p3a, p3b) = make_connected_port_pair("R134a", 5.0, 400_000.0);
|
||||
let (p4a, p4b) = make_connected_port_pair("R134a", 5.0, 400_000.0);
|
||||
|
||||
// Create 4 balanced components (simulating compressor, condenser, valve, evaporator)
|
||||
let mass_flow = 0.1; // kg/s
|
||||
let comp1 = Box::new(BalancedComponent::new(vec![p1a, p1b], mass_flow));
|
||||
let comp2 = Box::new(BalancedComponent::new(vec![p2a, p2b], mass_flow));
|
||||
let comp3 = Box::new(BalancedComponent::new(vec![p3a, p3b], mass_flow));
|
||||
let comp4 = Box::new(BalancedComponent::new(vec![p4a, p4b], mass_flow));
|
||||
|
||||
// Add components to system
|
||||
let n1 = system.add_component(comp1);
|
||||
let n2 = system.add_component(comp2);
|
||||
let n3 = system.add_component(comp3);
|
||||
let n4 = system.add_component(comp4);
|
||||
|
||||
// Connect in a cycle
|
||||
system.add_edge(n1, n2).unwrap();
|
||||
system.add_edge(n2, n3).unwrap();
|
||||
system.add_edge(n3, n4).unwrap();
|
||||
system.add_edge(n4, n1).unwrap();
|
||||
|
||||
system.finalize().unwrap();
|
||||
|
||||
// Test with zero state vector
|
||||
let state = vec![0.0; system.full_state_vector_len()];
|
||||
let result = system.check_mass_balance(&state);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Mass balance should pass for balanced 4-component cycle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mass_balance_detects_imbalance_in_cycle() {
|
||||
// Create a cycle with one imbalanced component
|
||||
let mut system = System::new();
|
||||
|
||||
let (p1a, p1b) = make_connected_port_pair("R134a", 5.0, 400_000.0);
|
||||
let (p2a, p2b) = make_connected_port_pair("R134a", 5.0, 400_000.0);
|
||||
let (p3a, p3b) = make_connected_port_pair("R134a", 5.0, 400_000.0);
|
||||
|
||||
// Two balanced components
|
||||
let comp1 = Box::new(BalancedComponent::new(vec![p1a, p1b], 0.1));
|
||||
let comp3 = Box::new(BalancedComponent::new(vec![p3a, p3b], 0.1));
|
||||
|
||||
// One imbalanced component
|
||||
let comp2 = Box::new(ImbalancedComponent::new(vec![p2a, p2b]));
|
||||
|
||||
let n1 = system.add_component(comp1);
|
||||
let n2 = system.add_component(comp2);
|
||||
let n3 = system.add_component(comp3);
|
||||
|
||||
system.add_edge(n1, n2).unwrap();
|
||||
system.add_edge(n2, n3).unwrap();
|
||||
system.add_edge(n3, n1).unwrap();
|
||||
|
||||
system.finalize().unwrap();
|
||||
|
||||
let state = vec![0.0; system.full_state_vector_len()];
|
||||
let result = system.check_mass_balance(&state);
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Mass balance should fail when one component is imbalanced"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mass_balance_multiple_components_same_flow() {
|
||||
// Test that multiple components with the same mass flow pass validation
|
||||
let mut system = System::new();
|
||||
|
||||
// Create 6 components in a chain
|
||||
let mut ports = Vec::new();
|
||||
for _ in 0..6 {
|
||||
let (pa, pb) = make_connected_port_pair("R134a", 5.0, 400_000.0);
|
||||
ports.push((pa, pb));
|
||||
}
|
||||
|
||||
let mass_flow = 0.5; // kg/s
|
||||
let components: Vec<_> = ports
|
||||
.into_iter()
|
||||
.map(|(pa, pb)| Box::new(BalancedComponent::new(vec![pa, pb], mass_flow)))
|
||||
.collect();
|
||||
|
||||
let nodes: Vec<_> = components
|
||||
.into_iter()
|
||||
.map(|c| system.add_component(c))
|
||||
.collect();
|
||||
|
||||
// Connect in a cycle
|
||||
for i in 0..nodes.len() {
|
||||
let next = (i + 1) % nodes.len();
|
||||
system.add_edge(nodes[i], nodes[next]).unwrap();
|
||||
}
|
||||
|
||||
system.finalize().unwrap();
|
||||
|
||||
let state = vec![0.0; system.full_state_vector_len()];
|
||||
let result = system.check_mass_balance(&state);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Mass balance should pass for multiple balanced components"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mass_balance_tolerance_constant_accessible() {
|
||||
// Verify the tolerance constant is accessible
|
||||
assert_eq!(System::MASS_BALANCE_TOLERANCE_KG_S, 1e-9);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Tests circuits from 2 up to the maximum of 5 circuits (circuit IDs 0-4).
|
||||
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::ThermalConductance;
|
||||
use entropyk_solver::{CircuitId, System, ThermalCoupling, TopologyError};
|
||||
@@ -17,7 +17,7 @@ struct RefrigerantMock {
|
||||
impl Component for RefrigerantMock {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
for r in residuals.iter_mut().take(self.n_equations) {
|
||||
@@ -28,7 +28,7 @@ impl Component for RefrigerantMock {
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
Ok(())
|
||||
|
||||
@@ -388,7 +388,7 @@ fn test_jacobian_non_square_overdetermined() {
|
||||
fn test_convergence_status_converged() {
|
||||
use entropyk_solver::ConvergedState;
|
||||
|
||||
let state = ConvergedState::new(vec![1.0, 2.0], 10, 1e-8, ConvergenceStatus::Converged);
|
||||
let state = ConvergedState::new(vec![1.0, 2.0], 10, 1e-8, ConvergenceStatus::Converged, entropyk_solver::SimulationMetadata::new("".to_string()));
|
||||
|
||||
assert!(state.is_converged());
|
||||
assert_eq!(state.status, ConvergenceStatus::Converged);
|
||||
@@ -404,6 +404,7 @@ fn test_convergence_status_timed_out() {
|
||||
50,
|
||||
1e-3,
|
||||
ConvergenceStatus::TimedOutWithBestState,
|
||||
entropyk_solver::SimulationMetadata::new("".to_string()),
|
||||
);
|
||||
|
||||
assert!(!state.is_converged());
|
||||
|
||||
@@ -226,7 +226,7 @@ fn test_converged_state_is_converged() {
|
||||
use entropyk_solver::ConvergedState;
|
||||
use entropyk_solver::ConvergenceStatus;
|
||||
|
||||
let state = ConvergedState::new(vec![1.0, 2.0, 3.0], 10, 1e-8, ConvergenceStatus::Converged);
|
||||
let state = ConvergedState::new(vec![1.0, 2.0, 3.0], 10, 1e-8, ConvergenceStatus::Converged, entropyk_solver::SimulationMetadata::new("".to_string()));
|
||||
|
||||
assert!(state.is_converged());
|
||||
assert_eq!(state.iterations, 10);
|
||||
@@ -243,6 +243,7 @@ fn test_converged_state_timed_out() {
|
||||
50,
|
||||
1e-3,
|
||||
ConvergenceStatus::TimedOutWithBestState,
|
||||
entropyk_solver::SimulationMetadata::new("".to_string()),
|
||||
);
|
||||
|
||||
assert!(!state.is_converged());
|
||||
|
||||
@@ -321,7 +321,7 @@ fn test_error_display_invalid_system() {
|
||||
fn test_converged_state_is_converged() {
|
||||
use entropyk_solver::{ConvergedState, ConvergenceStatus};
|
||||
|
||||
let state = ConvergedState::new(vec![1.0, 2.0, 3.0], 25, 1e-7, ConvergenceStatus::Converged);
|
||||
let state = ConvergedState::new(vec![1.0, 2.0, 3.0], 25, 1e-7, ConvergenceStatus::Converged, entropyk_solver::SimulationMetadata::new("".to_string()));
|
||||
|
||||
assert!(state.is_converged());
|
||||
assert_eq!(state.iterations, 25);
|
||||
@@ -338,6 +338,7 @@ fn test_converged_state_timed_out() {
|
||||
75,
|
||||
1e-2,
|
||||
ConvergenceStatus::TimedOutWithBestState,
|
||||
entropyk_solver::SimulationMetadata::new("".to_string()),
|
||||
);
|
||||
|
||||
assert!(!state.is_converged());
|
||||
|
||||
206
crates/solver/tests/refrigeration_cycle_integration.rs
Normal file
206
crates/solver/tests/refrigeration_cycle_integration.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
/// Test d'intégration : boucle réfrigération simple R134a en Rust natif.
|
||||
///
|
||||
/// Ce test valide que le solveur Newton converge sur un cycle 4 composants
|
||||
/// en utilisant des mock components algébriques linéaires dont les équations
|
||||
/// sont mathématiquement cohérentes (ferment la boucle).
|
||||
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::{Enthalpy, MassFlow, Pressure};
|
||||
use entropyk_solver::{
|
||||
solver::{NewtonConfig, Solver},
|
||||
system::System,
|
||||
};
|
||||
use entropyk_components::port::{Connected, FluidId, Port};
|
||||
|
||||
// Type alias: Port<Connected> ≡ ConnectedPort
|
||||
type CP = Port<Connected>;
|
||||
|
||||
// ─── Mock compresseur ─────────────────────────────────────────────────────────
|
||||
// r[0] = p_disc - (p_suc + 1 MPa)
|
||||
// r[1] = h_disc - (h_suc + 75 kJ/kg)
|
||||
struct MockCompressor { port_suc: CP, port_disc: CP }
|
||||
impl Component for MockCompressor {
|
||||
fn compute_residuals(&self, _s: &StateSlice, r: &mut ResidualVector) -> Result<(), ComponentError> {
|
||||
r[0] = self.port_disc.pressure().to_pascals() - (self.port_suc.pressure().to_pascals() + 1_000_000.0);
|
||||
r[1] = self.port_disc.enthalpy().to_joules_per_kg() - (self.port_suc.enthalpy().to_joules_per_kg() + 75_000.0);
|
||||
Ok(())
|
||||
}
|
||||
fn jacobian_entries(&self, _s: &StateSlice, _j: &mut JacobianBuilder) -> Result<(), ComponentError> { Ok(()) }
|
||||
fn n_equations(&self) -> usize { 2 }
|
||||
fn get_ports(&self) -> &[ConnectedPort] { &[] }
|
||||
fn port_mass_flows(&self, _: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
|
||||
Ok(vec![MassFlow::from_kg_per_s(0.05), MassFlow::from_kg_per_s(-0.05)])
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mock condenseur ──────────────────────────────────────────────────────────
|
||||
// r[0] = p_out - p_in
|
||||
// r[1] = h_out - (h_in - 225 kJ/kg)
|
||||
struct MockCondenser { port_in: CP, port_out: CP }
|
||||
impl Component for MockCondenser {
|
||||
fn compute_residuals(&self, _s: &StateSlice, r: &mut ResidualVector) -> Result<(), ComponentError> {
|
||||
r[0] = self.port_out.pressure().to_pascals() - self.port_in.pressure().to_pascals();
|
||||
r[1] = self.port_out.enthalpy().to_joules_per_kg() - (self.port_in.enthalpy().to_joules_per_kg() - 225_000.0);
|
||||
Ok(())
|
||||
}
|
||||
fn jacobian_entries(&self, _s: &StateSlice, _j: &mut JacobianBuilder) -> Result<(), ComponentError> { Ok(()) }
|
||||
fn n_equations(&self) -> usize { 2 }
|
||||
fn get_ports(&self) -> &[ConnectedPort] { &[] }
|
||||
fn port_mass_flows(&self, _: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
|
||||
Ok(vec![MassFlow::from_kg_per_s(0.05), MassFlow::from_kg_per_s(-0.05)])
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mock détendeur ───────────────────────────────────────────────────────────
|
||||
// r[0] = p_out - (p_in - 1 MPa)
|
||||
// r[1] = h_out - h_in
|
||||
struct MockValve { port_in: CP, port_out: CP }
|
||||
impl Component for MockValve {
|
||||
fn compute_residuals(&self, _s: &StateSlice, r: &mut ResidualVector) -> Result<(), ComponentError> {
|
||||
r[0] = self.port_out.pressure().to_pascals() - (self.port_in.pressure().to_pascals() - 1_000_000.0);
|
||||
r[1] = self.port_out.enthalpy().to_joules_per_kg() - self.port_in.enthalpy().to_joules_per_kg();
|
||||
Ok(())
|
||||
}
|
||||
fn jacobian_entries(&self, _s: &StateSlice, _j: &mut JacobianBuilder) -> Result<(), ComponentError> { Ok(()) }
|
||||
fn n_equations(&self) -> usize { 2 }
|
||||
fn get_ports(&self) -> &[ConnectedPort] { &[] }
|
||||
fn port_mass_flows(&self, _: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
|
||||
Ok(vec![MassFlow::from_kg_per_s(0.05), MassFlow::from_kg_per_s(-0.05)])
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mock évaporateur ─────────────────────────────────────────────────────────
|
||||
// r[0] = p_out - p_in
|
||||
// r[1] = h_out - (h_in + 150 kJ/kg)
|
||||
struct MockEvaporator { port_in: CP, port_out: CP }
|
||||
impl Component for MockEvaporator {
|
||||
fn compute_residuals(&self, _s: &StateSlice, r: &mut ResidualVector) -> Result<(), ComponentError> {
|
||||
r[0] = self.port_out.pressure().to_pascals() - self.port_in.pressure().to_pascals();
|
||||
r[1] = self.port_out.enthalpy().to_joules_per_kg() - (self.port_in.enthalpy().to_joules_per_kg() + 150_000.0);
|
||||
Ok(())
|
||||
}
|
||||
fn jacobian_entries(&self, _s: &StateSlice, _j: &mut JacobianBuilder) -> Result<(), ComponentError> { Ok(()) }
|
||||
fn n_equations(&self) -> usize { 2 }
|
||||
fn get_ports(&self) -> &[ConnectedPort] { &[] }
|
||||
fn port_mass_flows(&self, _: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
|
||||
Ok(vec![MassFlow::from_kg_per_s(0.05), MassFlow::from_kg_per_s(-0.05)])
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
fn port(p_pa: f64, h_j_kg: f64) -> CP {
|
||||
let (connected, _) = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_pascals(p_pa),
|
||||
Enthalpy::from_joules_per_kg(h_j_kg),
|
||||
).connect(Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_pascals(p_pa),
|
||||
Enthalpy::from_joules_per_kg(h_j_kg),
|
||||
)).unwrap();
|
||||
connected
|
||||
}
|
||||
|
||||
// ─── Test ─────────────────────────────────────────────────────────────────────
|
||||
#[test]
|
||||
fn test_simple_refrigeration_loop_rust() {
|
||||
// Les équations :
|
||||
// Comp : p0 = p3 + 1 MPa ; h0 = h3 + 75 kJ/kg
|
||||
// Cond : p1 = p0 ; h1 = h0 - 225 kJ/kg
|
||||
// Valve : p2 = p1 - 1 MPa ; h2 = h1
|
||||
// Evap : p3 = p2 ; h3 = h2 + 150 kJ/kg
|
||||
//
|
||||
// Bilan enthalpique en boucle : 75 - 225 + 150 = 0 → fermé ✓
|
||||
// Bilan pressionnel en boucle : +1 - 0 - 1 - 0 = 0 → fermé ✓
|
||||
//
|
||||
// Solution analytique (8 inconnues, 8 équations → infinité de solutions
|
||||
// dépendant du point de référence, mais le solveur en trouve une) :
|
||||
// En posant h3 = 410 kJ/kg, p3 = 350 kPa :
|
||||
// h0 = 485, p0 = 1.35 MPa
|
||||
// h1 = 260, p1 = 1.35 MPa
|
||||
// h2 = 260, p2 = 350 kPa
|
||||
// h3 = 410, p3 = 350 kPa
|
||||
|
||||
let p_lp = 350_000.0_f64; // Pa
|
||||
let p_hp = 1_350_000.0_f64; // Pa = p_lp + 1 MPa
|
||||
|
||||
// Les 4 bords (edge) du cycle :
|
||||
// edge0 : comp → cond
|
||||
// edge1 : cond → valve
|
||||
// edge2 : valve → evap
|
||||
// edge3 : evap → comp
|
||||
let comp = Box::new(MockCompressor {
|
||||
port_suc: port(p_lp, 410_000.0),
|
||||
port_disc: port(p_hp, 485_000.0),
|
||||
});
|
||||
let cond = Box::new(MockCondenser {
|
||||
port_in: port(p_hp, 485_000.0),
|
||||
port_out: port(p_hp, 260_000.0),
|
||||
});
|
||||
let valv = Box::new(MockValve {
|
||||
port_in: port(p_hp, 260_000.0),
|
||||
port_out: port(p_lp, 260_000.0),
|
||||
});
|
||||
let evap = Box::new(MockEvaporator {
|
||||
port_in: port(p_lp, 260_000.0),
|
||||
port_out: port(p_lp, 410_000.0),
|
||||
});
|
||||
|
||||
let mut system = System::new();
|
||||
let n_comp = system.add_component(comp);
|
||||
let n_cond = system.add_component(cond);
|
||||
let n_valv = system.add_component(valv);
|
||||
let n_evap = system.add_component(evap);
|
||||
|
||||
system.add_edge(n_comp, n_cond).unwrap();
|
||||
system.add_edge(n_cond, n_valv).unwrap();
|
||||
system.add_edge(n_valv, n_evap).unwrap();
|
||||
system.add_edge(n_evap, n_comp).unwrap();
|
||||
|
||||
system.finalize().unwrap();
|
||||
|
||||
let n_vars = system.full_state_vector_len();
|
||||
println!("Variables d'état : {}", n_vars);
|
||||
|
||||
// État initial = solution analytique exacte → résidus = 0 → converge 1 itération
|
||||
let initial_state = vec![
|
||||
p_hp, 485_000.0, // edge0 comp→cond
|
||||
p_hp, 260_000.0, // edge1 cond→valve
|
||||
p_lp, 260_000.0, // edge2 valve→evap
|
||||
p_lp, 410_000.0, // edge3 evap→comp
|
||||
];
|
||||
|
||||
let mut config = NewtonConfig {
|
||||
max_iterations: 50,
|
||||
tolerance: 1e-6,
|
||||
line_search: false,
|
||||
use_numerical_jacobian: true, // analytique vide → numérique
|
||||
initial_state: Some(initial_state),
|
||||
..NewtonConfig::default()
|
||||
};
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let result = config.solve(&mut system);
|
||||
let elapsed = t0.elapsed();
|
||||
|
||||
println!("Durée : {:?}", elapsed);
|
||||
|
||||
match &result {
|
||||
Ok(converged) => {
|
||||
println!("✅ Convergé en {} itérations ({:?})", converged.iterations, elapsed);
|
||||
let sv = &converged.state;
|
||||
println!(" comp→cond : P={:.2} bar, h={:.1} kJ/kg", sv[0]/1e5, sv[1]/1e3);
|
||||
println!(" cond→valve : P={:.2} bar, h={:.1} kJ/kg", sv[2]/1e5, sv[3]/1e3);
|
||||
println!(" valve→evap : P={:.2} bar, h={:.1} kJ/kg", sv[4]/1e5, sv[5]/1e3);
|
||||
println!(" evap→comp : P={:.2} bar, h={:.1} kJ/kg", sv[6]/1e5, sv[7]/1e3);
|
||||
}
|
||||
Err(e) => {
|
||||
panic!("❌ Solveur échoué : {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(elapsed.as_millis() < 5000, "Doit converger en < 5 secondes");
|
||||
assert!(result.is_ok(), "Solveur doit converger");
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use approx::assert_relative_eq;
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, JacobianBuilder, ResidualVector, SystemState,
|
||||
Component, ComponentError, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_core::{Enthalpy, Pressure, Temperature};
|
||||
use entropyk_solver::{
|
||||
@@ -36,7 +36,7 @@ impl LinearTargetSystem {
|
||||
impl Component for LinearTargetSystem {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &SystemState,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
for (i, &t) in self.targets.iter().enumerate() {
|
||||
@@ -47,7 +47,7 @@ impl Component for LinearTargetSystem {
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
for i in 0..self.targets.len() {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! - Timeout across fallback switches preserves best state
|
||||
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, JacobianBuilder, ResidualVector, SystemState,
|
||||
Component, ComponentError, JacobianBuilder, ResidualVector, StateSlice,
|
||||
};
|
||||
use entropyk_solver::solver::{
|
||||
ConvergenceStatus, FallbackConfig, FallbackSolver, NewtonConfig, PicardConfig, Solver,
|
||||
@@ -39,7 +39,7 @@ impl LinearSystem2x2 {
|
||||
impl Component for LinearSystem2x2 {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &SystemState,
|
||||
state: &StateSlice,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
residuals[0] = self.a[0][0] * state[0] + self.a[0][1] * state[1] - self.b[0];
|
||||
@@ -49,7 +49,7 @@ impl Component for LinearSystem2x2 {
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
_state: &StateSlice,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
jacobian.add_entry(0, 0, self.a[0][0]);
|
||||
|
||||
81
crates/solver/tests/traceability.rs
Normal file
81
crates/solver/tests/traceability.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use entropyk_components::port::{FluidId, Port};
|
||||
use entropyk_components::{Component, ComponentError, ConnectedPort, JacobianBuilder, StateSlice};
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
use entropyk_solver::solver::{NewtonConfig, Solver};
|
||||
use entropyk_solver::system::System;
|
||||
|
||||
struct DummyComponent {
|
||||
ports: Vec<ConnectedPort>,
|
||||
}
|
||||
|
||||
impl Component for DummyComponent {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
_state: &StateSlice,
|
||||
residuals: &mut entropyk_components::ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
residuals[0] = 0.0;
|
||||
residuals[1] = 0.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] {
|
||||
&self.ports
|
||||
}
|
||||
}
|
||||
|
||||
fn make_dummy_component() -> Box<dyn Component> {
|
||||
let inlet = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_pascals(100_000.0),
|
||||
Enthalpy::from_joules_per_kg(400_000.0),
|
||||
);
|
||||
let outlet = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_pascals(100_000.0),
|
||||
Enthalpy::from_joules_per_kg(400_000.0),
|
||||
);
|
||||
let (connected_inlet, connected_outlet) = inlet.connect(outlet).unwrap();
|
||||
let ports = vec![connected_inlet, connected_outlet];
|
||||
Box::new(DummyComponent { ports })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simulation_metadata_outputs() {
|
||||
let mut sys = System::new();
|
||||
let n0 = sys.add_component(make_dummy_component());
|
||||
let n1 = sys.add_component(make_dummy_component());
|
||||
sys.add_edge_with_ports(n0, 1, n1, 0).unwrap();
|
||||
sys.add_edge_with_ports(n1, 1, n0, 0).unwrap();
|
||||
|
||||
sys.finalize().unwrap();
|
||||
|
||||
let input_hash = sys.input_hash();
|
||||
|
||||
let mut solver = NewtonConfig {
|
||||
max_iterations: 5,
|
||||
..Default::default()
|
||||
};
|
||||
let result = solver.solve(&mut sys).unwrap();
|
||||
|
||||
assert!(result.is_converged());
|
||||
|
||||
let metadata = result.metadata;
|
||||
assert_eq!(metadata.input_hash, input_hash);
|
||||
assert_eq!(metadata.solver_version, env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(metadata.fluid_backend_version, "0.1.0");
|
||||
}
|
||||
Reference in New Issue
Block a user