chore: remove deprecated flow_boundary and update docs to match new architecture

This commit is contained in:
Sepehr
2026-03-01 20:00:09 +01:00
parent 20700afce8
commit d88914a44f
105 changed files with 11222 additions and 2994 deletions

View File

@@ -0,0 +1,121 @@
{
"name": "Chiller MCHX Condensers - Démonstration CLI",
"description": "Démontre l'utilisation des MchxCondenserCoil (4 coils) et FloodedEvaporator dans le pipeline CLI. Utilise des Placeholder pour simuler compresseur et vanne. Topology linéaire pour compatibilité CLI graphe.",
"fluid": "R134a",
"circuits": [
{
"id": 0,
"components": [
{
"type": "Placeholder",
"name": "comp_0",
"n_equations": 2
},
{
"type": "MchxCondenserCoil",
"name": "mchx_0a",
"ua": 15000.0,
"coil_index": 0,
"n_air": 0.5,
"t_air_celsius": 35.0,
"fan_speed_ratio": 1.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_0b",
"ua": 15000.0,
"coil_index": 1,
"n_air": 0.5,
"t_air_celsius": 35.0,
"fan_speed_ratio": 0.8
},
{
"type": "Placeholder",
"name": "exv_0",
"n_equations": 2
},
{
"type": "FloodedEvaporator",
"name": "evap_0",
"ua": 20000.0,
"refrigerant": "R134a",
"secondary_fluid": "MEG",
"target_quality": 0.7
}
],
"edges": [
{ "from": "comp_0:outlet", "to": "mchx_0a:inlet" },
{ "from": "mchx_0a:outlet", "to": "mchx_0b:inlet" },
{ "from": "mchx_0b:outlet", "to": "exv_0:inlet" },
{ "from": "exv_0:outlet", "to": "evap_0:inlet" },
{ "from": "evap_0:outlet", "to": "comp_0:inlet" }
]
},
{
"id": 1,
"components": [
{
"type": "Placeholder",
"name": "comp_1",
"n_equations": 2
},
{
"type": "MchxCondenserCoil",
"name": "mchx_1a",
"ua": 15000.0,
"coil_index": 2,
"n_air": 0.5,
"t_air_celsius": 35.0,
"fan_speed_ratio": 1.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_1b",
"ua": 15000.0,
"coil_index": 3,
"n_air": 0.5,
"t_air_celsius": 35.0,
"fan_speed_ratio": 0.9
},
{
"type": "Placeholder",
"name": "exv_1",
"n_equations": 2
},
{
"type": "FloodedEvaporator",
"name": "evap_1",
"ua": 20000.0,
"refrigerant": "R134a",
"secondary_fluid": "MEG",
"target_quality": 0.7
}
],
"edges": [
{ "from": "comp_1:outlet", "to": "mchx_1a:inlet" },
{ "from": "mchx_1a:outlet", "to": "mchx_1b:inlet" },
{ "from": "mchx_1b:outlet", "to": "exv_1:inlet" },
{ "from": "exv_1:outlet", "to": "evap_1:inlet" },
{ "from": "evap_1:outlet", "to": "comp_1:inlet" }
]
}
],
"solver": {
"strategy": "newton",
"max_iterations": 100,
"tolerance": 1e-6
},
"metadata": {
"note": "Demo MCHX 4 coils + FloodedEvap 2 circuits via CLI",
"mchx_coil_0_fan": "100% (design point)",
"mchx_coil_1_fan": "80% (anti-override actif)",
"mchx_coil_2_fan": "100% (design point)",
"mchx_coil_3_fan": "90%",
"glycol_type": "MEG 35%",
"t_air_celsius": 35.0
}
}

View File

