Files
Entropyk/crates/cli/tests/single_run.rs

655 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Tests for single simulation execution.
use entropyk_cli::error::ExitCode;
use entropyk_cli::run::{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: Some(25),
state: Some(vec![entropyk_cli::run::StateEntry {
edge: 0,
pressure_bar: 10.0,
enthalpy_kj_kg: 400.0,
}]),
performance: None,
error: 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()),
elapsed_ms: 0,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("Configuration error"));
}
#[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),
}
}
/// 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,
screw_economizer_compressor::{ScrewEconomizerCompressor, ScrewPerformanceCurves},
port::{FluidId, Port},
};
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 entropyk_components::heat_exchanger::MchxCondenserCoil;
use approx::assert_relative_eq;
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
);
}
}