823 lines
27 KiB
Rust
823 lines
27 KiB
Rust
//! 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,
|
||
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.
|
||
}
|
||
}
|