@@ -0,0 +1,159 @@
{
"name": "Chiller Air-Glycol 2 Circuits - Screw Economisé + MCHX",
"description": "Machine frigorifique 2 circuits indépendants. R134a, condenseurs MCHX (4 coils, air 35°C), évaporateurs noyés (MEG 35%, 12→7°C), compresseurs vis économisés VFD.",
"fluid": "R134a",
"circuits": [
{
"id": 0,
"components": [
{
"type": "ScrewEconomizerCompressor",
"name": "screw_0",
"fluid": "R134a",
"nominal_frequency_hz": 50.0,
"mechanical_efficiency": 0.92,
"economizer_fraction": 0.12,
"mf_a00": 1.20,
"mf_a10": 0.003,
"mf_a01": -0.002,
"mf_a11": 0.00001,
"pw_b00": 55000.0,
"pw_b10": 200.0,
"pw_b01": -300.0,
"pw_b11": 0.5,
"p_suction_bar": 3.2,
"h_suction_kj_kg": 400.0,
"p_discharge_bar": 12.8,
"h_discharge_kj_kg": 440.0,
"p_eco_bar": 6.4,
"h_eco_kj_kg": 260.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_0a",
"ua": 15000.0,
"coil_index": 0,
"n_air": 0.5,
"t_air_celsius": 35.0,
"fan_speed_ratio": 1.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_0b",
"ua": 15000.0,
"coil_index": 1,
"n_air": 0.5,
"t_air_celsius": 35.0,
"fan_speed_ratio": 1.0
},
{
"type": "Placeholder",
"name": "exv_0",
"n_equations": 2
},
{
"type": "FloodedEvaporator",
"name": "evap_0",
"ua": 20000.0,
"refrigerant": "R134a",
"secondary_fluid": "MEG",
"target_quality": 0.7
}
],
"edges": [
{ "from": "screw_0:outlet", "to": "mchx_0a:inlet" },
{ "from": "mchx_0a:outlet", "to": "mchx_0b:inlet" },
{ "from": "mchx_0b:outlet", "to": "exv_0:inlet" },
{ "from": "exv_0:outlet", "to": "evap_0:inlet" },
{ "from": "evap_0:outlet", "to": "screw_0:inlet" }
]
},
{
"id": 1,
"components": [
{
"type": "ScrewEconomizerCompressor",
"name": "screw_1",
"fluid": "R134a",
"nominal_frequency_hz": 50.0,
"mechanical_efficiency": 0.92,
"economizer_fraction": 0.12,
"mf_a00": 1.20,
"mf_a10": 0.003,
"mf_a01": -0.002,
"mf_a11": 0.00001,
"pw_b00": 55000.0,
"pw_b10": 200.0,
"pw_b01": -300.0,
"pw_b11": 0.5,
"p_suction_bar": 3.2,
"h_suction_kj_kg": 400.0,
"p_discharge_bar": 12.8,
"h_discharge_kj_kg": 440.0,
"p_eco_bar": 6.4,
"h_eco_kj_kg": 260.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_1a",
"ua": 15000.0,
"coil_index": 2,
"n_air": 0.5,
"t_air_celsius": 35.0,
"fan_speed_ratio": 1.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_1b",
"ua": 15000.0,
"coil_index": 3,
"n_air": 0.5,
"t_air_celsius": 35.0,
"fan_speed_ratio": 1.0
},
{
"type": "Placeholder",
"name": "exv_1",
"n_equations": 2
},
{
"type": "FloodedEvaporator",
"name": "evap_1",
"ua": 20000.0,
"refrigerant": "R134a",
"secondary_fluid": "MEG",
"target_quality": 0.7
}
],
"edges": [
{ "from": "screw_1:outlet", "to": "mchx_1a:inlet" },
{ "from": "mchx_1a:outlet", "to": "mchx_1b:inlet" },
{ "from": "mchx_1b:outlet", "to": "exv_1:inlet" },
{ "from": "exv_1:outlet", "to": "evap_1:inlet" },
{ "from": "evap_1:outlet", "to": "screw_1:inlet" }
]
}
],
"solver": {
"strategy": "fallback",
"max_iterations": 150,
"tolerance": 1e-6,
"timeout_ms": 5000,
"verbose": false
},
"metadata": {
"refrigerant": "R134a",
"application": "Air-cooled chiller",
"glycol_type": "MEG 35%",
"glycol_inlet_celsius": 12.0,
"glycol_outlet_celsius": 7.0,
"ambient_air_celsius": 35.0,
"nominal_capacity_kw": 400.0,
"n_coils": 4,
"n_circuits": 2
}
}

View File

