feat(components): add ThermoState generators and Eurovent backend demo

This commit is contained in:
Sepehr
2026-02-20 22:01:38 +01:00
parent 375d288950
commit 4a40fddfe3
271 changed files with 28614 additions and 447 deletions

View File

@@ -0,0 +1,435 @@
//! Thermal coupling between circuits for heat transfer.
//!
//! This module provides the infrastructure for modeling heat exchange between
//! independent fluid circuits. Thermal couplings represent heat exchangers
//! that transfer heat from a "hot" circuit to a "cold" circuit without
//! fluid mixing.
//!
//! ## Sign Convention
//!
//! Heat transfer Q > 0 means heat flows INTO the cold circuit (out of hot circuit).
//! This follows the convention that the cold circuit receives heat.
//!
//! ## Coupling Graph and Circular Dependencies
//!
//! Thermal couplings form a directed graph where:
//! - Nodes are circuits (CircuitId)
//! - Edges point from hot_circuit to cold_circuit (direction of heat flow)
//!
//! 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 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
/// temperature difference and thermal conductance (UA value).
#[derive(Debug, Clone, PartialEq)]
pub struct ThermalCoupling {
/// Circuit that supplies heat (higher temperature side).
pub hot_circuit: CircuitId,
/// Circuit that receives heat (lower temperature side).
pub cold_circuit: CircuitId,
/// Thermal conductance (UA) in W/K. Higher values = more heat transfer.
pub ua: ThermalConductance,
/// Efficiency factor (0.0 to 1.0). Default is 1.0 (no losses).
pub efficiency: f64,
}
impl ThermalCoupling {
/// Creates a new thermal coupling between two circuits.
///
/// # Arguments
///
/// * `hot_circuit` - Circuit at higher temperature (heat source)
/// * `cold_circuit` - Circuit at lower temperature (heat sink)
/// * `ua` - Thermal conductance in W/K
///
/// # Example
///
/// ```
/// use entropyk_solver::{ThermalCoupling, CircuitId};
/// use entropyk_core::ThermalConductance;
///
/// let coupling = ThermalCoupling::new(
/// CircuitId(0),
/// CircuitId(1),
/// ThermalConductance::from_watts_per_kelvin(1000.0),
/// );
/// ```
pub fn new(hot_circuit: CircuitId, cold_circuit: CircuitId, ua: ThermalConductance) -> Self {
Self {
hot_circuit,
cold_circuit,
ua,
efficiency: 1.0,
}
}
/// Sets the efficiency factor for the coupling.
///
/// Efficiency accounts for heat losses in the heat exchanger.
/// A value of 0.9 means 90% of theoretical heat is transferred.
pub fn with_efficiency(mut self, efficiency: f64) -> Self {
self.efficiency = efficiency.clamp(0.0, 1.0);
self
}
}
/// Computes heat transfer for a thermal coupling.
///
/// # Formula
///
/// Q = η × UA × (T_hot - T_cold)
///
/// Where:
/// - Q is the heat transfer rate (W), positive means heat INTO cold circuit
/// - η is the efficiency factor
/// - UA is the thermal conductance (W/K)
/// - T_hot, T_cold are temperatures (K)
///
/// # Sign Convention
///
/// - Q > 0: Heat flows from hot to cold (normal operation)
/// - Q = 0: No temperature difference
/// - Q < 0: Cold is hotter than hot (reverse flow, unusual)
///
/// # Example
///
/// ```
/// use entropyk_solver::{ThermalCoupling, CircuitId, compute_coupling_heat};
/// use entropyk_core::{Temperature, ThermalConductance};
///
/// let coupling = ThermalCoupling::new(
/// CircuitId(0),
/// CircuitId(1),
/// ThermalConductance::from_watts_per_kelvin(1000.0),
/// );
///
/// let t_hot = Temperature::from_kelvin(350.0);
/// let t_cold = Temperature::from_kelvin(300.0);
///
/// let q = compute_coupling_heat(&coupling, t_hot, t_cold);
/// assert!(q > 0.0, "Heat should flow from hot to cold");
/// ```
pub fn compute_coupling_heat(
coupling: &ThermalCoupling,
t_hot: Temperature,
t_cold: Temperature,
) -> f64 {
coupling.efficiency
* coupling.ua.to_watts_per_kelvin()
* (t_hot.to_kelvin() - t_cold.to_kelvin())
}
/// Builds a coupling graph for dependency analysis.
///
/// Returns a directed graph where:
/// - Nodes are CircuitIds present in any coupling
/// - Edges point from hot_circuit to cold_circuit
fn build_coupling_graph(couplings: &[ThermalCoupling]) -> DiGraph<CircuitId, ()> {
let mut graph = DiGraph::new();
let mut circuit_to_node: HashMap<CircuitId, NodeIndex> = HashMap::new();
for coupling in couplings {
// Add hot_circuit node if not present
let hot_node = *circuit_to_node
.entry(coupling.hot_circuit)
.or_insert_with(|| graph.add_node(coupling.hot_circuit));
// Add cold_circuit node if not present
let cold_node = *circuit_to_node
.entry(coupling.cold_circuit)
.or_insert_with(|| graph.add_node(coupling.cold_circuit));
// Add directed edge: hot -> cold
graph.add_edge(hot_node, cold_node, ());
}
graph
}
/// Checks if the coupling graph contains circular dependencies.
///
/// Circular dependencies occur when circuits are mutually thermally coupled
/// (e.g., A heats B, and B heats A). When circular dependencies exist,
/// the solver must solve those circuits simultaneously rather than sequentially.
///
/// # Example
///
/// ```
/// use entropyk_solver::{ThermalCoupling, CircuitId, has_circular_dependencies};
/// use entropyk_core::ThermalConductance;
///
/// // No circular dependency: A → B → C
/// let couplings = vec![
/// ThermalCoupling::new(CircuitId(0), CircuitId(1), ThermalConductance::from_watts_per_kelvin(100.0)),
/// ThermalCoupling::new(CircuitId(1), CircuitId(2), ThermalConductance::from_watts_per_kelvin(100.0)),
/// ];
/// assert!(!has_circular_dependencies(&couplings));
///
/// // Circular dependency: A → B and B → A
/// let couplings_circular = vec![
/// ThermalCoupling::new(CircuitId(0), CircuitId(1), ThermalConductance::from_watts_per_kelvin(100.0)),
/// ThermalCoupling::new(CircuitId(1), CircuitId(0), ThermalConductance::from_watts_per_kelvin(100.0)),
/// ];
/// assert!(has_circular_dependencies(&couplings_circular));
/// ```
pub fn has_circular_dependencies(couplings: &[ThermalCoupling]) -> bool {
if couplings.is_empty() {
return false;
}
let graph = build_coupling_graph(couplings);
is_cyclic_directed(&graph)
}
/// Returns groups of circuits that must be solved simultaneously.
///
/// Groups are computed using strongly connected components (SCC) analysis
/// of the coupling graph. Circuits in the same SCC have circular thermal
/// dependencies and must be solved together.
///
/// # Returns
///
/// A vector of vectors, where each inner vector contains CircuitIds that
/// must be solved simultaneously. Single-element vectors indicate circuits
/// that can be solved independently (in topological order).
///
/// # Example
///
/// ```
/// use entropyk_solver::{ThermalCoupling, CircuitId, coupling_groups};
/// use entropyk_core::ThermalConductance;
///
/// // A → B, B and C independent
/// let couplings = vec![
/// ThermalCoupling::new(CircuitId(0), CircuitId(1), ThermalConductance::from_watts_per_kelvin(100.0)),
/// ];
/// let groups = coupling_groups(&couplings);
/// // Groups will contain individual circuits since there's no cycle
/// ```
pub fn coupling_groups(couplings: &[ThermalCoupling]) -> Vec<Vec<CircuitId>> {
if couplings.is_empty() {
return Vec::new();
}
let graph = build_coupling_graph(couplings);
let sccs = kosaraju_scc(&graph);
sccs.into_iter()
.map(|node_indices| node_indices.into_iter().map(|idx| graph[idx]).collect())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn make_coupling(hot: u8, cold: u8, ua_w_per_k: f64) -> ThermalCoupling {
ThermalCoupling::new(
CircuitId(hot),
CircuitId(cold),
ThermalConductance::from_watts_per_kelvin(ua_w_per_k),
)
}
#[test]
fn test_thermal_coupling_creation() {
let coupling = ThermalCoupling::new(
CircuitId(0),
CircuitId(1),
ThermalConductance::from_watts_per_kelvin(1000.0),
);
assert_eq!(coupling.hot_circuit, CircuitId(0));
assert_eq!(coupling.cold_circuit, CircuitId(1));
assert_relative_eq!(coupling.ua.to_watts_per_kelvin(), 1000.0, epsilon = 1e-10);
assert_relative_eq!(coupling.efficiency, 1.0, epsilon = 1e-10);
}
#[test]
fn test_thermal_coupling_with_efficiency() {
let coupling = ThermalCoupling::new(
CircuitId(0),
CircuitId(1),
ThermalConductance::from_watts_per_kelvin(1000.0),
)
.with_efficiency(0.85);
assert_relative_eq!(coupling.efficiency, 0.85, epsilon = 1e-10);
}
#[test]
fn test_efficiency_clamped() {
let coupling = make_coupling(0, 1, 100.0).with_efficiency(1.5);
assert_relative_eq!(coupling.efficiency, 1.0, epsilon = 1e-10);
let coupling = make_coupling(0, 1, 100.0).with_efficiency(-0.5);
assert_relative_eq!(coupling.efficiency, 0.0, epsilon = 1e-10);
}
#[test]
fn test_compute_coupling_heat_positive() {
let coupling = make_coupling(0, 1, 1000.0);
let t_hot = Temperature::from_kelvin(350.0);
let t_cold = Temperature::from_kelvin(300.0);
let q = compute_coupling_heat(&coupling, t_hot, t_cold);
// Q = 1.0 * 1000 * (350 - 300) = 50000 W
assert_relative_eq!(q, 50000.0, epsilon = 1e-10);
assert!(q > 0.0, "Heat should be positive (into cold circuit)");
}
#[test]
fn test_compute_coupling_heat_zero() {
let coupling = make_coupling(0, 1, 1000.0);
let t_hot = Temperature::from_kelvin(300.0);
let t_cold = Temperature::from_kelvin(300.0);
let q = compute_coupling_heat(&coupling, t_hot, t_cold);
assert_relative_eq!(q, 0.0, epsilon = 1e-10);
}
#[test]
fn test_compute_coupling_heat_negative() {
let coupling = make_coupling(0, 1, 1000.0);
let t_hot = Temperature::from_kelvin(280.0);
let t_cold = Temperature::from_kelvin(300.0);
let q = compute_coupling_heat(&coupling, t_hot, t_cold);
// Q = 1000 * (280 - 300) = -20000 W (reverse flow)
assert_relative_eq!(q, -20000.0, epsilon = 1e-10);
assert!(q < 0.0, "Heat should be negative (reverse flow)");
}
#[test]
fn test_compute_coupling_heat_with_efficiency() {
let coupling = make_coupling(0, 1, 1000.0).with_efficiency(0.9);
let t_hot = Temperature::from_kelvin(350.0);
let t_cold = Temperature::from_kelvin(300.0);
let q = compute_coupling_heat(&coupling, t_hot, t_cold);
// Q = 0.9 * 1000 * 50 = 45000 W
assert_relative_eq!(q, 45000.0, epsilon = 1e-10);
}
#[test]
fn test_energy_conservation() {
// For two circuits coupled, Q_hot = -Q_cold
// This means the heat leaving hot circuit equals heat entering cold circuit
let coupling = make_coupling(0, 1, 1000.0);
let t_hot = Temperature::from_kelvin(350.0);
let t_cold = Temperature::from_kelvin(300.0);
let q_into_cold = compute_coupling_heat(&coupling, t_hot, t_cold);
let q_out_of_hot = -q_into_cold; // By convention
// Heat into cold = - (heat out of hot)
assert_relative_eq!(q_into_cold, -q_out_of_hot, epsilon = 1e-10);
assert!(q_into_cold > 0.0, "Cold circuit receives heat");
assert!(q_out_of_hot < 0.0, "Hot circuit loses heat");
}
#[test]
fn test_no_circular_dependency() {
// Linear chain: A → B → C
let couplings = vec![make_coupling(0, 1, 100.0), make_coupling(1, 2, 100.0)];
assert!(!has_circular_dependencies(&couplings));
}
#[test]
fn test_circular_dependency_detection() {
// Mutual: A → B and B → A
let couplings = vec![make_coupling(0, 1, 100.0), make_coupling(1, 0, 100.0)];
assert!(has_circular_dependencies(&couplings));
}
#[test]
fn test_circular_dependency_complex() {
// Triangle: A → B → C → A
let couplings = vec![
make_coupling(0, 1, 100.0),
make_coupling(1, 2, 100.0),
make_coupling(2, 0, 100.0),
];
assert!(has_circular_dependencies(&couplings));
}
#[test]
fn test_empty_couplings_no_cycle() {
let couplings: Vec<ThermalCoupling> = vec![];
assert!(!has_circular_dependencies(&couplings));
}
#[test]
fn test_single_coupling_no_cycle() {
let couplings = vec![make_coupling(0, 1, 100.0)];
assert!(!has_circular_dependencies(&couplings));
}
#[test]
fn test_coupling_groups_no_cycle() {
// A → B, C independent
let couplings = vec![make_coupling(0, 1, 100.0)];
let groups = coupling_groups(&couplings);
// With no cycles, each circuit is its own group
assert_eq!(groups.len(), 2);
// Each group should have exactly one circuit
for group in &groups {
assert_eq!(group.len(), 1);
}
// Collect all circuit IDs
let all_circuits: std::collections::HashSet<CircuitId> =
groups.iter().flat_map(|g| g.iter().copied()).collect();
assert!(all_circuits.contains(&CircuitId(0)));
assert!(all_circuits.contains(&CircuitId(1)));
}
#[test]
fn test_coupling_groups_with_cycle() {
// A ↔ B (mutual), C → D
let couplings = vec![
make_coupling(0, 1, 100.0),
make_coupling(1, 0, 100.0),
make_coupling(2, 3, 100.0),
];
let groups = coupling_groups(&couplings);
// Should have 3 groups: [A, B] as one, C as one, D as one
assert_eq!(groups.len(), 3);
// Find the group with 2 circuits (A and B)
let large_group: Vec<&Vec<CircuitId>> = groups.iter().filter(|g| g.len() == 2).collect();
assert_eq!(large_group.len(), 1);
let ab_group = large_group[0];
assert!(ab_group.contains(&CircuitId(0)));
assert!(ab_group.contains(&CircuitId(1)));
}
#[test]
fn test_coupling_groups_empty() {
let couplings: Vec<ThermalCoupling> = vec![];
let groups = coupling_groups(&couplings);
assert!(groups.is_empty());
}
}

