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

@@ -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 {