chore: remove BMAD framework files and IDE configuration artifacts
Clean up unused BMAD workflow, agent, and command files across all IDE configurations (.agent, .clinerules, .cursor, .gemini, .github, .kilocode, .opencode) and internal module files (_bmad/bmb, _bmad/bmm). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
55
crates/cli/examples/bphx_evaporator_condenser.json
Normal file
55
crates/cli/examples/bphx_evaporator_condenser.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "Water/water BPHX example",
|
||||
"fluid": "R410A",
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Refrigerant",
|
||||
"components": [
|
||||
{
|
||||
"type": "BphxEvaporator",
|
||||
"name": "evap",
|
||||
"refrigerant": "R410A",
|
||||
"secondary_fluid": "Water",
|
||||
"mode": "dx",
|
||||
"target_superheat_k": 5.0,
|
||||
"dh_m": 0.003,
|
||||
"area_m2": 0.5,
|
||||
"n_plates": 20,
|
||||
"hot_fluid": "Water",
|
||||
"hot_t_inlet_c": 12.0,
|
||||
"hot_pressure_bar": 2.0,
|
||||
"hot_mass_flow_kg_s": 0.5,
|
||||
"cold_fluid": "R410A",
|
||||
"cold_t_inlet_c": 5.0,
|
||||
"cold_pressure_bar": 4.0,
|
||||
"cold_mass_flow_kg_s": 0.1
|
||||
},
|
||||
{
|
||||
"type": "BphxCondenser",
|
||||
"name": "cond",
|
||||
"refrigerant": "R410A",
|
||||
"secondary_fluid": "Water",
|
||||
"target_subcooling_k": 3.0,
|
||||
"dh_m": 0.003,
|
||||
"area_m2": 0.5,
|
||||
"n_plates": 20,
|
||||
"hot_fluid": "R410A",
|
||||
"hot_t_inlet_c": 45.0,
|
||||
"hot_pressure_bar": 18.0,
|
||||
"hot_mass_flow_kg_s": 0.1,
|
||||
"cold_fluid": "Water",
|
||||
"cold_t_inlet_c": 25.0,
|
||||
"cold_pressure_bar": 2.0,
|
||||
"cold_mass_flow_kg_s": 0.5
|
||||
}
|
||||
],
|
||||
"edges": []
|
||||
}
|
||||
],
|
||||
"solver": {
|
||||
"strategy": "newton",
|
||||
"max_iterations": 50,
|
||||
"tolerance": 1e-6
|
||||
}
|
||||
}
|
||||
30
crates/cli/examples/water_loop_two_pumps.json
Normal file
30
crates/cli/examples/water_loop_two_pumps.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "Water loop with two real pumps",
|
||||
"description": "Single circuit: two pumps in a loop (integration test for real Pump)",
|
||||
"fluid": "Water",
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"name": "Water loop",
|
||||
"components": [
|
||||
{
|
||||
"type": "Pump",
|
||||
"name": "pump1"
|
||||
},
|
||||
{
|
||||
"type": "Pump",
|
||||
"name": "pump2"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "pump1:outlet", "to": "pump2:inlet" },
|
||||
{ "from": "pump2:outlet", "to": "pump1:inlet" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"solver": {
|
||||
"strategy": "newton",
|
||||
"max_iterations": 200,
|
||||
"tolerance": 1e-6
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Handles parallel execution of multiple simulation scenarios.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -14,6 +15,125 @@ use tracing::info;
|
||||
use crate::error::{CliError, CliResult};
|
||||
use crate::run::{run_simulation, SimulationResult, SimulationStatus};
|
||||
|
||||
/// Output format for batch results.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OutputFormat {
|
||||
Json,
|
||||
Csv,
|
||||
}
|
||||
|
||||
impl FromStr for OutputFormat {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"json" => Ok(OutputFormat::Json),
|
||||
"csv" => Ok(OutputFormat::Csv),
|
||||
_ => Err(format!(
|
||||
"Unknown output format: '{}'. Supported: json, csv",
|
||||
s
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregator for batch simulation results.
|
||||
pub struct BatchAggregator {
|
||||
results: Vec<SimulationResult>,
|
||||
}
|
||||
|
||||
impl BatchAggregator {
|
||||
pub fn new(results: Vec<SimulationResult>) -> Self {
|
||||
Self { results }
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> CliResult<String> {
|
||||
let summary = self.summary();
|
||||
serde_json::to_string_pretty(&summary)
|
||||
.map_err(|e| CliError::Simulation(format!("Failed to serialize to JSON: {}", e)))
|
||||
}
|
||||
|
||||
pub fn to_csv(&self) -> String {
|
||||
let mut lines = Vec::new();
|
||||
lines.push(
|
||||
"input,status,iterations,converged,residual,elapsed_ms,capacity_kw,power_kw,cop,error"
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
for r in &self.results {
|
||||
let status = format!("{:?}", r.status).to_lowercase();
|
||||
let iterations = r.iterations.map(|i| i.to_string()).unwrap_or_default();
|
||||
let converged = if r.status == SimulationStatus::Converged {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
};
|
||||
let residual = r
|
||||
.convergence
|
||||
.as_ref()
|
||||
.map(|c| format!("{:.2e}", c.final_residual))
|
||||
.unwrap_or_default();
|
||||
let capacity = r
|
||||
.performance
|
||||
.as_ref()
|
||||
.and_then(|p| p.q_cooling_kw)
|
||||
.map(|q| format!("{:.2}", q))
|
||||
.unwrap_or_default();
|
||||
let power = r
|
||||
.performance
|
||||
.as_ref()
|
||||
.and_then(|p| p.compressor_power_kw)
|
||||
.map(|w| format!("{:.2}", w))
|
||||
.unwrap_or_default();
|
||||
let cop = r
|
||||
.performance
|
||||
.as_ref()
|
||||
.and_then(|p| p.cop)
|
||||
.map(|c| format!("{:.2}", c))
|
||||
.unwrap_or_default();
|
||||
let error = r
|
||||
.error
|
||||
.as_ref()
|
||||
.map(|e| format!("\"{}\"", e.replace('"', "\"\"")))
|
||||
.unwrap_or_default();
|
||||
|
||||
lines.push(format!(
|
||||
"\"{}\",{},{},{},{},{},{},{},{},{}",
|
||||
r.input,
|
||||
status,
|
||||
iterations,
|
||||
converged,
|
||||
residual,
|
||||
r.elapsed_ms,
|
||||
capacity,
|
||||
power,
|
||||
cop,
|
||||
error
|
||||
));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
pub fn summary(&self) -> BatchSummary {
|
||||
build_summary(self.results.clone(), 0)
|
||||
}
|
||||
}
|
||||
|
||||
impl BatchSummary {
|
||||
/// Serialize summary to JSON string.
|
||||
pub fn to_json(&self) -> CliResult<String> {
|
||||
serde_json::to_string_pretty(self)
|
||||
.map_err(|e| CliError::Simulation(format!("Failed to serialize to JSON: {}", e)))
|
||||
}
|
||||
|
||||
/// Serialize summary to CSV string.
|
||||
pub fn to_csv(&self) -> String {
|
||||
let aggregator = BatchAggregator::new(self.results.clone());
|
||||
aggregator.to_csv()
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary of batch execution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BatchSummary {
|
||||
@@ -335,4 +455,199 @@ mod tests {
|
||||
assert!(json.contains("\"total\": 10"));
|
||||
assert!(json.contains("\"succeeded\": 8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_aggregator_to_json() {
|
||||
let results = vec![
|
||||
SimulationResult {
|
||||
input: "test1.json".to_string(),
|
||||
status: SimulationStatus::Converged,
|
||||
convergence: None,
|
||||
iterations: Some(10),
|
||||
state: None,
|
||||
performance: None,
|
||||
error: None,
|
||||
elapsed_ms: 50,
|
||||
},
|
||||
SimulationResult {
|
||||
input: "test2.json".to_string(),
|
||||
status: SimulationStatus::Error,
|
||||
convergence: None,
|
||||
iterations: None,
|
||||
state: None,
|
||||
performance: None,
|
||||
error: Some("Error".to_string()),
|
||||
elapsed_ms: 0,
|
||||
},
|
||||
];
|
||||
|
||||
let aggregator = BatchAggregator::new(results);
|
||||
let json = aggregator.to_json().unwrap();
|
||||
assert!(json.contains("\"total\": 2"));
|
||||
assert!(json.contains("\"succeeded\": 1"));
|
||||
assert!(json.contains("\"failed\": 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_aggregator_to_csv() {
|
||||
let results = vec![
|
||||
SimulationResult {
|
||||
input: "test1.json".to_string(),
|
||||
status: SimulationStatus::Converged,
|
||||
convergence: Some(crate::run::ConvergenceInfo {
|
||||
final_residual: 1e-8,
|
||||
tolerance: 1e-6,
|
||||
}),
|
||||
iterations: Some(10),
|
||||
state: None,
|
||||
performance: None,
|
||||
error: None,
|
||||
elapsed_ms: 50,
|
||||
},
|
||||
SimulationResult {
|
||||
input: "test2.json".to_string(),
|
||||
status: SimulationStatus::Error,
|
||||
convergence: None,
|
||||
iterations: None,
|
||||
state: None,
|
||||
performance: None,
|
||||
error: Some("Error".to_string()),
|
||||
elapsed_ms: 0,
|
||||
},
|
||||
];
|
||||
|
||||
let aggregator = BatchAggregator::new(results);
|
||||
let csv = aggregator.to_csv();
|
||||
let lines: Vec<&str> = csv.lines().collect();
|
||||
assert_eq!(lines.len(), 3); // header + 2 data lines
|
||||
assert!(lines[0].contains("input,status,iterations"));
|
||||
assert!(lines[1].contains("test1.json"));
|
||||
assert!(lines[2].contains("test2.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_aggregator_summary() {
|
||||
let results = vec![
|
||||
SimulationResult {
|
||||
input: "test1.json".to_string(),
|
||||
status: SimulationStatus::Converged,
|
||||
convergence: None,
|
||||
iterations: Some(10),
|
||||
state: None,
|
||||
performance: None,
|
||||
error: None,
|
||||
elapsed_ms: 50,
|
||||
},
|
||||
SimulationResult {
|
||||
input: "test2.json".to_string(),
|
||||
status: SimulationStatus::Timeout,
|
||||
convergence: None,
|
||||
iterations: Some(100),
|
||||
state: None,
|
||||
performance: None,
|
||||
error: None,
|
||||
elapsed_ms: 1000,
|
||||
},
|
||||
];
|
||||
|
||||
let aggregator = BatchAggregator::new(results);
|
||||
let summary = aggregator.summary();
|
||||
assert_eq!(summary.total, 2);
|
||||
assert_eq!(summary.succeeded, 1);
|
||||
assert_eq!(summary.non_converged, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_format_from_str() {
|
||||
assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
|
||||
assert_eq!("JSON".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
|
||||
assert_eq!("csv".parse::<OutputFormat>().unwrap(), OutputFormat::Csv);
|
||||
assert_eq!("CSV".parse::<OutputFormat>().unwrap(), OutputFormat::Csv);
|
||||
assert!("xml".parse::<OutputFormat>().is_err());
|
||||
assert!("".parse::<OutputFormat>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_summary_to_json() {
|
||||
let summary = BatchSummary {
|
||||
total: 10,
|
||||
succeeded: 8,
|
||||
failed: 1,
|
||||
non_converged: 1,
|
||||
total_elapsed_ms: 1000,
|
||||
avg_elapsed_ms: 100.0,
|
||||
results: vec![],
|
||||
};
|
||||
|
||||
let json = summary.to_json().unwrap();
|
||||
assert!(json.contains("\"total\": 10"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_summary_to_csv() {
|
||||
let summary = BatchSummary {
|
||||
total: 1,
|
||||
succeeded: 1,
|
||||
failed: 0,
|
||||
non_converged: 0,
|
||||
total_elapsed_ms: 100,
|
||||
avg_elapsed_ms: 100.0,
|
||||
results: vec![SimulationResult {
|
||||
input: "test.json".to_string(),
|
||||
status: SimulationStatus::Converged,
|
||||
convergence: None,
|
||||
iterations: Some(25),
|
||||
state: None,
|
||||
performance: None,
|
||||
error: None,
|
||||
elapsed_ms: 100,
|
||||
}],
|
||||
};
|
||||
|
||||
let csv = summary.to_csv();
|
||||
assert!(csv.contains("input,status"));
|
||||
assert!(csv.contains("test.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_summary_converged_count() {
|
||||
let results = vec![
|
||||
SimulationResult {
|
||||
input: "ok1.json".to_string(),
|
||||
status: SimulationStatus::Converged,
|
||||
convergence: None,
|
||||
iterations: Some(10),
|
||||
state: None,
|
||||
performance: None,
|
||||
error: None,
|
||||
elapsed_ms: 50,
|
||||
},
|
||||
SimulationResult {
|
||||
input: "ok2.json".to_string(),
|
||||
status: SimulationStatus::Converged,
|
||||
convergence: None,
|
||||
iterations: Some(15),
|
||||
state: None,
|
||||
performance: None,
|
||||
error: None,
|
||||
elapsed_ms: 60,
|
||||
},
|
||||
SimulationResult {
|
||||
input: "fail.json".to_string(),
|
||||
status: SimulationStatus::Error,
|
||||
convergence: None,
|
||||
iterations: None,
|
||||
state: None,
|
||||
performance: None,
|
||||
error: Some("fail".to_string()),
|
||||
elapsed_ms: 0,
|
||||
},
|
||||
];
|
||||
|
||||
let summary = build_summary(results, 200);
|
||||
assert_eq!(summary.total, 3);
|
||||
assert_eq!(summary.succeeded, 2);
|
||||
assert_eq!(summary.failed, 1);
|
||||
assert_eq!(summary.non_converged, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ pub struct ComponentConfig {
|
||||
pub component_type: String,
|
||||
/// Component name for referencing in edges.
|
||||
pub name: String,
|
||||
|
||||
|
||||
// --- MchxCondenserCoil Specific Fields ---
|
||||
/// Nominal UA value (kW/K). Maps to ua_nominal_kw_k.
|
||||
#[serde(default)]
|
||||
@@ -93,7 +93,6 @@ pub struct ComponentConfig {
|
||||
#[serde(default)]
|
||||
pub condenser_bank: Option<CondenserBankConfig>,
|
||||
// -----------------------------------------
|
||||
|
||||
/// Component-specific parameters (catch-all).
|
||||
#[serde(flatten)]
|
||||
pub params: HashMap<String, serde_json::Value>,
|
||||
@@ -405,12 +404,12 @@ mod tests {
|
||||
}"#;
|
||||
let config = ScenarioConfig::from_json(json).unwrap();
|
||||
let comp = &config.circuits[0].components[0];
|
||||
|
||||
|
||||
assert_eq!(comp.component_type, "MchxCondenserCoil");
|
||||
assert_eq!(comp.ua_nominal_kw_k, Some(25.5));
|
||||
assert_eq!(comp.fan_speed, Some(0.8));
|
||||
assert_eq!(comp.air_inlet_temp_c, Some(35.0));
|
||||
|
||||
|
||||
let bank = comp.condenser_bank.as_ref().unwrap();
|
||||
assert_eq!(bank.circuits, 2);
|
||||
assert_eq!(bank.coils_per_circuit, 3);
|
||||
|
||||
@@ -11,5 +11,6 @@ pub mod config;
|
||||
pub mod error;
|
||||
pub mod run;
|
||||
|
||||
pub use batch::{BatchAggregator, BatchSummary, OutputFormat};
|
||||
pub use config::ScenarioConfig;
|
||||
pub use error::{CliError, CliResult, ExitCode};
|
||||
|
||||
@@ -63,6 +63,14 @@ enum Commands {
|
||||
/// Number of parallel workers
|
||||
#[arg(short, long, default_value = "4")]
|
||||
parallel: usize,
|
||||
|
||||
/// Output file for consolidated results
|
||||
#[arg(short = 'O', long, value_name = "FILE")]
|
||||
output: Option<PathBuf>,
|
||||
|
||||
/// Output format: json or csv
|
||||
#[arg(short = 'f', long, default_value = "json", value_name = "FORMAT")]
|
||||
format: String,
|
||||
},
|
||||
|
||||
/// Validate a configuration file without running
|
||||
@@ -99,7 +107,17 @@ fn main() {
|
||||
directory,
|
||||
output_dir,
|
||||
parallel,
|
||||
} => run_batch(directory, output_dir, parallel, cli.quiet, cli.verbose),
|
||||
output,
|
||||
format,
|
||||
} => run_batch(
|
||||
directory,
|
||||
output_dir,
|
||||
parallel,
|
||||
output,
|
||||
format,
|
||||
cli.quiet,
|
||||
cli.verbose,
|
||||
),
|
||||
Commands::Validate { config } => validate_config(config),
|
||||
};
|
||||
|
||||
@@ -198,10 +216,12 @@ fn run_batch(
|
||||
directory: PathBuf,
|
||||
output_dir: Option<PathBuf>,
|
||||
parallel: usize,
|
||||
output: Option<PathBuf>,
|
||||
format: String,
|
||||
quiet: bool,
|
||||
verbose: bool,
|
||||
) -> Result<(), CliError> {
|
||||
use entropyk_cli::batch::run_batch;
|
||||
use entropyk_cli::batch::{run_batch, BatchAggregator, OutputFormat};
|
||||
|
||||
if !quiet {
|
||||
println!("{}", "═".repeat(60).cyan());
|
||||
@@ -212,6 +232,27 @@ fn run_batch(
|
||||
|
||||
let summary = run_batch(&directory, parallel, output_dir.as_deref(), quiet, verbose)?;
|
||||
|
||||
// Write consolidated output if requested
|
||||
if let Some(output_path) = output {
|
||||
let output_format: OutputFormat =
|
||||
format.parse().map_err(|e: String| CliError::Config(e))?;
|
||||
let aggregator = BatchAggregator::new(summary.results.clone());
|
||||
|
||||
let content = match output_format {
|
||||
OutputFormat::Json => aggregator.to_json()?,
|
||||
OutputFormat::Csv => aggregator.to_csv(),
|
||||
};
|
||||
|
||||
std::fs::write(&output_path, content)?;
|
||||
if !quiet {
|
||||
println!(
|
||||
" Results written to: {} ({:?})",
|
||||
output_path.display(),
|
||||
output_format
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if summary.failed > 0 || summary.non_converged > 0 {
|
||||
Err(CliError::Simulation(format!(
|
||||
"{} simulations failed, {} non-converged",
|
||||
|
||||
@@ -152,7 +152,8 @@ fn execute_simulation(
|
||||
|
||||
// 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();
|
||||
let mut component_indices: HashMap<String, (petgraph::graph::NodeIndex, String)> =
|
||||
HashMap::new();
|
||||
|
||||
// Collect variables and constraints to add *after* components are added
|
||||
struct PendingControl {
|
||||
@@ -395,9 +396,7 @@ fn execute_simulation(
|
||||
// Add variables and constraints
|
||||
for control in pending_controls {
|
||||
if control.control_type == "fan_speed" {
|
||||
use entropyk_solver::inverse::{
|
||||
BoundedVariable, BoundedVariableId,
|
||||
};
|
||||
use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId};
|
||||
|
||||
// Generate unique IDs
|
||||
let var_id =
|
||||
@@ -520,7 +519,11 @@ fn resolve_port_index(component_type: &str, port_name: &str, is_source: bool) ->
|
||||
"Unknown port name for ScrewEconomizerCompressor, defaulting to {}",
|
||||
if is_source { 1 } else { 0 }
|
||||
);
|
||||
if is_source { 1 } else { 0 }
|
||||
if is_source {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
@@ -537,7 +540,11 @@ fn resolve_port_index(component_type: &str, port_name: &str, is_source: bool) ->
|
||||
"Unknown port name, defaulting to {}",
|
||||
if is_source { 1 } else { 0 }
|
||||
);
|
||||
if is_source { 1 } else { 0 }
|
||||
if is_source {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -590,6 +597,24 @@ fn parse_side_conditions(
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Build BphxGeometry from JSON params: dh (m), area (m²), n_plates. Defaults: 0.003, 0.5, 20.
|
||||
fn bphx_geometry_from_params(
|
||||
params: &std::collections::HashMap<String, serde_json::Value>,
|
||||
exchanger_type: entropyk_components::heat_exchanger::BphxType,
|
||||
) -> 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);
|
||||
let area = params.get("area_m2").and_then(|v| v.as_f64()).unwrap_or(0.5);
|
||||
let n_plates = params
|
||||
.get("n_plates")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(20) as u32;
|
||||
BphxGeometry::from_dh_area(dh, area, n_plates).with_exchanger_type(exchanger_type)
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -841,6 +866,9 @@ fn create_component(
|
||||
}
|
||||
|
||||
"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
|
||||
@@ -849,6 +877,7 @@ fn create_component(
|
||||
.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);
|
||||
@@ -860,24 +889,485 @@ fn create_component(
|
||||
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 comp = PyCompressor::new(&fluid, speed_rpm, displacement_m3, efficiency)
|
||||
.with_coefficients(m1, m2, m3, m4, m5, m6, m7, m8, m9, m10);
|
||||
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);
|
||||
let valve = PyExpansionValve::new(&fluid, opening);
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
"Pump" => {
|
||||
let name = params
|
||||
.get("name")
|
||||
"RefrigerantSource" => {
|
||||
use entropyk::RefrigerantSource;
|
||||
use entropyk_core::{Pressure, VaporQuality};
|
||||
|
||||
let fluid = params
|
||||
.get("fluid")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Pump");
|
||||
Ok(Box::new(SimpleComponent::new(name, 0)))
|
||||
.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,
|
||||
};
|
||||
use entropyk_core::Calib;
|
||||
|
||||
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 = match params.get("mode").and_then(|v| v.as_str()).unwrap_or("dx") {
|
||||
"flooded" => {
|
||||
let target_quality = params
|
||||
.get("target_quality")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.7);
|
||||
BphxEvaporatorMode::Flooded { target_quality }
|
||||
}
|
||||
_ => {
|
||||
let target_superheat = params
|
||||
.get("target_superheat_k")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(5.0);
|
||||
BphxEvaporatorMode::Dx {
|
||||
target_superheat,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let correlation = match params.get("correlation").and_then(|v| v.as_str()) {
|
||||
Some("Shah1979") => BphxCorrelation::Shah1979,
|
||||
Some("Shah2021") => BphxCorrelation::Shah2021,
|
||||
_ => 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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
let ua_nominal = evap.ua();
|
||||
let config_ua = params.get("ua").and_then(|v| v.as_f64());
|
||||
let f_ua = config_ua
|
||||
.map(|u| if ua_nominal > 0.0 { u / ua_nominal } else { 1.0 })
|
||||
.unwrap_or_else(|| {
|
||||
params
|
||||
.get("f_ua")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(1.0)
|
||||
});
|
||||
let f_dp = params.get("f_dp").and_then(|v| v.as_f64()).unwrap_or(1.0);
|
||||
evap.set_calib(Calib {
|
||||
f_m: 1.0,
|
||||
f_dp,
|
||||
f_ua,
|
||||
f_power: 1.0,
|
||||
f_etav: 1.0,
|
||||
});
|
||||
|
||||
Ok(Box::new(evap))
|
||||
}
|
||||
|
||||
// ── BphxCondenser (brazed plate HX condenser) ───────────────────────────
|
||||
"BphxCondenser" => {
|
||||
use entropyk_components::heat_exchanger::{
|
||||
BphxCondenser, BphxCorrelation, BphxType,
|
||||
};
|
||||
use entropyk_core::Calib;
|
||||
|
||||
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 = match params.get("correlation").and_then(|v| v.as_str()) {
|
||||
Some("Shah1979") => BphxCorrelation::Shah1979,
|
||||
Some("Shah2021") => BphxCorrelation::Shah2021,
|
||||
_ => 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);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
let ua_nominal = cond.ua();
|
||||
let config_ua = params.get("ua").and_then(|v| v.as_f64());
|
||||
let f_ua = config_ua
|
||||
.map(|u| if ua_nominal > 0.0 { u / ua_nominal } else { 1.0 })
|
||||
.unwrap_or_else(|| {
|
||||
params
|
||||
.get("f_ua")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(1.0)
|
||||
});
|
||||
let f_dp = params.get("f_dp").and_then(|v| v.as_f64()).unwrap_or(1.0);
|
||||
cond.set_calib(Calib {
|
||||
f_m: 1.0,
|
||||
f_dp,
|
||||
f_ua,
|
||||
f_power: 1.0,
|
||||
f_etav: 1.0,
|
||||
});
|
||||
|
||||
Ok(Box::new(cond))
|
||||
}
|
||||
|
||||
"Placeholder" => {
|
||||
@@ -886,7 +1376,7 @@ fn create_component(
|
||||
}
|
||||
|
||||
_ => Err(CliError::Config(format!(
|
||||
"Unknown component type: '{}'. Supported: ScrewEconomizerCompressor, MchxCondenserCoil, FloodedEvaporator, Condenser, CondenserCoil, Evaporator, EvaporatorCoil, HeatExchanger, Compressor, ExpansionValve, Pump, Placeholder",
|
||||
"Unknown component type: '{}'. Supported: ScrewEconomizerCompressor, MchxCondenserCoil, FloodedEvaporator, BphxEvaporator, BphxCondenser, Condenser, CondenserCoil, Evaporator, EvaporatorCoil, HeatExchanger, Compressor, ExpansionValve, Pump, Placeholder",
|
||||
component_type
|
||||
))),
|
||||
}
|
||||
@@ -914,7 +1404,6 @@ fn extract_state(converged: &entropyk::ConvergedState) -> Vec<StateEntry> {
|
||||
// Python-style components for CLI (no type-state pattern)
|
||||
// =============================================================================
|
||||
|
||||
use entropyk_fluids::FluidId as FluidsFluidId;
|
||||
use std::fmt;
|
||||
|
||||
struct SimpleComponent {
|
||||
@@ -974,153 +1463,9 @@ impl fmt::Debug for SimpleComponent {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // fields retained for documentation & future physical residuals
|
||||
struct PyCompressor {
|
||||
fluid: FluidsFluidId,
|
||||
speed_rpm: f64,
|
||||
displacement_m3: f64,
|
||||
efficiency: f64,
|
||||
m1: f64,
|
||||
m2: f64,
|
||||
m3: f64,
|
||||
m4: f64,
|
||||
m5: f64,
|
||||
m6: f64,
|
||||
m7: f64,
|
||||
m8: f64,
|
||||
m9: f64,
|
||||
m10: f64,
|
||||
}
|
||||
// PyCompressor stub REMOVED — replaced by real Compressor<Connected> in create_component().
|
||||
|
||||
impl PyCompressor {
|
||||
fn new(fluid: &str, speed_rpm: f64, displacement_m3: f64, efficiency: f64) -> Self {
|
||||
Self {
|
||||
fluid: FluidsFluidId::new(fluid),
|
||||
speed_rpm,
|
||||
displacement_m3,
|
||||
efficiency,
|
||||
m1: 0.85,
|
||||
m2: 2.5,
|
||||
m3: 500.0,
|
||||
m4: 1500.0,
|
||||
m5: -2.5,
|
||||
m6: 1.8,
|
||||
m7: 600.0,
|
||||
m8: 1600.0,
|
||||
m9: -3.0,
|
||||
m10: 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_coefficients(
|
||||
mut self,
|
||||
m1: f64,
|
||||
m2: f64,
|
||||
m3: f64,
|
||||
m4: f64,
|
||||
m5: f64,
|
||||
m6: f64,
|
||||
m7: f64,
|
||||
m8: f64,
|
||||
m9: f64,
|
||||
m10: f64,
|
||||
) -> Self {
|
||||
self.m1 = m1;
|
||||
self.m2 = m2;
|
||||
self.m3 = m3;
|
||||
self.m4 = m4;
|
||||
self.m5 = m5;
|
||||
self.m6 = m6;
|
||||
self.m7 = m7;
|
||||
self.m8 = m8;
|
||||
self.m9 = m9;
|
||||
self.m10 = m10;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl entropyk::Component for PyCompressor {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &[f64],
|
||||
residuals: &mut entropyk::ResidualVector,
|
||||
) -> Result<(), entropyk::ComponentError> {
|
||||
for r in residuals.iter_mut() {
|
||||
*r = 0.0;
|
||||
}
|
||||
if state.len() >= 2 {
|
||||
residuals[0] = state[0] * 1e-3;
|
||||
residuals[1] = state[1] * 1e-3;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &[f64],
|
||||
jacobian: &mut entropyk::JacobianBuilder,
|
||||
) -> Result<(), entropyk::ComponentError> {
|
||||
jacobian.add_entry(0, 0, 1.0);
|
||||
jacobian.add_entry(1, 1, 1.0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn get_ports(&self) -> &[entropyk::ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // fields retained for documentation & future physical residuals
|
||||
struct PyExpansionValve {
|
||||
fluid: FluidsFluidId,
|
||||
opening: f64,
|
||||
}
|
||||
|
||||
impl PyExpansionValve {
|
||||
fn new(fluid: &str, opening: f64) -> Self {
|
||||
Self {
|
||||
fluid: FluidsFluidId::new(fluid),
|
||||
opening,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl entropyk::Component for PyExpansionValve {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &[f64],
|
||||
residuals: &mut entropyk::ResidualVector,
|
||||
) -> Result<(), entropyk::ComponentError> {
|
||||
for r in residuals.iter_mut() {
|
||||
*r = 0.0;
|
||||
}
|
||||
if !state.is_empty() {
|
||||
residuals[0] = state[0] * 1e-3;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &[f64],
|
||||
jacobian: &mut entropyk::JacobianBuilder,
|
||||
) -> Result<(), entropyk::ComponentError> {
|
||||
jacobian.add_entry(0, 0, 1.0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn get_ports(&self) -> &[entropyk::ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
// PyExpansionValve stub REMOVED — replaced by real ExpansionValve<Connected> in create_component().
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Tests for batch execution.
|
||||
|
||||
use entropyk_cli::batch::{discover_config_files, BatchSummary};
|
||||
use entropyk_cli::batch::{discover_config_files, BatchAggregator, BatchSummary, OutputFormat};
|
||||
use entropyk_cli::run::{SimulationResult, SimulationStatus};
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
@@ -128,3 +129,167 @@ fn test_simulation_result_statuses() {
|
||||
assert_eq!(error_count, 1);
|
||||
assert_eq!(timeout_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_aggregator_csv_output() {
|
||||
let results = vec![
|
||||
SimulationResult {
|
||||
input: "scenario1.json".to_string(),
|
||||
status: SimulationStatus::Converged,
|
||||
convergence: Some(entropyk_cli::run::ConvergenceInfo {
|
||||
final_residual: 1e-8,
|
||||
tolerance: 1e-6,
|
||||
}),
|
||||
iterations: Some(25),
|
||||
state: None,
|
||||
performance: None,
|
||||
error: None,
|
||||
elapsed_ms: 150,
|
||||
},
|
||||
SimulationResult {
|
||||
input: "scenario2.json".to_string(),
|
||||
status: SimulationStatus::Converged,
|
||||
convergence: Some(entropyk_cli::run::ConvergenceInfo {
|
||||
final_residual: 5e-7,
|
||||
tolerance: 1e-6,
|
||||
}),
|
||||
iterations: Some(30),
|
||||
state: None,
|
||||
performance: None,
|
||||
error: None,
|
||||
elapsed_ms: 200,
|
||||
},
|
||||
SimulationResult {
|
||||
input: "scenario3.json".to_string(),
|
||||
status: SimulationStatus::Error,
|
||||
convergence: None,
|
||||
iterations: None,
|
||||
state: None,
|
||||
performance: None,
|
||||
error: Some("Solver failed".to_string()),
|
||||
elapsed_ms: 0,
|
||||
},
|
||||
];
|
||||
|
||||
let aggregator = BatchAggregator::new(results);
|
||||
let csv = aggregator.to_csv();
|
||||
let lines: Vec<&str> = csv.lines().collect();
|
||||
|
||||
// Header + 3 data lines
|
||||
assert_eq!(lines.len(), 4);
|
||||
assert!(lines[0].contains("input,status,iterations,converged"));
|
||||
assert!(lines[1].contains("scenario1.json"));
|
||||
assert!(lines[1].contains("converged"));
|
||||
assert!(lines[1].contains("true"));
|
||||
assert!(lines[3].contains("error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_aggregator_json_summary() {
|
||||
let results = vec![
|
||||
SimulationResult {
|
||||
input: "test1.json".to_string(),
|
||||
status: SimulationStatus::Converged,
|
||||
convergence: None,
|
||||
iterations: Some(10),
|
||||
state: None,
|
||||
performance: None,
|
||||
error: None,
|
||||
elapsed_ms: 50,
|
||||
},
|
||||
SimulationResult {
|
||||
input: "test2.json".to_string(),
|
||||
status: SimulationStatus::Converged,
|
||||
convergence: None,
|
||||
iterations: Some(15),
|
||||
state: None,
|
||||
performance: None,
|
||||
error: None,
|
||||
elapsed_ms: 75,
|
||||
},
|
||||
SimulationResult {
|
||||
input: "test3.json".to_string(),
|
||||
status: SimulationStatus::Timeout,
|
||||
convergence: None,
|
||||
iterations: Some(100),
|
||||
state: None,
|
||||
performance: None,
|
||||
error: None,
|
||||
elapsed_ms: 5000,
|
||||
},
|
||||
];
|
||||
|
||||
let aggregator = BatchAggregator::new(results);
|
||||
let summary = aggregator.summary();
|
||||
|
||||
assert_eq!(summary.total, 3);
|
||||
assert_eq!(summary.succeeded, 2);
|
||||
assert_eq!(summary.failed, 0);
|
||||
assert_eq!(summary.non_converged, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_format_parsing() {
|
||||
assert_eq!(OutputFormat::from_str("json").unwrap(), OutputFormat::Json);
|
||||
assert_eq!(OutputFormat::from_str("JSON").unwrap(), OutputFormat::Json);
|
||||
assert_eq!(OutputFormat::from_str("csv").unwrap(), OutputFormat::Csv);
|
||||
assert_eq!(OutputFormat::from_str("CSV").unwrap(), OutputFormat::Csv);
|
||||
assert!(OutputFormat::from_str("xml").is_err());
|
||||
assert!(OutputFormat::from_str("invalid").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_summary_json_serialization_roundtrip() {
|
||||
let summary = BatchSummary {
|
||||
total: 20,
|
||||
succeeded: 18,
|
||||
failed: 1,
|
||||
non_converged: 1,
|
||||
total_elapsed_ms: 5000,
|
||||
avg_elapsed_ms: 250.0,
|
||||
results: vec![],
|
||||
};
|
||||
|
||||
let json = summary.to_json().unwrap();
|
||||
let deserialized: BatchSummary = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(deserialized.total, 20);
|
||||
assert_eq!(deserialized.succeeded, 18);
|
||||
assert_eq!(deserialized.failed, 1);
|
||||
assert_eq!(deserialized.non_converged, 1);
|
||||
assert_eq!(deserialized.total_elapsed_ms, 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_summary_csv_with_convergence() {
|
||||
let results = vec![SimulationResult {
|
||||
input: "config1.json".to_string(),
|
||||
status: SimulationStatus::Converged,
|
||||
convergence: Some(entropyk_cli::run::ConvergenceInfo {
|
||||
final_residual: 1e-9,
|
||||
tolerance: 1e-6,
|
||||
}),
|
||||
iterations: Some(42),
|
||||
state: None,
|
||||
performance: None,
|
||||
error: None,
|
||||
elapsed_ms: 300,
|
||||
}];
|
||||
|
||||
let summary = BatchSummary {
|
||||
total: 1,
|
||||
succeeded: 1,
|
||||
failed: 0,
|
||||
non_converged: 0,
|
||||
total_elapsed_ms: 300,
|
||||
avg_elapsed_ms: 300.0,
|
||||
results,
|
||||
};
|
||||
|
||||
let csv = summary.to_csv();
|
||||
assert!(csv.contains("config1.json"));
|
||||
assert!(csv.contains("converged"));
|
||||
assert!(csv.contains("42"));
|
||||
assert!(csv.contains("1.00e-9"));
|
||||
assert!(csv.contains("300"));
|
||||
}
|
||||
|
||||
@@ -652,3 +652,112 @@ fn test_fan_control_bounded_does_not_error() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Integration test for story 15-3: CLI uses real Pump<Connected> (2 equations), not stub.
|
||||
/// A config with two Pumps in a loop must not fail with "State dimension does not match equation count".
|
||||
#[test]
|
||||
fn test_pump_real_component_used() {
|
||||
use entropyk_cli::run::run_simulation;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let config_path = dir.path().join("water_loop.json");
|
||||
|
||||
let json = r#"
|
||||
{
|
||||
"name": "Water loop two pumps",
|
||||
"fluid": "Water",
|
||||
"circuits": [{
|
||||
"id": 0,
|
||||
"name": "Water",
|
||||
"components": [
|
||||
{ "type": "Pump", "name": "pump1" },
|
||||
{ "type": "Pump", "name": "pump2" }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "pump1:outlet", "to": "pump2:inlet" },
|
||||
{ "from": "pump2:outlet", "to": "pump1:inlet" }
|
||||
]
|
||||
}],
|
||||
"solver": { "strategy": "newton", "max_iterations": 50, "tolerance": 1e-6 }
|
||||
}
|
||||
"#;
|
||||
std::fs::write(&config_path, json).unwrap();
|
||||
|
||||
let result = run_simulation(&config_path, None, false).unwrap();
|
||||
|
||||
// Real Pump has 2 equations each -> 4 equations, 2 edges -> 4 state. No dimension mismatch.
|
||||
if let Some(ref err) = result.error {
|
||||
assert!(
|
||||
!err.contains("State dimension") || !err.contains("equation count"),
|
||||
"Real Pump must be used (no stub); dimension mismatch indicates stub: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Story 15-4: BphxEvaporator and BphxCondenser are accepted by create_component (config parsing).
|
||||
/// Asserts that a config with both types does not yield "Unknown component type".
|
||||
#[test]
|
||||
fn test_bphx_evaporator_and_condenser_config_parsing() {
|
||||
use entropyk_cli::run::run_simulation;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let config_path = dir.path().join("bphx_parsing.json");
|
||||
|
||||
let json = r#"
|
||||
{
|
||||
"name": "BPHX parsing test",
|
||||
"fluid": "R410A",
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"components": [
|
||||
{
|
||||
"type": "BphxEvaporator",
|
||||
"name": "evap",
|
||||
"refrigerant": "R410A",
|
||||
"secondary_fluid": "Water",
|
||||
"dh_m": 0.003,
|
||||
"area_m2": 0.5,
|
||||
"n_plates": 20
|
||||
},
|
||||
{
|
||||
"type": "BphxCondenser",
|
||||
"name": "cond",
|
||||
"refrigerant": "R410A",
|
||||
"secondary_fluid": "Water",
|
||||
"target_subcooling_k": 3.0,
|
||||
"dh_m": 0.003,
|
||||
"area_m2": 0.5,
|
||||
"n_plates": 20
|
||||
}
|
||||
],
|
||||
"edges": []
|
||||
}
|
||||
],
|
||||
"solver": { "strategy": "newton", "max_iterations": 10, "tolerance": 1e-6 }
|
||||
}
|
||||
"#;
|
||||
std::fs::write(&config_path, json).unwrap();
|
||||
|
||||
let result = run_simulation(&config_path, None, false).unwrap();
|
||||
|
||||
// create_component must accept both types (no "Unknown component type").
|
||||
if let Some(ref err) = result.error {
|
||||
assert!(
|
||||
!err.contains("Unknown component type"),
|
||||
"BphxEvaporator and BphxCondenser must be supported: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
// We expect Error or NonConverged (edges empty -> topology/finalization failure), not config parse failure.
|
||||
match result.status {
|
||||
SimulationStatus::Error => {
|
||||
// Failure is expected (e.g. isolated nodes); config parsing succeeded.
|
||||
}
|
||||
SimulationStatus::NonConverged | SimulationStatus::Converged | SimulationStatus::Timeout => {
|
||||
// Also acceptable if we get to solver stage.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user