//! Single simulation execution module. //! //! Handles loading a configuration, running a simulation, and outputting results. use std::path::Path; use std::sync::Arc; use serde::{Deserialize, Serialize}; use tracing::info; use crate::config::ScenarioConfig; use crate::error::{CliError, CliResult}; /// Result of a single simulation run. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SimulationResult { /// Input configuration name or path. pub input: String, /// Simulation status. pub status: SimulationStatus, /// Convergence information. pub convergence: Option, /// Solver iterations. pub iterations: Option, /// Final state vector (P, h per edge). pub state: Option>, /// Performance metrics. pub performance: Option, /// Error message if failed. pub error: Option, /// Execution time in milliseconds. pub elapsed_ms: u64, } /// Performance metrics from simulation. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PerformanceMetrics { /// Cooling capacity in kW. pub q_cooling_kw: Option, /// Heating capacity in kW. pub q_heating_kw: Option, /// Compressor power in kW. pub compressor_power_kw: Option, /// Coefficient of performance. pub cop: Option, } /// Simulation status. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum SimulationStatus { Converged, Timeout, NonConverged, Error, } /// Convergence information. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConvergenceInfo { /// Final residual norm. pub final_residual: f64, /// Convergence tolerance achieved. pub tolerance: f64, } /// State entry for one edge. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StateEntry { /// Edge index. pub edge: usize, /// Pressure in bar. pub pressure_bar: f64, /// Enthalpy in kJ/kg. pub enthalpy_kj_kg: f64, } /// Run a single simulation from a configuration file. pub fn run_simulation( config_path: &Path, output_path: Option<&Path>, verbose: bool, ) -> CliResult { let start = std::time::Instant::now(); let input_name = config_path.display().to_string(); if verbose { info!("Loading configuration from: {}", config_path.display()); } let config = ScenarioConfig::from_file(config_path)?; if verbose { info!("Scenario: {:?}", config.name); info!("Primary fluid: {}", config.fluid); info!("Circuits: {}", config.circuits.len()); info!("Thermal couplings: {}", config.thermal_couplings.len()); info!("Solver: {}", config.solver.strategy); } let result = execute_simulation(&config, &input_name, start.elapsed().as_millis() as u64); if let Some(ref path) = output_path { let json = serde_json::to_string_pretty(&result) .map_err(|e| CliError::Simulation(format!("Failed to serialize result: {}", e)))?; std::fs::write(path, json)?; if verbose { info!("Results written to: {}", path.display()); } } Ok(result) } /// Execute the simulation with the given configuration. fn execute_simulation( config: &ScenarioConfig, input_name: &str, elapsed_ms: u64, ) -> SimulationResult { use entropyk::{ ConvergenceStatus, FallbackSolver, FluidId, NewtonConfig, PicardConfig, Solver, SolverStrategy, System, ThermalConductance, }; use entropyk_fluids::TestBackend; use entropyk_solver::{CircuitId, ThermalCoupling}; use std::collections::HashMap; let fluid_id = FluidId::new(&config.fluid); let backend: Arc = match config.fluid_backend.as_deref() { Some("CoolProp") => Arc::new(entropyk_fluids::CoolPropBackend::new()), Some("Test") | None => Arc::new(TestBackend::new()), Some(other) => { return SimulationResult { input: input_name.to_string(), status: SimulationStatus::Error, convergence: None, iterations: None, state: None, performance: None, error: Some(format!( "Unknown fluid backend: '{}'. Supported: 'CoolProp', 'Test'", other )), elapsed_ms, }; } }; let mut system = System::new(); // Track component name -> (node index, component type) mapping per circuit // The component type is needed for port-name-to-index resolution (Task 3.3) let mut component_indices: HashMap = HashMap::new(); // Collect variables and constraints to add *after* components are added struct PendingControl { component_node: petgraph::graph::NodeIndex, control_type: String, min: f64, max: f64, initial: f64, } let mut pending_controls = Vec::new(); for circuit_config in &config.circuits { let circuit_id = CircuitId(circuit_config.id as u16); // Pre-process components to expand banks let mut expanded_components = Vec::new(); for component_config in &circuit_config.components { if let Some(bank_config) = &component_config.condenser_bank { // Expand MCHX condenser bank into multiple coils for c in 0..bank_config.circuits { for i in 0..bank_config.coils_per_circuit { let mut expanded = component_config.clone(); // Clear the bank config to avoid infinite recursion logically expanded.condenser_bank = None; // Set the specific coil index let coil_index = c * bank_config.coils_per_circuit + i; expanded.params.insert( "coil_index".to_string(), serde_json::Value::Number(coil_index.into()), ); // Modify the name (e.g., mchx_0a, mchx_0b for circuit 0, coils a, b) let letter = (b'a' + (i as u8)) as char; expanded.name = format!("{}_{}{}", component_config.name, c, letter); expanded_components.push(expanded); } } } else { expanded_components.push(component_config.clone()); } } for component_config in &expanded_components { match create_component(&component_config, &fluid_id, Arc::clone(&backend)) { Ok(component) => match system.add_component_to_circuit(component, circuit_id) { Ok(node_id) => { component_indices.insert( component_config.name.clone(), (node_id, component_config.component_type.clone()), ); // Check if this component needs explicit fan control if let Some(fan_control) = component_config .params .get("fan_control") .and_then(|v| v.as_str()) { if fan_control == "bounded" { let min = component_config .params .get("fan_speed_min") .and_then(|v| v.as_f64()) .unwrap_or(0.1); let max = component_config .params .get("fan_speed_max") .and_then(|v| v.as_f64()) .unwrap_or(1.0); let initial = component_config .fan_speed .or_else(|| { component_config .params .get("fan_speed") .and_then(|v| v.as_f64()) }) .unwrap_or(1.0); pending_controls.push(PendingControl { component_node: node_id, control_type: "fan_speed".to_string(), min, max, initial, }); } } // Register component name for constraint validation system.register_component_name(&component_config.name, node_id); } Err(e) => { return SimulationResult { input: input_name.to_string(), status: SimulationStatus::Error, convergence: None, iterations: None, state: None, performance: None, error: Some(format!( "Failed to add component '{}': {:?}", component_config.name, e )), elapsed_ms, }; } }, Err(e) => { return SimulationResult { input: input_name.to_string(), status: SimulationStatus::Error, convergence: None, iterations: None, state: None, performance: None, error: Some(format!( "Failed to create component '{}': {}", component_config.name, e )), elapsed_ms, }; } } } } // Add edges between components (Task 3.3: port-aware edge routing) // Port specifications (e.g., "screw_0:economizer") are resolved to port indices. // For components with ports, add_edge_with_ports() is used to allow multi-port routing. // Unknown port names default to index 0 (inlet) or 1 (outlet) with a warning. for circuit_config in &config.circuits { for edge in &circuit_config.edges { let from_parts: Vec<&str> = edge.from.split(':').collect(); let to_parts: Vec<&str> = edge.to.split(':').collect(); let from_name = from_parts.get(0).copied().unwrap_or(""); let to_name = to_parts.get(0).copied().unwrap_or(""); let from_port_name = from_parts.get(1).copied(); let to_port_name = to_parts.get(1).copied(); let from_entry = component_indices.get(from_name); let to_entry = component_indices.get(to_name); match (from_entry, to_entry) { (Some((from_node, from_type)), Some((to_node, to_type))) => { // Resolve port names to indices for port-aware routing let from_port_idx = from_port_name .map(|p| resolve_port_index(from_type, p, true)) .unwrap_or(1); // default: outlet = port 1 let to_port_idx = to_port_name .map(|p| resolve_port_index(to_type, p, false)) .unwrap_or(0); // default: inlet = port 0 let add_result = system .add_edge_with_ports(*from_node, from_port_idx, *to_node, to_port_idx) .map_err(|e| format!("{:?}", e)); if let Err(e) = add_result { // Fallback: try without port validation if port counts don't match // (allows portless components like Placeholder to connect freely) tracing::warn!( from = %edge.from, to = %edge.to, error = %e, "add_edge_with_ports failed — falling back to portless add_edge" ); if let Err(fallback_err) = system.add_edge(*from_node, *to_node) { return SimulationResult { input: input_name.to_string(), status: SimulationStatus::Error, convergence: None, iterations: None, state: None, performance: None, error: Some(format!( "Failed to add edge '{} -> {}': {} (fallback: {:?})", edge.from, edge.to, e, fallback_err )), elapsed_ms, }; } } } _ => { return SimulationResult { input: input_name.to_string(), status: SimulationStatus::Error, convergence: None, iterations: None, state: None, performance: None, error: Some(format!( "Edge references unknown component: '{}' or '{}'", from_name, to_name )), elapsed_ms, }; } } } } for coupling_config in &config.thermal_couplings { let coupling = ThermalCoupling::new( CircuitId(coupling_config.hot_circuit as u16), CircuitId(coupling_config.cold_circuit as u16), ThermalConductance::from_watts_per_kelvin(coupling_config.ua), ) .with_efficiency(coupling_config.efficiency); if let Err(e) = system.add_thermal_coupling(coupling) { return SimulationResult { input: input_name.to_string(), status: SimulationStatus::Error, convergence: None, iterations: None, state: None, performance: None, error: Some(format!("Failed to add thermal coupling: {:?}", e)), elapsed_ms, }; } } if let Err(e) = system.finalize() { return SimulationResult { input: input_name.to_string(), status: SimulationStatus::Error, convergence: None, iterations: None, state: None, performance: None, error: Some(format!("System finalization failed: {:?}", e)), elapsed_ms, }; } // Add variables and constraints for control in pending_controls { if control.control_type == "fan_speed" { use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId}; // Generate unique IDs let var_id = BoundedVariableId::new(format!("fan_speed_var_{}", control.component_node.index())); // Find the component's generated name to use in BoundedVariable let mut comp_name = String::new(); for (name, (node, _)) in &component_indices { if *node == control.component_node { comp_name = name.clone(); break; } } let var = BoundedVariable::with_component( var_id, &comp_name, control.initial, control.min, control.max, ); if let Ok(var) = var { if let Err(e) = system.add_bounded_variable(var) { tracing::warn!("Failed to add fan_speed variable: {:?}", e); } } } } let result = match config.solver.strategy.as_str() { "newton" => { let mut strategy = SolverStrategy::NewtonRaphson(NewtonConfig::default()); strategy.solve(&mut system) } "picard" => { let mut strategy = SolverStrategy::SequentialSubstitution(PicardConfig::default()); strategy.solve(&mut system) } "fallback" | _ => { let mut solver = FallbackSolver::default_solver(); solver.solve(&mut system) } }; match result { Ok(converged) => { let status = match converged.status { ConvergenceStatus::Converged => SimulationStatus::Converged, ConvergenceStatus::TimedOutWithBestState => SimulationStatus::Timeout, ConvergenceStatus::ControlSaturation => SimulationStatus::NonConverged, }; let state = extract_state(&converged); SimulationResult { input: input_name.to_string(), status, convergence: Some(ConvergenceInfo { final_residual: converged.final_residual, tolerance: config.solver.tolerance, }), iterations: Some(converged.iterations), state: Some(state), performance: None, error: None, elapsed_ms, } } Err(e) => { let e_str = format!("{:?}", e); let error_msg = if e_str.contains("FluidError") || e_str.contains("backend") || e_str.contains("CoolProp") { format!("Thermodynamic/Fluid error: {}", e_str) } else { format!("Solver error: {}", e_str) }; SimulationResult { input: input_name.to_string(), status: SimulationStatus::Error, convergence: None, iterations: None, state: None, performance: None, error: Some(error_msg), elapsed_ms, } } } } /// Resolves a port name string to a port index for the given component type. /// /// This enables named port connections in the edge JSON config, e.g.: /// ```json /// { "from": "screw_0:discharge", "to": "mchx_0:inlet" } /// ``` /// /// Port index conventions: /// - `ScrewEconomizerCompressor`: suction=0, discharge=1, economizer=2 /// - All other components: inlet=0, outlet=1 /// /// `is_source` is true when resolving the "from" side of an edge (outlet type), /// false when resolving the "to" side (inlet type). This affects the default fallback. fn resolve_port_index(component_type: &str, port_name: &str, is_source: bool) -> usize { let port_lower = port_name.to_lowercase(); match component_type { "ScrewEconomizerCompressor" | "ScrewCompressor" => match port_lower.as_str() { "suction" | "inlet" | "in" => 0, "discharge" | "outlet" | "out" => 1, "economizer" | "eco" | "economiser" | "flash_in" => 2, _ => { tracing::warn!( port_name, component_type, "Unknown port name for ScrewEconomizerCompressor, defaulting to {}", if is_source { 1 } else { 0 } ); if is_source { 1 } else { 0 } } }, // BphxEvaporator and BphxCondenser: 2-port refrigerant circuit (inlet=0, outlet=1). // Secondary-fluid conditions are set via JSON params, not graph edges. "BphxEvaporator" | "BphxCondenser" => match port_lower.as_str() { "inlet" | "in" | "refrigerant_in" => 0, "outlet" | "out" | "refrigerant_out" => 1, _ => { tracing::warn!( port_name, component_type, "Unknown port name for {}, defaulting to {}", component_type, if is_source { 1 } else { 0 } ); if is_source { 1 } else { 0 } } }, _ => { // Default: inlet=0, outlet=1 for all 2-port components match port_lower.as_str() { "inlet" | "in" | "suction" | "cold_in" | "hot_in" | "refrigerant_in" | "flash_in" => 0, "outlet" | "out" | "discharge" | "cold_out" | "hot_out" | "refrigerant_out" | "flash_out" => 1, _ => { tracing::warn!( port_name, component_type, "Unknown port name, defaulting to {}", if is_source { 1 } else { 0 } ); if is_source { 1 } else { 0 } } } } } } fn get_param_f64( params: &std::collections::HashMap, key: &str, ) -> CliResult { params .get(key) .and_then(|v| v.as_f64()) .ok_or_else(|| CliError::Config(format!("Missing required parameter: {}", key))) } fn get_param_string( params: &std::collections::HashMap, key: &str, ) -> CliResult { params .get(key) .and_then(|v| v.as_str()) .map(|s| s.to_string()) .ok_or_else(|| CliError::Config(format!("Missing required parameter: {}", key))) } fn parse_side_conditions( params: &std::collections::HashMap, prefix: &str, ) -> CliResult { use entropyk::{HxSideConditions, MassFlow, Pressure, Temperature}; let fluid = get_param_string(params, &format!("{}_fluid", prefix))?; let t_inlet_c = get_param_f64(params, &format!("{}_t_inlet_c", prefix))?; let pressure_bar = params .get(&format!("{}_pressure_bar", prefix)) .and_then(|v| v.as_f64()) .unwrap_or(1.0); let mass_flow = params .get(&format!("{}_mass_flow_kg_s", prefix)) .and_then(|v| v.as_f64()) .unwrap_or(0.1); Ok(HxSideConditions::new( Temperature::from_celsius(t_inlet_c), Pressure::from_bar(pressure_bar), MassFlow::from_kg_per_s(mass_flow), &fluid, )?) } /// Build BphxGeometry from JSON params: dh (m), area (m²), n_plates. Defaults: 0.003, 0.5, 20. /// /// Returns an error if any parameter is physically invalid (≤ 0). fn bphx_geometry_from_params( params: &std::collections::HashMap, exchanger_type: entropyk_components::heat_exchanger::BphxType, ) -> CliResult { use entropyk_components::heat_exchanger::BphxGeometry; let dh = params.get("dh_m").and_then(|v| v.as_f64()).unwrap_or(0.003); if dh <= 0.0 { return Err(CliError::Config(format!( "BphxGeometry: dh_m must be > 0 (got {:.6} m)", dh ))); } let area = params .get("area_m2") .and_then(|v| v.as_f64()) .unwrap_or(0.5); if area <= 0.0 { return Err(CliError::Config(format!( "BphxGeometry: area_m2 must be > 0 (got {:.4} m²)", area ))); } let n_plates_raw = params .get("n_plates") .and_then(|v| v.as_u64()) .unwrap_or(20); if n_plates_raw > u32::MAX as u64 { return Err(CliError::Config(format!( "BphxGeometry: n_plates too large (got {}, max {})", n_plates_raw, u32::MAX ))); } let n_plates = n_plates_raw as u32; if n_plates == 0 { return Err(CliError::Config( "BphxGeometry: n_plates must be > 0".into(), )); } Ok(BphxGeometry::from_dh_area(dh, area, n_plates).with_exchanger_type(exchanger_type)) } /// Extract calibration factors for BphxEvaporator/BphxCondenser from JSON params. /// /// Errors if `ua_nominal == 0` and an explicit `ua` override is provided (geometry is /// likely invalid). Warns if both `ua` and `f_ua` are provided simultaneously. fn bphx_calib_from_params( params: &std::collections::HashMap, ua_nominal: f64, ) -> CliResult { use entropyk_core::Calib; let config_ua = params.get("ua").and_then(|v| v.as_f64()); let explicit_f_ua = params.get("f_ua").and_then(|v| v.as_f64()); if config_ua.is_some() && explicit_f_ua.is_some() { tracing::warn!( "BphxExchanger: both 'ua' and 'f_ua' provided — 'ua' takes precedence, 'f_ua' ignored" ); } let f_ua = match config_ua { Some(u) => { if u < 0.0 { return Err(CliError::Config(format!( "BphxExchanger: ua must be >= 0 (got {:.2} W/K)", u ))); } if ua_nominal > 0.0 { u / ua_nominal } else { return Err(CliError::Config( "BphxExchanger: ua_nominal is zero — cannot compute f_ua from explicit 'ua' override. Check geometry parameters.".into(), )); } } None => explicit_f_ua.unwrap_or(1.0), }; if f_ua <= 0.0 { return Err(CliError::Config(format!( "BphxExchanger: f_ua must be > 0 (got {:.4})", f_ua ))); } let f_dp = params.get("f_dp").and_then(|v| v.as_f64()).unwrap_or(1.0); if f_dp <= 0.0 { return Err(CliError::Config(format!( "BphxExchanger: f_dp must be > 0 (got {:.4})", f_dp ))); } Ok(Calib { f_m: 1.0, f_dp, f_ua, f_power: 1.0, f_etav: 1.0, calibration_source: None, }) } /// Creates a pair of connected ports for components that need them (screw, MCHX, fan...). /// /// Ports are initialised at the given pressure and enthalpy. Both ports are connected /// to each other — the first port is returned as the `ConnectedPort`. fn make_connected_port(fluid: &str, p_bar: f64, h_kj_kg: f64) -> entropyk::ConnectedPort { use entropyk::{ComponentFluidId, Enthalpy, Port, Pressure}; let a = Port::new( ComponentFluidId::new(fluid), Pressure::from_bar(p_bar), Enthalpy::from_joules_per_kg(h_kj_kg * 1000.0), ); let b = Port::new( ComponentFluidId::new(fluid), Pressure::from_bar(p_bar), Enthalpy::from_joules_per_kg(h_kj_kg * 1000.0), ); a.connect(b).expect("port connection ok").0 } /// Create a component from configuration. fn create_component( component_config: &crate::config::ComponentConfig, _primary_fluid: &entropyk::FluidId, backend: Arc, ) -> CliResult> { use entropyk::{Condenser, CondenserCoil, Evaporator, EvaporatorCoil, HeatExchanger}; use entropyk_components::heat_exchanger::{FlowConfiguration, LmtdModel}; let params = &component_config.params; let component_type = component_config.component_type.as_str(); match component_type { // ── NEW: ScrewEconomizerCompressor ───────────────────────────────────── "ScrewEconomizerCompressor" | "ScrewCompressor" => { use entropyk::{Polynomial2D, ScrewEconomizerCompressor, ScrewPerformanceCurves}; let fluid = params .get("fluid") .and_then(|v| v.as_str()) .unwrap_or_else(|| _primary_fluid.as_str()); let nominal_freq = params .get("nominal_frequency_hz") .and_then(|v| v.as_f64()) .unwrap_or(50.0); let eta_mech = params .get("mechanical_efficiency") .and_then(|v| v.as_f64()) .unwrap_or(0.92); // Economizer fraction (default 12%) let eco_frac_param = params .get("economizer_fraction") .and_then(|v| v.as_f64()); // Task 3.4: Built-in manufacturer curve presets. // Presets set default polynomial coefficients; explicit params override them. // Available: "bitzer_generic_200kw", "grasso_generic_200kw" let preset = params.get("preset").and_then(|v| v.as_str()).unwrap_or(""); let ( preset_mf_a00, preset_mf_a10, preset_mf_a01, preset_mf_a11, preset_pw_b00, preset_pw_b10, preset_pw_b01, preset_pw_b11, preset_eco_frac, ) = match preset { "bitzer_generic_200kw" => { // Bitzer screw ~200 kW R134a, SST=-5..+10°C, SDT=+35..+55°C // ṁ_suc [kg/s] ≈ 1.35 + 0.004·SST - 0.0025·SDT + 0.000012·SST·SDT // W_shaft [W] ≈ 58000 + 180·SST - 280·SDT + 0.4·SST·SDT (1.35_f64, 0.004, -0.0025, 0.000_012, 58_000.0, 180.0, -280.0, 0.4, 0.13) } "grasso_generic_200kw" => { // Grasso screw ~200 kW R134a, SST=-5..+10°C, SDT=+35..+55°C // Similar range, slightly different power curve (1.30_f64, 0.0035, -0.0022, 0.000_010, 60_000.0, 190.0, -310.0, 0.45, 0.11) } _ => { // Default values (no preset) (1.2_f64, 0.003, -0.002, 1e-5, 55_000.0, 200.0, -300.0, 0.5, 0.12) } }; // Mass-flow polynomial coefficients (bilinear SST/SDT) // Explicit params override preset defaults let mf_a00 = params.get("mf_a00").and_then(|v| v.as_f64()).unwrap_or(preset_mf_a00); let mf_a10 = params.get("mf_a10").and_then(|v| v.as_f64()).unwrap_or(preset_mf_a10); let mf_a01 = params.get("mf_a01").and_then(|v| v.as_f64()).unwrap_or(preset_mf_a01); let mf_a11 = params.get("mf_a11").and_then(|v| v.as_f64()).unwrap_or(preset_mf_a11); // Power polynomial coefficients (bilinear) let pw_b00 = params.get("pw_b00").and_then(|v| v.as_f64()).unwrap_or(preset_pw_b00); let pw_b10 = params.get("pw_b10").and_then(|v| v.as_f64()).unwrap_or(preset_pw_b10); let pw_b01 = params.get("pw_b01").and_then(|v| v.as_f64()).unwrap_or(preset_pw_b01); let pw_b11 = params.get("pw_b11").and_then(|v| v.as_f64()).unwrap_or(preset_pw_b11); // Use preset eco fraction if not specified explicitly let eco_frac = eco_frac_param.unwrap_or(preset_eco_frac); let curves = ScrewPerformanceCurves::with_fixed_eco_fraction( Polynomial2D::bilinear(mf_a00, mf_a10, mf_a01, mf_a11), Polynomial2D::bilinear(pw_b00, pw_b10, pw_b01, pw_b11), eco_frac, ); // Initial port conditions — use typical chiller values as defaults let p_suc = params.get("p_suction_bar").and_then(|v| v.as_f64()).unwrap_or(3.2); let h_suc = params.get("h_suction_kj_kg").and_then(|v| v.as_f64()).unwrap_or(400.0); let p_dis = params.get("p_discharge_bar").and_then(|v| v.as_f64()).unwrap_or(12.8); let h_dis = params.get("h_discharge_kj_kg").and_then(|v| v.as_f64()).unwrap_or(440.0); let p_eco = params.get("p_eco_bar").and_then(|v| v.as_f64()).unwrap_or(6.4); let h_eco = params.get("h_eco_kj_kg").and_then(|v| v.as_f64()).unwrap_or(260.0); let port_suc = make_connected_port(fluid, p_suc, h_suc); let port_dis = make_connected_port(fluid, p_dis, h_dis); let port_eco = make_connected_port(fluid, p_eco, h_eco); let mut comp = ScrewEconomizerCompressor::new( curves, fluid, nominal_freq, eta_mech, port_suc, port_dis, port_eco, ) .map_err(|e| CliError::Component(e))?; if let Some(freq_hz) = params.get("frequency_hz").and_then(|v| v.as_f64()) { comp.set_frequency_hz(freq_hz) .map_err(|e| CliError::Component(e))?; } Ok(Box::new(comp)) } // ── NEW: MchxCondenserCoil ───────────────────────────────────────────── "MchxCondenserCoil" | "MchxCoil" => { use entropyk::MchxCondenserCoil; // Optional explicit field vs fallback to params for backward compatibility let ua_kw_k = component_config.ua_nominal_kw_k.or_else(|| { params.get("ua_nominal_kw_k").and_then(|v| v.as_f64()) }).unwrap_or(15.0); // Safe fallback 15 kW/K let ua_w_k = ua_kw_k * 1000.0; let n_air = component_config.n_air_exponent.or_else(|| { params.get("n_air_exponent").and_then(|v| v.as_f64()) }).unwrap_or(0.5); // ASHRAE louvered-fin default let coil_index = params .get("coil_index") .and_then(|v| v.as_u64()) .unwrap_or(0) as usize; let t_air_c = component_config.air_inlet_temp_c.or_else(|| { params.get("air_inlet_temp_c").and_then(|v| v.as_f64()) }).unwrap_or(35.0); let fan_speed = component_config.fan_speed.or_else(|| { params.get("fan_speed").and_then(|v| v.as_f64()) }).unwrap_or(1.0); let mut coil = MchxCondenserCoil::new(ua_w_k, n_air, coil_index); coil.set_air_temperature_celsius(t_air_c); coil.set_fan_speed_ratio(fan_speed); Ok(Box::new(coil)) } // ── NEW: FloodedEvaporator ───────────────────────────────────────────── "FloodedEvaporator" => { use entropyk::FloodedEvaporator; let ua = get_param_f64(params, "ua")?; let target_quality = params .get("target_quality") .and_then(|v| v.as_f64()) .unwrap_or(0.7); let refrigerant = params .get("refrigerant") .and_then(|v| v.as_str()) .unwrap_or_else(|| _primary_fluid.as_str()); let secondary_fluid = params .get("secondary_fluid") .and_then(|v| v.as_str()) .unwrap_or("MEG"); let evap = FloodedEvaporator::new(ua) .with_target_quality(target_quality) .with_refrigerant(refrigerant) .with_secondary_fluid(secondary_fluid) .with_fluid_backend(Arc::clone(&backend)); Ok(Box::new(evap)) } "Condenser" | "CondenserCoil" => { let ua = get_param_f64(params, "ua")?; let t_sat_k = params.get("t_sat_k").and_then(|v| v.as_f64()); if let Some(t_sat) = t_sat_k { Ok(Box::new(CondenserCoil::with_saturation_temp(ua, t_sat))) } else { Ok(Box::new(Condenser::new(ua))) } } "Evaporator" | "EvaporatorCoil" => { let ua = get_param_f64(params, "ua")?; let t_sat_k = params.get("t_sat_k").and_then(|v| v.as_f64()); let superheat_k = params.get("superheat_k").and_then(|v| v.as_f64()); let default_superheat = 5.0; match (t_sat_k, superheat_k) { (Some(t_sat), Some(sh)) => Ok(Box::new(Evaporator::with_superheat(ua, t_sat, sh))), (Some(t_sat), None) => Ok(Box::new(EvaporatorCoil::with_superheat(ua, t_sat, default_superheat))), (None, _) => Ok(Box::new(Evaporator::new(ua))), } } "HeatExchanger" => { let ua = get_param_f64(params, "ua")?; let name = params .get("name") .and_then(|v| v.as_str()) .unwrap_or("HeatExchanger"); let model = LmtdModel::new(ua, FlowConfiguration::CounterFlow); let mut hx = HeatExchanger::new(model, name).with_fluid_backend(backend); if params.contains_key("hot_fluid") { let hot = parse_side_conditions(params, "hot")?; hx = hx.with_hot_conditions(hot); } if params.contains_key("cold_fluid") { let cold = parse_side_conditions(params, "cold")?; hx = hx.with_cold_conditions(cold); } Ok(Box::new(hx)) } "Compressor" => { use entropyk::{Ahri540Coefficients, Compressor, ComponentFluidId, Port}; use entropyk_core::{Enthalpy, Pressure}; let speed_rpm = get_param_f64(params, "speed_rpm")?; let displacement_m3 = get_param_f64(params, "displacement_m3")?; let efficiency = params .get("efficiency") .and_then(|v| v.as_f64()) .unwrap_or(0.85); let fluid = get_param_string(params, "fluid")?; // AHRI 540 coefficients (M1-M10) let m1 = params.get("m1").and_then(|v| v.as_f64()).unwrap_or(0.85); let m2 = params.get("m2").and_then(|v| v.as_f64()).unwrap_or(2.5); let m3 = params.get("m3").and_then(|v| v.as_f64()).unwrap_or(500.0); let m4 = params.get("m4").and_then(|v| v.as_f64()).unwrap_or(1500.0); let m5 = params.get("m5").and_then(|v| v.as_f64()).unwrap_or(-2.5); let m6 = params.get("m6").and_then(|v| v.as_f64()).unwrap_or(1.8); let m7 = params.get("m7").and_then(|v| v.as_f64()).unwrap_or(600.0); let m8 = params.get("m8").and_then(|v| v.as_f64()).unwrap_or(1600.0); let m9 = params.get("m9").and_then(|v| v.as_f64()).unwrap_or(-3.0); let m10 = params.get("m10").and_then(|v| v.as_f64()).unwrap_or(2.0); let coeffs = Ahri540Coefficients::new(m1, m2, m3, m4, m5, m6, m7, m8, m9, m10); // Initial port conditions (same pattern as ScrewCompressor) let p_suc = params.get("p_suction_bar").and_then(|v| v.as_f64()).unwrap_or(3.5); let h_suc = params.get("h_suction_kj_kg").and_then(|v| v.as_f64()).unwrap_or(400.0); let p_dis = params.get("p_discharge_bar").and_then(|v| v.as_f64()).unwrap_or(12.0); let h_dis = params.get("h_discharge_kj_kg").and_then(|v| v.as_f64()).unwrap_or(440.0); let fluid_id = ComponentFluidId::new(&fluid); // Create disconnected ports for building the compressor let suction_a = Port::new( fluid_id.clone(), Pressure::from_bar(p_suc), Enthalpy::from_joules_per_kg(h_suc * 1000.0), ); let discharge_a = Port::new( fluid_id.clone(), Pressure::from_bar(p_dis), Enthalpy::from_joules_per_kg(h_dis * 1000.0), ); // Build Compressor with AHRI 540 model let comp_disconnected = Compressor::new( coeffs, suction_a, discharge_a, speed_rpm, displacement_m3, efficiency, ) .map_err(|e| CliError::Component(e))?; // Connect ports to transition Disconnected → Connected let suction_b = Port::new( fluid_id.clone(), Pressure::from_bar(p_suc), Enthalpy::from_joules_per_kg(h_suc * 1000.0), ); let discharge_b = Port::new( fluid_id, Pressure::from_bar(p_dis), Enthalpy::from_joules_per_kg(h_dis * 1000.0), ); let comp = comp_disconnected.connect(suction_b, discharge_b) .map_err(|e| CliError::Component(e))?; Ok(Box::new(comp)) } "ExpansionValve" => { use entropyk::{ComponentFluidId, ExpansionValve, Port}; use entropyk_core::{Enthalpy, Pressure}; let fluid = get_param_string(params, "fluid")?; let opening = params.get("opening").and_then(|v| v.as_f64()).unwrap_or(1.0); // Initial port conditions let p_in = params.get("p_inlet_bar").and_then(|v| v.as_f64()).unwrap_or(12.0); let h_in = params.get("h_inlet_kj_kg").and_then(|v| v.as_f64()).unwrap_or(260.0); let p_out = params.get("p_outlet_bar").and_then(|v| v.as_f64()).unwrap_or(3.5); let h_out = params.get("h_outlet_kj_kg").and_then(|v| v.as_f64()).unwrap_or(260.0); let fluid_id = ComponentFluidId::new(&fluid); // Create disconnected ports let inlet_a = Port::new( fluid_id.clone(), Pressure::from_bar(p_in), Enthalpy::from_joules_per_kg(h_in * 1000.0), ); let outlet_a = Port::new( fluid_id.clone(), Pressure::from_bar(p_out), Enthalpy::from_joules_per_kg(h_out * 1000.0), ); // Build ExpansionValve with isenthalpic model let valve_disconnected = ExpansionValve::new(inlet_a, outlet_a, Some(opening)) .map_err(|e| CliError::Component(e))?; // Connect ports to transition Disconnected → Connected let inlet_b = Port::new( fluid_id.clone(), Pressure::from_bar(p_in), Enthalpy::from_joules_per_kg(h_in * 1000.0), ); let outlet_b = Port::new( fluid_id, Pressure::from_bar(p_out), Enthalpy::from_joules_per_kg(h_out * 1000.0), ); let valve = valve_disconnected.connect(inlet_b, outlet_b) .map_err(|e| CliError::Component(e))?; Ok(Box::new(valve)) } "RefrigerantSource" => { use entropyk::RefrigerantSource; use entropyk_core::{Pressure, VaporQuality}; let fluid = params .get("fluid") .and_then(|v| v.as_str()) .unwrap_or_else(|| _primary_fluid.as_str()); let p_set = params.get("p_set_bar").and_then(|v| v.as_f64()).unwrap_or(10.0); let q = params.get("quality").and_then(|v| v.as_f64()).unwrap_or(1.0); let outlet = make_connected_port(fluid, p_set, 250.0); let comp = RefrigerantSource::new( fluid, Pressure::from_bar(p_set), VaporQuality::from_fraction(q), backend, outlet, ) .map_err(CliError::Component)?; Ok(Box::new(comp)) } "RefrigerantSink" => { use entropyk::RefrigerantSink; use entropyk_core::{Pressure, VaporQuality}; let fluid = params .get("fluid") .and_then(|v| v.as_str()) .unwrap_or_else(|| _primary_fluid.as_str()); let p_back = params.get("p_back_bar").and_then(|v| v.as_f64()).unwrap_or(10.0); let q_opt = params .get("quality") .and_then(|v| v.as_f64()) .map(VaporQuality::from_fraction); let inlet = make_connected_port(fluid, p_back, 250.0); let comp = RefrigerantSink::new( fluid, Pressure::from_bar(p_back), q_opt, backend, inlet, ) .map_err(CliError::Component)?; Ok(Box::new(comp)) } "BrineSource" => { use entropyk::BrineSource; use entropyk_core::{Concentration, Pressure, Temperature}; let fluid = params.get("fluid").and_then(|v| v.as_str()).unwrap_or("Water"); let p_set = params.get("p_set_bar").and_then(|v| v.as_f64()).unwrap_or(2.0); let t_set = params.get("t_set_c").and_then(|v| v.as_f64()).unwrap_or(12.0); let conc = params.get("concentration").and_then(|v| v.as_f64()).unwrap_or(0.0); let outlet = make_connected_port(fluid, p_set, 100.0); let comp = BrineSource::new( fluid, Pressure::from_bar(p_set), Temperature::from_celsius(t_set), Concentration::from_percent(conc), backend, outlet, ) .map_err(CliError::Component)?; Ok(Box::new(comp)) } "BrineSink" => { use entropyk::BrineSink; use entropyk_core::{Concentration, Pressure, Temperature}; let fluid = params.get("fluid").and_then(|v| v.as_str()).unwrap_or("Water"); let p_back = params.get("p_back_bar").and_then(|v| v.as_f64()).unwrap_or(2.0); let t_opt = params .get("t_set_c") .and_then(|v| v.as_f64()) .map(Temperature::from_celsius); let conc = params.get("concentration").and_then(|v| v.as_f64()).unwrap_or(0.0); let conc_opt = if t_opt.is_some() { Some(Concentration::from_percent(conc)) } else { None }; let inlet = make_connected_port(fluid, p_back, 100.0); let comp = BrineSink::new( fluid, Pressure::from_bar(p_back), t_opt, conc_opt, backend, inlet, ) .map_err(CliError::Component)?; Ok(Box::new(comp)) } "AirSource" => { use entropyk::AirSource; use entropyk_core::{Pressure, RelativeHumidity, Temperature}; let p_set = params.get("p_set_bar").and_then(|v| v.as_f64()).unwrap_or(1.01325); let t_dry = params.get("t_dry_c").and_then(|v| v.as_f64()).unwrap_or(35.0); let rh = params.get("rh").and_then(|v| v.as_f64()).unwrap_or(50.0); let t_wet = params.get("t_wet_c").and_then(|v| v.as_f64()); let outlet = make_connected_port("Air", p_set, 50.0); let comp = if let Some(tw) = t_wet { AirSource::from_dry_and_wet_bulb( Temperature::from_celsius(t_dry), Temperature::from_celsius(tw), Pressure::from_bar(p_set), outlet, ) } else { AirSource::from_dry_bulb_rh( Temperature::from_celsius(t_dry), RelativeHumidity::from_percent(rh), Pressure::from_bar(p_set), outlet, ) } .map_err(CliError::Component)?; Ok(Box::new(comp)) } "AirSink" => { use entropyk::AirSink; use entropyk_core::{Pressure, RelativeHumidity, Temperature}; let p_back = params.get("p_back_bar").and_then(|v| v.as_f64()).unwrap_or(1.01325); let t_back = params.get("t_back_c").and_then(|v| v.as_f64()); let rh_back = params.get("rh_back").and_then(|v| v.as_f64()).unwrap_or(50.0); let inlet = make_connected_port("Air", p_back, 50.0); let mut comp = AirSink::new(Pressure::from_bar(p_back), inlet) .map_err(CliError::Component)?; if let Some(tb) = t_back { comp.set_return_temperature( Temperature::from_celsius(tb), RelativeHumidity::from_percent(rh_back), ) .map_err(CliError::Component)?; } Ok(Box::new(comp)) } "Pump" => { use entropyk::{ComponentFluidId, Pump, PumpCurves, Port}; use entropyk_core::{Enthalpy, Pressure}; let fluid = params .get("fluid") .and_then(|v| v.as_str()) .unwrap_or_else(|| _primary_fluid.as_str()); let fluid_density = params .get("fluid_density_kg_m3") .and_then(|v| v.as_f64()) .unwrap_or(1000.0); let speed_ratio = params .get("speed_ratio") .and_then(|v| v.as_f64()) .unwrap_or(1.0); let curves = if let (Some(h), Some(e)) = ( params.get("head_coeffs").and_then(|v| v.as_array()), params.get("eff_coeffs").and_then(|v| v.as_array()), ) { let head_coeffs: Vec = h.iter().filter_map(|v| v.as_f64()).collect(); let eff_coeffs: Vec = e.iter().filter_map(|v| v.as_f64()).collect(); if !head_coeffs.is_empty() && !eff_coeffs.is_empty() { PumpCurves::from_coefficients(head_coeffs, eff_coeffs).map_err(CliError::Component)? } else { PumpCurves::default() } } else { PumpCurves::default() }; let p_in = params .get("p_inlet_bar") .and_then(|v| v.as_f64()) .unwrap_or(2.0); let h_in = params .get("h_inlet_kj_kg") .and_then(|v| v.as_f64()) .unwrap_or(100.0); let p_out = params .get("p_outlet_bar") .and_then(|v| v.as_f64()) .unwrap_or(2.0); let h_out = params .get("h_outlet_kj_kg") .and_then(|v| v.as_f64()) .unwrap_or(100.0); let fluid_id = ComponentFluidId::new(fluid); let inlet_a = Port::new( fluid_id.clone(), Pressure::from_bar(p_in), Enthalpy::from_joules_per_kg(h_in * 1000.0), ); let outlet_a = Port::new( fluid_id.clone(), Pressure::from_bar(p_out), Enthalpy::from_joules_per_kg(h_out * 1000.0), ); let pump_disconnected = Pump::new(curves, inlet_a, outlet_a, fluid_density) .map_err(CliError::Component)?; let inlet_b = Port::new( fluid_id.clone(), Pressure::from_bar(p_in), Enthalpy::from_joules_per_kg(h_in * 1000.0), ); let outlet_b = Port::new( fluid_id, Pressure::from_bar(p_out), Enthalpy::from_joules_per_kg(h_out * 1000.0), ); let mut pump = pump_disconnected .connect(inlet_b, outlet_b) .map_err(CliError::Component)?; if (speed_ratio - 1.0).abs() > 1e-9 { pump.set_speed_ratio(speed_ratio) .map_err(CliError::Component)?; } Ok(Box::new(pump)) } // ── BphxEvaporator (brazed plate HX evaporator) ───────────────────────── "BphxEvaporator" => { use entropyk_components::heat_exchanger::{ BphxCorrelation, BphxEvaporator, BphxEvaporatorMode, BphxType, }; let geo = bphx_geometry_from_params(params, BphxType::Evaporator)?; let refrigerant = params .get("refrigerant") .and_then(|v| v.as_str()) .unwrap_or_else(|| _primary_fluid.as_str()); let secondary_fluid = params .get("secondary_fluid") .and_then(|v| v.as_str()) .unwrap_or("Water"); let mode_str = params .get("mode") .and_then(|v| v.as_str()) .unwrap_or("dx") .to_lowercase(); let mode = match mode_str.as_str() { "flooded" => { let target_quality = params .get("target_quality") .and_then(|v| v.as_f64()) .unwrap_or(0.7); if !(0.0..=1.0).contains(&target_quality) { return Err(CliError::Config(format!( "BphxEvaporator: target_quality must be in [0, 1] (got {:.4})", target_quality ))); } BphxEvaporatorMode::Flooded { target_quality } } other => { if other != "dx" { tracing::warn!( mode = other, "Unknown BphxEvaporator mode '{}', falling back to 'dx'", other ); } let target_superheat = params .get("target_superheat_k") .and_then(|v| v.as_f64()) .unwrap_or(5.0); if target_superheat < 0.0 { return Err(CliError::Config(format!( "BphxEvaporator: target_superheat_k must be >= 0 (got {:.2} K)", target_superheat ))); } BphxEvaporatorMode::Dx { target_superheat } } }; let correlation_str = params .get("correlation") .and_then(|v| v.as_str()) .unwrap_or("Longo2004") .to_lowercase(); let correlation = match correlation_str.as_str() { "shah1979" => BphxCorrelation::Shah1979, "shah2021" => BphxCorrelation::Shah2021, "longo2004" => BphxCorrelation::Longo2004, other => { tracing::warn!( correlation = other, "Unknown BphxEvaporator correlation '{}', falling back to Longo2004", other ); BphxCorrelation::Longo2004 } }; let mut evap = BphxEvaporator::new(geo) .with_mode(mode) .with_refrigerant(refrigerant) .with_secondary_fluid(secondary_fluid) .with_fluid_backend(Arc::clone(&backend)) .with_correlation(correlation); // Convention (Evaporator): hot_fluid = secondary (brine/water), cold_fluid = refrigerant. // The refrigerant evaporates (absorbs heat from the secondary). // Note: this is opposite to the Condenser convention — see BphxCondenser. if params.contains_key("hot_fluid") { let hot = parse_side_conditions(params, "hot")?; evap.set_secondary_conditions(hot); } if params.contains_key("cold_fluid") { let cold = parse_side_conditions(params, "cold")?; evap.set_refrigerant_conditions(cold); } evap.set_calib(bphx_calib_from_params(params, evap.ua())?); Ok(Box::new(evap)) } // ── BphxCondenser (brazed plate HX condenser) ─────────────────────────── "BphxCondenser" => { use entropyk_components::heat_exchanger::{ BphxCondenser, BphxCorrelation, BphxType, }; let geo = bphx_geometry_from_params(params, BphxType::Condenser)?; let refrigerant = params .get("refrigerant") .and_then(|v| v.as_str()) .unwrap_or_else(|| _primary_fluid.as_str()); let secondary_fluid = params .get("secondary_fluid") .and_then(|v| v.as_str()) .unwrap_or("Water"); let target_subcooling = params .get("target_subcooling_k") .and_then(|v| v.as_f64()) .unwrap_or(3.0); let correlation_str = params .get("correlation") .and_then(|v| v.as_str()) .unwrap_or("Longo2004") .to_lowercase(); let correlation = match correlation_str.as_str() { "shah1979" => BphxCorrelation::Shah1979, "shah2021" => BphxCorrelation::Shah2021, "longo2004" => BphxCorrelation::Longo2004, other => { tracing::warn!( correlation = other, "Unknown BphxCondenser correlation '{}', falling back to Longo2004", other ); BphxCorrelation::Longo2004 } }; let mut cond = BphxCondenser::new(geo) .with_refrigerant(refrigerant) .with_secondary_fluid(secondary_fluid) .with_fluid_backend(Arc::clone(&backend)) .with_target_subcooling(target_subcooling) .with_correlation(correlation); // Convention (Condenser): hot_fluid = refrigerant, cold_fluid = secondary (brine/water). // The refrigerant condenses (releases heat to the secondary). // Note: this is opposite to the Evaporator convention — see BphxEvaporator. if params.contains_key("hot_fluid") { let hot = parse_side_conditions(params, "hot")?; cond.set_refrigerant_conditions(hot); } if params.contains_key("cold_fluid") { let cold = parse_side_conditions(params, "cold")?; cond.set_secondary_conditions(cold); } cond.set_calib(bphx_calib_from_params(params, cond.ua())?); Ok(Box::new(cond)) } "Placeholder" => { let n_eqs = params.get("n_equations").and_then(|v| v.as_u64()).unwrap_or(0) as usize; Ok(Box::new(SimpleComponent::new("", n_eqs))) } "FreeCoolingExchanger" | "FreeCooling" => { use entropyk::{FreeCoolingConfig, FreeCoolingControlMode, FreeCoolingExchanger, FreeCoolingMode}; use entropyk_components::port::{FluidId, Port}; use entropyk_core::{CircuitId, Enthalpy, Pressure}; let effectiveness = params.get("effectiveness").and_then(|v| v.as_f64()).unwrap_or(0.85); let ua = params.get("ua").and_then(|v| v.as_f64()).unwrap_or(10_000.0); let cold_mass_flow = params.get("coldMassFlow").and_then(|v| v.as_f64()).unwrap_or(0.5); let hot_mass_flow = params.get("hotMassFlow").and_then(|v| v.as_f64()).unwrap_or(0.5); let cold_cp = params.get("coldCp").and_then(|v| v.as_f64()).unwrap_or(4186.0); let hot_cp = params.get("hotCp").and_then(|v| v.as_f64()).unwrap_or(4186.0); let config = FreeCoolingConfig { effectiveness, ua, cold_mass_flow, hot_mass_flow, cold_cp, hot_cp, ..Default::default() }; let circuit_id = CircuitId(0); let fluid = FluidId::new("Water"); let p = Pressure::from_pascals(3e5); let h = Enthalpy::from_joules_per_kg(63_000.0); let (ci, co) = Port::new(FluidId::new("Water"), p, h) .connect(Port::new(FluidId::new("Water"), p, h)) .map_err(|e| CliError::Config(format!("Port connect error: {}", e)))?; let (hi, ho) = Port::new(FluidId::new("Water"), p, h) .connect(Port::new(FluidId::new("Water"), p, h)) .map_err(|e| CliError::Config(format!("Port connect error: {}", e)))?; let fc = FreeCoolingExchanger::new("freecooling", circuit_id, config, ci, co, hi, ho) .map_err(|e| CliError::Config(format!("FreeCoolingExchanger error: {}", e)))?; Ok(Box::new(fc)) } _ => Err(CliError::Config(format!( "Unknown component type: '{}'. Supported: ScrewEconomizerCompressor, MchxCondenserCoil, FloodedEvaporator, BphxEvaporator, BphxCondenser, FreeCoolingExchanger, Condenser, CondenserCoil, Evaporator, EvaporatorCoil, HeatExchanger, Compressor, ExpansionValve, Pump, Placeholder", component_type ))), } } /// Extract state entries from converged state. fn extract_state(converged: &entropyk::ConvergedState) -> Vec { let state = &converged.state; let edge_count = state.len() / 2; (0..edge_count) .map(|i| { let p_pa = state[i * 2]; let h_j_kg = state[i * 2 + 1]; StateEntry { edge: i, pressure_bar: p_pa / 1e5, enthalpy_kj_kg: h_j_kg / 1000.0, } }) .collect() } // ============================================================================= // Python-style components for CLI (no type-state pattern) // ============================================================================= use std::fmt; struct SimpleComponent { name: String, n_eqs: usize, } impl SimpleComponent { fn new(name: &str, n_eqs: usize) -> Self { Self { name: name.to_string(), n_eqs, } } } impl entropyk::Component for SimpleComponent { fn compute_residuals( &self, state: &[f64], residuals: &mut entropyk::ResidualVector, ) -> Result<(), entropyk::ComponentError> { for i in 0..self.n_eqs.min(residuals.len()) { residuals[i] = if state.is_empty() { 0.0 } else { state[i % state.len()] * 1e-3 }; } Ok(()) } fn jacobian_entries( &self, _state: &[f64], jacobian: &mut entropyk::JacobianBuilder, ) -> Result<(), entropyk::ComponentError> { for i in 0..self.n_eqs { jacobian.add_entry(i, i, 1.0); } Ok(()) } fn n_equations(&self) -> usize { self.n_eqs } fn get_ports(&self) -> &[entropyk::ConnectedPort] { &[] } } impl fmt::Debug for SimpleComponent { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("SimpleComponent") .field("name", &self.name) .finish() } } // PyCompressor stub REMOVED — replaced by real Compressor in create_component(). // PyExpansionValve stub REMOVED — replaced by real ExpansionValve in create_component(). #[cfg(test)] mod tests { use super::*; #[test] fn test_simulation_status_serialization() { let status = SimulationStatus::Converged; let json = serde_json::to_string(&status).unwrap(); assert_eq!(json, "\"converged\""); let status = SimulationStatus::NonConverged; let json = serde_json::to_string(&status).unwrap(); assert_eq!(json, "\"non_converged\""); } #[test] fn test_simulation_result_serialization() { let result = SimulationResult { input: "test.json".to_string(), status: SimulationStatus::Converged, convergence: Some(ConvergenceInfo { final_residual: 1e-8, tolerance: 1e-6, }), iterations: Some(25), state: Some(vec![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")); } }