Files
Entropyk/crates/cli/tests/single_run.rs
sepehr 3358b74342 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>
2026-07-17 22:46:46 +02:00

2469 lines
97 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Tests for single simulation execution.
use entropyk_cli::error::ExitCode;
use entropyk_cli::run::{FailureDiagnostics, SimulationResult, SimulationStatus};
use tempfile::tempdir;
#[test]
fn test_simulation_result_serialization() {
let result = SimulationResult {
input: "test.json".to_string(),
status: SimulationStatus::Converged,
convergence: Some(entropyk_cli::run::ConvergenceInfo {
final_residual: 1e-8,
tolerance: 1e-6,
iterations: None,
strategy: None,
iteration_history: vec![],
}),
iterations: Some(25),
state: Some(vec![entropyk_cli::run::StateEntry {
edge: 0,
pressure_bar: 10.0,
enthalpy_kj_kg: 400.0,
..Default::default()
}]),
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 50,
};
let json = serde_json::to_string_pretty(&result).unwrap();
assert!(json.contains("\"status\": \"converged\""));
assert!(json.contains("\"iterations\": 25"));
assert!(json.contains("\"pressure_bar\": 10.0"));
}
#[test]
fn test_simulation_status_values() {
assert_eq!(SimulationStatus::Converged, SimulationStatus::Converged);
assert_ne!(SimulationStatus::Converged, SimulationStatus::Error);
let status = SimulationStatus::NonConverged;
let json = serde_json::to_string(&status).unwrap();
assert_eq!(json, "\"non_converged\"");
}
#[test]
fn test_exit_codes() {
assert_eq!(ExitCode::Success as i32, 0);
assert_eq!(ExitCode::SimulationError as i32, 1);
assert_eq!(ExitCode::ConfigError as i32, 2);
assert_eq!(ExitCode::IoError as i32, 3);
}
#[test]
fn test_error_result_serialization() {
let result = SimulationResult {
input: "invalid.json".to_string(),
status: SimulationStatus::Error,
convergence: None,
iterations: None,
state: None,
performance: None,
error: Some("Configuration error".to_string()),
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("Configuration error"));
}
#[test]
fn test_error_result_serializes_failure_diagnostics() {
let result = SimulationResult {
input: "hard_demo.json".to_string(),
status: SimulationStatus::Error,
convergence: None,
iterations: None,
state: None,
performance: None,
error: Some("Solver error: NonConvergence".to_string()),
failure_diagnostics: Some(FailureDiagnostics {
final_residual_norm: 42.0,
last_residual_norm: Some(42.0),
dominant_residual_index: Some(3),
dominant_residual_value: Some(41.0),
}),
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("failure_diagnostics"));
assert!(json.contains("dominant_residual_index"));
assert!(json.contains("dominant_residual_value"));
}
#[test]
fn test_create_minimal_config_file() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("minimal.json");
let json = r#"{ "fluid": "R134a" }"#;
std::fs::write(&config_path, json).unwrap();
assert!(config_path.exists());
let content = std::fs::read_to_string(&config_path).unwrap();
assert!(content.contains("R134a"));
}
#[test]
fn test_screw_compressor_frequency_hz_config() {
use entropyk_cli::config::ScenarioConfig;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let config_path = dir.path().join("screw_vfd.json");
let json = r#"
{
"name": "Screw VFD Test",
"fluid": "R134a",
"circuits": [
{
"id": 0,
"components": [
{
"type": "ScrewEconomizerCompressor",
"name": "screw_test",
"fluid": "R134a",
"nominal_frequency_hz": 50.0,
"frequency_hz": 40.0,
"mechanical_efficiency": 0.92,
"economizer_fraction": 0.12,
"mf_a00": 1.2,
"mf_a10": 0.003,
"mf_a01": -0.002,
"mf_a11": 0.00001,
"pw_b00": 55000.0,
"pw_b10": 200.0,
"pw_b01": -300.0,
"pw_b11": 0.5,
"p_suction_bar": 3.2,
"h_suction_kj_kg": 400.0,
"p_discharge_bar": 12.8,
"h_discharge_kj_kg": 440.0,
"p_eco_bar": 6.4,
"h_eco_kj_kg": 260.0
}
],
"edges": []
}
],
"solver": {
"strategy": "fallback",
"max_iterations": 10
}
}
"#;
std::fs::write(&config_path, json).unwrap();
let config = ScenarioConfig::from_file(&config_path);
assert!(config.is_ok(), "Config should parse successfully");
let config = config.unwrap();
assert_eq!(config.circuits.len(), 1);
let screw_params = &config.circuits[0].components[0].params;
assert_eq!(
screw_params.get("frequency_hz").and_then(|v| v.as_f64()),
Some(40.0)
);
assert_eq!(
screw_params
.get("nominal_frequency_hz")
.and_then(|v| v.as_f64()),
Some(50.0)
);
}
#[test]
fn test_run_simulation_with_coolprop() {
use entropyk_cli::run::run_simulation;
let dir = tempdir().unwrap();
let config_path = dir.path().join("coolprop.json");
let json = r#"
{
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [
{
"id": 0,
"components": [
{
"type": "HeatExchanger",
"name": "hx1",
"ua": 1000.0,
"hot_fluid": "Water",
"hot_t_inlet_c": 25.0,
"cold_fluid": "R134a",
"cold_t_inlet_c": 15.0
}
],
"edges": []
}
],
"solver": { "max_iterations": 1 }
}
"#;
std::fs::write(&config_path, json).unwrap();
let result = run_simulation(&config_path, None, false).unwrap();
match result.status {
SimulationStatus::Converged | SimulationStatus::NonConverged => {}
SimulationStatus::Error => {
let err_msg = result.error.unwrap();
assert!(
err_msg.contains("CoolProp")
|| err_msg.contains("Fluid")
|| err_msg.contains("Component")
|| err_msg.contains("IsolatedNode")
|| err_msg.contains("finalization"),
"Unexpected error: {}",
err_msg
);
}
_ => panic!("Unexpected status: {:?}", result.status),
}
}
/// arch-3: A declared `SaturatedController` capacity loop is co-solved inside the
/// same Newton system and genuinely regulates the evaporator cooling capacity to the
/// requested target by manipulating the compressor mass-flow factor `f_m`. This proves
/// the control block is a real steady-state DoF (no bricolage): changing only the
/// control target moves the solved operating point so that capacity tracks it.
#[test]
fn test_saturated_capacity_control_tracks_target() {
use entropyk_cli::run::run_simulation;
// Fully-coupled emergent-pressure chiller + one saturated-PI capacity loop.
// `{TARGET}` is substituted per run so we can assert the solved capacity follows it.
let template = r#"
{
"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" }
]
}
],
"controls": [
{
"type": "SaturatedController", "id": "evap_capacity",
"measure": { "component": "evap", "output": "capacity" },
"actuator": {
"component": "comp", "factor": "f_m",
"initial": 1.0, "min": 0.5, "max": 1.5
},
"target": {TARGET}, "gain": 0.01, "band": 1.0
}
],
"solver": { "strategy": "fallback", "max_iterations": 300, "tolerance": 1e-6 }
}
"#;
let solve = |target_w: f64| -> f64 {
let dir = tempdir().unwrap();
let config_path = dir.path().join("capacity_control.json");
let json = template.replace("{TARGET}", &format!("{target_w}"));
std::fs::write(&config_path, json).unwrap();
let result = run_simulation(&config_path, None, false).unwrap();
assert!(
matches!(result.status, SimulationStatus::Converged),
"capacity-control cycle did not converge: {:?} ({:?})",
result.status,
result.error
);
result
.performance
.expect("performance metrics present")
.q_cooling_kw
.expect("cooling capacity computed")
};
// The loop must drive the solved cooling capacity to each target (within 5 %).
let q_high = solve(7000.0);
let q_low = solve(6000.0);
assert!(
(q_high - 7.0).abs() / 7.0 < 0.05,
"capacity should track 7 kW target, got {q_high:.3} kW"
);
assert!(
(q_low - 6.0).abs() / 6.0 < 0.05,
"capacity should track 6 kW target, got {q_low:.3} kW"
);
// And a lower target must yield a genuinely lower solved capacity (loop acts).
assert!(
q_low < q_high - 0.5,
"lower capacity target must reduce solved capacity: {q_low:.3} !< {q_high:.3}"
);
}
/// Override / selector network end-to-end in a real emergent-pressure cycle:
/// one actuator (EXV opening) driven by a primary superheat setpoint AND a
/// capacity-max protection folded through a softMin selector (the Modelica-style
/// Min/Max override tree). This exercises the new offset-free control law + the
/// override wiring (network residuals + analytic Jacobian) inside a full Newton
/// solve, and proves the selector keeps the primary objective in authority while
/// the protection is slack: the solved duty is invariant to a slack limit.
#[test]
fn test_exv_override_network_solves_and_primary_keeps_authority() {
use entropyk_cli::run::run_simulation;
let template = r#"
{
"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,
"orifice_kv": 2.0e-6, "orifice_opening_init": 0.5,
"orifice_opening_min": 0.02, "orifice_opening_max": 1.0
},
{
"type": "Evaporator", "name": "evap", "ua": 1468.0,
"emergent_pressure": true, "superheat_regulated": 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" }
]
}
],
"controls": [
{
"type": "SaturatedController", "id": "sh_with_cap_limit",
"measure": { "component": "evap", "output": "superheat" },
"actuator": {
"component": "exv", "factor": "opening",
"initial": 0.5, "min": 0.02, "max": 1.0
},
"target": 5.0, "gain": -0.8, "band": 1.0, "alpha": 5e-2,
"objectives": [
{
"component": "evap", "output": "capacity",
"setpoint": {CAPMAX}, "gain": 1e-4, "combine": "min"
}
]
}
],
"solver": { "strategy": "fallback", "max_iterations": 300, "tolerance": 1e-6 }
}
"#;
let solve = |cap_max_w: f64| -> f64 {
let dir = tempdir().unwrap();
let config_path = dir.path().join("exv_override.json");
let json = template.replace("{CAPMAX}", &format!("{cap_max_w}"));
std::fs::write(&config_path, json).unwrap();
let result = run_simulation(&config_path, None, false).unwrap();
assert!(
matches!(result.status, SimulationStatus::Converged),
"override cycle did not converge (cap_max={cap_max_w} W): {:?} ({:?})",
result.status,
result.error
);
result
.performance
.expect("performance metrics present")
.q_cooling_kw
.expect("cooling capacity computed")
};
// Two slack limits, both far above the natural duty (~7 kW): the softMin
// selector must keep the superheat objective in authority in BOTH runs, so
// the solved duty is essentially identical (invariant to the slack limit).
let q_a = solve(1.0e6);
let q_b = solve(2.0e5);
assert!(
q_a > 0.5,
"override network must solve to a positive duty: {q_a:.3} kW"
);
assert!(
(q_a - q_b).abs() / q_a < 0.02,
"slack capacity protection must not change the primary solution: {q_a:.3} vs {q_b:.3} kW"
);
}
/// Override network where the protection must TAKE AUTHORITY. The primary
/// objective asks for an unreachable capacity (so it always wants max compressor
/// speed), and a capacity-max protection is folded in via softMin. When the limit
/// is set below the natural duty, the protection wins and holds the duty at the
/// limit — the hard cold-start switching case. The warm-started **activation
/// continuation** (λ: primary-only → full network) plus the now-honoured
/// `solver.max_iterations` make it converge where a direct solve would slam the
/// actuator and diverge.
#[test]
fn test_override_capacity_limit_takes_authority_via_continuation() {
use entropyk_cli::run::run_simulation;
let template = r#"
{
"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" }
]
}
],
"controls": [
{
"type": "SaturatedController", "id": "cap_with_cap_limit",
"measure": { "component": "evap", "output": "capacity" },
"actuator": {
"component": "comp", "factor": "f_m",
"initial": 1.0, "min": 0.5, "max": 1.5
},
"target": 20000.0, "gain": 0.01, "band": 1.0, "alpha": 5e-2,
"objectives": [
{
"component": "evap", "output": "capacity",
"setpoint": {CAPMAX}, "gain": 0.01, "combine": "min"
}
]
}
],
"solver": { "strategy": "fallback", "max_iterations": 400, "tolerance": 1e-6 }
}
"#;
let solve = |cap_max_w: f64| -> f64 {
let dir = tempdir().unwrap();
let config_path = dir.path().join("override_active.json");
let json = template.replace("{CAPMAX}", &format!("{cap_max_w}"));
std::fs::write(&config_path, json).unwrap();
let result = run_simulation(&config_path, None, false).unwrap();
assert!(
matches!(result.status, SimulationStatus::Converged),
"override cycle did not converge (cap_max={cap_max_w} W): {:?} ({:?})",
result.status,
result.error
);
result
.performance
.expect("performance metrics present")
.q_cooling_kw
.expect("cooling capacity computed")
};
// Baseline (slack limit) → primary wants 20 kW → compressor pinned at max.
let q0_kw = solve(1.0e7);
// Active limit well below baseline → protection takes authority.
let cap_max_w = 0.80 * q0_kw * 1000.0;
let q1_kw = solve(cap_max_w);
assert!(
q1_kw < q0_kw - 0.2,
"override must reduce duty below baseline: {q1_kw:.3} !< {q0_kw:.3} kW"
);
let target_kw = cap_max_w / 1000.0;
assert!(
(q1_kw - target_kw).abs() / target_kw < 0.10,
"override must hold duty near the capacity limit: got {q1_kw:.3} kW, limit {target_kw:.3} kW"
);
}
/// arch-4: A subsystem template instantiated twice (circuits A and B) is flattened
/// into an ordinary two-loop graph and co-solved. This proves hierarchical modeling
/// end-to-end: one parameterized template -> two independent emergent-pressure loops,
/// each with its own secondary conditions, both converging in the same Newton system
/// (the multi-loop staged-seed fix makes the 61XW System_2C topology solvable).
#[test]
fn test_dual_circuit_subsystem_flatten_and_solve() {
use entropyk_cli::run::run_simulation;
let json = r#"
{
"fluid": "R134a",
"fluid_backend": "CoolProp",
"subsystems": {
"EmergentCircuit": {
"params": {
"ua_cond": 766.0, "ua_evap": 1468.0,
"t_evap_secondary_c": 12.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": "$ua_cond",
"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": "$ua_evap",
"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": "$t_evap_secondary_c", "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" }
],
"ports": { "suction": "evap:outlet", "discharge": "comp:outlet" }
}
},
"instances": [
{ "of": "EmergentCircuit", "name": "A", "circuit": 0,
"params": { "ua_cond": 766.0, "t_evap_secondary_c": 12.0 } },
{ "of": "EmergentCircuit", "name": "B", "circuit": 1,
"params": { "ua_cond": 900.0, "t_evap_secondary_c": 10.0 } }
],
"solver": { "strategy": "fallback", "max_iterations": 300, "tolerance": 1e-6 }
}
"#;
let dir = tempdir().unwrap();
let config_path = dir.path().join("dual_circuit.json");
std::fs::write(&config_path, json).unwrap();
let result = run_simulation(&config_path, None, false).unwrap();
assert!(
matches!(result.status, SimulationStatus::Converged),
"dual-circuit subsystem cycle did not converge: {:?} ({:?})",
result.status,
result.error
);
let perf = result.performance.expect("performance metrics present");
let q = perf.q_cooling_kw.expect("cooling capacity computed");
// Two ~7 kW circuits -> total cooling well above a single circuit.
assert!(
q > 12.0 && q < 20.0,
"dual-circuit total cooling capacity should be ~14 kW, got {q:.3} kW"
);
}
/// Task 3.3: Verify that port-spec syntax in edges (e.g., "screw_0:discharge")
/// is correctly parsed - the config should parse and the component/type info should
/// be available with named port reference.
#[test]
fn test_edge_port_spec_syntax_parsed() {
use entropyk_cli::config::ScenarioConfig;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let config_path = dir.path().join("screw_port_spec.json");
// Config with correct port spec syntax: "component:port_name"
let json = r#"
{
"name": "Port Spec Test",
"fluid": "R134a",
"circuits": [
{
"id": 0,
"components": [
{
"type": "ScrewEconomizerCompressor",
"name": "screw_0",
"nominal_frequency_hz": 50.0,
"mechanical_efficiency": 0.92,
"economizer_fraction": 0.12,
"mf_a00": 1.2, "mf_a10": 0.003, "mf_a01": -0.002, "mf_a11": 0.00001,
"pw_b00": 55000.0, "pw_b10": 200.0, "pw_b01": -300.0, "pw_b11": 0.5,
"p_suction_bar": 3.2, "h_suction_kj_kg": 400.0,
"p_discharge_bar": 12.8, "h_discharge_kj_kg": 440.0,
"p_eco_bar": 6.4, "h_eco_kj_kg": 260.0
},
{
"type": "Placeholder",
"name": "condenser",
"n_equations": 2
},
{
"type": "Placeholder",
"name": "evaporator",
"n_equations": 2
}
],
"edges": [
{ "from": "screw_0:discharge", "to": "condenser:inlet" },
{ "from": "condenser:outlet", "to": "evaporator:inlet" },
{ "from": "evaporator:outlet", "to": "screw_0:suction" }
]
}
],
"solver": { "strategy": "fallback", "max_iterations": 5 }
}
"#;
std::fs::write(&config_path, json).unwrap();
let config = ScenarioConfig::from_file(&config_path);
assert!(config.is_ok(), "Config should parse successfully");
let config = config.unwrap();
// Verify the edge port specs are preserved in the raw config
let edges = &config.circuits[0].edges;
assert_eq!(edges.len(), 3);
assert_eq!(edges[0].from, "screw_0:discharge");
assert_eq!(edges[0].to, "condenser:inlet");
assert_eq!(edges[2].from, "evaporator:outlet");
assert_eq!(edges[2].to, "screw_0:suction");
}
/// Task 3.4: Verify preset configuration is correctly parsed and overridable.
#[test]
fn test_screw_compressor_preset_config() {
use entropyk_cli::config::ScenarioConfig;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let config_path = dir.path().join("screw_preset.json");
// Config using preset with explicit frequency override
let json = r#"
{
"name": "Preset Bitzer Test",
"fluid": "R134a",
"circuits": [
{
"id": 0,
"components": [
{
"type": "ScrewEconomizerCompressor",
"name": "screw_0",
"preset": "bitzer_generic_200kw",
"nominal_frequency_hz": 50.0,
"frequency_hz": 45.0,
"mechanical_efficiency": 0.92,
"p_suction_bar": 3.2, "h_suction_kj_kg": 400.0,
"p_discharge_bar": 12.8, "h_discharge_kj_kg": 440.0,
"p_eco_bar": 6.4, "h_eco_kj_kg": 260.0
}
],
"edges": []
}
],
"solver": { "strategy": "fallback", "max_iterations": 5 }
}
"#;
std::fs::write(&config_path, json).unwrap();
let config = ScenarioConfig::from_file(&config_path);
assert!(
config.is_ok(),
"Config with preset should parse successfully"
);
let config = config.unwrap();
let params = &config.circuits[0].components[0].params;
// Verify preset is stored as param
assert_eq!(
params.get("preset").and_then(|v| v.as_str()),
Some("bitzer_generic_200kw"),
"preset field should be in params"
);
// Verify frequency_hz override
assert_eq!(
params.get("frequency_hz").and_then(|v| v.as_f64()),
Some(45.0),
"frequency_hz should be overridden to 45.0"
);
// Verify that explicit mf coefficients can coexist with preset
// (no explicit mf_a00 means it will use the preset default 1.35)
assert!(
params.get("mf_a00").is_none(),
"Preset should not require explicit mf_a00"
);
}
/// Task 3.4: Verify grasso preset is also recognized.
#[test]
fn test_screw_compressor_grasso_preset_config() {
use entropyk_cli::config::ScenarioConfig;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let config_path = dir.path().join("screw_grasso.json");
let json = r#"
{
"fluid": "R134a",
"circuits": [
{
"id": 0,
"components": [
{
"type": "ScrewEconomizerCompressor",
"name": "screw_0",
"preset": "grasso_generic_200kw",
"nominal_frequency_hz": 50.0,
"mechanical_efficiency": 0.90,
"p_suction_bar": 3.2, "h_suction_kj_kg": 400.0,
"p_discharge_bar": 12.8, "h_discharge_kj_kg": 440.0,
"p_eco_bar": 6.4, "h_eco_kj_kg": 260.0
}
],
"edges": []
}
],
"solver": { "max_iterations": 1 }
}
"#;
std::fs::write(&config_path, json).unwrap();
let config = ScenarioConfig::from_file(&config_path).unwrap();
let params = &config.circuits[0].components[0].params;
assert_eq!(
params.get("preset").and_then(|v| v.as_str()),
Some("grasso_generic_200kw")
);
}
/// AC2 validation: Given frequency_hz: 40.0 in config, the CLI path correctly applies
/// set_frequency_hz(), yielding frequency_ratio() == 0.8.
///
/// Replicates the create_component() logic for ScrewEconomizerCompressor to validate AC2.
#[test]
fn test_ac2_frequency_ratio_set_correctly_by_cli() {
use entropyk_components::{
polynomials::Polynomial2D,
port::{FluidId, Port},
screw_economizer_compressor::{ScrewEconomizerCompressor, ScrewPerformanceCurves},
};
use entropyk_core::{Enthalpy, Pressure};
let make_port = |p_bar: f64, h_kj_kg: f64| {
let a = Port::new(
FluidId::new("R134a"),
Pressure::from_bar(p_bar),
Enthalpy::from_joules_per_kg(h_kj_kg * 1000.0),
);
let b = Port::new(
FluidId::new("R134a"),
Pressure::from_bar(p_bar),
Enthalpy::from_joules_per_kg(h_kj_kg * 1000.0),
);
a.connect(b).unwrap().0
};
let curves = ScrewPerformanceCurves::with_fixed_eco_fraction(
Polynomial2D::bilinear(1.2, 0.003, -0.002, 1e-5),
Polynomial2D::bilinear(55_000.0, 200.0, -300.0, 0.5),
0.12,
);
let mut comp = ScrewEconomizerCompressor::new(
curves,
"R134a",
50.0, // nominal_frequency_hz: 50 Hz
0.92,
make_port(3.2, 400.0),
make_port(12.8, 440.0),
make_port(6.4, 260.0),
)
.expect("valid compressor");
// Mirrors what create_component() does when "frequency_hz" present in JSON params
comp.set_frequency_hz(40.0)
.expect("set_frequency_hz(40.0) should succeed");
// AC2 core assertion: 40 / 50 == 0.8
assert!(
(comp.frequency_ratio() - 0.8).abs() < 1e-10,
"AC2 FAILED: expected frequency_ratio 0.8 but got {:.6}",
comp.frequency_ratio()
);
}
/// AC1: Given ua_nominal_kw_k: 8.5, component's ua_nominal() == 8500.0 W/K.
#[test]
fn test_ac1_mchx_ua_nominal_parsed_from_config() {
use entropyk_cli::config::ScenarioConfig;
let json = r#"
{
"fluid": "R134a",
"circuits": [{
"id": 0,
"components": [{
"type": "MchxCondenserCoil",
"name": "mchx_coil",
"ua_nominal_kw_k": 8.5,
"fan_speed": 1.0,
"air_inlet_temp_c": 35.0
}],
"edges": []
}]
}"#;
let config = ScenarioConfig::from_json(json).unwrap();
let comp = &config.circuits[0].components[0];
// AC1: ua_nominal_kw_k field parsed correctly
assert_eq!(
comp.ua_nominal_kw_k,
Some(8.5),
"ua_nominal_kw_k should be 8.5 kW/K"
);
assert_eq!(comp.fan_speed, Some(1.0));
assert_eq!(comp.air_inlet_temp_c, Some(35.0));
}
/// AC2: Given fan_speed=0.64, n_air_exponent=0.5, UA_eff ≈ UA_nom × √0.64 = UA_nom × 0.8.
#[test]
fn test_ac2_fan_speed_064_yields_ua_eff_08() {
use approx::assert_relative_eq;
use entropyk_components::heat_exchanger::MchxCondenserCoil;
let ua_nominal = 8_500.0; // W/K (8.5 kW/K)
let n_air = 0.5;
let mut coil = MchxCondenserCoil::new(ua_nominal, n_air, 0);
// Set design conditions: 35°C air, fan_speed=0.64
coil.set_air_temperature_celsius(35.0);
coil.set_fan_speed_ratio(0.64);
// AC2: UA_eff ≈ UA_nom × 0.64^0.5 = UA_nom × 0.8
let expected_ua = ua_nominal * 0.8; // 0.64^0.5 = 0.8
// Allow 5% tolerance for density correction at 35°C
let ua_eff = coil.ua_effective();
assert_relative_eq!(ua_eff, expected_ua, epsilon = expected_ua * 0.05);
}
/// AC3: condenser_bank with 2 circuits × 2 coils → 4 components with names mchx_0a..mchx_1b.
#[test]
fn test_ac3_condenser_bank_2x2_generates_4_components() {
use entropyk_cli::config::ScenarioConfig;
let json = r#"
{
"fluid": "R134a",
"circuits": [{
"id": 0,
"components": [{
"type": "MchxCondenserCoil",
"name": "mchx",
"ua_nominal_kw_k": 8.5,
"fan_speed": 1.0,
"air_inlet_temp_c": 35.0,
"condenser_bank": {
"circuits": 2,
"coils_per_circuit": 2
}
}],
"edges": []
}]
}"#;
let config = ScenarioConfig::from_json(json).unwrap();
let bank_comp = &config.circuits[0].components[0];
// Verify bank config parsed
let bank = bank_comp
.condenser_bank
.as_ref()
.expect("condenser_bank must be present");
assert_eq!(bank.circuits, 2);
assert_eq!(bank.coils_per_circuit, 2);
// Verify bank expansion logic: 2*2 = 4 coils with correct names
// This mirrors the bank expansion in execute_simulation()
let mut expanded_names = Vec::new();
for c in 0..bank.circuits {
for i in 0..bank.coils_per_circuit {
let letter = (b'a' + (i as u8)) as char;
expanded_names.push(format!("{}_{}{}", bank_comp.name, c, letter));
}
}
assert_eq!(expanded_names.len(), 4, "2×2 bank should expand to 4 coils");
assert_eq!(expanded_names[0], "mchx_0a");
assert_eq!(expanded_names[1], "mchx_0b");
assert_eq!(expanded_names[2], "mchx_1a");
assert_eq!(expanded_names[3], "mchx_1b");
}
/// Integration: run_simulation() with frequency_hz: 40.0 in a complete 3-port
/// screw topology does not produce a frequency-validation error.
#[test]
fn test_frequency_hz_40_passes_cli_simulation() {
use entropyk_cli::run::run_simulation;
let dir = tempdir().unwrap();
let config_path = dir.path().join("screw_freq_integration.json");
let json = r#"
{
"name": "AC2 Integration",
"fluid": "R134a",
"circuits": [
{
"id": 0,
"components": [
{
"type": "ScrewEconomizerCompressor",
"name": "screw_0",
"nominal_frequency_hz": 50.0,
"frequency_hz": 40.0,
"mechanical_efficiency": 0.92,
"economizer_fraction": 0.12,
"mf_a00": 1.2, "mf_a10": 0.003, "mf_a01": -0.002, "mf_a11": 0.00001,
"pw_b00": 55000.0, "pw_b10": 200.0, "pw_b01": -300.0, "pw_b11": 0.5,
"p_suction_bar": 3.2, "h_suction_kj_kg": 400.0,
"p_discharge_bar": 12.8, "h_discharge_kj_kg": 440.0,
"p_eco_bar": 6.4, "h_eco_kj_kg": 260.0
},
{ "type": "Placeholder", "name": "cond", "n_equations": 2 },
{ "type": "Placeholder", "name": "evap", "n_equations": 2 },
{ "type": "Placeholder", "name": "eco_hx", "n_equations": 2 }
],
"edges": [
{ "from": "screw_0:discharge", "to": "cond:inlet" },
{ "from": "cond:outlet", "to": "evap:inlet" },
{ "from": "evap:outlet", "to": "screw_0:suction" },
{ "from": "eco_hx:outlet", "to": "screw_0:economizer" }
]
}
],
"solver": { "strategy": "fallback", "max_iterations": 5 }
}
"#;
std::fs::write(&config_path, json).unwrap();
let result = run_simulation(&config_path, None, false).unwrap();
// The simulation may fail due to topology/solver mismatches with placeholder components.
// Critical assertion: it must NOT error because of frequency validation (= AC2 would fail).
if let Some(err) = &result.error {
assert!(
!err.to_lowercase().contains("frequency"),
"CLI must not error on frequency validation (AC2): {}",
err
);
}
}
/// Task 4.3: Verify that fan_control: "bounded" config goes through the full CLI pipeline
/// without panicking or erroring at the BoundedVariable insertion step.
///
/// This exercises the post-finalize() control path in execute_simulation().
#[test]
fn test_fan_control_bounded_does_not_error() {
use entropyk_cli::run::run_simulation;
let dir = tempdir().unwrap();
let config_path = dir.path().join("mchx_fan_bounded.json");
let json = r#"
{
"fluid": "R134a",
"circuits": [{
"id": 0,
"components": [{
"type": "MchxCondenserCoil",
"name": "mchx_coil",
"ua_nominal_kw_k": 8.5,
"fan_speed": 0.8,
"air_inlet_temp_c": 35.0,
"fan_control": "bounded",
"fan_speed_min": 0.1,
"fan_speed_max": 1.0
}],
"edges": []
}],
"solver": { "strategy": "fallback", "max_iterations": 3 }
}
"#;
std::fs::write(&config_path, json).unwrap();
let result = run_simulation(&config_path, None, false).unwrap();
// The simulation should proceed without erroring at config/finalize/variable-insertion stage.
// It may not converge (isolated single-port component) but must not produce a
// fan_speed-related or bounded-variable insertion error.
if let Some(ref err) = result.error {
assert!(
!err.to_lowercase().contains("bounded"),
"CLI must not error on bounded-variable insertion (Task 4.3): {}",
err
);
assert!(
!err.to_lowercase().contains("fan_speed"),
"CLI must not error on fan_speed variable creation (Task 4.3): {}",
err
);
}
}
/// Integration test for story 15-3: CLI uses real Pump<Connected> (2 equations), not stub.
/// A config with two Pumps in a loop must not fail with "State dimension does not match equation count".
#[test]
fn test_pump_real_component_used() {
use entropyk_cli::run::run_simulation;
let dir = tempdir().unwrap();
let config_path = dir.path().join("water_loop.json");
let json = r#"
{
"name": "Water loop two pumps",
"fluid": "Water",
"circuits": [{
"id": 0,
"name": "Water",
"components": [
{ "type": "Pump", "name": "pump1" },
{ "type": "Pump", "name": "pump2" }
],
"edges": [
{ "from": "pump1:outlet", "to": "pump2:inlet" },
{ "from": "pump2:outlet", "to": "pump1:inlet" }
]
}],
"solver": { "strategy": "newton", "max_iterations": 50, "tolerance": 1e-6 }
}
"#;
std::fs::write(&config_path, json).unwrap();
let result = run_simulation(&config_path, None, false).unwrap();
// Real Pump has 2 equations each -> 4 equations, 2 edges -> 4 state. No dimension mismatch.
if let Some(ref err) = result.error {
assert!(
!err.contains("State dimension") || !err.contains("equation count"),
"Real Pump must be used (no stub); dimension mismatch indicates stub: {}",
err
);
}
}
/// Story 15-4: BphxEvaporator and BphxCondenser are accepted by create_component (config parsing).
/// Asserts that a config with both types does not yield "Unknown component type".
#[test]
fn test_bphx_evaporator_and_condenser_config_parsing() {
use entropyk_cli::run::run_simulation;
let dir = tempdir().unwrap();
let config_path = dir.path().join("bphx_parsing.json");
let json = r#"
{
"name": "BPHX parsing test",
"fluid": "R410A",
"circuits": [
{
"id": 0,
"components": [
{
"type": "BphxEvaporator",
"name": "evap",
"refrigerant": "R410A",
"secondary_fluid": "Water",
"dh_m": 0.003,
"area_m2": 0.5,
"n_plates": 20
},
{
"type": "BphxCondenser",
"name": "cond",
"refrigerant": "R410A",
"secondary_fluid": "Water",
"target_subcooling_k": 3.0,
"dh_m": 0.003,
"area_m2": 0.5,
"n_plates": 20
}
],
"edges": []
}
],
"solver": { "strategy": "newton", "max_iterations": 10, "tolerance": 1e-6 }
}
"#;
std::fs::write(&config_path, json).unwrap();
let result = run_simulation(&config_path, None, false).unwrap();
// create_component must accept both types. Two distinct assertions:
// (a) no "Unknown component type" — both Bphx types must be registered.
// (b) no "Failed to create component" — construction must succeed, not just be recognised.
if let Some(ref err) = result.error {
assert!(
!err.contains("Unknown component type"),
"BphxEvaporator and BphxCondenser must be registered in create_component: {}",
err
);
assert!(
!err.contains("Failed to create component"),
"BphxEvaporator/BphxCondenser construction must not fail: {}",
err
);
}
// We expect Error or NonConverged (edges empty -> topology/finalization failure), not config parse failure.
match result.status {
SimulationStatus::Error => {
// Failure is expected (e.g. isolated nodes); config parsing and construction succeeded.
}
SimulationStatus::NonConverged
| SimulationStatus::Converged
| SimulationStatus::Timeout => {
// Also acceptable if we get to solver stage.
}
}
}
/// Story 15-4 — Integration: BphxEvaporator and BphxCondenser in bounded circuits
/// (RefrigerantSource → Bphx → RefrigerantSink) must reach the solver stage.
/// Validates that config parsing, component construction, AND edge routing all succeed.
#[test]
fn test_bphx_bounded_circuit_reaches_solver_stage() {
use entropyk_cli::run::run_simulation;
let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples/bphx_evaporator_condenser.json");
if !example.exists() {
panic!(
"Test fixture missing: {} — this test requires the example file to exist",
example.display()
);
}
let result = run_simulation(&example, None, false).unwrap();
// Three-gate assertion: config → construction → edge routing must all succeed.
if let Some(ref err) = result.error {
assert!(
!err.contains("Unknown component type"),
"[Gate 1] Bphx type not registered: {}",
err
);
assert!(
!err.contains("Failed to create component"),
"[Gate 2] Bphx construction failed: {}",
err
);
assert!(
!err.contains("Failed to add edge") && !err.contains("Edge references unknown"),
"[Gate 3] Edge routing failed: {}",
err
);
// Any remaining error (e.g. solver non-convergence) is acceptable.
}
}
/// 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`.
///
/// 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.
#[test]
fn test_failed_run_json_includes_failure_diagnostics() {
use entropyk_cli::run::run_simulation;
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" }
]
}],
"solver": {
"strategy": "newton",
"max_iterations": 2,
"tolerance": 1e-100
}
}
"#;
std::fs::write(&config_path, json).unwrap();
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!(
result.status,
SimulationStatus::NonConverged | SimulationStatus::Error
);
assert!(
failed || matches!(result.status, SimulationStatus::Converged),
"Unexpected status: {:?}",
result.status
);
// 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
/// triggers `InvalidSystem` before any solver iterations, the CLI serializes the result
/// successfully with `failure_diagnostics` absent from the JSON and no panic.
#[test]
fn test_empty_system_error_omits_failure_diagnostics() {
use entropyk_cli::run::run_simulation;
let dir = tempdir().unwrap();
let config_path = dir.path().join("empty_system.json");
let json = r#"
{
"fluid": "R134a",
"circuits": [],
"solver": { "strategy": "newton", "max_iterations": 5, "tolerance": 1e-6 }
}
"#;
std::fs::write(&config_path, json).unwrap();
let result = run_simulation(&config_path, None, false).unwrap();
// Empty system must fail (no state variables or equations).
assert_eq!(
result.status,
SimulationStatus::Error,
"Empty system must result in Error status"
);
assert!(result.error.is_some(), "error field must be present");
// No solver iterations ran: failure_diagnostics must be absent.
assert!(
result.failure_diagnostics.is_none(),
"failure_diagnostics must be absent for pre-iteration structural failures"
);
// Serialization must succeed and omit the field.
let json_str = serde_json::to_string(&result).unwrap();
assert!(
!json_str.contains("failure_diagnostics"),
"failure_diagnostics key must be absent from JSON for structural failures"
);
}
/// Integration: the emergent-pressure chiller example must converge AND report
/// genuine cycle performance (cooling/heating capacity, power, COP) that closes
/// the First Law (Q_heating = Q_cooling + W_input). Also checks that raising the
/// condenser secondary water temperature lowers the COP — i.e. performance truly
/// responds to the secondary conditions, not fixed design points.
#[test]
fn test_emergent_chiller_reports_performance_and_reacts_to_secondary() {
use entropyk_cli::run::{run_simulation, simulate_from_json};
let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples/chiller_r134a_emergent_pressure.json");
if !example.exists() {
panic!("Test fixture missing: {}", example.display());
}
let result = run_simulation(&example, None, false).unwrap();
// CoolProp may be unavailable in some build environments; only assert the
// performance contract when the solve actually converged.
if result.status != SimulationStatus::Converged {
return;
}
let perf = result
.performance
.expect("converged emergent cycle must report performance");
let q_cool = perf.q_cooling_kw.expect("cooling capacity");
let q_heat = perf.q_heating_kw.expect("heating capacity");
let power = perf.compressor_power_kw.expect("power input");
let cop = perf.cop.expect("COP");
assert!(q_cool > 0.0, "cooling capacity must be positive: {q_cool}");
assert!(power > 0.0, "power input must be positive: {power}");
assert!(cop > 1.0 && cop < 15.0, "COP must be physical: {cop}");
// First Law: rejected heat = absorbed heat + shaft work.
assert!(
(q_heat - (q_cool + power)).abs() < 1e-3 * q_heat.max(1.0),
"First Law must close: q_heat={q_heat}, q_cool={q_cool}, power={power}"
);
// Raising the condenser secondary (water) inlet temperature raises the
// condensing pressure and lowers the COP — a genuine coupled response.
let base_json = std::fs::read_to_string(&example).unwrap();
let hot_json = base_json.replace("\"t_set_c\": 30.0", "\"t_set_c\": 40.0");
assert_ne!(
base_json, hot_json,
"condenser secondary temp must be patched"
);
let hot = simulate_from_json(&hot_json).unwrap();
if hot.status == SimulationStatus::Converged {
let hot_cop = hot.performance.and_then(|p| p.cop).expect("hot COP");
assert!(
hot_cop < cop,
"warmer condenser water must lower COP: {hot_cop} !< {cop}"
);
}
}
/// Integration (Story 3.4 completion): a PHYSICAL `thermal_couplings` entry
/// (`hot_component` + `cold_component`) must genuinely transfer the condenser's
/// measured duty into the coupled water loop's `ThermalLoad`:
///
/// 1. the two-circuit system converges;
/// 2. the water-side enthalpy rise carries EXACTLY the rejected duty
/// (ṁ_w·Δh = Q_heating, First Law across circuits);
/// 3. halving the coupling `efficiency` halves the transferred heat — proving
/// the coupled response is real, not an inert stub.
#[test]
fn test_physical_thermal_coupling_transfers_condenser_duty_to_water_loop() {
use entropyk_cli::run::{run_simulation, simulate_from_json};
let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples/chiller_r410a_coupled_water_loop.json");
if !example.exists() {
panic!("Test fixture missing: {}", example.display());
}
let result = run_simulation(&example, None, false).unwrap();
// CoolProp may be unavailable in some build environments; only assert the
// coupling contract when the solve actually converged.
if result.status != SimulationStatus::Converged {
return;
}
let perf = result.performance.expect("performance metrics");
let q_heat_kw = perf.q_heating_kw.expect("heating capacity");
let q_cool_kw = perf.q_cooling_kw.expect("cooling capacity");
let power_kw = perf.compressor_power_kw.expect("power input");
assert!(q_heat_kw > 1.0, "rejected duty must be real: {q_heat_kw}");
// First Law on the refrigerant circuit (ThermalLoad must NOT be counted
// as extra cooling capacity).
assert!(
(q_heat_kw - (q_cool_kw + power_kw)).abs() < 1e-3 * q_heat_kw,
"First Law must close: q_heat={q_heat_kw}, q_cool={q_cool_kw}, power={power_kw}"
);
// Water loop = edges 6 (load inlet) and 7 (load outlet), per config order.
// Circuit 0 has 6 edges (4 refrigerant + 2 evaporator secondary); circuit 1
// (water loop) edges follow at indices 6 and 7.
let state = result.state.expect("state entries");
let h_in = state[6].enthalpy_kj_kg;
let h_out = state[7].enthalpy_kj_kg;
let m_water = 0.9; // kg/s, from the fixture
let q_water_kw = m_water * (h_out - h_in);
assert!(
(q_water_kw - q_heat_kw).abs() < 1e-2 * q_heat_kw,
"coupled water loop must absorb the rejected duty: \
m*dh = {q_water_kw} kW vs Q_heating = {q_heat_kw} kW"
);
// Loop pressure pinned by BrineSource/BrineSink boundaries at 2 bar.
assert!(
(state[6].pressure_bar - 2.0).abs() < 1e-6 && (state[7].pressure_bar - 2.0).abs() < 1e-6,
"water loop pressure must be pinned by the boundaries: {} / {}",
state[6].pressure_bar,
state[7].pressure_bar
);
// Halving the coupling efficiency must halve the transferred heat.
let base_json = std::fs::read_to_string(&example).unwrap();
let half_json = base_json.replace("\"efficiency\": 1.0", "\"efficiency\": 0.5");
assert_ne!(base_json, half_json, "efficiency must be patched");
let half = simulate_from_json(&half_json).unwrap();
if half.status == SimulationStatus::Converged {
let half_state = half.state.expect("state");
let dh_half = half_state[7].enthalpy_kj_kg - half_state[6].enthalpy_kj_kg;
let dh_full = h_out - h_in;
assert!(
(dh_half - 0.5 * dh_full).abs() < 1e-2 * dh_full,
"half efficiency must halve the water enthalpy rise: \
{dh_half} vs 0.5*{dh_full}"
);
}
}
/// Integration (BOLT node parity): inline `Anchor` (probe mode) and
/// `HeatSource` (fixed motor-cooling duty) inserted into the full-physics
/// chiller must:
///
/// 1. keep the cycle square and convergent (both are DoF-neutral inline);
/// 2. preserve pass-through continuity across the anchor (liquid line);
/// 3. inject EXACTLY q_w into the suction gas: ṁ·Δh_suction = 500 W;
/// 4. close the First Law including the parasitic heat:
/// Q_cond = Q_evap + W_comp + Q_motor (HeatSource is excluded from the
/// cooling-capacity aggregation).
#[test]
fn test_bolt_inline_nodes_anchor_probe_and_heat_source() {
use entropyk_cli::run::run_simulation;
let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples/chiller_r410a_bolt_nodes.json");
if !example.exists() {
panic!("Test fixture missing: {}", example.display());
}
let result = run_simulation(&example, None, false).unwrap();
if result.status != SimulationStatus::Converged {
return; // CoolProp may be unavailable in some environments.
}
// Edge order per config: 0 comp→cond, 1 cond→anchor, 2 anchor→exv,
// 3 exv→evap, 4 evap→heat_source, 5 heat_source→comp.
let state = result.state.expect("state entries");
// (2) Anchor continuity: identical P and h on both sides.
assert!(
(state[1].pressure_bar - state[2].pressure_bar).abs() < 1e-9
&& (state[1].enthalpy_kj_kg - state[2].enthalpy_kj_kg).abs() < 1e-9,
"anchor must be transparent: {:?} vs {:?}",
(state[1].pressure_bar, state[1].enthalpy_kj_kg),
(state[2].pressure_bar, state[2].enthalpy_kj_kg),
);
let perf = result.performance.expect("performance metrics");
let q_cool_kw = perf.q_cooling_kw.expect("cooling capacity");
let q_heat_kw = perf.q_heating_kw.expect("heating capacity");
let power_kw = perf.compressor_power_kw.expect("power input");
// (3) HeatSource: suction enthalpy rise carries exactly q_w = 500 W.
// Recover ṁ from the evaporator duty: ṁ = Q_evap / Δh_evap.
let dh_evap = state[4].enthalpy_kj_kg - state[3].enthalpy_kj_kg;
assert!(
dh_evap > 1.0,
"evaporator enthalpy rise must be real: {dh_evap}"
);
let m_dot = q_cool_kw / dh_evap;
assert!(m_dot > 1e-3, "refrigerant flow must be real: {m_dot}");
let q_injected_w = m_dot * (state[5].enthalpy_kj_kg - state[4].enthalpy_kj_kg) * 1e3;
assert!(
(q_injected_w - 500.0).abs() < 5.0,
"motor-cooling duty must be injected: {q_injected_w} W"
);
// (4) First Law with the parasitic term.
assert!(
(q_heat_kw - (q_cool_kw + power_kw + 0.5)).abs() < 1e-2 * q_heat_kw,
"First Law with motor heat must close: q_heat={q_heat_kw}, \
q_cool={q_cool_kw}, power={power_kw}, q_motor=0.5"
);
}
/// Integration (arch-6): the physical EXV orifice actuator must (a) keep the
/// emergent cycle DoF-balanced and convergent, and (b) act as a genuine inverse
/// design — since the compressor fixes the mass flow, the orifice equation solves
/// for the required fractional opening. Changing only `Kv` must therefore leave
/// the solved thermodynamic cycle unchanged (the opening absorbs the difference),
/// proving the extra unknown/equation are balanced and non-invasive.
#[test]
fn test_exv_orifice_actuator_balances_dof_and_is_inverse_design() {
use entropyk_cli::run::{run_simulation, simulate_from_json};
let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples/chiller_r134a_exv_orifice.json");
if !example.exists() {
panic!("Test fixture missing: {}", example.display());
}
let result = run_simulation(&example, None, false).unwrap();
if result.status != SimulationStatus::Converged {
return; // CoolProp may be unavailable in some environments.
}
let perf = result
.performance
.expect("converged orifice cycle must report performance");
let q_cool = perf.q_cooling_kw.expect("cooling capacity");
let q_heat = perf.q_heating_kw.expect("heating capacity");
let power = perf.compressor_power_kw.expect("power input");
let cop = perf.cop.expect("COP");
assert!(q_cool > 0.0 && power > 0.0 && cop > 1.0 && cop < 15.0);
assert!(
(q_heat - (q_cool + power)).abs() < 1e-3 * q_heat.max(1.0),
"First Law must close with the orifice actuator active"
);
// Baseline evaporating/condensing pressures from the solved refrigerant edges.
// Only the first 4 edges are refrigerant; the rest are secondary water loop.
let base_state = result.state.expect("state");
let base_p_cond = base_state
.iter()
.take(4)
.map(|e| e.pressure_bar)
.fold(f64::MIN, f64::max);
let base_p_evap = base_state
.iter()
.take(4)
.map(|e| e.pressure_bar)
.fold(f64::MAX, f64::min);
// Double Kv: the required opening halves, but the cycle is unchanged.
let base_json = std::fs::read_to_string(&example).unwrap();
let big_kv = base_json.replace("\"orifice_kv\": 2.0e-6", "\"orifice_kv\": 4.0e-6");
assert_ne!(base_json, big_kv, "Kv must be patched");
let alt = simulate_from_json(&big_kv).unwrap();
if alt.status == SimulationStatus::Converged {
let alt_cop = alt.performance.and_then(|p| p.cop).expect("alt COP");
assert!(
(alt_cop - cop).abs() < 1e-3 * cop.max(1.0),
"orifice is inverse design: doubling Kv must not change COP ({alt_cop} vs {cop})"
);
let alt_state = alt.state.expect("alt state");
let alt_p_cond = alt_state
.iter()
.take(4)
.map(|e| e.pressure_bar)
.fold(f64::MIN, f64::max);
let alt_p_evap = alt_state
.iter()
.take(4)
.map(|e| e.pressure_bar)
.fold(f64::MAX, f64::min);
assert!(
(alt_p_cond - base_p_cond).abs() < 1e-2 && (alt_p_evap - base_p_evap).abs() < 1e-2,
"emergent pressures must be invariant to Kv (inverse design)"
);
}
}
/// Integration: the condenser fan head-pressure actuator (arch-6) must hold the
/// condensing temperature at its setpoint by modulating fan speed. Raising the
/// target must raise the emergent condensing pressure (genuine control, not a
/// fixed design point), and the DoF must stay balanced (converges).
#[test]
fn test_fan_head_pressure_actuator_tracks_condensing_setpoint() {
use entropyk_cli::run::{run_simulation, simulate_from_json};
let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples/heatpump_r134a_fan_headpressure.json");
if !example.exists() {
panic!("Test fixture missing: {}", example.display());
}
let result = run_simulation(&example, None, false).unwrap();
if result.status != SimulationStatus::Converged {
return; // CoolProp may be unavailable in some environments.
}
let perf = result
.performance
.expect("converged fan cycle must report performance");
let q_cool = perf.q_cooling_kw.expect("cooling capacity");
let q_heat = perf.q_heating_kw.expect("heating capacity");
let power = perf.compressor_power_kw.expect("power input");
let cop = perf.cop.expect("COP");
assert!(q_cool > 0.0 && power > 0.0 && cop > 1.0 && cop < 15.0);
assert!(
(q_heat - (q_cool + power)).abs() < 1e-3 * q_heat.max(1.0),
"First Law must close with the fan actuator active"
);
// Condensing pressure from the solved edges (highest edge pressure).
let base_state = result.state.expect("state");
let base_p_cond = base_state
.iter()
.take(4)
.map(|e| e.pressure_bar)
.fold(f64::MIN, f64::max);
// R134a saturation at 45 °C ≈ 11.6 bar → the fan must hold the setpoint.
assert!(
(base_p_cond - 11.6).abs() < 0.6,
"fan must hold T_cond ≈ 45 °C (P_cond ≈ 11.6 bar), got {base_p_cond} bar"
);
// Raise the head-pressure target 45 → 52 °C: the fan slows and the emergent
// condensing pressure must rise (genuine setpoint tracking).
let base_json = std::fs::read_to_string(&example).unwrap();
let hotter = base_json.replace(
"\"fan_head_pressure_target_c\": 45.0",
"\"fan_head_pressure_target_c\": 52.0",
);
assert_ne!(base_json, hotter, "target must be patched");
let alt = simulate_from_json(&hotter).unwrap();
if alt.status == SimulationStatus::Converged {
let alt_state = alt.state.expect("alt state");
let alt_p_cond = alt_state
.iter()
.take(4)
.map(|e| e.pressure_bar)
.fold(f64::MIN, f64::max);
assert!(
alt_p_cond > base_p_cond + 0.5,
"higher condensing setpoint must raise P_cond: {alt_p_cond} !> {base_p_cond}"
);
}
}
/// Integration: the flooded-condenser head-pressure actuator (arch-6) must hold
/// the condensing-saturated temperature at its setpoint by flooding the condenser
/// with liquid (reducing the active area UA_eff = (1-lambda)*UA) in low ambient.
/// Lowering the secondary air temperature further must NOT collapse the condensing
/// pressure (the flood level rises to hold it), and raising the target must raise
/// the emergent condensing pressure (genuine head-pressure control), with the DoF
/// balanced (converges).
#[test]
fn test_flooded_head_pressure_actuator_tracks_condensing_setpoint() {
use entropyk_cli::run::{run_simulation, simulate_from_json};
let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples/chiller_r134a_flooded_headpressure.json");
if !example.exists() {
panic!("Test fixture missing: {}", example.display());
}
let result = run_simulation(&example, None, false).unwrap();
if result.status != SimulationStatus::Converged {
return; // CoolProp may be unavailable in some environments.
}
let perf = result
.performance
.expect("converged flooded cycle must report performance");
let q_cool = perf.q_cooling_kw.expect("cooling capacity");
let q_heat = perf.q_heating_kw.expect("heating capacity");
let power = perf.compressor_power_kw.expect("power input");
let cop = perf.cop.expect("COP");
assert!(q_cool > 0.0 && power > 0.0 && cop > 1.0 && cop < 15.0);
assert!(
(q_heat - (q_cool + power)).abs() < 1e-3 * q_heat.max(1.0),
"First Law must close with the flooded head-pressure actuator active"
);
// Condensing pressure from the solved edges (highest edge pressure).
let base_state = result.state.expect("state");
let base_p_cond = base_state
.iter()
.take(4)
.map(|e| e.pressure_bar)
.fold(f64::MIN, f64::max);
// R134a saturation at 45 °C ≈ 11.6 bar → flooding must hold the setpoint even
// though the ambient air is cold (5 °C).
assert!(
(base_p_cond - 11.6).abs() < 0.6,
"flooding must hold T_cond ≈ 45 °C (P_cond ≈ 11.6 bar), got {base_p_cond} bar"
);
// Drop the ambient air 5 → -5 °C: the flood level rises to compensate but the
// condensing pressure must NOT collapse (still held near the setpoint).
let base_json = std::fs::read_to_string(&example).unwrap();
let colder = base_json.replace("\"t_dry_c\": 5.0", "\"t_dry_c\": -5.0");
assert_ne!(base_json, colder, "ambient must be patched");
let alt = simulate_from_json(&colder).unwrap();
if alt.status == SimulationStatus::Converged {
let alt_state = alt.state.expect("alt state");
let alt_p_cond = alt_state
.iter()
.take(4)
.map(|e| e.pressure_bar)
.fold(f64::MIN, f64::max);
assert!(
(alt_p_cond - 11.6).abs() < 0.8,
"flooding must still hold T_cond ≈ 45 °C in colder ambient, got {alt_p_cond} bar"
);
}
// Raise the head-pressure target 45 → 52 °C: the emergent condensing pressure
// must rise (genuine setpoint tracking).
let hotter = base_json.replace(
"\"flooded_head_pressure_target_c\": 45.0",
"\"flooded_head_pressure_target_c\": 52.0",
);
assert_ne!(base_json, hotter, "target must be patched");
let alt2 = simulate_from_json(&hotter).unwrap();
if alt2.status == SimulationStatus::Converged {
let alt2_state = alt2.state.expect("alt2 state");
let alt2_p_cond = alt2_state
.iter()
.take(4)
.map(|e| e.pressure_bar)
.fold(f64::MIN, f64::max);
assert!(
alt2_p_cond > base_p_cond + 0.5,
"higher condensing setpoint must raise P_cond: {alt2_p_cond} !> {base_p_cond}"
);
}
}
/// Integration: multi-circuit staging (circuit on/off). A dual-circuit chiller
/// with both circuits energized must deliver ~2x the cooling capacity of the same
/// unit with one circuit staged OFF (enabled=false). The staged-off circuit must
/// be excluded from the solve entirely (its components/edges never added), the DoF
/// must stay balanced (converges), and the COP must be unchanged for identical
/// circuits (staging halves both capacity and power).
#[test]
fn test_circuit_staging_on_off_scales_capacity() {
use entropyk_cli::run::{run_simulation, simulate_from_json};
let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples/chiller_r134a_dual_circuit_staging.json");
if !example.exists() {
panic!("Test fixture missing: {}", example.display());
}
// Full load: both circuits energized.
let full = run_simulation(&example, None, false).unwrap();
if full.status != SimulationStatus::Converged {
return; // CoolProp may be unavailable in some environments.
}
let full_perf = full
.performance
.expect("full-load dual-circuit run must report performance");
let full_q = full_perf.q_cooling_kw.expect("full cooling capacity");
let full_cop = full_perf.cop.expect("full COP");
// Both circuits solved: 16 edges total (8 per circuit: 4 refrigerant + 4 secondary).
let full_edges = full.state.expect("full state").len();
assert_eq!(full_edges, 16, "both circuits must contribute 8 edges each");
// Part load: stage circuit B (id 1) OFF by patching enabled -> false.
// Part load: stage circuit B (id 1) OFF by patching its enabled flag ->
// false. Split at "Circuit B" so we flip the second circuit's flag only,
// independent of CRLF/LF line endings.
let base_json = std::fs::read_to_string(&example).unwrap();
let split_at = base_json.find("Circuit B").expect("circuit B marker");
let (head, tail) = base_json.split_at(split_at);
let tail = tail.replacen("\"enabled\": true", "\"enabled\": false", 1);
let staged = format!("{head}{tail}");
assert_ne!(base_json, staged, "circuit B enabled flag must be patched");
let part = simulate_from_json(&staged).unwrap();
assert_eq!(
part.status,
SimulationStatus::Converged,
"single staged circuit must still converge"
);
let part_perf = part
.performance
.expect("part-load run must report performance");
let part_q = part_perf.q_cooling_kw.expect("part cooling capacity");
let part_cop = part_perf.cop.expect("part COP");
// Only circuit A solved: 8 edges (the staged-off circuit is excluded).
let part_edges = part.state.expect("part state").len();
assert_eq!(
part_edges, 8,
"the staged-off circuit's edges must be excluded from the solve"
);
// Identical circuits => staging halves capacity but keeps COP.
assert!(
(part_q - 0.5 * full_q).abs() < 0.05 * full_q,
"staging one of two identical circuits must ~halve capacity: {part_q} vs {full_q}"
);
assert!(
(part_cop - full_cop).abs() < 0.02 * full_cop,
"COP must be unchanged when staging identical circuits: {part_cop} vs {full_cop}"
);
}
/// Integration: staging every circuit OFF must be rejected with a clear error
/// rather than producing an empty/degenerate solve.
#[test]
fn test_all_circuits_disabled_is_rejected() {
use entropyk_cli::run::simulate_from_json;
let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples/chiller_r134a_dual_circuit_staging.json");
if !example.exists() {
panic!("Test fixture missing: {}", example.display());
}
let base_json = std::fs::read_to_string(&example).unwrap();
let all_off = base_json.replace("\"enabled\": true", "\"enabled\": false");
let result = simulate_from_json(&all_off).unwrap();
assert_eq!(result.status, SimulationStatus::Error);
assert!(
result
.error
.as_deref()
.unwrap_or_default()
.contains("All circuits are disabled"),
"must report a clear all-circuits-disabled error, got: {:?}",
result.error
);
}
/// Integration: the four-way reversing valve (arch-6) must impose a genuine
/// pressure drop and internal-leakage penalty on the cycle. The condenser-inlet
/// pressure (edge downstream of the valve) must be measurably below the
/// compressor-discharge pressure, and removing the valve's pressure drop must
/// raise the cooling EER (proving the penalty is real thermodynamics, not a
/// cosmetic derating). DoF must stay balanced (converges).
#[test]
fn test_reversing_valve_imposes_pressure_drop_penalty() {
use entropyk_cli::run::{run_simulation, simulate_from_json};
let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples/heatpump_r410a_reversing_valve.json");
if !example.exists() {
panic!("Test fixture missing: {}", example.display());
}
let result = run_simulation(&example, None, false).unwrap();
if result.status != SimulationStatus::Converged {
return; // CoolProp may be unavailable in some environments.
}
let perf = result
.performance
.expect("converged reversing-valve cycle must report performance");
let q_cool = perf.q_cooling_kw.expect("cooling capacity");
let q_heat = perf.q_heating_kw.expect("heating capacity");
let power = perf.compressor_power_kw.expect("power input");
let base_eer = perf.cop.expect("EER");
assert!(q_cool > 0.0 && power > 0.0 && base_eer > 0.5 && base_eer < 15.0);
assert!(
(q_heat - (q_cool + power)).abs() < 1e-3 * q_heat.max(1.0),
"First Law must close with the reversing valve active"
);
// The valve sits between the compressor discharge (edge 0) and the condenser
// inlet (edge 1). The condenser-inlet pressure must be below the discharge
// pressure by the valve ΔP (~0.25 bar baseline + quadratic term).
let state = result.state.expect("state");
let p_disch = state[0].pressure_bar;
let p_cond_in = state[1].pressure_bar;
assert!(
p_disch - p_cond_in > 0.15,
"reversing valve must drop pressure discharge->condenser: {p_disch} -> {p_cond_in} bar"
);
// Remove the valve's pressure drop (and leak): the cooling EER must rise,
// confirming the valve imposes a genuine penalty rather than a cosmetic one.
let base_json = std::fs::read_to_string(&example).unwrap();
let no_dp = base_json
.replace("\"pressure_drop_kpa\": 25.0", "\"pressure_drop_kpa\": 0.0")
.replace(
"\"pressure_drop_coeff\": 5.0e5",
"\"pressure_drop_coeff\": 0.0",
);
assert_ne!(base_json, no_dp, "valve penalty params must be patched out");
let ideal = simulate_from_json(&no_dp).unwrap();
if ideal.status == SimulationStatus::Converged {
let ideal_eer = ideal.performance.expect("ideal perf").cop.expect("EER");
assert!(
ideal_eer > base_eer,
"removing the valve pressure drop must raise EER: {ideal_eer} !> {base_eer}"
);
// And the condenser-inlet pressure must now equal the discharge pressure.
let ideal_state = ideal.state.expect("ideal state");
assert!(
(ideal_state[0].pressure_bar - ideal_state[1].pressure_bar).abs() < 0.02,
"with ΔP removed the valve must be a pass-through"
);
}
}
/// Integration: the compressor slide-valve actuator (arch-6) must hold the
/// suction-saturated temperature (SST) at its setpoint by unloading the screw
/// compressor. Raising the SST target must raise the emergent evaporating
/// pressure (genuine capacity control, not a fixed design point), and the DoF
/// must stay balanced (converges).
#[test]
fn test_slide_valve_actuator_tracks_suction_setpoint() {
use entropyk_cli::run::{run_simulation, simulate_from_json};
let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples/chiller_r134a_slide_valve.json");
if !example.exists() {
panic!("Test fixture missing: {}", example.display());
}
let result = run_simulation(&example, None, false).unwrap();
if result.status != SimulationStatus::Converged {
return; // CoolProp may be unavailable in some environments.
}
let perf = result
.performance
.expect("converged slide-valve cycle must report performance");
let q_cool = perf.q_cooling_kw.expect("cooling capacity");
let q_heat = perf.q_heating_kw.expect("heating capacity");
let power = perf.compressor_power_kw.expect("power input");
let cop = perf.cop.expect("COP");
assert!(q_cool > 0.0 && power > 0.0 && cop > 1.0 && cop < 15.0);
assert!(
(q_heat - (q_cool + power)).abs() < 1e-3 * q_heat.max(1.0),
"First Law must close with the slide-valve actuator active"
);
// Evaporating pressure from the solved refrigerant edges (lowest edge pressure).
// Only the first 4 edges are refrigerant; the rest are secondary water/air loop.
let base_state = result.state.expect("state");
let base_p_evap = base_state
.iter()
.take(4)
.map(|e| e.pressure_bar)
.fold(f64::MAX, f64::min);
// R134a saturation at 3 °C ≈ 3.26 bar → the slide must hold the SST setpoint.
assert!(
(base_p_evap - 3.26).abs() < 0.4,
"slide must hold SST ≈ 3 °C (P_evap ≈ 3.26 bar), got {base_p_evap} bar"
);
// Raise the SST target 3 → 6 °C: the slide unloads and the emergent
// evaporating pressure must rise (genuine setpoint tracking).
let base_json = std::fs::read_to_string(&example).unwrap();
let warmer = base_json.replace(
"\"slide_valve_sst_target_c\": 3.0",
"\"slide_valve_sst_target_c\": 6.0",
);
assert_ne!(base_json, warmer, "target must be patched");
let alt = simulate_from_json(&warmer).unwrap();
if alt.status == SimulationStatus::Converged {
let alt_state = alt.state.expect("alt state");
let alt_p_evap = alt_state
.iter()
.take(4)
.map(|e| e.pressure_bar)
.fold(f64::MAX, f64::min);
assert!(
alt_p_evap > base_p_evap + 0.2,
"higher SST setpoint must raise P_evap: {alt_p_evap} !> {base_p_evap}"
);
}
}
/// Integration: the compressor liquid-injection actuator (arch-6) must be driven
/// by a declared `controls[]` loop that holds the discharge gas temperature (DGT)
/// at a maximum limit. Enabling the limiter (lower target) must genuinely
/// desuperheat the discharge stream (lower comp-outlet enthalpy) versus a run
/// where the limit is inactive, while the DoF stays balanced (converges) and the
/// reported electrical power / COP stay physical (the shaft work must NOT collapse
/// to the desuperheated edge enthalpy).
#[test]
fn test_liquid_injection_actuator_desuperheats_discharge() {
use entropyk_cli::run::{run_simulation, simulate_from_json};
let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples/chiller_r134a_liquid_injection.json");
if !example.exists() {
panic!("Test fixture missing: {}", example.display());
}
let result = run_simulation(&example, None, false).unwrap();
if result.status != SimulationStatus::Converged {
return; // CoolProp may be unavailable in some environments.
}
// Physical performance contract: injection must not deflate the electrical
// power. A collapsed power (≈0) would signal the shaft work was mistakenly
// taken from the desuperheated edge enthalpy.
let perf = result
.performance
.expect("converged injection cycle must report performance");
let q_cool = perf.q_cooling_kw.expect("cooling capacity");
let power = perf.compressor_power_kw.expect("power input");
let cop = perf.cop.expect("COP");
assert!(q_cool > 0.0, "cooling capacity must be positive: {q_cool}");
assert!(
power > 0.5,
"electrical power must stay physical with injection, got {power} kW"
);
assert!(cop > 1.0 && cop < 12.0, "COP must be physical: {cop}");
// Comp-outlet (edge 0) enthalpy with the active DGT limiter.
let base_state = result.state.expect("state");
let h_dis_limited = base_state[0].enthalpy_kj_kg;
// Raise the DGT target far above the natural discharge temperature so the
// limiter never engages (injection ≈ off): the discharge enthalpy must then
// be HIGHER (no desuperheat). This proves the injection genuinely acts.
let base_json = std::fs::read_to_string(&example).unwrap();
// The fixture's DGT limit (330 K) sits below the cycle's natural discharge
// temperature, so the limiter genuinely binds in the base run.
let no_inj = base_json.replace("\"target\": 330.0", "\"target\": 500.0");
assert_ne!(base_json, no_inj, "target must be patched");
let alt = simulate_from_json(&no_inj).unwrap();
if alt.status == SimulationStatus::Converged {
let alt_state = alt.state.expect("alt state");
let h_dis_natural = alt_state[0].enthalpy_kj_kg;
assert!(
h_dis_natural > h_dis_limited + 5.0,
"active DGT limiter must desuperheat the discharge: \
limited {h_dis_limited} !< natural {h_dis_natural} kJ/kg"
);
}
}
/// Integration: the `rate` command must re-solve the emergent chiller at four
/// standardized part-load points and integrate a genuine IPLV. Also verifies the
/// coupled physics: as load drops (colder condenser water + reduced compressor
/// speed), the pressure lift falls and the per-point EER increases monotonically.
#[test]
fn test_rate_command_computes_iplv_and_part_load_eers() {
use entropyk_cli::rate::run_rate;
let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples/rate_chiller_iplv_ahri.json");
if !example.exists() {
panic!("Test fixture missing: {}", example.display());
}
let report = run_rate(&example, None).expect("rate command must run");
assert_eq!(report.metric, "AHRI 550/590 IPLV");
assert_eq!(report.points.len(), 4, "four part-load points expected");
// CoolProp may be unavailable in some build environments; only assert the
// physics contract when every point actually converged.
if report.points.iter().any(|p| p.status != "converged") {
return;
}
// Points are ordered by descending load fraction (100/75/50/25 %).
let eers: Vec<f64> = report
.points
.iter()
.map(|p| p.eer.expect("converged point must report EER"))
.collect();
for e in &eers {
assert!(*e > 1.0 && *e < 20.0, "EER must be physical: {e}");
}
// Colder condenser water + lower speed at part load ⇒ smaller lift ⇒ higher EER.
assert!(
eers[0] < eers[1] && eers[1] < eers[2] && eers[2] < eers[3],
"part-load EER must increase as load drops: {eers:?}"
);
let iplv = report
.integrated_value
.expect("IPLV must be integrated when all points converge");
// IPLV is the AHRI-weighted average, so it must lie within the EER range.
let min = eers.iter().cloned().fold(f64::INFINITY, f64::min);
let max = eers.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
assert!(
iplv >= min && iplv <= max,
"IPLV {iplv} must lie within per-point EER range [{min}, {max}]"
);
}
/// End-to-end SCOP (EN 14825 bin method): the air-source heat pump example is
/// re-solved at every climate bin. As the outdoor temperature rises the evaporator
/// warms, the pressure lift shrinks and the full-load COP increases monotonically;
/// the aggregated SCOP must be a physical value bounded by the per-bin COPs.
#[test]
fn test_scop_command_computes_seasonal_cop_over_bins() {
use entropyk_cli::seasonal::{run_seasonal, SeasonalMode};
let example =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/scop_heatpump_r134a.json");
if !example.exists() {
panic!("Test fixture missing: {}", example.display());
}
let report = run_seasonal(&example, None, SeasonalMode::Scop).expect("scop command must run");
assert_eq!(report.metric, "SCOP");
assert_eq!(
report.total_hours, 4910.0,
"EN 14825 average season total hours"
);
assert_eq!(report.bins.len(), 26, "EN 14825 average has 26 bins");
// CoolProp may be unavailable in some build environments; only assert the
// physics contract when every demanded bin actually converged.
if report
.bins
.iter()
.any(|b| b.demand_w > 0.0 && b.full_cop.is_none())
{
return;
}
// Full-load COP must rise monotonically as the outdoor (evaporator) air warms.
let cops: Vec<f64> = report.bins.iter().filter_map(|b| b.full_cop).collect();
for w in cops.windows(2) {
assert!(
w[1] > w[0],
"full-load COP must increase as outdoor temp rises: {cops:?}"
);
}
let scop = report
.integrated_value
.expect("SCOP must be integrated when all demanded bins converge");
let min = cops.iter().cloned().fold(f64::INFINITY, f64::min);
let max = cops.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
// The seasonal COP is an energy-weighted average degraded by cycling and
// backup, so it must not exceed the best full-load COP.
assert!(
scop > 1.0 && scop <= max,
"SCOP {scop} must be physical and <= best full COP {max}"
);
assert!(
scop > min * 0.5,
"SCOP {scop} unreasonably low vs min COP {min}"
);
}
/// spec-cli-failure-diagnostics.md AC3: Given a structural invalid system that fails
/// before any solver iterations (e.g. empty system), the JSON serializes successfully
/// with diagnostics absent and no panic.
#[test]
fn test_structural_failure_serializes_without_diagnostics() {
use entropyk_cli::run::{SimulationResult, SimulationStatus};
// A result representing an InvalidSystem failure (no iterations occurred).
let result = SimulationResult {
input: "empty.json".to_string(),
status: SimulationStatus::Error,
convergence: None,
iterations: None,
state: None,
performance: None,
error: Some("Invalid system: Empty system has no state variables or equations".to_string()),
failure_diagnostics: None, // no diagnostics for structural failure
initialization_diagnostics: None,
dof: None,
elapsed_ms: 0,
};
let json = serde_json::to_string(&result).unwrap();
// Must serialize successfully (no panic).
assert!(json.contains("\"status\":\"error\"") || json.contains("\"status\": \"error\""));
assert!(json.contains("Empty system"));
// `failure_diagnostics` key must be absent when None (skip_serializing_if).
assert!(
!json.contains("failure_diagnostics"),
"failure_diagnostics must be omitted when None (backward-compatible)"
);
}
/// spec-cli-failure-diagnostics.md AC4: `simple_working.json` success path is
/// backward-compatible — `failure_diagnostics` is absent from successful JSON output.
#[test]
fn test_success_result_does_not_include_failure_diagnostics() {
use entropyk_cli::run::{ConvergenceInfo, SimulationResult, SimulationStatus, StateEntry};
let result = SimulationResult {
input: "simple_working.json".to_string(),
status: SimulationStatus::Converged,
convergence: Some(ConvergenceInfo {
final_residual: 1e-8,
tolerance: 1e-6,
iterations: None,
strategy: None,
iteration_history: vec![],
}),
iterations: Some(12),
state: Some(vec![StateEntry {
edge: 0,
pressure_bar: 10.0,
enthalpy_kj_kg: 420.0,
..Default::default()
}]),
performance: None,
error: None,
failure_diagnostics: None,
initialization_diagnostics: None,
dof: None,
elapsed_ms: 120,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"converged\"") || json.contains("converged"));
assert!(
!json.contains("failure_diagnostics"),
"Successful result must not include failure_diagnostics (backward-compatible)"
);
}
/// Real evaporator-outlet superheat [K] from solved edge states, using the same
/// definition the controller measures: `SH = T(P_out, h_out) T_sat(P_out)`.
/// The suction line is the lowest-pressure edge carrying the highest enthalpy.
fn evap_outlet_superheat_k(state: &[entropyk_cli::run::StateEntry], fluid: &str) -> f64 {
use entropyk_core::{Enthalpy, Pressure};
use entropyk_fluids::{CoolPropBackend, FluidBackend, FluidId, FluidState, Property, Quality};
// Only consider refrigerant edges (first 4); secondary water/air loop edges
// sit at lower pressures and would corrupt the suction-edge search.
let p_min = state
.iter()
.take(4)
.map(|e| e.pressure_bar)
.fold(f64::MAX, f64::min);
let suction = state
.iter()
.take(4)
.filter(|e| (e.pressure_bar - p_min).abs() < 0.1)
.max_by(|a, b| a.enthalpy_kj_kg.partial_cmp(&b.enthalpy_kj_kg).unwrap())
.expect("suction (evaporator outlet) edge");
let backend = CoolPropBackend::new();
let id = FluidId::new(fluid);
let p = Pressure::from_bar(suction.pressure_bar);
let h = Enthalpy::from_joules_per_kg(suction.enthalpy_kj_kg * 1000.0);
let t = backend
.property(
id.clone(),
Property::Temperature,
FluidState::PressureEnthalpy(p, h),
)
.expect("T(P,h)");
let t_sat = backend
.property(
id,
Property::Temperature,
FluidState::PressureQuality(p, Quality(1.0)),
)
.expect("T_sat(P)");
t - t_sat
}
/// Integration (p0b): the evaporator superheat is REGULATED by the expansion-valve
/// opening through a co-solved saturated-control loop. The evaporator runs in
/// regulated-superheat mode (its outlet-closure is dropped), so superheat emerges
/// from the ε-NTU energy balance and the loop drives the EXV opening to the target.
/// Changing the target must move the solved superheat toward it, shift the operating
/// point (opening moved), keep the DoF balanced (converges), and close the First Law.
#[test]
fn test_superheat_control_regulates_via_exv_opening() {
use entropyk_cli::run::{run_simulation, simulate_from_json};
let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples/chiller_r134a_superheat_control.json");
if !example.exists() {
panic!("Test fixture missing: {}", example.display());
}
let result = run_simulation(&example, None, false).unwrap();
if result.status != SimulationStatus::Converged {
return; // CoolProp may be unavailable in some environments.
}
// First Law must close with the superheat-control loop active.
let perf = result
.performance
.expect("converged superheat-control cycle must report performance");
let q_cool = perf.q_cooling_kw.expect("cooling capacity");
let q_heat = perf.q_heating_kw.expect("heating capacity");
let power = perf.compressor_power_kw.expect("power input");
let cop = perf.cop.expect("COP");
assert!(q_cool > 0.0 && power > 0.0 && cop > 1.0 && cop < 15.0);
assert!(
(q_heat - (q_cool + power)).abs() < 1e-3 * q_heat.max(1.0),
"First Law must close with the superheat actuator active"
);
// Target 5 K: the solved superheat must track it (proportional loop tolerance).
let base_state = result.state.expect("state");
let sh_5 = evap_outlet_superheat_k(&base_state, "R134a");
assert!(
(sh_5 - 5.0).abs() < 0.5,
"superheat should track the 5 K target, got {sh_5:.3} K"
);
let p_evap_5 = base_state
.iter()
.take(4)
.map(|e| e.pressure_bar)
.fold(f64::MAX, f64::min);
// Raise the target to 8 K: the loop must drive a genuinely higher superheat and
// a different operating point (the EXV opening moved).
let base_json = std::fs::read_to_string(&example).unwrap();
let hotter = base_json.replace("\"target\": 5.0", "\"target\": 8.0");
assert_ne!(base_json, hotter, "superheat target must be patched");
let alt = simulate_from_json(&hotter).unwrap();
if alt.status == SimulationStatus::Converged {
let alt_state = alt.state.expect("alt state");
let sh_8 = evap_outlet_superheat_k(&alt_state, "R134a");
assert!(
(sh_8 - 8.0).abs() < 0.5,
"superheat should track the 8 K target, got {sh_8:.3} K"
);
assert!(
sh_8 > sh_5 + 1.0,
"a higher superheat target must raise the solved superheat: {sh_8:.3} !> {sh_5:.3}"
);
let p_evap_8 = alt_state
.iter()
.take(4)
.map(|e| e.pressure_bar)
.fold(f64::MAX, f64::min);
assert!(
(p_evap_8 - p_evap_5).abs() > 1e-3,
"regulating a different superheat must shift the operating point (opening moved): \
P_evap {p_evap_8:.4} vs {p_evap_5:.4} bar"
);
}
}