//! 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 (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 = 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 = 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" ); } }