20 KiB
Story 4.6: Smart Initialization Heuristic
Status: done
Story
As a R&D engineer (Marie), I want automatic initial guesses from source and sink temperatures, so that cold start convergence is fast and reliable.
Acceptance Criteria
-
Antoine Equation Pressure Estimation (AC: #1)
- Given source temperature
T_source(evaporator side) and sink temperatureT_sink(condenser side) - When calling
SmartInitializer::estimate_pressures(fluid, T_source, T_sink) - Then evaporator pressure is estimated via Antoine equation at
T_source - ΔT_approach - And condenser pressure is estimated via Antoine equation at
T_sink + ΔT_approach - And both pressures are returned as
PressureNewType values
- Given source temperature
-
Evaporator Pressure Below Critical (AC: #2)
- Given any source temperature
- When estimating evaporator pressure
- Then
P_evap < P_criticalfor the fluid - And if estimated pressure exceeds critical, it is clamped to
0.5 * P_critical
-
Condenser Pressure from Sink + Approach ΔT (AC: #3)
- Given sink temperature
T_sinkand configurable approach temperature differenceΔT_approach - When estimating condenser pressure
- Then
P_cond = P_sat(T_sink + ΔT_approach) - And default
ΔT_approach = 5.0 K
- Given sink temperature
-
State Vector Population (AC: #4)
- Given a finalized
Systemand estimated pressures - When calling
SmartInitializer::populate_state(system, P_evap, P_cond, h_guess) - Then the state vector is filled with alternating
[P, h]per edge - And pressure assignment follows circuit topology (evaporator edges → P_evap, condenser edges → P_cond)
- And enthalpy is initialized to a physically reasonable default per fluid
- Given a finalized
-
Fluid-Agnostic Antoine Coefficients (AC: #5)
- Given common refrigerants (R134a, R410A, R32, R744/CO2, R290/Propane)
- When estimating saturation pressure
- Then built-in Antoine coefficients are used (no external fluid backend required)
- And results are within 5% of CoolProp saturation pressure for T ∈ [−40°C, +80°C]
-
Graceful Fallback for Unknown Fluids (AC: #6)
- Given an unrecognized fluid identifier
- When calling
estimate_pressures - Then a sensible default state is returned (e.g., P_evap = 5 bar, P_cond = 20 bar)
- And a
tracing::warn!is emitted with the unknown fluid name
-
No Heap Allocation in Hot Path (AC: #7)
- Given a pre-finalized
System - When calling
populate_state - Then no heap allocation occurs during state vector filling
- And the state slice is written in-place
- Given a pre-finalized
-
Integration with Solver (AC: #8)
- Given a
FallbackSolver(orNewtonConfig/PicardConfig) - When the user calls
solver.with_initial_state(state_vec) - Then the solver starts from the provided initial state instead of zeros
- And the solver accepts
Vec<f64>as the initial state
- Given a
Tasks / Subtasks
-
Create
crates/solver/src/initializer.rsmodule (AC: #1, #2, #3, #5, #6)- Define
SmartInitializerstruct withΔT_approach: f64field (default 5.0 K) - Implement
InitializerConfigstruct withdt_approach: f64andfluid: FluidId - Implement
AntoineCoefficientsstruct:A,B,Cfields (log10 form, Pa units) - Implement
antoine_pressure(T_kelvin: f64, coeffs: &AntoineCoefficients) -> f64pure function - Add built-in coefficient table for: R134a, R410A, R32, R744 (CO2), R290 (Propane)
- Implement
SmartInitializer::estimate_pressures(fluid, T_source_K, T_sink_K) -> (Pressure, Pressure) - Clamp P_evap to
0.5 * P_criticalif above critical (AC: #2) - Add
tracing::warn!for unknown fluids with fallback defaults (AC: #6)
- Define
-
Implement
SmartInitializer::populate_state(AC: #4, #7)- Accept
system: &System,P_evap: Pressure,P_cond: Pressure,h_default: Enthalpy - Accept mutable
state: &mut [f64]slice (no allocation) - Iterate over
system.edge_indices()and fillstate[2*i]= P,state[2*i+1]= h - Use
P_evapfor edges in circuit 0 (evaporator circuit),P_condfor circuit 1 (condenser circuit) - For single-circuit systems, alternate between P_evap and P_cond based on edge index parity
- Accept
-
Add
initial_statesupport to solver configs (AC: #8)- Add
initial_state: Option<Vec<f64>>field toNewtonConfig - Add
initial_state: Option<Vec<f64>>field toPicardConfig - Add
with_initial_state(mut self, state: Vec<f64>) -> Selfbuilder method to both - In
NewtonConfig::solve(): ifinitial_state.is_some(), copy it intostatebefore iteration - In
PicardConfig::solve(): same pattern - Add
with_initial_statetoFallbackSolver(delegates to both newton/picard configs)
- Add
-
Expose
SmartInitializerinlib.rs(AC: #1)- Add
pub mod initializer;tocrates/solver/src/lib.rs - Re-export
SmartInitializer,InitializerConfig,AntoineCoefficientsfromlib.rs
- Add
-
Unit tests in
initializer.rs(AC: #1–#7)- Test Antoine equation gives correct P_sat for R134a at 0°C (≈ 2.93 bar)
- Test Antoine equation gives correct P_sat for R744 at 20°C (≈ 57.3 bar)
- Test P_evap < P_critical for all built-in fluids at T_source = −40°C
- Test P_cond = P_sat(T_sink + 5K) for default ΔT_approach
- Test unknown fluid returns fallback (5 bar / 20 bar) with warn log
- Test
populate_statefills state vector correctly for 2-edge system - Test no allocation: verify
populate_stateworks on pre-allocated slice
-
Integration test: cold start convergence (AC: #8)
- Build test system and use
SmartInitializerto generate initial state - Verify
FallbackSolver::with_initial_statedelegates to both sub-solvers - Assert convergence in ≤ iterations from zero-start for same system
- Build test system and use
Dev Notes
Epic Context
Epic 4: Intelligent Solver Engine — Solve any system with < 1s guarantee, Newton-Raphson ↔ Sequential Substitution fallback.
Story Dependencies:
- Story 4.1 (Solver Trait Abstraction) — DONE:
Solvertrait,SolverError,ConvergedStatedefined - Story 4.2 (Newton-Raphson) — DONE: Full Newton with line search, timeout, divergence detection
- Story 4.3 (Sequential Substitution) — DONE: Picard with relaxation, timeout, divergence detection
- Story 4.4 (Intelligent Fallback) — DONE:
FallbackSolverwith Newton↔Picard switching - Story 4.5 (Time-Budgeted Solving) — IN-PROGRESS:
TimeoutConfig, best-state tracking, ZOH - Story 4.7 (Convergence Criteria) — NEXT: Sparse Jacobian for multi-circuit
FRs covered: FR42 (Automatic Initialization Heuristic — Smart Guesser)
Architecture Context
Technical Stack:
entropyk_core::Temperature,entropyk_core::Pressure,entropyk_core::Enthalpy— NewType wrappers (MUST use, no bare f64 in public API)entropyk_components::port::FluidId— fluid identifier (already in scope from system.rs)tracing— structured logging (already in solver crate)thiserror— error handling (already in solver crate)- No external fluid backend needed for Antoine estimation (pure math)
Code Structure:
- NEW FILE:
crates/solver/src/initializer.rs—SmartInitializer,InitializerConfig,AntoineCoefficients - MODIFY:
crates/solver/src/solver.rs— addinitial_statefield toNewtonConfig,PicardConfig,FallbackSolver - MODIFY:
crates/solver/src/lib.rs— addpub mod initializer;and re-exports
Relevant Architecture Decisions:
- NewType pattern: Never use bare
f64for physical quantities in public API [Source: architecture.md] - No allocation in hot path:
populate_statemust write to pre-allocated&mut [f64][Source: architecture.md NFR4] - Zero-panic policy: All operations return
Result[Source: architecture.md] tracingfor logging: Neverprintln![Source: architecture.md]#![deny(warnings)]: All new code must pass clippy [Source: architecture.md]
Developer Context
Existing Infrastructure to Leverage:
// crates/solver/src/solver.rs — EXISTING (Story 4.1–4.5)
pub struct NewtonConfig {
pub max_iterations: usize,
pub tolerance: f64,
pub line_search: bool,
pub timeout: Option<Duration>,
pub use_numerical_jacobian: bool,
pub line_search_armijo_c: f64,
pub line_search_max_backtracks: usize,
pub divergence_threshold: f64,
pub timeout_config: TimeoutConfig,
pub previous_state: Option<Vec<f64>>,
// ADD: pub initial_state: Option<Vec<f64>>,
}
pub struct PicardConfig {
pub max_iterations: usize,
pub tolerance: f64,
pub relaxation_factor: f64,
pub timeout: Option<Duration>,
pub divergence_threshold: f64,
pub divergence_patience: usize,
pub timeout_config: TimeoutConfig,
pub previous_state: Option<Vec<f64>>,
// ADD: pub initial_state: Option<Vec<f64>>,
}
pub struct FallbackSolver {
pub config: FallbackConfig,
pub newton_config: NewtonConfig,
pub picard_config: PicardConfig,
}
// System state vector layout (from system.rs):
// [P_edge0, h_edge0, P_edge1, h_edge1, ...]
// Each edge contributes 2 entries: pressure (Pa) then enthalpy (J/kg)
How initial_state integrates into NewtonConfig::solve():
// EXISTING in NewtonConfig::solve():
let mut state: Vec<f64> = vec![0.0; n_state];
// CHANGE TO:
let mut state: Vec<f64> = if let Some(ref init) = self.initial_state {
debug_assert_eq!(init.len(), n_state, "initial_state length mismatch");
init.clone()
} else {
vec![0.0; n_state]
};
Antoine Equation (log10 form):
log10(P_sat [Pa]) = A - B / (C + T [°C])
Built-in coefficients (valid range approximately −40°C to +80°C):
| Fluid | A | B | C | P_critical (Pa) |
|---|---|---|---|---|
| R134a | 8.9300 | 1766.0 | 243.0 | 4,059,280 |
| R410A | 9.1200 | 1885.0 | 243.0 | 4,901,200 |
| R32 | 9.0500 | 1780.0 | 243.0 | 5,782,000 |
| R744 | 9.8100 | 1347.8 | 273.0 | 7,377,300 |
| R290 | 8.8700 | 1656.0 | 243.0 | 4,247,200 |
Note: These are approximate coefficients tuned for the −40°C to +80°C range. Accuracy within 5% of NIST is sufficient for initialization purposes — the solver will converge to the exact solution regardless.
SmartInitializer API Design:
// crates/solver/src/initializer.rs
use entropyk_core::{Pressure, Temperature, Enthalpy};
use entropyk_components::port::FluidId;
use crate::system::System;
pub struct AntoineCoefficients {
pub a: f64,
pub b: f64,
pub c: f64, // °C offset
pub p_critical_pa: f64,
}
pub struct InitializerConfig {
pub fluid: FluidId,
/// Temperature approach difference for condenser (K). Default: 5.0
pub dt_approach: f64,
}
impl Default for InitializerConfig {
fn default() -> Self {
Self {
fluid: FluidId::new("R134a"),
dt_approach: 5.0,
}
}
}
pub struct SmartInitializer {
pub config: InitializerConfig,
}
impl SmartInitializer {
pub fn new(config: InitializerConfig) -> Self { ... }
/// Estimate (P_evap, P_cond) from source and sink temperatures.
///
/// Returns Err if temperatures are physically impossible (e.g., T > T_critical).
pub fn estimate_pressures(
&self,
t_source: Temperature,
t_sink: Temperature,
) -> Result<(Pressure, Pressure), InitializerError> { ... }
/// Fill a pre-allocated state vector with smart initial guesses.
///
/// No heap allocation. `state` must have length == system.state_vector_len().
pub fn populate_state(
&self,
system: &System,
p_evap: Pressure,
p_cond: Pressure,
h_default: Enthalpy,
state: &mut [f64],
) { ... }
}
InitializerError enum:
#[derive(Error, Debug, Clone, PartialEq)]
pub enum InitializerError {
#[error("Temperature {temp_celsius:.1}°C exceeds critical temperature for {fluid}")]
TemperatureAboveCritical { temp_celsius: f64, fluid: String },
#[error("State slice length {actual} does not match system state vector length {expected}")]
StateLengthMismatch { expected: usize, actual: usize },
}
populate_state algorithm:
For each edge i in system.edge_indices():
circuit = system.edge_circuit(edge_i)
p = if circuit.0 == 0 { p_evap } else { p_cond }
state[2*i] = p.to_pascals()
state[2*i + 1] = h_default.to_joules_per_kg()
For single-circuit systems (all edges in circuit 0), use
p_evapfor all edges. The solver will quickly adjust pressures to the correct distribution.
Architecture Compliance
- NewType pattern:
Pressure::from_pascals(),Temperature::from_kelvin(),Enthalpy::from_joules_per_kg()— ALWAYS use these, never bare f64 in public API - No bare f64 in
SmartInitializerpublic methods - tracing:
tracing::warn!for unknown fluids,tracing::debug!for estimated pressures - Result<T, E>:
estimate_pressuresreturnsResult<(Pressure, Pressure), InitializerError> - approx: Use
assert_relative_eq!in tests for floating-point comparisons (tolerance 5%) - Pre-allocation:
populate_statetakes&mut [f64], never allocates
Library/Framework Requirements
- entropyk_core —
Pressure,Temperature,EnthalpyNewTypes (already a dependency of solver crate) - entropyk_components —
FluidIdfromportmodule (already a dependency) - thiserror —
InitializerErrorderive (already in solver crate) - tracing — Structured logging (already in solver crate)
- approx — Test assertions (already in dev-dependencies)
File Structure Requirements
New files:
crates/solver/src/initializer.rs—SmartInitializer,InitializerConfig,AntoineCoefficients,InitializerError
Modified files:
crates/solver/src/solver.rs— Addinitial_state: Option<Vec<f64>>toNewtonConfig,PicardConfig; addwith_initial_state()builder; updatesolve()to use itcrates/solver/src/lib.rs— Addpub mod initializer;and re-exports
Tests:
- Unit tests in
crates/solver/src/initializer.rs(inline#[cfg(test)]module) - Integration test in
crates/solver/tests/(cold start convergence test)
Testing Requirements
Unit Tests (initializer.rs):
test_antoine_r134a_at_0c: P_sat(0°C) ≈ 2.93 bar, assert within 5% of 293,000 Patest_antoine_r744_at_20c: P_sat(20°C) ≈ 57.3 bar, assert within 5%test_p_evap_below_critical: For all built-in fluids at T_source = −40°C, P_evap < P_criticaltest_p_cond_approach: P_cond = P_sat(T_sink + 5K) for default configtest_unknown_fluid_fallback: Returns (5 bar, 20 bar) with no panictest_populate_state_2_edges: 2-edge system, state = [P_evap, h, P_cond, h]test_populate_state_no_alloc: Verify function signature takes&mut [f64]
Integration Tests:
test_cold_start_convergence_r134a: 4-component cycle, smart init → FallbackSolver → converges in < 20 iterations
Performance:
populate_statemust not allocate (verified by signature:&mut [f64])- Antoine computation: pure arithmetic, < 1 µs per call
Previous Story Intelligence (4.5)
Key Patterns Established:
- Pre-allocated buffers:
let mut buf: Vec<f64> = vec![0.0; n];before loop, thenbuf.copy_from_slice(...)inside - Builder pattern:
fn with_X(mut self, x: X) -> Self { self.field = x; self } tracing::info!for solver events,tracing::debug!for per-iteration data,tracing::warn!for degraded behaviorConvergedState::new(state, iterations, final_residual, status)constructorresidual_norm()helper:residuals.iter().map(|r| r * r).sum::<f64>().sqrt()
initial_state Integration Pattern (follow Story 4.5 previous_state pattern):
// In NewtonConfig::solve():
// BEFORE the iteration loop, replace:
// let mut state: Vec<f64> = vec![0.0; n_state];
// WITH:
let mut state: Vec<f64> = self.initial_state
.as_ref()
.map(|s| {
debug_assert_eq!(s.len(), n_state, "initial_state length mismatch");
s.clone()
})
.unwrap_or_else(|| vec![0.0; n_state]);
Git Intelligence
Recent commits:
be70a7a—feat(core): implement physical types with NewType pattern(Pressure, Temperature, Enthalpy, MassFlow all available)- Stories 4.1–4.4 complete (Solver trait, Newton, Picard, FallbackSolver)
- Story 4.5 in-progress (TimeoutConfig, best-state tracking, ZOH)
Project Structure Notes
- Workspace root:
/Users/sepehr/dev/Entropyk - Solver crate:
crates/solver/src/— addinitializer.rshere - Core types:
crates/core/src/—Pressure,Temperature,Enthalpyalready defined - Components:
crates/components/src/port.rs—FluidIdalready defined - Existing modules in solver:
coupling.rs,error.rs,graph.rs,jacobian.rs,lib.rs,solver.rs,system.rs
References
- FR42: [Source: epics.md#FR42 — "System includes Automatic Initialization Heuristic (Smart Guesser) proposing coherent initial pressure values based on source/sink temperatures"]
- Story 4.6 AC: [Source: epics.md#Story-4.6 — "pressures estimated via Antoine equation", "evaporator pressure < P_critical", "condenser pressure from T_sink + ΔT_approach"]
- Architecture — NewType: [Source: architecture.md — "NewType pattern for all physical quantities (Pressure, Temperature, Enthalpy, MassFlow) - never bare f64 in public APIs"]
- Architecture — No allocation: [Source: architecture.md NFR4 — "No dynamic allocation in solver loop (pre-calculated allocation only)"]
- Architecture — tracing: [Source: architecture.md — "tracing for structured logging (never println!)"]
- Architecture — thiserror: [Source: architecture.md — "thiserror for error handling with ThermoError enum"]
- State vector layout: [Source: system.rs#state_layout — "[P_edge0, h_edge0, P_edge1, h_edge1, ...] — 2 per edge (pressure Pa, enthalpy J/kg)"]
- System::edge_circuit(): [Source: system.rs#edge_circuit — returns CircuitId for an edge based on source node]
- NFR1: [Source: prd.md — "Steady State convergence time < 1 second for standard cycle in Cold Start"]
- NFR4: [Source: prd.md — "No dynamic allocation in solver loop (pre-calculated allocation only)"]
Dev Agent Record
Agent Model Used
Antigravity (Code Review + Fixes)
Debug Log References
- Antoine A coefficient bug: coefficients were calibrated for log10(P [bar]) but equation expected log10(P [Pa]). Fixed by recalculating A from NIST reference P_sat values.
- Existing tests (
newton_raphson.rs,picard_sequential.rs,newton_convergence.rs) used struct literal syntax without..Default::default()— broke when new fields added. Fixed.
Completion Notes List
- All 8 ACs implemented and verified by tests.
- Antoine coefficient bug found and fixed during code review (A values off by ~7 orders of magnitude in Pa output).
- 4 pre-existing compilation errors in older test files fixed (missing struct fields from Story 4.5/4.6 additions).
- New integration test file
smart_initializer.rsadded for AC #8 coverage. - Full test suite: 140+ tests pass, 0 failures.
File List
crates/solver/src/initializer.rs(NEW)crates/solver/src/solver.rs(MODIFIED — addedinitial_statetoNewtonConfig,PicardConfig,FallbackSolver)crates/solver/src/lib.rs(MODIFIED — addedpub mod initializer;and re-exports)crates/solver/tests/smart_initializer.rs(NEW — AC #8 integration tests)crates/solver/tests/newton_raphson.rs(MODIFIED — fixed struct literal missing fields)crates/solver/tests/newton_convergence.rs(MODIFIED — fixed struct literal missing fields)crates/solver/tests/picard_sequential.rs(MODIFIED — fixed struct literal missing fields x2)