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>
2469 lines
97 KiB
Rust
2469 lines
97 KiB
Rust
//! 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"
|
||
);
|
||
}
|
||
}
|