1676 lines
64 KiB
Rust
1676 lines
64 KiB
Rust
//! 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<ConvergenceInfo>,
|
|
/// Solver iterations.
|
|
pub iterations: Option<usize>,
|
|
/// Final state vector (P, h per edge).
|
|
pub state: Option<Vec<StateEntry>>,
|
|
/// Performance metrics.
|
|
pub performance: Option<PerformanceMetrics>,
|
|
/// Error message if failed.
|
|
pub error: Option<String>,
|
|
/// 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<f64>,
|
|
/// Heating capacity in kW.
|
|
pub q_heating_kw: Option<f64>,
|
|
/// Compressor power in kW.
|
|
pub compressor_power_kw: Option<f64>,
|
|
/// Coefficient of performance.
|
|
pub cop: Option<f64>,
|
|
}
|
|
|
|
/// 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<SimulationResult> {
|
|
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<dyn entropyk_fluids::FluidBackend> = 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<String, (petgraph::graph::NodeIndex, String)> =
|
|
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<String, serde_json::Value>,
|
|
key: &str,
|
|
) -> CliResult<f64> {
|
|
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<String, serde_json::Value>,
|
|
key: &str,
|
|
) -> CliResult<String> {
|
|
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<String, serde_json::Value>,
|
|
prefix: &str,
|
|
) -> CliResult<entropyk::HxSideConditions> {
|
|
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<String, serde_json::Value>,
|
|
exchanger_type: entropyk_components::heat_exchanger::BphxType,
|
|
) -> CliResult<entropyk_components::heat_exchanger::BphxGeometry> {
|
|
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<String, serde_json::Value>,
|
|
ua_nominal: f64,
|
|
) -> CliResult<entropyk_core::Calib> {
|
|
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<dyn entropyk_fluids::FluidBackend>,
|
|
) -> CliResult<Box<dyn entropyk::Component>> {
|
|
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<Disconnected> 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<Disconnected> 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<f64> = h.iter().filter_map(|v| v.as_f64()).collect();
|
|
let eff_coeffs: Vec<f64> = 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<StateEntry> {
|
|
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<Connected> in create_component().
|
|
|
|
// PyExpansionValve stub REMOVED — replaced by real ExpansionValve<Connected> 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"));
|
|
}
|
|
}
|