Snapshot WIP: solver HP epic progress, BPHX/HX physics, BMAD skill refresh.
Some checks failed
CI / check (push) Has been cancelled

Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-19 16:35:31 +02:00
parent 88620790d6
commit 5bd180b5b8
1363 changed files with 101041 additions and 58547 deletions

View File

@@ -29,6 +29,7 @@ fn test_simulation_result_serialization() {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
raw_state_vector: None,
};
let json = serde_json::to_string_pretty(&result).unwrap();
@@ -69,6 +70,7 @@ fn test_error_result_serialization() {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
raw_state_vector: None,
};
let json = serde_json::to_string(&result).unwrap();
@@ -94,6 +96,7 @@ fn test_error_result_serializes_failure_diagnostics() {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
raw_state_vector: None,
};
let json = serde_json::to_string(&result).unwrap();
@@ -1293,13 +1296,13 @@ fn test_bphx_bounded_circuit_reaches_solver_stage() {
}
}
/// AC2 + spec-cli-failure-diagnostics.md: Given a closed-loop simulation that
/// the solver fails to converge (limited iterations), the JSON result includes
/// `failure_diagnostics` with `dominant_residual_index` and `dominant_residual_value`.
/// AC2 + spec-cli-failure-diagnostics.md: Given a DoF-square closed-loop simulation
/// that the solver fails to converge (iteration budget exhausted), the JSON result
/// includes `failure_diagnostics` with a finite residual norm.
///
/// This test uses a 2-component closed loop (Pump → Pump) with extremely tight
/// tolerance so Newton runs at least one iteration before reporting NonConvergence,
/// verifying that the CLI captures and surfaces the post-mortem diagnostics.
/// Fixture: a DoF-square R134a vapor-compression cycle. Solver budget is capped so
/// Newton cannot reach tolerance; the run must fail at the solver stage (not during
/// finalize) and surface post-mortem diagnostics.
#[test]
fn test_failed_run_json_includes_failure_diagnostics() {
use entropyk_cli::run::run_simulation;
@@ -1307,25 +1310,53 @@ fn test_failed_run_json_includes_failure_diagnostics() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("tight_tolerance_failure.json");
// A closed water loop (two pumps) with an impossibly tight tolerance
// ensures Newton runs at least one iteration then reports NonConvergence.
// The CLI wraps this in a FallbackSolver with VerboseConfig enabled,
// so `failure_diagnostics` must be present in the result.
let json = r#"
{
"name": "Tight tolerance failure diagnostics test",
"fluid": "Water",
"circuits": [{
"id": 0,
"components": [
{ "type": "Pump", "name": "pump1" },
{ "type": "Pump", "name": "pump2" }
],
"edges": [
{ "from": "pump1:outlet", "to": "pump2:inlet" },
{ "from": "pump2:outlet", "to": "pump1:inlet" }
]
}],
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [
{
"id": 0,
"components": [
{
"type": "IsentropicCompressor", "name": "comp",
"isentropic_efficiency": 0.70,
"t_cond_k": 318.15, "t_evap_k": 278.15, "superheat_k": 5.0,
"emergent_pressure": true,
"displacement_m3": 6.5e-5, "speed_hz": 50.0,
"volumetric_efficiency": 0.92
},
{
"type": "Condenser", "name": "cond", "ua": 766.0,
"emergent_pressure": true, "subcooling_k": 5.0,
"secondary_fluid": "Water"
},
{
"type": "IsenthalpicExpansionValve", "name": "exv",
"t_evap_k": 278.15, "emergent_pressure": true
},
{
"type": "Evaporator", "name": "evap", "ua": 1468.0,
"emergent_pressure": true,
"secondary_fluid": "Water"
},
{ "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.3583 },
{ "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
{ "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.4778 },
{ "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
],
"edges": [
{ "from": "comp:outlet", "to": "cond:inlet" },
{ "from": "cond:outlet", "to": "exv:inlet" },
{ "from": "exv:outlet", "to": "evap:inlet" },
{ "from": "evap:outlet", "to": "comp:inlet" },
{ "from": "cond_water_in:outlet", "to": "cond:secondary_inlet" },
{ "from": "cond:secondary_outlet", "to": "cond_water_out:inlet" },
{ "from": "evap_water_in:outlet", "to": "evap:secondary_inlet" },
{ "from": "evap:secondary_outlet", "to": "evap_water_out:inlet" }
]
}
],
"solver": {
"strategy": "newton",
"max_iterations": 2,
@@ -1337,54 +1368,34 @@ fn test_failed_run_json_includes_failure_diagnostics() {
let result = run_simulation(&config_path, None, false).unwrap();
// The solver must have failed (either NonConverged or Error after iterations).
// Accepted statuses: NonConverged or Error — both indicate the solver gave up.
let failed = matches!(
assert!(
matches!(
result.status,
SimulationStatus::NonConverged | SimulationStatus::Error
),
"expected solver-stage failure, got {:?} (error: {:?})",
result.status,
SimulationStatus::NonConverged | SimulationStatus::Error
result.error
);
let diagnostics = result.failure_diagnostics.as_ref().unwrap_or_else(|| {
panic!(
"failure_diagnostics must be present after solver-stage failure \
(status: {:?}, error: {:?})",
result.status, result.error
)
});
let json_str = serde_json::to_string(&result).unwrap();
assert!(
json_str.contains("failure_diagnostics"),
"JSON result must contain 'failure_diagnostics' key"
);
assert!(
failed || matches!(result.status, SimulationStatus::Converged),
"Unexpected status: {:?}",
result.status
diagnostics.final_residual_norm.is_finite() && diagnostics.final_residual_norm >= 0.0,
"failure_diagnostics.final_residual_norm must be finite and non-negative, got {}",
diagnostics.final_residual_norm
);
// The pump loop must reach the solver stage (2 pumps in a closed loop finalize cleanly).
// With max_iterations=2 and tolerance=1e-100, Newton runs 2 iterations then declares
// NonConvergence — diagnostics MUST be present.
if matches!(
result.status,
SimulationStatus::NonConverged | SimulationStatus::Error
) {
if let Some(ref err_msg) = result.error {
let is_pre_solver_error = err_msg.contains("finalization")
|| err_msg.contains("Unknown component")
|| err_msg.contains("Failed to add");
if is_pre_solver_error {
return; // pre-solver failure: no diagnostics expected
}
}
// The solver ran and failed: failure_diagnostics MUST be present.
assert!(
result.failure_diagnostics.is_some(),
"failure_diagnostics must be present when solver ran at least one iteration and failed \
(status: {:?}, error: {:?})",
result.status,
result.error
);
let json_str = serde_json::to_string(&result).unwrap();
assert!(
json_str.contains("failure_diagnostics"),
"JSON result must contain 'failure_diagnostics' key"
);
let fd = result.failure_diagnostics.as_ref().unwrap();
assert!(
fd.final_residual_norm >= 0.0,
"failure_diagnostics.final_residual_norm must be non-negative"
);
}
}
/// spec-cli-failure-diagnostics.md AC3 (integration): Given an empty system config that
@@ -2292,6 +2303,7 @@ fn test_structural_failure_serializes_without_diagnostics() {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
raw_state_vector: None,
};
let json = serde_json::to_string(&result).unwrap();
@@ -2336,6 +2348,7 @@ fn test_success_result_does_not_include_failure_diagnostics() {
initialization_diagnostics: None,
dof: None,
elapsed_ms: 120,
raw_state_vector: None,
};
let json = serde_json::to_string(&result).unwrap();