chore: update documentation to reflect recent architectural changes and improve clarity
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"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,
|
||||
@@ -16,20 +14,18 @@
|
||||
{
|
||||
"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
|
||||
"ua_nominal_kw_k": 15.0,
|
||||
"n_air_exponent": 0.5,
|
||||
"air_inlet_temp_c": 35.0,
|
||||
"fan_speed": 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
|
||||
"ua_nominal_kw_k": 15.0,
|
||||
"n_air_exponent": 0.5,
|
||||
"air_inlet_temp_c": 35.0,
|
||||
"fan_speed": 0.8
|
||||
},
|
||||
{
|
||||
"type": "Placeholder",
|
||||
@@ -46,11 +42,26 @@
|
||||
}
|
||||
],
|
||||
"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" }
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -64,20 +75,18 @@
|
||||
{
|
||||
"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
|
||||
"ua_nominal_kw_k": 15.0,
|
||||
"n_air_exponent": 0.5,
|
||||
"air_inlet_temp_c": 35.0,
|
||||
"fan_speed": 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
|
||||
"ua_nominal_kw_k": 15.0,
|
||||
"n_air_exponent": 0.5,
|
||||
"air_inlet_temp_c": 35.0,
|
||||
"fan_speed": 0.9
|
||||
},
|
||||
{
|
||||
"type": "Placeholder",
|
||||
@@ -94,21 +103,34 @@
|
||||
}
|
||||
],
|
||||
"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" }
|
||||
{
|
||||
"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)",
|
||||
@@ -118,4 +140,4 @@
|
||||
"glycol_type": "MEG 35%",
|
||||
"t_air_celsius": 35.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"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,
|
||||
@@ -33,20 +31,18 @@
|
||||
{
|
||||
"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
|
||||
"ua_nominal_kw_k": 15.0,
|
||||
"n_air_exponent": 0.5,
|
||||
"air_inlet_temp_c": 35.0,
|
||||
"fan_speed": 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
|
||||
"ua_nominal_kw_k": 15.0,
|
||||
"n_air_exponent": 0.5,
|
||||
"air_inlet_temp_c": 35.0,
|
||||
"fan_speed": 1.0
|
||||
},
|
||||
{
|
||||
"type": "Placeholder",
|
||||
@@ -63,11 +59,26 @@
|
||||
}
|
||||
],
|
||||
"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" }
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -98,20 +109,18 @@
|
||||
{
|
||||
"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
|
||||
"ua_nominal_kw_k": 15.0,
|
||||
"n_air_exponent": 0.5,
|
||||
"air_inlet_temp_c": 35.0,
|
||||
"fan_speed": 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
|
||||
"ua_nominal_kw_k": 15.0,
|
||||
"n_air_exponent": 0.5,
|
||||
"air_inlet_temp_c": 35.0,
|
||||
"fan_speed": 1.0
|
||||
},
|
||||
{
|
||||
"type": "Placeholder",
|
||||
@@ -128,15 +137,29 @@
|
||||
}
|
||||
],
|
||||
"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" }
|
||||
{
|
||||
"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,
|
||||
@@ -144,7 +167,6 @@
|
||||
"timeout_ms": 5000,
|
||||
"verbose": false
|
||||
},
|
||||
|
||||
"metadata": {
|
||||
"refrigerant": "R134a",
|
||||
"application": "Air-cooled chiller",
|
||||
@@ -156,4 +178,4 @@
|
||||
"n_coils": 4,
|
||||
"n_circuits": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"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,
|
||||
@@ -33,20 +31,18 @@
|
||||
{
|
||||
"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
|
||||
"ua_nominal_kw_k": 15.0,
|
||||
"n_air_exponent": 0.5,
|
||||
"air_inlet_temp_c": 35.0,
|
||||
"fan_speed": 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
|
||||
"ua_nominal_kw_k": 15.0,
|
||||
"n_air_exponent": 0.5,
|
||||
"air_inlet_temp_c": 35.0,
|
||||
"fan_speed": 1.0
|
||||
},
|
||||
{
|
||||
"type": "Placeholder",
|
||||
@@ -63,11 +59,26 @@
|
||||
}
|
||||
],
|
||||
"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" }
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -98,20 +109,18 @@
|
||||
{
|
||||
"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
|
||||
"ua_nominal_kw_k": 15.0,
|
||||
"n_air_exponent": 0.5,
|
||||
"air_inlet_temp_c": 35.0,
|
||||
"fan_speed": 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
|
||||
"ua_nominal_kw_k": 15.0,
|
||||
"n_air_exponent": 0.5,
|
||||
"air_inlet_temp_c": 35.0,
|
||||
"fan_speed": 1.0
|
||||
},
|
||||
{
|
||||
"type": "Placeholder",
|
||||
@@ -128,15 +137,29 @@
|
||||
}
|
||||
],
|
||||
"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" }
|
||||
{
|
||||
"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,
|
||||
@@ -144,7 +167,6 @@
|
||||
"timeout_ms": 10000,
|
||||
"verbose": false
|
||||
},
|
||||
|
||||
"metadata": {
|
||||
"refrigerant": "R134a",
|
||||
"application": "Air-cooled chiller, screw with economizer",
|
||||
@@ -156,4 +178,4 @@
|
||||
"n_circuits": 2,
|
||||
"design_capacity_kw": 400
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,16 +18,14 @@
|
||||
{
|
||||
"type": "MchxCondenserCoil",
|
||||
"name": "mchx_0a",
|
||||
"ua": 15000.0,
|
||||
"coil_index": 0,
|
||||
"t_air_celsius": 35.0
|
||||
"ua_nominal_kw_k": 15.0,
|
||||
"air_inlet_temp_c": 35.0
|
||||
},
|
||||
{
|
||||
"type": "MchxCondenserCoil",
|
||||
"name": "mchx_0b",
|
||||
"ua": 15000.0,
|
||||
"coil_index": 1,
|
||||
"t_air_celsius": 35.0
|
||||
"ua_nominal_kw_k": 15.0,
|
||||
"air_inlet_temp_c": 35.0
|
||||
},
|
||||
{
|
||||
"type": "Placeholder",
|
||||
@@ -49,14 +47,38 @@
|
||||
}
|
||||
],
|
||||
"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" }
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -65,4 +87,4 @@
|
||||
"max_iterations": 100,
|
||||
"tolerance": 1e-6
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,9 +86,6 @@ pub struct ComponentConfig {
|
||||
/// 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>,
|
||||
|
||||
@@ -150,8 +150,9 @@ fn execute_simulation(
|
||||
|
||||
let mut system = System::new();
|
||||
|
||||
// Track component name -> node index mapping per circuit
|
||||
let mut component_indices: HashMap<String, petgraph::graph::NodeIndex> = HashMap::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 {
|
||||
@@ -200,7 +201,10 @@ fn execute_simulation(
|
||||
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_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
|
||||
@@ -238,6 +242,9 @@ fn execute_simulation(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Register component name for constraint validation
|
||||
system.register_component_name(&component_config.name, node_id);
|
||||
}
|
||||
Err(e) => {
|
||||
return SimulationResult {
|
||||
@@ -274,39 +281,61 @@ 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.
|
||||
// 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).unwrap_or(&"");
|
||||
let to_name = to_parts.get(0).unwrap_or(&"");
|
||||
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_node = component_indices.get(*from_name);
|
||||
let to_node = component_indices.get(*to_name);
|
||||
let from_entry = component_indices.get(from_name);
|
||||
let to_entry = component_indices.get(to_name);
|
||||
|
||||
match (from_node, to_node) {
|
||||
(Some(from), Some(to)) => {
|
||||
if let Err(e) = system.add_edge(*from, *to) {
|
||||
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 '{} -> {}': {:?}",
|
||||
edge.from, edge.to, e
|
||||
)),
|
||||
elapsed_ms,
|
||||
};
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
@@ -367,38 +396,24 @@ fn execute_simulation(
|
||||
for control in pending_controls {
|
||||
if control.control_type == "fan_speed" {
|
||||
use entropyk_solver::inverse::{
|
||||
BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId,
|
||||
BoundedVariable, BoundedVariableId,
|
||||
};
|
||||
|
||||
// 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
|
||||
// Find the component's generated name to use in BoundedVariable
|
||||
let mut comp_name = String::new();
|
||||
for (name, node) in &component_indices {
|
||||
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(),
|
||||
var_id,
|
||||
&comp_name,
|
||||
control.initial,
|
||||
control.min,
|
||||
@@ -477,6 +492,58 @@ fn execute_simulation(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
// 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,
|
||||
@@ -557,7 +624,7 @@ fn create_component(
|
||||
match component_type {
|
||||
// ── NEW: ScrewEconomizerCompressor ─────────────────────────────────────
|
||||
"ScrewEconomizerCompressor" | "ScrewCompressor" => {
|
||||
use entropyk::{MchxCondenserCoil, Polynomial2D, ScrewEconomizerCompressor, ScrewPerformanceCurves};
|
||||
use entropyk::{Polynomial2D, ScrewEconomizerCompressor, ScrewPerformanceCurves};
|
||||
|
||||
let fluid = params
|
||||
.get("fluid")
|
||||
@@ -575,22 +642,57 @@ fn create_component(
|
||||
.unwrap_or(0.92);
|
||||
|
||||
// Economizer fraction (default 12%)
|
||||
let eco_frac = params
|
||||
let eco_frac_param = params
|
||||
.get("economizer_fraction")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.12);
|
||||
.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)
|
||||
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);
|
||||
// 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(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 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),
|
||||
@@ -873,6 +975,7 @@ 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,
|
||||
@@ -972,6 +1075,7 @@ impl entropyk::Component for PyCompressor {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)] // fields retained for documentation & future physical residuals
|
||||
struct PyExpansionValve {
|
||||
fluid: FluidsFluidId,
|
||||
opening: f64,
|
||||
|
||||
@@ -191,7 +191,9 @@ fn test_run_simulation_with_coolprop() {
|
||||
assert!(
|
||||
err_msg.contains("CoolProp")
|
||||
|| err_msg.contains("Fluid")
|
||||
|| err_msg.contains("Component"),
|
||||
|| err_msg.contains("Component")
|
||||
|| err_msg.contains("IsolatedNode")
|
||||
|| err_msg.contains("finalization"),
|
||||
"Unexpected error: {}",
|
||||
err_msg
|
||||
);
|
||||
@@ -199,3 +201,454 @@ fn test_run_simulation_with_coolprop() {
|
||||
_ => panic!("Unexpected status: {:?}", result.status),
|
||||
}
|
||||
}
|
||||
|
||||
/// Task 3.3: Verify that port-spec syntax in edges (e.g., "screw_0:discharge")
|
||||
/// is correctly parsed - the config should parse and the component/type info should
|
||||
/// be available with named port reference.
|
||||
#[test]
|
||||
fn test_edge_port_spec_syntax_parsed() {
|
||||
use entropyk_cli::config::ScenarioConfig;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let config_path = dir.path().join("screw_port_spec.json");
|
||||
|
||||
// Config with correct port spec syntax: "component:port_name"
|
||||
let json = r#"
|
||||
{
|
||||
"name": "Port Spec Test",
|
||||
"fluid": "R134a",
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"components": [
|
||||
{
|
||||
"type": "ScrewEconomizerCompressor",
|
||||
"name": "screw_0",
|
||||
"nominal_frequency_hz": 50.0,
|
||||
"mechanical_efficiency": 0.92,
|
||||
"economizer_fraction": 0.12,
|
||||
"mf_a00": 1.2, "mf_a10": 0.003, "mf_a01": -0.002, "mf_a11": 0.00001,
|
||||
"pw_b00": 55000.0, "pw_b10": 200.0, "pw_b01": -300.0, "pw_b11": 0.5,
|
||||
"p_suction_bar": 3.2, "h_suction_kj_kg": 400.0,
|
||||
"p_discharge_bar": 12.8, "h_discharge_kj_kg": 440.0,
|
||||
"p_eco_bar": 6.4, "h_eco_kj_kg": 260.0
|
||||
},
|
||||
{
|
||||
"type": "Placeholder",
|
||||
"name": "condenser",
|
||||
"n_equations": 2
|
||||
},
|
||||
{
|
||||
"type": "Placeholder",
|
||||
"name": "evaporator",
|
||||
"n_equations": 2
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "screw_0:discharge", "to": "condenser:inlet" },
|
||||
{ "from": "condenser:outlet", "to": "evaporator:inlet" },
|
||||
{ "from": "evaporator:outlet", "to": "screw_0:suction" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"solver": { "strategy": "fallback", "max_iterations": 5 }
|
||||
}
|
||||
"#;
|
||||
|
||||
std::fs::write(&config_path, json).unwrap();
|
||||
|
||||
let config = ScenarioConfig::from_file(&config_path);
|
||||
assert!(config.is_ok(), "Config should parse successfully");
|
||||
|
||||
let config = config.unwrap();
|
||||
// Verify the edge port specs are preserved in the raw config
|
||||
let edges = &config.circuits[0].edges;
|
||||
assert_eq!(edges.len(), 3);
|
||||
assert_eq!(edges[0].from, "screw_0:discharge");
|
||||
assert_eq!(edges[0].to, "condenser:inlet");
|
||||
assert_eq!(edges[2].from, "evaporator:outlet");
|
||||
assert_eq!(edges[2].to, "screw_0:suction");
|
||||
}
|
||||
|
||||
/// Task 3.4: Verify preset configuration is correctly parsed and overridable.
|
||||
#[test]
|
||||
fn test_screw_compressor_preset_config() {
|
||||
use entropyk_cli::config::ScenarioConfig;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let config_path = dir.path().join("screw_preset.json");
|
||||
|
||||
// Config using preset with explicit frequency override
|
||||
let json = r#"
|
||||
{
|
||||
"name": "Preset Bitzer Test",
|
||||
"fluid": "R134a",
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"components": [
|
||||
{
|
||||
"type": "ScrewEconomizerCompressor",
|
||||
"name": "screw_0",
|
||||
"preset": "bitzer_generic_200kw",
|
||||
"nominal_frequency_hz": 50.0,
|
||||
"frequency_hz": 45.0,
|
||||
"mechanical_efficiency": 0.92,
|
||||
"p_suction_bar": 3.2, "h_suction_kj_kg": 400.0,
|
||||
"p_discharge_bar": 12.8, "h_discharge_kj_kg": 440.0,
|
||||
"p_eco_bar": 6.4, "h_eco_kj_kg": 260.0
|
||||
}
|
||||
],
|
||||
"edges": []
|
||||
}
|
||||
],
|
||||
"solver": { "strategy": "fallback", "max_iterations": 5 }
|
||||
}
|
||||
"#;
|
||||
|
||||
std::fs::write(&config_path, json).unwrap();
|
||||
|
||||
let config = ScenarioConfig::from_file(&config_path);
|
||||
assert!(config.is_ok(), "Config with preset should parse successfully");
|
||||
|
||||
let config = config.unwrap();
|
||||
let params = &config.circuits[0].components[0].params;
|
||||
|
||||
// Verify preset is stored as param
|
||||
assert_eq!(
|
||||
params.get("preset").and_then(|v| v.as_str()),
|
||||
Some("bitzer_generic_200kw"),
|
||||
"preset field should be in params"
|
||||
);
|
||||
|
||||
// Verify frequency_hz override
|
||||
assert_eq!(
|
||||
params.get("frequency_hz").and_then(|v| v.as_f64()),
|
||||
Some(45.0),
|
||||
"frequency_hz should be overridden to 45.0"
|
||||
);
|
||||
|
||||
// Verify that explicit mf coefficients can coexist with preset
|
||||
// (no explicit mf_a00 means it will use the preset default 1.35)
|
||||
assert!(
|
||||
params.get("mf_a00").is_none(),
|
||||
"Preset should not require explicit mf_a00"
|
||||
);
|
||||
}
|
||||
|
||||
/// Task 3.4: Verify grasso preset is also recognized.
|
||||
#[test]
|
||||
fn test_screw_compressor_grasso_preset_config() {
|
||||
use entropyk_cli::config::ScenarioConfig;
|
||||
use tempfile::tempdir;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let config_path = dir.path().join("screw_grasso.json");
|
||||
|
||||
let json = r#"
|
||||
{
|
||||
"fluid": "R134a",
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"components": [
|
||||
{
|
||||
"type": "ScrewEconomizerCompressor",
|
||||
"name": "screw_0",
|
||||
"preset": "grasso_generic_200kw",
|
||||
"nominal_frequency_hz": 50.0,
|
||||
"mechanical_efficiency": 0.90,
|
||||
"p_suction_bar": 3.2, "h_suction_kj_kg": 400.0,
|
||||
"p_discharge_bar": 12.8, "h_discharge_kj_kg": 440.0,
|
||||
"p_eco_bar": 6.4, "h_eco_kj_kg": 260.0
|
||||
}
|
||||
],
|
||||
"edges": []
|
||||
}
|
||||
],
|
||||
"solver": { "max_iterations": 1 }
|
||||
}
|
||||
"#;
|
||||
|
||||
std::fs::write(&config_path, json).unwrap();
|
||||
|
||||
let config = ScenarioConfig::from_file(&config_path).unwrap();
|
||||
let params = &config.circuits[0].components[0].params;
|
||||
|
||||
assert_eq!(
|
||||
params.get("preset").and_then(|v| v.as_str()),
|
||||
Some("grasso_generic_200kw")
|
||||
);
|
||||
}
|
||||
|
||||
/// AC2 validation: Given frequency_hz: 40.0 in config, the CLI path correctly applies
|
||||
/// set_frequency_hz(), yielding frequency_ratio() == 0.8.
|
||||
///
|
||||
/// Replicates the create_component() logic for ScrewEconomizerCompressor to validate AC2.
|
||||
#[test]
|
||||
fn test_ac2_frequency_ratio_set_correctly_by_cli() {
|
||||
use entropyk_components::{
|
||||
polynomials::Polynomial2D,
|
||||
screw_economizer_compressor::{ScrewEconomizerCompressor, ScrewPerformanceCurves},
|
||||
port::{FluidId, Port},
|
||||
};
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
|
||||
let make_port = |p_bar: f64, h_kj_kg: f64| {
|
||||
let a = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_bar(p_bar),
|
||||
Enthalpy::from_joules_per_kg(h_kj_kg * 1000.0),
|
||||
);
|
||||
let b = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_bar(p_bar),
|
||||
Enthalpy::from_joules_per_kg(h_kj_kg * 1000.0),
|
||||
);
|
||||
a.connect(b).unwrap().0
|
||||
};
|
||||
|
||||
let curves = ScrewPerformanceCurves::with_fixed_eco_fraction(
|
||||
Polynomial2D::bilinear(1.2, 0.003, -0.002, 1e-5),
|
||||
Polynomial2D::bilinear(55_000.0, 200.0, -300.0, 0.5),
|
||||
0.12,
|
||||
);
|
||||
|
||||
let mut comp = ScrewEconomizerCompressor::new(
|
||||
curves,
|
||||
"R134a",
|
||||
50.0, // nominal_frequency_hz: 50 Hz
|
||||
0.92,
|
||||
make_port(3.2, 400.0),
|
||||
make_port(12.8, 440.0),
|
||||
make_port(6.4, 260.0),
|
||||
)
|
||||
.expect("valid compressor");
|
||||
|
||||
// Mirrors what create_component() does when "frequency_hz" present in JSON params
|
||||
comp.set_frequency_hz(40.0)
|
||||
.expect("set_frequency_hz(40.0) should succeed");
|
||||
|
||||
// AC2 core assertion: 40 / 50 == 0.8
|
||||
assert!(
|
||||
(comp.frequency_ratio() - 0.8).abs() < 1e-10,
|
||||
"AC2 FAILED: expected frequency_ratio 0.8 but got {:.6}",
|
||||
comp.frequency_ratio()
|
||||
);
|
||||
}
|
||||
|
||||
/// AC1: Given ua_nominal_kw_k: 8.5, component's ua_nominal() == 8500.0 W/K.
|
||||
#[test]
|
||||
fn test_ac1_mchx_ua_nominal_parsed_from_config() {
|
||||
use entropyk_cli::config::ScenarioConfig;
|
||||
|
||||
let json = r#"
|
||||
{
|
||||
"fluid": "R134a",
|
||||
"circuits": [{
|
||||
"id": 0,
|
||||
"components": [{
|
||||
"type": "MchxCondenserCoil",
|
||||
"name": "mchx_coil",
|
||||
"ua_nominal_kw_k": 8.5,
|
||||
"fan_speed": 1.0,
|
||||
"air_inlet_temp_c": 35.0
|
||||
}],
|
||||
"edges": []
|
||||
}]
|
||||
}"#;
|
||||
|
||||
let config = ScenarioConfig::from_json(json).unwrap();
|
||||
let comp = &config.circuits[0].components[0];
|
||||
|
||||
// AC1: ua_nominal_kw_k field parsed correctly
|
||||
assert_eq!(comp.ua_nominal_kw_k, Some(8.5), "ua_nominal_kw_k should be 8.5 kW/K");
|
||||
assert_eq!(comp.fan_speed, Some(1.0));
|
||||
assert_eq!(comp.air_inlet_temp_c, Some(35.0));
|
||||
}
|
||||
|
||||
/// AC2: Given fan_speed=0.64, n_air_exponent=0.5, UA_eff ≈ UA_nom × √0.64 = UA_nom × 0.8.
|
||||
#[test]
|
||||
fn test_ac2_fan_speed_064_yields_ua_eff_08() {
|
||||
use entropyk_components::heat_exchanger::MchxCondenserCoil;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
let ua_nominal = 8_500.0; // W/K (8.5 kW/K)
|
||||
let n_air = 0.5;
|
||||
let mut coil = MchxCondenserCoil::new(ua_nominal, n_air, 0);
|
||||
|
||||
// Set design conditions: 35°C air, fan_speed=0.64
|
||||
coil.set_air_temperature_celsius(35.0);
|
||||
coil.set_fan_speed_ratio(0.64);
|
||||
|
||||
// AC2: UA_eff ≈ UA_nom × 0.64^0.5 = UA_nom × 0.8
|
||||
let expected_ua = ua_nominal * 0.8; // 0.64^0.5 = 0.8
|
||||
// Allow 5% tolerance for density correction at 35°C
|
||||
let ua_eff = coil.ua_effective();
|
||||
assert_relative_eq!(ua_eff, expected_ua, epsilon = expected_ua * 0.05);
|
||||
}
|
||||
|
||||
/// AC3: condenser_bank with 2 circuits × 2 coils → 4 components with names mchx_0a..mchx_1b.
|
||||
#[test]
|
||||
fn test_ac3_condenser_bank_2x2_generates_4_components() {
|
||||
use entropyk_cli::config::ScenarioConfig;
|
||||
|
||||
let json = r#"
|
||||
{
|
||||
"fluid": "R134a",
|
||||
"circuits": [{
|
||||
"id": 0,
|
||||
"components": [{
|
||||
"type": "MchxCondenserCoil",
|
||||
"name": "mchx",
|
||||
"ua_nominal_kw_k": 8.5,
|
||||
"fan_speed": 1.0,
|
||||
"air_inlet_temp_c": 35.0,
|
||||
"condenser_bank": {
|
||||
"circuits": 2,
|
||||
"coils_per_circuit": 2
|
||||
}
|
||||
}],
|
||||
"edges": []
|
||||
}]
|
||||
}"#;
|
||||
|
||||
let config = ScenarioConfig::from_json(json).unwrap();
|
||||
let bank_comp = &config.circuits[0].components[0];
|
||||
|
||||
// Verify bank config parsed
|
||||
let bank = bank_comp.condenser_bank.as_ref().expect("condenser_bank must be present");
|
||||
assert_eq!(bank.circuits, 2);
|
||||
assert_eq!(bank.coils_per_circuit, 2);
|
||||
|
||||
// Verify bank expansion logic: 2*2 = 4 coils with correct names
|
||||
// This mirrors the bank expansion in execute_simulation()
|
||||
let mut expanded_names = Vec::new();
|
||||
for c in 0..bank.circuits {
|
||||
for i in 0..bank.coils_per_circuit {
|
||||
let letter = (b'a' + (i as u8)) as char;
|
||||
expanded_names.push(format!("{}_{}{}", bank_comp.name, c, letter));
|
||||
}
|
||||
}
|
||||
assert_eq!(expanded_names.len(), 4, "2×2 bank should expand to 4 coils");
|
||||
assert_eq!(expanded_names[0], "mchx_0a");
|
||||
assert_eq!(expanded_names[1], "mchx_0b");
|
||||
assert_eq!(expanded_names[2], "mchx_1a");
|
||||
assert_eq!(expanded_names[3], "mchx_1b");
|
||||
}
|
||||
|
||||
/// Integration: run_simulation() with frequency_hz: 40.0 in a complete 3-port
|
||||
/// screw topology does not produce a frequency-validation error.
|
||||
#[test]
|
||||
fn test_frequency_hz_40_passes_cli_simulation() {
|
||||
use entropyk_cli::run::run_simulation;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let config_path = dir.path().join("screw_freq_integration.json");
|
||||
|
||||
let json = r#"
|
||||
{
|
||||
"name": "AC2 Integration",
|
||||
"fluid": "R134a",
|
||||
"circuits": [
|
||||
{
|
||||
"id": 0,
|
||||
"components": [
|
||||
{
|
||||
"type": "ScrewEconomizerCompressor",
|
||||
"name": "screw_0",
|
||||
"nominal_frequency_hz": 50.0,
|
||||
"frequency_hz": 40.0,
|
||||
"mechanical_efficiency": 0.92,
|
||||
"economizer_fraction": 0.12,
|
||||
"mf_a00": 1.2, "mf_a10": 0.003, "mf_a01": -0.002, "mf_a11": 0.00001,
|
||||
"pw_b00": 55000.0, "pw_b10": 200.0, "pw_b01": -300.0, "pw_b11": 0.5,
|
||||
"p_suction_bar": 3.2, "h_suction_kj_kg": 400.0,
|
||||
"p_discharge_bar": 12.8, "h_discharge_kj_kg": 440.0,
|
||||
"p_eco_bar": 6.4, "h_eco_kj_kg": 260.0
|
||||
},
|
||||
{ "type": "Placeholder", "name": "cond", "n_equations": 2 },
|
||||
{ "type": "Placeholder", "name": "evap", "n_equations": 2 },
|
||||
{ "type": "Placeholder", "name": "eco_hx", "n_equations": 2 }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "screw_0:discharge", "to": "cond:inlet" },
|
||||
{ "from": "cond:outlet", "to": "evap:inlet" },
|
||||
{ "from": "evap:outlet", "to": "screw_0:suction" },
|
||||
{ "from": "eco_hx:outlet", "to": "screw_0:economizer" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"solver": { "strategy": "fallback", "max_iterations": 5 }
|
||||
}
|
||||
"#;
|
||||
|
||||
std::fs::write(&config_path, json).unwrap();
|
||||
|
||||
let result = run_simulation(&config_path, None, false).unwrap();
|
||||
|
||||
// The simulation may fail due to topology/solver mismatches with placeholder components.
|
||||
// Critical assertion: it must NOT error because of frequency validation (= AC2 would fail).
|
||||
if let Some(err) = &result.error {
|
||||
assert!(
|
||||
!err.to_lowercase().contains("frequency"),
|
||||
"CLI must not error on frequency validation (AC2): {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Task 4.3: Verify that fan_control: "bounded" config goes through the full CLI pipeline
|
||||
/// without panicking or erroring at the BoundedVariable insertion step.
|
||||
///
|
||||
/// This exercises the post-finalize() control path in execute_simulation().
|
||||
#[test]
|
||||
fn test_fan_control_bounded_does_not_error() {
|
||||
use entropyk_cli::run::run_simulation;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let config_path = dir.path().join("mchx_fan_bounded.json");
|
||||
|
||||
let json = r#"
|
||||
{
|
||||
"fluid": "R134a",
|
||||
"circuits": [{
|
||||
"id": 0,
|
||||
"components": [{
|
||||
"type": "MchxCondenserCoil",
|
||||
"name": "mchx_coil",
|
||||
"ua_nominal_kw_k": 8.5,
|
||||
"fan_speed": 0.8,
|
||||
"air_inlet_temp_c": 35.0,
|
||||
"fan_control": "bounded",
|
||||
"fan_speed_min": 0.1,
|
||||
"fan_speed_max": 1.0
|
||||
}],
|
||||
"edges": []
|
||||
}],
|
||||
"solver": { "strategy": "fallback", "max_iterations": 3 }
|
||||
}
|
||||
"#;
|
||||
|
||||
std::fs::write(&config_path, json).unwrap();
|
||||
|
||||
let result = run_simulation(&config_path, None, false).unwrap();
|
||||
|
||||
// The simulation should proceed without erroring at config/finalize/variable-insertion stage.
|
||||
// It may not converge (isolated single-port component) but must not produce a
|
||||
// fan_speed-related or bounded-variable insertion error.
|
||||
if let Some(ref err) = result.error {
|
||||
assert!(
|
||||
!err.to_lowercase().contains("bounded"),
|
||||
"CLI must not error on bounded-variable insertion (Task 4.3): {}",
|
||||
err
|
||||
);
|
||||
assert!(
|
||||
!err.to_lowercase().contains("fan_speed"),
|
||||
"CLI must not error on fan_speed variable creation (Task 4.3): {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user