@@ -0,0 +1,159 @@
{
"name": "Chiller Air-Glycol - Screw MCHX Run (Compatible)",
"description": "Simulation chiller 2 circuits avec ScrewEconomizerCompressor et MchxCondenserCoil. Les composants utilisent les n_equations compatibles avec le graphe (2 par arête).",
"fluid": "R134a",
"circuits": [
{
"id": 0,
"components": [
{
"type": "ScrewEconomizerCompressor",
"name": "screw_0",
"fluid": "R134a",
"nominal_frequency_hz": 50.0,
"mechanical_efficiency": 0.92,
"economizer_fraction": 0.12,
"mf_a00": 1.20,
"mf_a10": 0.003,
"mf_a01": -0.002,
"mf_a11": 0.00001,
"pw_b00": 55000.0,
"pw_b10": 200.0,
"pw_b01": -300.0,
"pw_b11": 0.5,
"p_suction_bar": 3.2,
"h_suction_kj_kg": 400.0,
"p_discharge_bar": 12.8,
"h_discharge_kj_kg": 440.0,
"p_eco_bar": 6.4,
"h_eco_kj_kg": 260.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_0a",
"ua": 15000.0,
"coil_index": 0,
"n_air": 0.5,
"t_air_celsius": 35.0,
"fan_speed_ratio": 1.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_0b",
"ua": 15000.0,
"coil_index": 1,
"n_air": 0.5,
"t_air_celsius": 35.0,
"fan_speed_ratio": 1.0
},
{
"type": "Placeholder",
"name": "exv_0",
"n_equations": 2
},
{
"type": "FloodedEvaporator",
"name": "evap_0",
"ua": 20000.0,
"refrigerant": "R134a",
"secondary_fluid": "MEG",
"target_quality": 0.7
}
],
"edges": [
{ "from": "screw_0:outlet", "to": "mchx_0a:inlet" },
{ "from": "mchx_0a:outlet", "to": "mchx_0b:inlet" },
{ "from": "mchx_0b:outlet", "to": "exv_0:inlet" },
{ "from": "exv_0:outlet", "to": "evap_0:inlet" },
{ "from": "evap_0:outlet", "to": "screw_0:inlet" }
]
},
{
"id": 1,
"components": [
{
"type": "ScrewEconomizerCompressor",
"name": "screw_1",
"fluid": "R134a",
"nominal_frequency_hz": 50.0,
"mechanical_efficiency": 0.92,
"economizer_fraction": 0.12,
"mf_a00": 1.20,
"mf_a10": 0.003,
"mf_a01": -0.002,
"mf_a11": 0.00001,
"pw_b00": 55000.0,
"pw_b10": 200.0,
"pw_b01": -300.0,
"pw_b11": 0.5,
"p_suction_bar": 3.2,
"h_suction_kj_kg": 400.0,
"p_discharge_bar": 12.8,
"h_discharge_kj_kg": 440.0,
"p_eco_bar": 6.4,
"h_eco_kj_kg": 260.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_1a",
"ua": 15000.0,
"coil_index": 2,
"n_air": 0.5,
"t_air_celsius": 35.0,
"fan_speed_ratio": 1.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_1b",
"ua": 15000.0,
"coil_index": 3,
"n_air": 0.5,
"t_air_celsius": 35.0,
"fan_speed_ratio": 1.0
},
{
"type": "Placeholder",
"name": "exv_1",
"n_equations": 2
},
{
"type": "FloodedEvaporator",
"name": "evap_1",
"ua": 20000.0,
"refrigerant": "R134a",
"secondary_fluid": "MEG",
"target_quality": 0.7
}
],
"edges": [
{ "from": "screw_1:outlet", "to": "mchx_1a:inlet" },
{ "from": "mchx_1a:outlet", "to": "mchx_1b:inlet" },
{ "from": "mchx_1b:outlet", "to": "exv_1:inlet" },
{ "from": "exv_1:outlet", "to": "evap_1:inlet" },
{ "from": "evap_1:outlet", "to": "screw_1:inlet" }
]
}
],
"solver": {
"strategy": "fallback",
"max_iterations": 200,
"tolerance": 1e-4,
"timeout_ms": 10000,
"verbose": false
},
"metadata": {
"refrigerant": "R134a",
"application": "Air-cooled chiller, screw with economizer",
"glycol_type": "MEG 35%",
"glycol_inlet_celsius": 12.0,
"glycol_outlet_celsius": 7.0,
"ambient_air_celsius": 35.0,
"n_coils": 4,
"n_circuits": 2,
"design_capacity_kw": 400
}
}