View File

@@ -0,0 +1,72 @@
//! Topology and solver error types.
use thiserror::Error;
/// Errors that can occur during topology validation or system construction.
#[derive(Error, Debug, Clone, PartialEq)]
pub enum TopologyError {
/// A node has no edges (isolated/dangling node).
#[error("Isolated node at index {node_index}: all components must be connected")]
IsolatedNode {
/// Index of the isolated node in the graph
node_index: usize,
},
/// Not all ports are connected (reserved for Story 3.2 port validation).
#[error("Unconnected ports: {message}")]
#[allow(dead_code)]
UnconnectedPorts {
/// Description of which ports are unconnected
message: String,
},
/// Topology validation failed for another reason.
#[error("Invalid topology: {message}")]
#[allow(dead_code)]
InvalidTopology {
/// Description of the validation failure
message: String,
},
/// Attempted to connect nodes in different circuits via a flow edge.
/// Flow edges must connect nodes within the same circuit. Cross-circuit
/// thermal coupling is handled in Story 3.4.
#[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,
/// Circuit ID of the target node
target_circuit: u8,
},
/// 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,
},
/// Attempted to add thermal coupling with a circuit that doesn't exist.
#[error(
"Invalid circuit for thermal coupling: circuit {circuit_id} does not exist in the system"
)]
InvalidCircuitForCoupling {
/// The circuit ID that was referenced but doesn't exist
circuit_id: u8,
},
}
/// Error when adding an edge with port validation.
///
/// Combines port validation errors ([`entropyk_components::ConnectionError`]) and topology errors
/// ([`TopologyError`]) such as cross-circuit connection attempts.
#[derive(Error, Debug, Clone, PartialEq)]
pub enum AddEdgeError {
/// Port validation failed (fluid, pressure, enthalpy mismatch).
#[error(transparent)]
Connection(#[from] entropyk_components::ConnectionError),
/// Topology validation failed (e.g. cross-circuit connection).
#[error(transparent)]
Topology(#[from] TopologyError),
}

View File

@@ -0,0 +1,6 @@
//! Graph building helpers for system topology.
//!
//! This module provides utilities for constructing and manipulating
//! the system graph. The main [`System`](crate::system::System) struct
//! handles graph operations; this module may be extended with convenience
//! builders in future stories.

View File

@@ -0,0 +1,675 @@
//! Smart initialization heuristic for thermodynamic system solvers.
//!
//! This module provides [`SmartInitializer`], which generates physically
//! reasonable initial guesses for the solver state vector from source and sink
//! temperatures. It uses the Antoine equation to estimate saturation pressures
//! for common refrigerants without requiring an external fluid backend.
//!
//! # Algorithm
//!
//! 1. Estimate evaporator pressure: `P_evap = P_sat(T_source - ΔT_approach)`
//! 2. Estimate condenser pressure: `P_cond = P_sat(T_sink + ΔT_approach)`
//! 3. Clamp `P_evap` to `0.5 * P_critical` if it exceeds the critical pressure
//! 4. Fill the state vector with `[P, h_default]` per edge, using circuit topology
//!
//! # Supported Fluids
//!
//! Built-in Antoine coefficients are provided for:
//! - R134a, R410A, R32, R744 (CO2), R290 (Propane)
//!
//! Unknown fluids fall back to sensible defaults (5 bar / 20 bar) with a warning.
//!
//! # No-Allocation Guarantee
//!
//! [`SmartInitializer::populate_state`] writes to a pre-allocated `&mut [f64]`
//! slice and performs no heap allocation.
use entropyk_components::port::FluidId;
use entropyk_core::{Enthalpy, Pressure, Temperature};
use thiserror::Error;
use crate::system::System;
// ─────────────────────────────────────────────────────────────────────────────
// Error types
// ─────────────────────────────────────────────────────────────────────────────
/// Errors that can occur during smart initialization.
#[derive(Error, Debug, Clone, PartialEq)]
pub enum InitializerError {
/// Source or sink temperature exceeds the critical temperature for the fluid.
///
/// Antoine equation is not valid above the critical temperature. The caller
/// should either use a different fluid or provide a manual initial state.
#[error("Temperature {temp_celsius:.1}°C exceeds critical temperature for {fluid}")]
TemperatureAboveCritical {
/// Temperature that triggered the error (°C).
temp_celsius: f64,
/// Fluid identifier string.
fluid: String,
},
/// The provided state slice length does not match the system state vector length.
#[error(
"State slice length {actual} does not match system state vector length {expected}"
)]
StateLengthMismatch {
/// Expected length (from `system.state_vector_len()`).
expected: usize,
/// Actual length of the provided slice.
actual: usize,
},
}
// ─────────────────────────────────────────────────────────────────────────────
// Antoine coefficients
// ─────────────────────────────────────────────────────────────────────────────
/// Antoine equation coefficients for saturation pressure estimation.
///
/// The Antoine equation (log₁₀ form) is:
///
/// ```text
/// log10(P_sat [Pa]) = A - B / (C + T [°C])
/// ```
///
/// Coefficients are tuned for the 40°C to +80°C range. Accuracy is within 5%
/// of NIST/CoolProp values — sufficient for initialization purposes.
#[derive(Debug, Clone, PartialEq)]
pub struct AntoineCoefficients {
/// Antoine constant A (dimensionless, log₁₀ scale, Pa units).
pub a: f64,
/// Antoine constant B (°C).
pub b: f64,
/// Antoine constant C (°C offset).
pub c: f64,
/// Critical pressure of the fluid (Pa).
pub p_critical_pa: f64,
}
impl AntoineCoefficients {
/// Returns the built-in coefficients for the given fluid identifier string.
///
/// Matching is case-insensitive. Returns `None` for unknown fluids.
pub fn for_fluid(fluid_str: &str) -> Option<&'static AntoineCoefficients> {
// Normalize: uppercase, strip dashes/spaces
let normalized = fluid_str.to_uppercase().replace(['-', ' '], "");
ANTOINE_TABLE
.iter()
.find(|(name, _)| *name == normalized.as_str())
.map(|(_, coeffs)| coeffs)
}
}
/// Compute saturation pressure (Pa) from temperature (°C) using Antoine equation.
///
/// `log10(P_sat [Pa]) = A - B / (C + T [°C])`
///
/// This is a pure arithmetic function with no heap allocation.
pub fn antoine_pressure(t_celsius: f64, coeffs: &AntoineCoefficients) -> f64 {
let log10_p = coeffs.a - coeffs.b / (coeffs.c + t_celsius);
10f64.powf(log10_p)
}
/// Built-in Antoine coefficient table for common refrigerants.
///
/// Coefficients valid for approximately 40°C to +80°C.
/// Accuracy: within 5% of NIST saturation pressure values.
///
/// Formula: `log10(P_sat [Pa]) = A - B / (C + T [°C])`
///
/// A values are derived from NIST reference saturation pressures:
/// - R134a: P_sat(0°C) = 292,800 Pa → A = log10(292800) + 1766/243 = 12.739
/// - R410A: P_sat(0°C) = 798,000 Pa → A = log10(798000) + 1885/243 = 13.659
/// - R32: P_sat(0°C) = 810,000 Pa → A = log10(810000) + 1780/243 = 13.233
/// - R744: P_sat(20°C) = 5,730,000 Pa → A = log10(5730000) + 1347.8/293 = 11.357
/// - R290: P_sat(0°C) = 474,000 Pa → A = log10(474000) + 1656/243 = 12.491
///
/// | Fluid | A (for Pa) | B | C | P_critical (Pa) |
/// |--------|------------|---------|-------|-----------------|
/// | R134a | 12.739 | 1766.0 | 243.0 | 4,059,280 |
/// | R410A | 13.659 | 1885.0 | 243.0 | 4,901,200 |
/// | R32 | 13.233 | 1780.0 | 243.0 | 5,782,000 |
/// | R744 | 11.357 | 1347.8 | 273.0 | 7,377,300 |
/// | R290 | 12.491 | 1656.0 | 243.0 | 4,247,200 |
static ANTOINE_TABLE: &[(&str, AntoineCoefficients)] = &[
(
"R134A",
AntoineCoefficients {
a: 12.739,
b: 1766.0,
c: 243.0,
p_critical_pa: 4_059_280.0,
},
),
(
"R410A",
AntoineCoefficients {
a: 13.659,
b: 1885.0,
c: 243.0,
p_critical_pa: 4_901_200.0,
},
),
(
"R32",
AntoineCoefficients {
a: 13.233,
b: 1780.0,
c: 243.0,
p_critical_pa: 5_782_000.0,
},
),
(
"R744",
AntoineCoefficients {
a: 11.357,
b: 1347.8,
c: 273.0,
p_critical_pa: 7_377_300.0,
},
),
(
"R290",
AntoineCoefficients {
a: 12.491,
b: 1656.0,
c: 243.0,
p_critical_pa: 4_247_200.0,
},
),
];
// ─────────────────────────────────────────────────────────────────────────────
// Initializer configuration
// ─────────────────────────────────────────────────────────────────────────────
/// Configuration for [`SmartInitializer`].
#[derive(Debug, Clone, PartialEq)]
pub struct InitializerConfig {
/// Fluid identifier used for Antoine coefficient lookup.
pub fluid: FluidId,
/// Temperature approach difference for pressure estimation (K).
///
/// - Evaporator: `P_evap = P_sat(T_source - dt_approach)`
/// - Condenser: `P_cond = P_sat(T_sink + dt_approach)`
///
/// Default: 5.0 K.
pub dt_approach: f64,
}
impl Default for InitializerConfig {
fn default() -> Self {
Self {
fluid: FluidId::new("R134a"),
dt_approach: 5.0,
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// SmartInitializer
// ─────────────────────────────────────────────────────────────────────────────
/// Smart initialization heuristic for thermodynamic solver state vectors.
///
/// Uses the Antoine equation to estimate saturation pressures from source and
/// sink temperatures, then fills a pre-allocated state vector with physically
/// reasonable initial guesses.
///
/// # Example
///
/// ```rust,no_run
/// use entropyk_solver::initializer::{SmartInitializer, InitializerConfig};
/// use entropyk_core::{Temperature, Enthalpy};
///
/// let init = SmartInitializer::new(InitializerConfig::default());
/// let (p_evap, p_cond) = init
/// .estimate_pressures(
/// Temperature::from_celsius(5.0),
/// Temperature::from_celsius(40.0),
/// )
/// .unwrap();
/// ```
#[derive(Debug, Clone)]
pub struct SmartInitializer {
/// Configuration for this initializer.
pub config: InitializerConfig,
}
impl SmartInitializer {
/// Creates a new `SmartInitializer` with the given configuration.
pub fn new(config: InitializerConfig) -> Self {
Self { config }
}
/// Estimate `(P_evap, P_cond)` from source and sink temperatures.
///
/// Uses the Antoine equation with the configured fluid and approach ΔT:
/// - `P_evap = P_sat(T_source - ΔT_approach)`, clamped to `0.5 * P_critical`
/// - `P_cond = P_sat(T_sink + ΔT_approach)`
///
/// For unknown fluids, returns sensible defaults (5 bar / 20 bar) with a
/// `tracing::warn!` log entry.
///
/// # Errors
///
/// Returns [`InitializerError::TemperatureAboveCritical`] if the adjusted
/// source temperature exceeds the critical temperature for a known fluid.
pub fn estimate_pressures(
&self,
t_source: Temperature,
t_sink: Temperature,
) -> Result<(Pressure, Pressure), InitializerError> {
let fluid_str = self.config.fluid.to_string();
match AntoineCoefficients::for_fluid(&fluid_str) {
None => {
// Unknown fluid: emit warning and return sensible defaults
tracing::warn!(
fluid = %fluid_str,
"Unknown fluid for Antoine estimation — using fallback pressures \
(P_evap = 5 bar, P_cond = 20 bar)"
);
Ok((
Pressure::from_bar(5.0),
Pressure::from_bar(20.0),
))
}
Some(coeffs) => {
let t_source_c = t_source.to_celsius();
let t_sink_c = t_sink.to_celsius();
// Evaporator: T_source - ΔT_approach
let t_evap_c = t_source_c - self.config.dt_approach;
let p_evap_pa = antoine_pressure(t_evap_c, coeffs);
// Clamp P_evap to 0.5 * P_critical (AC: #2)
let p_evap_pa = if p_evap_pa >= coeffs.p_critical_pa {
tracing::warn!(
fluid = %fluid_str,
t_evap_celsius = t_evap_c,
p_evap_pa = p_evap_pa,
p_critical_pa = coeffs.p_critical_pa,
"Estimated P_evap exceeds critical pressure — clamping to 0.5 * P_critical"
);
0.5 * coeffs.p_critical_pa
} else {
p_evap_pa
};
// Condenser: T_sink + ΔT_approach (AC: #3)
let t_cond_c = t_sink_c + self.config.dt_approach;
let p_cond_pa = antoine_pressure(t_cond_c, coeffs);
// Clamp P_cond to 0.5 * P_critical if it exceeds critical
let p_cond_pa = if p_cond_pa >= coeffs.p_critical_pa {
tracing::warn!(
fluid = %fluid_str,
t_cond_celsius = t_cond_c,
p_cond_pa = p_cond_pa,
p_critical_pa = coeffs.p_critical_pa,
"Estimated P_cond exceeds critical pressure — clamping to 0.5 * P_critical"
);
0.5 * coeffs.p_critical_pa
} else {
p_cond_pa
};
tracing::debug!(
fluid = %fluid_str,
t_source_celsius = t_source_c,
t_sink_celsius = t_sink_c,
p_evap_bar = p_evap_pa / 1e5,
p_cond_bar = p_cond_pa / 1e5,
"SmartInitializer: estimated pressures"
);
Ok((
Pressure::from_pascals(p_evap_pa),
Pressure::from_pascals(p_cond_pa),
))
}
}
}
/// Fill a pre-allocated state vector with smart initial guesses.
///
/// No heap allocation is performed. The `state` slice must have length equal
/// to `system.state_vector_len()` (i.e., `2 * edge_count`).
///
/// State layout per edge: `[P_edge_i, h_edge_i]`
///
/// Pressure assignment follows circuit topology:
/// - Edges in circuit 0 → `p_evap`
/// - Edges in circuit 1+ → `p_cond`
/// - Single-circuit systems: all edges use `p_evap`
///
/// # Errors
///
/// Returns [`InitializerError::StateLengthMismatch`] if `state.len()` does
/// not match `system.state_vector_len()`.
pub fn populate_state(
&self,
system: &System,
p_evap: Pressure,
p_cond: Pressure,
h_default: Enthalpy,
state: &mut [f64],
) -> Result<(), InitializerError> {
let expected = system.state_vector_len();
if state.len() != expected {
return Err(InitializerError::StateLengthMismatch {
expected,
actual: state.len(),
});
}
let p_evap_pa = p_evap.to_pascals();
let p_cond_pa = p_cond.to_pascals();
let h_jkg = h_default.to_joules_per_kg();
for (i, edge_idx) in system.edge_indices().enumerate() {
let circuit = system.edge_circuit(edge_idx);
let p = if circuit.0 == 0 { p_evap_pa } else { p_cond_pa };
state[2 * i] = p;
state[2 * i + 1] = h_jkg;
}
Ok(())
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
// ── Antoine equation unit tests ──────────────────────────────────────────
/// AC: #1, #5 — R134a at 0°C: P_sat ≈ 2.93 bar (293,000 Pa), within 5%
#[test]
fn test_antoine_r134a_at_0c() {
let coeffs = AntoineCoefficients::for_fluid("R134a").unwrap();
let p_pa = antoine_pressure(0.0, coeffs);
// Expected: ~2.93 bar = 293,000 Pa
assert_relative_eq!(p_pa, 293_000.0, max_relative = 0.05);
}
/// AC: #5 — R744 (CO2) at 20°C: P_sat ≈ 57.3 bar (5,730,000 Pa), within 5%
#[test]
fn test_antoine_r744_at_20c() {
let coeffs = AntoineCoefficients::for_fluid("R744").unwrap();
let p_pa = antoine_pressure(20.0, coeffs);
// Expected: ~57.3 bar = 5,730,000 Pa
assert_relative_eq!(p_pa, 5_730_000.0, max_relative = 0.05);
}
/// AC: #5 — Case-insensitive fluid lookup
#[test]
fn test_fluid_lookup_case_insensitive() {
assert!(AntoineCoefficients::for_fluid("r134a").is_some());
assert!(AntoineCoefficients::for_fluid("R134A").is_some());
assert!(AntoineCoefficients::for_fluid("R134a").is_some());
assert!(AntoineCoefficients::for_fluid("r744").is_some());
assert!(AntoineCoefficients::for_fluid("R290").is_some());
}
/// AC: #5 — Unknown fluid returns None
#[test]
fn test_fluid_lookup_unknown() {
assert!(AntoineCoefficients::for_fluid("R999").is_none());
assert!(AntoineCoefficients::for_fluid("").is_none());
}
// ── SmartInitializer::estimate_pressures tests ───────────────────────────
/// AC: #2 — P_evap < P_critical for all built-in fluids at T_source = 40°C
#[test]
fn test_p_evap_below_critical_all_fluids() {
let fluids = ["R134a", "R410A", "R32", "R744", "R290"];
for fluid in fluids {
let init = SmartInitializer::new(InitializerConfig {
fluid: FluidId::new(fluid),
dt_approach: 5.0,
});
let (p_evap, _) = init
.estimate_pressures(
Temperature::from_celsius(-40.0),
Temperature::from_celsius(40.0),
)
.unwrap();
let coeffs = AntoineCoefficients::for_fluid(fluid).unwrap();
assert!(
p_evap.to_pascals() < coeffs.p_critical_pa,
"P_evap ({:.0} Pa) should be < P_critical ({:.0} Pa) for {}",
p_evap.to_pascals(),
coeffs.p_critical_pa,
fluid
);
}
}
/// AC: #3 — P_cond = P_sat(T_sink + 5K) for default ΔT_approach
#[test]
fn test_p_cond_approach_default() {
let init = SmartInitializer::new(InitializerConfig::default()); // R134a, dt=5.0
let t_sink = Temperature::from_celsius(40.0);
let (_, p_cond) = init
.estimate_pressures(Temperature::from_celsius(5.0), t_sink)
.unwrap();
// Expected: P_sat(45°C) for R134a
let coeffs = AntoineCoefficients::for_fluid("R134a").unwrap();
let expected_pa = antoine_pressure(45.0, coeffs);
assert_relative_eq!(p_cond.to_pascals(), expected_pa, max_relative = 1e-9);
}
/// AC: #6 — Unknown fluid returns fallback (5 bar / 20 bar) without panic
#[test]
fn test_unknown_fluid_fallback() {
let init = SmartInitializer::new(InitializerConfig {
fluid: FluidId::new("R999-Unknown"),
dt_approach: 5.0,
});
let result = init.estimate_pressures(
Temperature::from_celsius(5.0),
Temperature::from_celsius(40.0),
);
assert!(result.is_ok(), "Unknown fluid should not return Err");
let (p_evap, p_cond) = result.unwrap();
assert_relative_eq!(p_evap.to_bar(), 5.0, max_relative = 1e-9);
assert_relative_eq!(p_cond.to_bar(), 20.0, max_relative = 1e-9);
}
/// AC: #1 — Verify evaporator pressure uses T_source - ΔT_approach
#[test]
fn test_p_evap_uses_approach_delta() {
let dt = 5.0;
let init = SmartInitializer::new(InitializerConfig {
fluid: FluidId::new("R134a"),
dt_approach: dt,
});
let t_source = Temperature::from_celsius(10.0);
let (p_evap, _) = init
.estimate_pressures(t_source, Temperature::from_celsius(40.0))
.unwrap();
let coeffs = AntoineCoefficients::for_fluid("R134a").unwrap();
let expected_pa = antoine_pressure(10.0 - dt, coeffs); // T_source - ΔT
assert_relative_eq!(p_evap.to_pascals(), expected_pa, max_relative = 1e-9);
}
// ── SmartInitializer::populate_state tests ───────────────────────────────
/// AC: #4, #7 — populate_state fills state vector correctly for a 2-edge system.
///
/// This test verifies the no-allocation signature: the function takes `&mut [f64]`
/// and writes in-place without allocating.
#[test]
fn test_populate_state_2_edges() {
use crate::system::System;
use entropyk_components::{Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState};
struct MockComp;
impl Component for MockComp {
fn compute_residuals(&self, _s: &SystemState, r: &mut ResidualVector) -> Result<(), ComponentError> {
for v in r.iter_mut() { *v = 0.0; }
Ok(())
}
fn jacobian_entries(&self, _s: &SystemState, j: &mut JacobianBuilder) -> Result<(), ComponentError> {
j.add_entry(0, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize { 1 }
fn get_ports(&self) -> &[ConnectedPort] { &[] }
}
let mut sys = System::new();
let n0 = sys.add_component(Box::new(MockComp));
let n1 = sys.add_component(Box::new(MockComp));
let n2 = sys.add_component(Box::new(MockComp));
sys.add_edge(n0, n1).unwrap();
sys.add_edge(n1, n2).unwrap();
sys.finalize().unwrap();
let init = SmartInitializer::new(InitializerConfig::default());
let p_evap = Pressure::from_bar(3.0);
let p_cond = Pressure::from_bar(15.0);
let h_default = Enthalpy::from_joules_per_kg(400_000.0);
// Pre-allocated slice — no allocation in populate_state
let mut state = vec![0.0f64; sys.state_vector_len()];
init.populate_state(&sys, p_evap, p_cond, h_default, &mut state)
.unwrap();
// All edges in circuit 0 (single-circuit) → p_evap
assert_eq!(state.len(), 4); // 2 edges × 2 entries
assert_relative_eq!(state[0], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[1], h_default.to_joules_per_kg(), max_relative = 1e-9);
assert_relative_eq!(state[2], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[3], h_default.to_joules_per_kg(), max_relative = 1e-9);
}
/// 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 entropyk_components::{Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState};
struct MockComp;
impl Component for MockComp {
fn compute_residuals(&self, _s: &SystemState, r: &mut ResidualVector) -> Result<(), ComponentError> {
for v in r.iter_mut() { *v = 0.0; }
Ok(())
}
fn jacobian_entries(&self, _s: &SystemState, j: &mut JacobianBuilder) -> Result<(), ComponentError> {
j.add_entry(0, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize { 1 }
fn get_ports(&self) -> &[ConnectedPort] { &[] }
}
let mut sys = System::new();
// Circuit 0: evaporator side
let n0 = sys.add_component_to_circuit(Box::new(MockComp), CircuitId(0)).unwrap();
let n1 = sys.add_component_to_circuit(Box::new(MockComp), CircuitId(0)).unwrap();
// Circuit 1: condenser side
let n2 = sys.add_component_to_circuit(Box::new(MockComp), CircuitId(1)).unwrap();
let n3 = sys.add_component_to_circuit(Box::new(MockComp), CircuitId(1)).unwrap();
sys.add_edge(n0, n1).unwrap(); // circuit 0 edge
sys.add_edge(n2, n3).unwrap(); // circuit 1 edge
sys.finalize().unwrap();
let init = SmartInitializer::new(InitializerConfig::default());
let p_evap = Pressure::from_bar(3.0);
let p_cond = Pressure::from_bar(15.0);
let h_default = Enthalpy::from_joules_per_kg(400_000.0);
let mut state = vec![0.0f64; sys.state_vector_len()];
init.populate_state(&sys, p_evap, p_cond, h_default, &mut state)
.unwrap();
assert_eq!(state.len(), 4); // 2 edges × 2 entries
// Edge 0 (circuit 0) → p_evap
assert_relative_eq!(state[0], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[1], h_default.to_joules_per_kg(), max_relative = 1e-9);
// Edge 1 (circuit 1) → p_cond
assert_relative_eq!(state[2], p_cond.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[3], h_default.to_joules_per_kg(), max_relative = 1e-9);
}
/// AC: #7 — populate_state returns error on length mismatch (no panic).
#[test]
fn test_populate_state_length_mismatch() {
use crate::system::System;
use entropyk_components::{Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState};
struct MockComp;
impl Component for MockComp {
fn compute_residuals(&self, _s: &SystemState, r: &mut ResidualVector) -> Result<(), ComponentError> {
for v in r.iter_mut() { *v = 0.0; }
Ok(())
}
fn jacobian_entries(&self, _s: &SystemState, j: &mut JacobianBuilder) -> Result<(), ComponentError> {
j.add_entry(0, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize { 1 }
fn get_ports(&self) -> &[ConnectedPort] { &[] }
}
let mut sys = System::new();
let n0 = sys.add_component(Box::new(MockComp));
let n1 = sys.add_component(Box::new(MockComp));
sys.add_edge(n0, n1).unwrap();
sys.finalize().unwrap();
let init = SmartInitializer::new(InitializerConfig::default());
let p_evap = Pressure::from_bar(3.0);
let p_cond = Pressure::from_bar(15.0);
let h_default = Enthalpy::from_joules_per_kg(400_000.0);
// Wrong length: system has 2 state entries (1 edge × 2), we provide 5
let mut state = vec![0.0f64; 5];
let result = init.populate_state(&sys, p_evap, p_cond, h_default, &mut state);
assert!(matches!(
result,
Err(InitializerError::StateLengthMismatch { expected: 2, actual: 5 })
));
}
/// AC: #2 — P_evap is clamped to 0.5 * P_critical when above critical.
///
/// We use R744 (CO2) at a very high source temperature to trigger clamping.
#[test]
fn test_p_evap_clamped_above_critical() {
// R744 critical: 7,377,300 Pa (~73.8 bar), critical T ≈ 31°C
// At T_source = 40°C, T_evap = 35°C → P_sat > P_critical → should clamp
let init = SmartInitializer::new(InitializerConfig {
fluid: FluidId::new("R744"),
dt_approach: 5.0,
});
let (p_evap, _) = init
.estimate_pressures(
Temperature::from_celsius(40.0),
Temperature::from_celsius(50.0),
)
.unwrap();
let coeffs = AntoineCoefficients::for_fluid("R744").unwrap();
// Must be clamped to 0.5 * P_critical
assert_relative_eq!(
p_evap.to_pascals(),
0.5 * coeffs.p_critical_pa,
max_relative = 1e-9
);
}
}

View File

@@ -67,6 +67,26 @@ impl JacobianMatrix {
JacobianMatrix(matrix)
}
/// Updates an existing Jacobian matrix from sparse entries in-place.
///
/// The matrix is first zeroed out, then filled with the new entries.
/// This avoids re-allocating memory during iterations, satisfying the
/// zero-allocation architecture constraint.
///
/// # Arguments
///
/// * `entries` - Slice of `(row, col, value)` tuples
pub fn update_from_builder(&mut self, entries: &[(usize, usize, f64)]) {
self.0.fill(0.0);
let n_rows = self.0.nrows();
let n_cols = self.0.ncols();
for &(row, col, value) in entries {
if row < n_rows && col < n_cols {
self.0[(row, col)] += value;
}
}
}
/// Creates a zero Jacobian matrix with the given dimensions.
pub fn zeros(n_rows: usize, n_cols: usize) -> Self {
JacobianMatrix(DMatrix::zeros(n_rows, n_cols))

33
crates/solver/src/lib.rs Normal file
View File

@@ -0,0 +1,33 @@
//! # Entropyk Solver
//!
//! System topology and solver engine for thermodynamic simulation.
//!
//! This crate provides the graph-based representation of thermodynamic systems,
//! where components are nodes and flow connections are edges. Edges index into
//! the solver's state vector (P and h per edge).
pub mod coupling;
pub mod criteria;
pub mod error;
pub mod graph;
pub mod initializer;
pub mod jacobian;
pub mod solver;
pub mod system;
pub use criteria::{CircuitConvergence, ConvergenceCriteria, ConvergenceReport};
pub use coupling::{
compute_coupling_heat, coupling_groups, has_circular_dependencies, ThermalCoupling,
};
pub use entropyk_components::ConnectionError;
pub use error::{AddEdgeError, TopologyError};
pub use initializer::{
antoine_pressure, AntoineCoefficients, InitializerConfig, InitializerError, SmartInitializer,
};
pub use jacobian::JacobianMatrix;
pub use solver::{
ConvergedState, ConvergenceStatus, FallbackConfig, FallbackSolver, JacobianFreezingConfig,
NewtonConfig, PicardConfig, Solver, SolverError, SolverStrategy, TimeoutConfig,
};
pub use system::{CircuitId, FlowEdge, System};

View File

@@ -302,6 +302,61 @@ impl Default for TimeoutConfig {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Jacobian Freezing Configuration (Story 4.8)
// ─────────────────────────────────────────────────────────────────────────────
/// Configuration for Jacobian-freezing optimization.
///
/// When enabled, the Newton-Raphson solver reuses the previously computed
/// Jacobian matrix for up to `max_frozen_iters` consecutive iterations,
/// provided the residual norm is still decreasing. This avoids expensive
/// Jacobian assembly and can reduce per-iteration CPU time by up to ~80%.
///
/// # Auto-disable on divergence
///
/// If the residual norm *increases* while a frozen Jacobian is being used,
/// the solver immediately forces a fresh Jacobian computation on the next
/// iteration and resets the frozen-iteration counter.
///
/// # Example
///
/// ```rust
/// use entropyk_solver::solver::{NewtonConfig, JacobianFreezingConfig};
///
/// let config = NewtonConfig::default()
/// .with_jacobian_freezing(JacobianFreezingConfig {
/// max_frozen_iters: 3,
/// threshold: 0.1,
/// });
/// ```
#[derive(Debug, Clone, PartialEq)]
pub struct JacobianFreezingConfig {
/// Maximum number of consecutive iterations the Jacobian may be reused
/// without recomputing.
///
/// After this many frozen iterations the solver forces a fresh assembly,
/// even if the residual is still decreasing. Default: 3.
pub max_frozen_iters: usize,
/// Residual-norm ratio threshold below which freezing is considered safe.
///
/// Freezing is only attempted when
/// `current_norm / previous_norm < (1.0 - threshold)`,
/// ensuring that convergence is still progressing sufficiently.
/// Default: 0.1 (i.e., at least a 10 % residual decrease per step).
pub threshold: f64,
}
impl Default for JacobianFreezingConfig {
fn default() -> Self {
Self {
max_frozen_iters: 3,
threshold: 0.1,
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Configuration structs
// ─────────────────────────────────────────────────────────────────────────────
@@ -393,6 +448,15 @@ pub struct NewtonConfig {
/// test instead of the raw L2-norm tolerance check. The old `tolerance` field is retained
/// for backward compatibility and is ignored when this is `Some`.
pub convergence_criteria: Option<ConvergenceCriteria>,
/// Jacobian-freezing optimization (Story 4.8).
///
/// When `Some`, the solver reuses the previous Jacobian matrix for up to
/// `max_frozen_iters` iterations while the residual is decreasing faster than
/// the configured threshold. Auto-disables when the residual increases.
///
/// Default: `None` (recompute every iteration — backward-compatible).
pub jacobian_freezing: Option<JacobianFreezingConfig>,
}
impl Default for NewtonConfig {
@@ -410,6 +474,7 @@ impl Default for NewtonConfig {
previous_state: None,
initial_state: None,
convergence_criteria: None,
jacobian_freezing: None,
}
}
}
@@ -435,6 +500,17 @@ impl NewtonConfig {
self
}
/// Enables Jacobian-freezing optimization (Story 4.8 — builder pattern).
///
/// When set, the solver skips Jacobian re-assembly for iterations where the
/// residual is still decreasing, up to `config.max_frozen_iters` consecutive
/// frozen steps. Freezing is automatically disabled when the residual
/// increases.
pub fn with_jacobian_freezing(mut self, config: JacobianFreezingConfig) -> Self {
self.jacobian_freezing = Some(config);
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()
@@ -658,6 +734,14 @@ impl Solver for NewtonConfig {
let mut best_state: Vec<f64> = vec![0.0; n_state];
let mut best_residual: f64;
// Story 4.8 — Jacobian-freezing tracking state.
// `frozen_count` tracks how many consecutive iterations have reused the Jacobian.
// `force_recompute` is set when a residual increase is detected.
// The Jacobian matrix itself is pre-allocated here (Zero Allocation AC)
let mut jacobian_matrix = JacobianMatrix::zeros(n_equations, n_state);
let mut frozen_count: usize = 0;
let mut force_recompute: bool = true; // Always compute on the very first iteration
// Initial residual computation
system
.compute_residuals(&state, &mut residuals)
@@ -728,32 +812,74 @@ impl Solver for NewtonConfig {
}
}
// Assemble Jacobian (AC: #3)
jacobian_builder.clear();
let jacobian_matrix = 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))
};
JacobianMatrix::numerical(compute_residuals_fn, &state, &residuals, 1e-8).map_err(
|e| SolverError::InvalidSystem {
message: format!("Failed to compute numerical Jacobian: {}", e),
},
)?
// ── Jacobian Assembly / Freeze Decision (AC: #3, Story 4.8) ──
//
// Decide whether to recompute or reuse the Jacobian based on the
// freezing configuration and convergence behaviour.
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 = iteration,
frozen_count = frozen_count,
"Jacobian freeze limit reached — recomputing"
);
true
} else {
false
}
} else {
// Analytical Jacobian from components
system
.assemble_jacobian(&state, &mut jacobian_builder)
.map_err(|e| SolverError::InvalidSystem {
message: format!("Failed to assemble Jacobian: {:?}", e),
})?;
JacobianMatrix::from_builder(jacobian_builder.entries(), n_equations, n_state)
// No freezing configured — always recompute (backward-compatible)
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))
};
// Rather than creating a new matrix, compute it and assign
let jm = JacobianMatrix::numerical(compute_residuals_fn, &state, &residuals, 1e-8)
.map_err(|e| SolverError::InvalidSystem {
message: format!("Failed to compute numerical Jacobian: {}", e),
})?;
// Deep copy elements to existing matrix (DMatrix::copy_from does not reallocate)
jacobian_matrix.as_matrix_mut().copy_from(jm.as_matrix());
} else {
// Analytical Jacobian from components
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 = iteration,
"Fresh Jacobian computed"
);
} else {
// Reuse the frozen Jacobian (Story 4.8 — AC: #2)
frozen_count += 1;
tracing::debug!(
iteration = iteration,
frozen_count = frozen_count,
"Reusing frozen Jacobian"
);
}
// Solve linear system J·Δx = -r (AC: #1)
let delta = match jacobian_matrix.solve(&residuals) {
Some(d) => d,
@@ -811,6 +937,29 @@ impl Solver for NewtonConfig {
);
}
// ── Story 4.8 — Jacobian-freeze feedback ──
//
// If the residual norm increased or did not decrease enough
// (below the threshold), force a fresh Jacobian on the next
// iteration and reset the frozen counter.
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 = iteration,
current_norm = current_norm,
previous_norm = previous_norm,
ratio = current_norm / previous_norm,
"Residual not decreasing fast enough — unfreezing Jacobian"
);
}
force_recompute = true;
frozen_count = 0;
}
}
tracing::debug!(
iteration = iteration,
residual_norm = current_norm,
@@ -1694,10 +1843,12 @@ impl FallbackSolver {
tracing::debug!(
final_residual = final_residual,
threshold = self.config.return_to_newton_threshold,
"Picard not yet stabilized, continuing with Picard"
"Picard not yet stabilized, aborting"
);
// Continue with Picard - no allocation overhead
continue;
return Err(SolverError::NonConvergence {
iterations,
final_residual,
});
}
}
}
@@ -1958,6 +2109,7 @@ mod tests {
previous_state: None,
initial_state: None,
convergence_criteria: None,
jacobian_freezing: None,
}
.with_timeout(Duration::from_millis(200));

1608
crates/solver/src/system.rs Normal file

File diff suppressed because it is too large Load Diff