//! 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 (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. } }