View File

@@ -0,0 +1,68 @@
{
"name": "Chiller Screw Economisé MCHX - Validation",
"description": "Fichier de validation pour tester le parsing du config sans lancer la simulation",
"fluid": "R134a",
"circuits": [
{
"id": 0,
"components": [
{
"type": "ScrewEconomizerCompressor",
"name": "screw_0"
},
{
"type": "Placeholder",
"name": "splitter_0",
"n_equations": 1
},
{
"type": "MchxCondenserCoil",
"name": "mchx_0a",
"ua": 15000.0,
"coil_index": 0,
"t_air_celsius": 35.0
},
{
"type": "MchxCondenserCoil",
"name": "mchx_0b",
"ua": 15000.0,
"coil_index": 1,
"t_air_celsius": 35.0
},
{
"type": "Placeholder",
"name": "merger_0",
"n_equations": 1
},
{
"type": "Placeholder",
"name": "exv_0",
"n_equations": 2
},
{
"type": "FloodedEvaporator",
"name": "evap_0",
"ua": 20000.0,
"refrigerant": "R134a",
"secondary_fluid": "MEG",
"target_quality": 0.7
}
],
"edges": [
{ "from": "screw_0:outlet", "to": "splitter_0:inlet" },
{ "from": "splitter_0:out_a", "to": "mchx_0a:inlet" },
{ "from": "splitter_0:out_b", "to": "mchx_0b:inlet" },
{ "from": "mchx_0a:outlet", "to": "merger_0:in_a" },
{ "from": "mchx_0b:outlet", "to": "merger_0:in_b" },
{ "from": "merger_0:outlet", "to": "exv_0:inlet" },
{ "from": "exv_0:outlet", "to": "evap_0:inlet" },
{ "from": "evap_0:outlet", "to": "screw_0:inlet" }
]
}
],
"solver": {
"strategy": "fallback",
"max_iterations": 100,
"tolerance": 1e-6
}
}

View File

@@ -16,6 +16,9 @@ pub struct ScenarioConfig {
pub name: Option<String>,
/// Fluid name (e.g., "R134a", "R410A", "R744").
pub fluid: String,
/// Fluid backend to use (e.g., "CoolProp", "Test"). Defaults to "Test".
#[serde(default)]
pub fluid_backend: Option<String>,
/// Circuit configurations.
#[serde(default)]
pub circuits: Vec<CircuitConfig>,
@@ -72,11 +75,42 @@ pub struct ComponentConfig {
pub component_type: String,
/// Component name for referencing in edges.
pub name: String,
/// Component-specific parameters.
// --- MchxCondenserCoil Specific Fields ---
/// Nominal UA value (kW/K). Maps to ua_nominal_kw_k.
#[serde(default)]
pub ua_nominal_kw_k: Option<f64>,
/// Fan speed ratio (0.0 to 1.0).
#[serde(default)]
pub fan_speed: Option<f64>,
/// Air inlet temperature in Celsius.
#[serde(default)]
pub air_inlet_temp_c: Option<f64>,
/// Air mass flow rate in kg/s.
#[serde(default)]
pub air_mass_flow_kg_s: Option<f64>,
/// Air side heat transfer exponent.
#[serde(default)]
pub n_air_exponent: Option<f64>,
/// Condenser bank spec identifier (used for creating multiple instances).
#[serde(default)]
pub condenser_bank: Option<CondenserBankConfig>,
// -----------------------------------------
/// Component-specific parameters (catch-all).
#[serde(flatten)]
pub params: HashMap<String, serde_json::Value>,
}
/// Configuration for a condenser bank (multi-circuit, multi-coil).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CondenserBankConfig {
/// Number of circuits.
pub circuits: usize,
/// Number of coils per circuit.
pub coils_per_circuit: usize,
}
/// Side conditions for a heat exchanger (hot or cold fluid).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SideConditionsConfig {
@@ -284,9 +318,17 @@ mod tests {
let json = r#"{ "fluid": "R134a" }"#;
let config = ScenarioConfig::from_json(json).unwrap();
assert_eq!(config.fluid, "R134a");
assert_eq!(config.fluid_backend, None);
assert!(config.circuits.is_empty());
}
#[test]
fn test_parse_config_with_backend() {
let json = r#"{ "fluid": "R134a", "fluid_backend": "CoolProp" }"#;
let config = ScenarioConfig::from_json(json).unwrap();
assert_eq!(config.fluid_backend.as_deref(), Some("CoolProp"));
}
#[test]
fn test_parse_full_config() {
let json = r#"
@@ -342,4 +384,38 @@ mod tests {
let result = ScenarioConfig::from_json(json);
assert!(result.is_err());
}
#[test]
fn test_parse_mchx_condenser_coil() {
let json = r#"
{
"fluid": "R134a",
"circuits": [{
"id": 0,
"components": [{
"type": "MchxCondenserCoil",
"name": "mchx_coil",
"ua_nominal_kw_k": 25.5,
"fan_speed": 0.8,
"air_inlet_temp_c": 35.0,
"condenser_bank": {
"circuits": 2,
"coils_per_circuit": 3
}
}],
"edges": []
}]
}"#;
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);
}
}

