Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.

Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

@@ -0,0 +1,415 @@
//! Integration tests for failure diagnostics propagation.
//!
//! Verifies that solver failures carry `ConvergenceDiagnostics` including
//! dominant residual index and value, satisfying spec-cli-failure-diagnostics.md AC1.
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use entropyk_components::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::MassFlow;
use entropyk_solver::{
solver::{NewtonConfig, Solver, SolverError, VerboseConfig},
system::System,
CircuitId, PicardConfig,
};
// ── Port-reading mock (constant residuals) ──────────────────────────────────
//
// Used for Picard and no-verbose tests where state-dependent residuals are not
// needed. The residuals are constant (don't depend on the state vector) but
// non-zero — the solver iterates until max_iterations without converging.
//
// For Picard this is fine; for Newton this produces a singular Jacobian (zero
// finite-difference columns), so Newton fails at iteration 1 without recording
// any iteration diagnostics.
use entropyk_components::port::{Connected, FluidId, Port};
use entropyk_core::{Enthalpy, Pressure};
type CP = Port<Connected>;
struct PortMock {
port_in: CP,
port_out: CP,
dp_pa: f64,
dh_jkg: f64,
/// Number of equations this mock reports to the solver.
/// CM1.4: in a 2-edge series cycle, state_len = 1 branch + 4 P,h = 5.
/// Use 3 for the "compressor" (pressure reference) and 2 for the "condenser"
/// to reach 3+2=5 total equations, matching state_len.
n_eqs: usize,
}
impl Component for PortMock {
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() + self.dp_pa);
r[1] = self.port_out.enthalpy().to_joules_per_kg()
- (self.port_in.enthalpy().to_joules_per_kg() + self.dh_jkg);
if self.n_eqs >= 3 {
r[2] = 0.0; // mass balance trivially satisfied (mock)
}
Ok(())
}
fn jacobian_entries(
&self,
_s: &StateSlice,
_j: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
Ok(())
}
fn n_equations(&self) -> usize {
self.n_eqs
}
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),
])
}
}
fn make_cp(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),
))
.expect("port connect ok");
connected
}
/// Build a 2-component closed loop whose residuals are constant (port-based).
/// The loop is physically inconsistent: compressor imposes +1 MPa pressure rise
/// while condenser imposes 0 pressure drop around the same loop, so the system
/// has no solution. Suitable for Picard tests (which don't need the Jacobian).
fn build_port_loop() -> System {
let p_high = 1_200_000.0_f64;
let p_low = 300_000.0_f64;
let h_high = 450_000.0_f64;
let h_low = 250_000.0_f64;
let mut system = System::new();
let cid = CircuitId(0);
// CM1.4: 2-edge series cycle → 1 branch + 4 P,h = 5 state unknowns.
// Compressor acts as the pressure-reference node (3 equations); condenser
// is a pure series-branch component (2 equations). Total: 3+2=5 = balanced.
let comp = PortMock {
port_in: make_cp(p_low, h_low),
port_out: make_cp(p_high, h_high),
dp_pa: 900_000.0,
dh_jkg: 200_000.0,
n_eqs: 3,
};
let cond = PortMock {
port_in: make_cp(p_high, h_high),
port_out: make_cp(p_low, h_low),
dp_pa: 0.0,
dh_jkg: -200_000.0,
n_eqs: 2,
};
let n0 = system
.add_component_to_circuit(Box::new(comp), cid)
.unwrap();
let n1 = system
.add_component_to_circuit(Box::new(cond), cid)
.unwrap();
system.add_edge(n0, n1).unwrap();
system.add_edge(n1, n0).unwrap();
system.finalize().unwrap();
system
}
// ── State-reading mock with nonlinear residuals ─────────────────────────────
//
// Constrains (P, h) of an edge using a weakly nonlinear equation:
// r[0] = state[pi] + C * state[pi]^3 - p_target
// r[1] = state[hi] + C * state[hi]^3 - h_target
//
// The cubic perturbation (C = 1e-10) is small enough to leave the Jacobian
// well-conditioned but large enough to prevent Newton from reaching residual
// zero in one step. For p_target = 1000:
//
// After step 1 (from state=0): state[pi] ≈ 1000,
// residual ≈ C * target^3 = 1e-10 * 1e9 = 0.1 >> 1e-100
// After 4-5 iterations: residual ≈ machine epsilon (1e-16) >> 1e-100
//
// Newton never meets tolerance = 1e-100, so NonConvergence is returned with a
// full iteration history and a non-zero dominant residual — satisfying AC1.
// C_NL = 1e-3: strong enough cubic perturbation so Newton converges slowly
// (residual ~7000 after 5 steps, >> 1e-100), but weak enough to avoid immediate
// divergence (each step reduces the residual monotonically toward x* ≈ 97 Pa).
const C_NL: f64 = 1e-3;
struct StateReadingMock {
p_idx: Arc<AtomicUsize>,
h_idx: Arc<AtomicUsize>,
p_target: f64,
h_target: f64,
}
impl Component for StateReadingMock {
fn compute_residuals(
&self,
state: &StateSlice,
r: &mut ResidualVector,
) -> Result<(), ComponentError> {
let pi = self.p_idx.load(Ordering::Relaxed);
let hi = self.h_idx.load(Ordering::Relaxed);
r[0] = state[pi] + C_NL * state[pi].powi(3) - self.p_target;
r[1] = state[hi] + C_NL * state[hi].powi(3) - self.h_target;
Ok(())
}
fn jacobian_entries(
&self,
state: &StateSlice,
j: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
let pi = self.p_idx.load(Ordering::Relaxed);
let hi = self.h_idx.load(Ordering::Relaxed);
j.add_entry(0, pi, 1.0 + 3.0 * C_NL * state[pi].powi(2));
j.add_entry(1, hi, 1.0 + 3.0 * C_NL * state[hi].powi(2));
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)])
}
}
/// Build a 2-component, 2-edge system with nonlinear, state-dependent residuals.
///
/// Each component constrains (P, h) of one edge through a mildly nonlinear
/// equation (cubic perturbation). The Jacobian is non-singular (diagonal,
/// values ≈ 1.0003). Newton takes real steps but cannot reach tolerance 1e-100
/// before `max_iterations` — residuals bottom out at machine epsilon (~1e-16)
/// which is still >> 1e-100.
///
/// State indices are injected post-finalization via `Arc<AtomicUsize>`.
fn build_state_reading_loop() -> System {
let p0 = Arc::new(AtomicUsize::new(0));
let h0 = Arc::new(AtomicUsize::new(0));
let p1 = Arc::new(AtomicUsize::new(0));
let h1 = Arc::new(AtomicUsize::new(0));
let comp = StateReadingMock {
p_idx: Arc::clone(&p0),
h_idx: Arc::clone(&h0),
p_target: 1000.0, // 1000 Pa — small but arbitrary for the test
h_target: 500.0,
};
let cond = StateReadingMock {
p_idx: Arc::clone(&p1),
h_idx: Arc::clone(&h1),
p_target: 800.0,
h_target: 300.0,
};
let mut system = System::new();
let cid = CircuitId(0);
let n0 = system
.add_component_to_circuit(Box::new(comp), cid)
.unwrap();
let n1 = system
.add_component_to_circuit(Box::new(cond), cid)
.unwrap();
let edge0 = system.add_edge(n0, n1).unwrap();
let edge1 = system.add_edge(n1, n0).unwrap();
system.finalize().unwrap();
// Inject real state indices now that finalization has assigned them.
let (p0_real, h0_real) = system.edge_state_indices(edge0);
let (p1_real, h1_real) = system.edge_state_indices(edge1);
p0.store(p0_real, Ordering::Relaxed);
h0.store(h0_real, Ordering::Relaxed);
p1.store(p1_real, Ordering::Relaxed);
h1.store(h1_real, Ordering::Relaxed);
system
}
// ── AC1: solver failure carries dominant residual diagnostics ─────────────────
/// Newton on a state-reading mock system with tolerance=1e-100:
/// - Jacobian is non-singular (permuted identity) → Newton takes real steps.
/// - After max_iterations=5, NonConvergence is returned.
/// - Error carries ConvergenceDiagnostics with non-empty iteration history.
/// - final_dominant_residual() returns Some with a positive value.
///
/// Validates AC1 of spec-cli-failure-diagnostics.md for the Newton solver.
#[test]
fn test_newton_failure_carries_dominant_residual_diagnostics() {
let mut system = build_state_reading_loop();
let verbose = VerboseConfig {
enabled: true,
log_residuals: true,
log_jacobian_condition: false,
log_solver_switches: false,
dump_final_state: false,
output_format: Default::default(),
};
let mut solver = NewtonConfig {
max_iterations: 5,
tolerance: 1e-100, // impossible — machine-epsilon residuals keep Newton spinning
verbose_config: verbose,
..NewtonConfig::default()
};
let result = solver.solve(&mut system);
assert!(
result.is_err(),
"Solver must fail to converge to tolerance 1e-100"
);
let err = result.unwrap_err();
// Base error must be an iterative failure (NonConvergence or Divergence),
// not a structural InvalidSystem error.
let is_iterative_failure = matches!(
err.base_error(),
SolverError::NonConvergence { .. } | SolverError::Divergence { .. }
);
assert!(
is_iterative_failure,
"Base error must be iterative failure, got: {:?}",
err.base_error()
);
// Diagnostics must be attached (verbose mode was enabled and iterations occurred).
let diag = err
.diagnostics()
.expect("SolverError must carry ConvergenceDiagnostics when verbose mode is enabled");
// At least one iteration must have been recorded.
assert!(
!diag.iteration_history.is_empty(),
"Diagnostics must contain at least one iteration record (got {})",
diag.iteration_history.len()
);
// final_residual must be positive (system never converged to 1e-100).
assert!(
diag.final_residual >= 0.0,
"final_residual must be non-negative, got {}",
diag.final_residual
);
// Dominant residual must be extractable from iteration history.
let (dom_index, dom_value) = diag
.final_dominant_residual()
.expect("final_dominant_residual must return Some when iteration_history is non-empty");
assert!(
dom_value >= 0.0,
"dominant residual value must be non-negative, got {}",
dom_value
);
// The dominant index must be a valid equation index.
// System: 2 components × 2 equations + 2 closure = 6 total equations.
assert!(
dom_index < 30,
"dominant residual index out of expected range: {}",
dom_index
);
}
/// Picard on a port-loop (constant residuals, non-zero) with tolerance=1e-100:
/// - Picard doesn't use a Jacobian → iterates regardless of Jacobian singularity.
/// - After max_iterations=3, NonConvergence is returned with non-empty history.
/// - final_dominant_residual() returns Some with a positive value.
///
/// Validates AC1 for the Picard solver (mirrors Newton AC1).
#[test]
fn test_picard_failure_carries_dominant_residual_diagnostics() {
let mut system = build_port_loop();
let verbose = VerboseConfig {
enabled: true,
log_residuals: true,
..VerboseConfig::default()
};
let mut solver = PicardConfig {
max_iterations: 3,
tolerance: 1e-12,
verbose_config: verbose,
..PicardConfig::default()
};
let result = solver.solve(&mut system);
assert!(result.is_err());
let err = result.unwrap_err();
let diag = err
.diagnostics()
.expect("Picard error must carry ConvergenceDiagnostics on iterative failure");
assert!(
!diag.iteration_history.is_empty(),
"Picard diagnostics must contain at least one iteration"
);
assert!(
diag.final_dominant_residual().is_some(),
"Picard diagnostics must expose dominant residual"
);
let (_, dom_value) = diag.final_dominant_residual().unwrap();
assert!(dom_value >= 0.0);
}
/// Without verbose mode, solver errors carry no diagnostics regardless of
/// failure type — verifying backward compatibility for callers that opt out
/// of verbose instrumentation.
#[test]
fn test_failure_without_verbose_carries_no_diagnostics() {
let mut system = build_port_loop();
let mut solver = NewtonConfig {
max_iterations: 2,
tolerance: 1e-12,
verbose_config: VerboseConfig::default(), // verbose disabled
..NewtonConfig::default()
};
let result = solver.solve(&mut system);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.diagnostics().is_none(),
"No diagnostics expected when verbose mode is disabled"
);
}