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:
@@ -9,9 +9,9 @@ use crate::criteria::ConvergenceCriteria;
|
||||
use crate::jacobian::JacobianMatrix;
|
||||
use crate::metadata::SimulationMetadata;
|
||||
use crate::solver::{
|
||||
apply_newton_step, ConvergedState, ConvergenceDiagnostics, ConvergenceStatus,
|
||||
IterationDiagnostics, JacobianFreezingConfig, Solver, SolverError, SolverType,
|
||||
TimeoutConfig, VerboseConfig,
|
||||
apply_newton_step, dominant_residual, ConvergedState, ConvergenceDiagnostics,
|
||||
ConvergenceStatus, IterationDiagnostics, JacobianFreezingConfig, Solver, SolverError,
|
||||
SolverType, TimeoutConfig, VerboseConfig,
|
||||
};
|
||||
use crate::system::System;
|
||||
use entropyk_components::JacobianBuilder;
|
||||
@@ -154,7 +154,10 @@ impl NewtonConfig {
|
||||
) -> Option<SolverError> {
|
||||
if current_norm > self.divergence_threshold {
|
||||
return Some(SolverError::Divergence {
|
||||
reason: format!("Residual {} exceeds threshold {}", current_norm, self.divergence_threshold),
|
||||
reason: format!(
|
||||
"Residual {} exceeds threshold {}",
|
||||
current_norm, self.divergence_threshold
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,7 +165,10 @@ impl NewtonConfig {
|
||||
*divergence_count += 1;
|
||||
if *divergence_count >= 3 {
|
||||
return Some(SolverError::Divergence {
|
||||
reason: format!("Residual increased 3x: {:.6e} → {:.6e}", previous_norm, current_norm),
|
||||
reason: format!(
|
||||
"Residual increased 3x: {:.6e} → {:.6e}",
|
||||
previous_norm, current_norm
|
||||
),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -201,7 +207,12 @@ impl NewtonConfig {
|
||||
|
||||
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");
|
||||
tracing::debug!(
|
||||
alpha,
|
||||
old_norm = current_norm,
|
||||
new_norm,
|
||||
"Line search accepted"
|
||||
);
|
||||
return Some(alpha);
|
||||
}
|
||||
|
||||
@@ -209,9 +220,45 @@ impl NewtonConfig {
|
||||
alpha *= 0.5;
|
||||
}
|
||||
|
||||
tracing::warn!("Line search failed after {} backtracks", self.line_search_max_backtracks);
|
||||
tracing::warn!(
|
||||
"Line search failed after {} backtracks",
|
||||
self.line_search_max_backtracks
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
fn finalize_failure_diagnostics(
|
||||
&self,
|
||||
mut diagnostics: Option<ConvergenceDiagnostics>,
|
||||
iterations: usize,
|
||||
final_residual: f64,
|
||||
best_residual: f64,
|
||||
elapsed_ms: u64,
|
||||
jacobian_condition_final: Option<f64>,
|
||||
final_state: Option<Vec<f64>>,
|
||||
) -> Option<ConvergenceDiagnostics> {
|
||||
if let Some(ref mut diag) = diagnostics {
|
||||
diag.iterations = iterations;
|
||||
diag.final_residual = final_residual;
|
||||
diag.best_residual = best_residual;
|
||||
diag.converged = false;
|
||||
diag.timing_ms = elapsed_ms;
|
||||
diag.jacobian_condition_final = jacobian_condition_final;
|
||||
diag.final_solver = Some(SolverType::NewtonRaphson);
|
||||
|
||||
if self.verbose_config.dump_final_state {
|
||||
diag.final_state = final_state;
|
||||
let json_output = diag.dump_diagnostics(self.verbose_config.output_format);
|
||||
tracing::warn!(
|
||||
iterations,
|
||||
final_residual,
|
||||
"Non-convergence diagnostics:\n{}",
|
||||
json_output
|
||||
);
|
||||
}
|
||||
}
|
||||
diagnostics
|
||||
}
|
||||
}
|
||||
|
||||
impl Solver for NewtonConfig {
|
||||
@@ -240,7 +287,9 @@ impl Solver for NewtonConfig {
|
||||
.map(|(_, c, _)| c.n_equations())
|
||||
.sum::<usize>()
|
||||
+ system.constraints().count()
|
||||
+ system.coupling_residual_count();
|
||||
+ system.coupling_residual_count()
|
||||
+ 2 * system.saturated_controller_count()
|
||||
+ system.mass_flow_closure_count();
|
||||
|
||||
if n_state == 0 || n_equations == 0 {
|
||||
return Err(SolverError::InvalidSystem {
|
||||
@@ -248,15 +297,22 @@ impl Solver for NewtonConfig {
|
||||
});
|
||||
}
|
||||
|
||||
// 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]);
|
||||
// Pre-allocate all buffers. A caller-supplied initial state MUST match
|
||||
// the full state length: a debug_assert would abort (violating zero-panic)
|
||||
// and a silent zeros fallback would solve a different problem. Fail cleanly.
|
||||
let mut state: Vec<f64> = match self.initial_state.as_ref() {
|
||||
Some(s) if s.len() == n_state => s.clone(),
|
||||
Some(s) => {
|
||||
return Err(SolverError::InvalidSystem {
|
||||
message: format!(
|
||||
"initial_state length {} does not match system state length {}",
|
||||
s.len(),
|
||||
n_state
|
||||
),
|
||||
});
|
||||
}
|
||||
None => 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;
|
||||
@@ -273,13 +329,13 @@ impl Solver for NewtonConfig {
|
||||
let mut jacobian_matrix = JacobianMatrix::zeros(n_equations, n_state);
|
||||
let mut frozen_count: usize = 0;
|
||||
let mut force_recompute: bool = true;
|
||||
|
||||
|
||||
// Cached condition number (for verbose mode when Jacobian frozen)
|
||||
let mut cached_condition: Option<f64> = None;
|
||||
|
||||
// Pre-compute clipping mask
|
||||
let clipping_mask: Vec<Option<(f64, f64)>> = (0..n_state)
|
||||
.map(|i| system.get_bounds_for_state_index(i))
|
||||
.map(|i| system.get_solver_bounds_for_state_index(i))
|
||||
.collect();
|
||||
|
||||
// Initial residual computation
|
||||
@@ -306,15 +362,32 @@ impl Solver for NewtonConfig {
|
||||
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)");
|
||||
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()),
|
||||
state,
|
||||
0,
|
||||
current_norm,
|
||||
status,
|
||||
report,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
tracing::info!(iterations = 0, final_residual = current_norm, "Converged at initial state");
|
||||
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()),
|
||||
state,
|
||||
0,
|
||||
current_norm,
|
||||
status,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -327,7 +400,18 @@ impl Solver for NewtonConfig {
|
||||
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);
|
||||
let failure_diagnostics = self.finalize_failure_diagnostics(
|
||||
diagnostics.take(),
|
||||
iteration - 1,
|
||||
current_norm,
|
||||
best_residual,
|
||||
start_time.elapsed().as_millis() as u64,
|
||||
cached_condition,
|
||||
Some(state.clone()),
|
||||
);
|
||||
return self
|
||||
.handle_timeout(&best_state, best_residual, iteration - 1, timeout, system)
|
||||
.map_err(|err| err.with_optional_diagnostics(failure_diagnostics));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +430,7 @@ impl Solver for NewtonConfig {
|
||||
};
|
||||
|
||||
let jacobian_frozen_this_iter = !should_recompute;
|
||||
|
||||
|
||||
if should_recompute {
|
||||
// Fresh Jacobian assembly (in-place update)
|
||||
jacobian_builder.clear();
|
||||
@@ -359,13 +443,15 @@ impl Solver for NewtonConfig {
|
||||
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 {
|
||||
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)
|
||||
system
|
||||
.assemble_jacobian(&state, &mut jacobian_builder)
|
||||
.map_err(|e| SolverError::InvalidSystem {
|
||||
message: format!("Failed to assemble Jacobian: {:?}", e),
|
||||
})?;
|
||||
@@ -374,19 +460,27 @@ impl Solver for NewtonConfig {
|
||||
|
||||
frozen_count = 0;
|
||||
force_recompute = false;
|
||||
|
||||
|
||||
// Compute and cache condition number if verbose mode enabled
|
||||
if verbose_enabled && self.verbose_config.log_jacobian_condition {
|
||||
let cond = jacobian_matrix.estimate_condition_number();
|
||||
cached_condition = cond;
|
||||
if let Some(c) = cond {
|
||||
tracing::info!(iteration, condition_number = c, "Jacobian condition number");
|
||||
tracing::info!(
|
||||
iteration,
|
||||
condition_number = c,
|
||||
"Jacobian condition number"
|
||||
);
|
||||
if c > 1e10 {
|
||||
tracing::warn!(iteration, condition_number = c, "Ill-conditioned Jacobian detected (κ > 1e10)");
|
||||
tracing::warn!(
|
||||
iteration,
|
||||
condition_number = c,
|
||||
"Ill-conditioned Jacobian detected (κ > 1e10)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
tracing::debug!(iteration, "Fresh Jacobian computed");
|
||||
} else {
|
||||
frozen_count += 1;
|
||||
@@ -397,23 +491,49 @@ impl Solver for NewtonConfig {
|
||||
let delta = match jacobian_matrix.solve(&residuals) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
let failure_diagnostics = self.finalize_failure_diagnostics(
|
||||
diagnostics.take(),
|
||||
iteration,
|
||||
current_norm,
|
||||
best_residual,
|
||||
start_time.elapsed().as_millis() as u64,
|
||||
cached_condition,
|
||||
Some(state.clone()),
|
||||
);
|
||||
return Err(SolverError::Divergence {
|
||||
reason: "Jacobian is singular".to_string(),
|
||||
});
|
||||
}
|
||||
.with_optional_diagnostics(failure_diagnostics));
|
||||
}
|
||||
};
|
||||
|
||||
// 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,
|
||||
system,
|
||||
&mut state,
|
||||
&delta,
|
||||
&residuals,
|
||||
current_norm,
|
||||
&mut state_copy,
|
||||
&mut new_residuals,
|
||||
&clipping_mask,
|
||||
) {
|
||||
Some(a) => a,
|
||||
None => {
|
||||
let failure_diagnostics = self.finalize_failure_diagnostics(
|
||||
diagnostics.take(),
|
||||
iteration,
|
||||
current_norm,
|
||||
best_residual,
|
||||
start_time.elapsed().as_millis() as u64,
|
||||
cached_condition,
|
||||
Some(state.clone()),
|
||||
);
|
||||
return Err(SolverError::Divergence {
|
||||
reason: "Line search failed".to_string(),
|
||||
});
|
||||
}
|
||||
.with_optional_diagnostics(failure_diagnostics));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -421,16 +541,18 @@ impl Solver for NewtonConfig {
|
||||
1.0
|
||||
};
|
||||
|
||||
system.compute_residuals(&state, &mut 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);
|
||||
|
||||
|
||||
// Compute delta norm for diagnostics
|
||||
let delta_norm: f64 = state.iter()
|
||||
let delta_norm: f64 = state
|
||||
.iter()
|
||||
.zip(prev_iteration_state.iter())
|
||||
.map(|(s, p)| (s - p).powi(2))
|
||||
.sum::<f64>()
|
||||
@@ -444,9 +566,16 @@ impl Solver for NewtonConfig {
|
||||
|
||||
// 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 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");
|
||||
tracing::debug!(
|
||||
iteration,
|
||||
current_norm,
|
||||
previous_norm,
|
||||
"Unfreezing Jacobian"
|
||||
);
|
||||
}
|
||||
force_recompute = true;
|
||||
frozen_count = 0;
|
||||
@@ -464,9 +593,10 @@ impl Solver for NewtonConfig {
|
||||
"Newton iteration"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Collect iteration diagnostics
|
||||
if let Some(ref mut diag) = diagnostics {
|
||||
let (max_residual_index, max_residual) = dominant_residual(&residuals);
|
||||
diag.push_iteration(IterationDiagnostics {
|
||||
iteration,
|
||||
residual_norm: current_norm,
|
||||
@@ -474,21 +604,29 @@ impl Solver for NewtonConfig {
|
||||
alpha: Some(alpha),
|
||||
jacobian_frozen: jacobian_frozen_this_iter,
|
||||
jacobian_condition: cached_condition,
|
||||
max_residual_index,
|
||||
max_residual,
|
||||
});
|
||||
}
|
||||
|
||||
tracing::debug!(iteration, residual_norm = current_norm, alpha, "Newton iteration complete");
|
||||
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);
|
||||
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
|
||||
};
|
||||
|
||||
|
||||
// Finalize diagnostics
|
||||
if let Some(ref mut diag) = diagnostics {
|
||||
diag.iterations = iteration;
|
||||
@@ -498,19 +636,33 @@ impl Solver for NewtonConfig {
|
||||
diag.timing_ms = start_time.elapsed().as_millis() as u64;
|
||||
diag.jacobian_condition_final = cached_condition;
|
||||
diag.final_solver = Some(SolverType::NewtonRaphson);
|
||||
|
||||
|
||||
if self.verbose_config.log_residuals {
|
||||
tracing::info!("{}", diag.summary());
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(iterations = iteration, final_residual = current_norm, "Converged (criteria)");
|
||||
|
||||
tracing::info!(
|
||||
iterations = iteration,
|
||||
final_residual = current_norm,
|
||||
"Converged (criteria)"
|
||||
);
|
||||
let result = ConvergedState::with_report(
|
||||
state, iteration, current_norm, status, report, SimulationMetadata::new(system.input_hash()),
|
||||
state,
|
||||
iteration,
|
||||
current_norm,
|
||||
status,
|
||||
report,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
);
|
||||
return Ok(if let Some(d) = diagnostics {
|
||||
ConvergedState { diagnostics: Some(d), ..result }
|
||||
} else { result });
|
||||
ConvergedState {
|
||||
diagnostics: Some(d),
|
||||
..result
|
||||
}
|
||||
} else {
|
||||
result
|
||||
});
|
||||
}
|
||||
false
|
||||
} else {
|
||||
@@ -523,7 +675,7 @@ impl Solver for NewtonConfig {
|
||||
} else {
|
||||
ConvergenceStatus::Converged
|
||||
};
|
||||
|
||||
|
||||
// Finalize diagnostics
|
||||
if let Some(ref mut diag) = diagnostics {
|
||||
diag.iterations = iteration;
|
||||
@@ -533,54 +685,76 @@ impl Solver for NewtonConfig {
|
||||
diag.timing_ms = start_time.elapsed().as_millis() as u64;
|
||||
diag.jacobian_condition_final = cached_condition;
|
||||
diag.final_solver = Some(SolverType::NewtonRaphson);
|
||||
|
||||
|
||||
if self.verbose_config.log_residuals {
|
||||
tracing::info!("{}", diag.summary());
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(iterations = iteration, final_residual = current_norm, "Converged");
|
||||
|
||||
tracing::info!(
|
||||
iterations = iteration,
|
||||
final_residual = current_norm,
|
||||
"Converged"
|
||||
);
|
||||
let result = ConvergedState::new(
|
||||
state, iteration, current_norm, status, SimulationMetadata::new(system.input_hash()),
|
||||
state,
|
||||
iteration,
|
||||
current_norm,
|
||||
status,
|
||||
SimulationMetadata::new(system.input_hash()),
|
||||
);
|
||||
return Ok(if let Some(d) = diagnostics {
|
||||
ConvergedState { diagnostics: Some(d), ..result }
|
||||
} else { result });
|
||||
ConvergedState {
|
||||
diagnostics: Some(d),
|
||||
..result
|
||||
}
|
||||
} else {
|
||||
result
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
if let Some(err) =
|
||||
self.check_divergence(current_norm, previous_norm, &mut divergence_count)
|
||||
{
|
||||
tracing::warn!(
|
||||
iteration,
|
||||
residual_norm = current_norm,
|
||||
"Divergence detected"
|
||||
);
|
||||
let failure_diagnostics = self.finalize_failure_diagnostics(
|
||||
diagnostics.take(),
|
||||
iteration,
|
||||
current_norm,
|
||||
best_residual,
|
||||
start_time.elapsed().as_millis() as u64,
|
||||
cached_condition,
|
||||
Some(state.clone()),
|
||||
);
|
||||
return Err(err.with_optional_diagnostics(failure_diagnostics));
|
||||
}
|
||||
}
|
||||
|
||||
// Non-convergence: dump diagnostics if enabled
|
||||
if let Some(ref mut diag) = diagnostics {
|
||||
diag.iterations = self.max_iterations;
|
||||
diag.final_residual = current_norm;
|
||||
diag.best_residual = best_residual;
|
||||
diag.converged = false;
|
||||
diag.timing_ms = start_time.elapsed().as_millis() as u64;
|
||||
diag.jacobian_condition_final = cached_condition;
|
||||
diag.final_solver = Some(SolverType::NewtonRaphson);
|
||||
|
||||
if self.verbose_config.dump_final_state {
|
||||
diag.final_state = Some(state.clone());
|
||||
let json_output = diag.dump_diagnostics(self.verbose_config.output_format);
|
||||
tracing::warn!(
|
||||
iterations = self.max_iterations,
|
||||
final_residual = current_norm,
|
||||
"Non-convergence diagnostics:\n{}",
|
||||
json_output
|
||||
);
|
||||
}
|
||||
}
|
||||
let failure_diagnostics = self.finalize_failure_diagnostics(
|
||||
diagnostics.take(),
|
||||
self.max_iterations,
|
||||
current_norm,
|
||||
best_residual,
|
||||
start_time.elapsed().as_millis() as u64,
|
||||
cached_condition,
|
||||
Some(state.clone()),
|
||||
);
|
||||
|
||||
tracing::warn!(max_iterations = self.max_iterations, final_residual = current_norm, "Did not converge");
|
||||
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,
|
||||
})
|
||||
}
|
||||
.with_optional_diagnostics(failure_diagnostics))
|
||||
}
|
||||
|
||||
fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
|
||||
Reference in New Issue
Block a user