View File

@@ -127,25 +127,117 @@ fn execute_simulation(
use std::collections::HashMap;
let fluid_id = FluidId::new(&config.fluid);
let backend: Arc<dyn entropyk_fluids::FluidBackend> = Arc::new(TestBackend::new());
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 mapping per circuit
let mut component_indices: HashMap<String, petgraph::graph::NodeIndex> = HashMap::new();
for circuit_config in &config.circuits {
let circuit_id = CircuitId(circuit_config.id as u8);
// 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 {
match create_component(
&component_config.component_type,
&component_config.params,
&fluid_id,
Arc::clone(&backend),
) {
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);
// 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,
});
}
}
}
Err(e) => {
return SimulationResult {
@@ -183,6 +275,11 @@ fn execute_simulation(
}
// Add edges between components
// NOTE: Port specifications (e.g., "component:port_name") are parsed but currently ignored.
// Components are treated as simple nodes without port-level routing.
// Multi-port components like ScrewEconomizerCompressor have all ports created,
// but the topology system doesn't yet support port-specific edge connections.
// See Story 12-3 Task 3.3 for port-aware edge implementation.
for circuit_config in &config.circuits {
for edge in &circuit_config.edges {
let from_parts: Vec<&str> = edge.from.split(':').collect();
@@ -233,8 +330,8 @@ fn execute_simulation(
for coupling_config in &config.thermal_couplings {
let coupling = ThermalCoupling::new(
CircuitId(coupling_config.hot_circuit as u8),
CircuitId(coupling_config.cold_circuit as u8),
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);
@@ -266,6 +363,56 @@ fn execute_simulation(
};
}
// Add variables and constraints
for control in pending_controls {
if control.control_type == "fan_speed" {
use entropyk_solver::inverse::{
BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId,
};
// Generate unique IDs
let var_id =
BoundedVariableId::new(format!("fan_speed_var_{}", control.component_node.index()));
let cons_id =
ConstraintId::new(format!("fan_speed_cons_{}", control.component_node.index()));
// Find the component's generated name to use in ComponentOutput
let mut comp_name = String::new();
for (name, node) in &component_indices {
if *node == control.component_node {
comp_name = name.clone();
break;
}
}
// In the MCHX MVP, we want the fan speed itself to be a DOFs.
// Wait, bounded variable links to a constraint. A constraint targets an output.
// If the user wants to control CAPACITY by varying FAN SPEED...
// Let's check config to see what output they want to control.
// Actually, AC says: "Paramètre fan_control: "bounded" (crée une BoundedVariable avec Constraint)"
// Let's implement this generically if they provided target parameters.
let target = 0.0; // Needs to come from config, but config parsing doesn't provide constraint target yet.
// Story says: "Si oui, on crée une BoundedVariable..." but then "Constraint".
// If we don't have the constraint target in ComponentConfig, we can't fully wire it up just for fan speed without knowing what it controls (e.g. pressure or capacity).
// Let's log a warning for now and wait for full control loop config in a future story, or just add the variable.
let var = BoundedVariable::with_component(
var_id.clone(),
&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());
@@ -305,16 +452,28 @@ fn execute_simulation(
elapsed_ms,
}
}
Err(e) => SimulationResult {
input: input_name.to_string(),
status: SimulationStatus::Error,
convergence: None,
iterations: None,
state: None,
performance: None,
error: Some(format!("Solver error: {:?}", e)),
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,
}
}
}
}
@@ -364,17 +523,174 @@ fn parse_side_conditions(
)?)
}
/// 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_type: &str,
params: &std::collections::HashMap<String, serde_json::Value>,
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::{MchxCondenserCoil, 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 = params
.get("economizer_fraction")
.and_then(|v| v.as_f64())
.unwrap_or(0.12);
// Mass-flow polynomial coefficients (bilinear SST/SDT)
let mf_a00 = params.get("mf_a00").and_then(|v| v.as_f64()).unwrap_or(1.2);
let mf_a10 = params.get("mf_a10").and_then(|v| v.as_f64()).unwrap_or(0.003);
let mf_a01 = params.get("mf_a01").and_then(|v| v.as_f64()).unwrap_or(-0.002);
let mf_a11 = params.get("mf_a11").and_then(|v| v.as_f64()).unwrap_or(1e-5);
// Power polynomial coefficients (bilinear)
let pw_b00 = params.get("pw_b00").and_then(|v| v.as_f64()).unwrap_or(55_000.0);
let pw_b10 = params.get("pw_b10").and_then(|v| v.as_f64()).unwrap_or(200.0);
let pw_b01 = params.get("pw_b01").and_then(|v| v.as_f64()).unwrap_or(-300.0);
let pw_b11 = params.get("pw_b11").and_then(|v| v.as_f64()).unwrap_or(0.5);
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());
@@ -468,7 +784,7 @@ fn create_component(
}
_ => Err(CliError::Config(format!(
"Unknown component type: '{}'. Supported: Condenser, CondenserCoil, Evaporator, EvaporatorCoil, HeatExchanger, Compressor, ExpansionValve, Pump, Placeholder",
"Unknown component type: '{}'. Supported: ScrewEconomizerCompressor, MchxCondenserCoil, FloodedEvaporator, Condenser, CondenserCoil, Evaporator, EvaporatorCoil, HeatExchanger, Compressor, ExpansionValve, Pump, Placeholder",
component_type
))),
}
@@ -516,7 +832,7 @@ impl SimpleComponent {
impl entropyk::Component for SimpleComponent {
fn compute_residuals(
&self,
state: &entropyk::SystemState,
state: &[f64],
residuals: &mut entropyk::ResidualVector,
) -> Result<(), entropyk::ComponentError> {
for i in 0..self.n_eqs.min(residuals.len()) {
@@ -531,7 +847,7 @@ impl entropyk::Component for SimpleComponent {
fn jacobian_entries(
&self,
_state: &entropyk::SystemState,
_state: &[f64],
jacobian: &mut entropyk::JacobianBuilder,
) -> Result<(), entropyk::ComponentError> {
for i in 0..self.n_eqs {
@@ -624,7 +940,7 @@ impl PyCompressor {
impl entropyk::Component for PyCompressor {
fn compute_residuals(
&self,
state: &entropyk::SystemState,
state: &[f64],
residuals: &mut entropyk::ResidualVector,
) -> Result<(), entropyk::ComponentError> {
for r in residuals.iter_mut() {
@@ -639,7 +955,7 @@ impl entropyk::Component for PyCompressor {
fn jacobian_entries(
&self,
_state: &entropyk::SystemState,
_state: &[f64],
jacobian: &mut entropyk::JacobianBuilder,
) -> Result<(), entropyk::ComponentError> {
jacobian.add_entry(0, 0, 1.0);
@@ -673,7 +989,7 @@ impl PyExpansionValve {
impl entropyk::Component for PyExpansionValve {
fn compute_residuals(
&self,
state: &entropyk::SystemState,
state: &[f64],
residuals: &mut entropyk::ResidualVector,
) -> Result<(), entropyk::ComponentError> {
for r in residuals.iter_mut() {
@@ -687,7 +1003,7 @@ impl entropyk::Component for PyExpansionValve {
fn jacobian_entries(
&self,
_state: &entropyk::SystemState,
_state: &[f64],
jacobian: &mut entropyk::JacobianBuilder,
) -> Result<(), entropyk::ComponentError> {
jacobian.add_entry(0, 0, 1.0);

View File

@@ -85,6 +85,7 @@ fn test_simulation_result_statuses() {
convergence: None,
iterations: Some(10),
state: None,
performance: None,
error: None,
elapsed_ms: 50,
},
@@ -94,6 +95,7 @@ fn test_simulation_result_statuses() {
convergence: None,
iterations: None,
state: None,
performance: None,
error: Some("Error".to_string()),
elapsed_ms: 0,
},
@@ -103,6 +105,7 @@ fn test_simulation_result_statuses() {
convergence: None,
iterations: Some(100),
state: None,
performance: None,
error: None,
elapsed_ms: 1000,
},

View File

@@ -19,6 +19,7 @@ fn test_simulation_result_serialization() {
pressure_bar: 10.0,
enthalpy_kj_kg: 400.0,
}]),
performance: None,
error: None,
elapsed_ms: 50,
};
@@ -55,6 +56,7 @@ fn test_error_result_serialization() {
convergence: None,
iterations: None,
state: None,
performance: None,
error: Some("Configuration error".to_string()),
elapsed_ms: 0,
};
@@ -75,3 +77,125 @@ fn test_create_minimal_config_file() {
let content = std::fs::read_to_string(&config_path).unwrap();
assert!(content.contains("R134a"));
}
#[test]
fn test_screw_compressor_frequency_hz_config() {
use entropyk_cli::config::ScenarioConfig;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let config_path = dir.path().join("screw_vfd.json");
let json = r#"
{
"name": "Screw VFD Test",
"fluid": "R134a",
"circuits": [
{
"id": 0,
"components": [
{
"type": "ScrewEconomizerCompressor",
"name": "screw_test",
"fluid": "R134a",
"nominal_frequency_hz": 50.0,
"frequency_hz": 40.0,
"mechanical_efficiency": 0.92,
"economizer_fraction": 0.12,
"mf_a00": 1.2,
"mf_a10": 0.003,
"mf_a01": -0.002,
"mf_a11": 0.00001,
"pw_b00": 55000.0,
"pw_b10": 200.0,
"pw_b01": -300.0,
"pw_b11": 0.5,
"p_suction_bar": 3.2,
"h_suction_kj_kg": 400.0,
"p_discharge_bar": 12.8,
"h_discharge_kj_kg": 440.0,
"p_eco_bar": 6.4,
"h_eco_kj_kg": 260.0
}
],
"edges": []
}
],
"solver": {
"strategy": "fallback",
"max_iterations": 10
}
}
"#;
std::fs::write(&config_path, json).unwrap();
let config = ScenarioConfig::from_file(&config_path);
assert!(config.is_ok(), "Config should parse successfully");
let config = config.unwrap();
assert_eq!(config.circuits.len(), 1);
let screw_params = &config.circuits[0].components[0].params;
assert_eq!(
screw_params.get("frequency_hz").and_then(|v| v.as_f64()),
Some(40.0)
);
assert_eq!(
screw_params
.get("nominal_frequency_hz")
.and_then(|v| v.as_f64()),
Some(50.0)
);
}
#[test]
fn test_run_simulation_with_coolprop() {
use entropyk_cli::run::run_simulation;
let dir = tempdir().unwrap();
let config_path = dir.path().join("coolprop.json");
let json = r#"
{
"fluid": "R134a",
"fluid_backend": "CoolProp",
"circuits": [
{
"id": 0,
"components": [
{
"type": "HeatExchanger",
"name": "hx1",
"ua": 1000.0,
"hot_fluid": "Water",
"hot_t_inlet_c": 25.0,
"cold_fluid": "R134a",
"cold_t_inlet_c": 15.0
}
],
"edges": []
}
],
"solver": { "max_iterations": 1 }
}
"#;
std::fs::write(&config_path, json).unwrap();
let result = run_simulation(&config_path, None, false).unwrap();
match result.status {
SimulationStatus::Converged | SimulationStatus::NonConverged => {}
SimulationStatus::Error => {
let err_msg = result.error.unwrap();
assert!(
err_msg.contains("CoolProp")
|| err_msg.contains("Fluid")
|| err_msg.contains("Component"),
"Unexpected error: {}",
err_msg
);
}
_ => panic!("Unexpected status: {:?}", result.status),
}
}