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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,14 +154,15 @@ impl ScrewPerformanceCurves {
|
||||
/// - `port_discharge` (index 1): High-pressure outlet
|
||||
/// - `port_economizer` (index 2): Intermediate-pressure injection inlet
|
||||
///
|
||||
/// **State variables (5 total):**
|
||||
/// - `state[0]`: ṁ_suction (kg/s)
|
||||
/// - `state[1]`: ṁ_eco (kg/s)
|
||||
/// - `state[2]`: h_suction (J/kg)
|
||||
/// - `state[3]`: h_discharge (J/kg)
|
||||
/// - `state[4]`: W_shaft (W)
|
||||
/// **Internal state variables (3 total, via `internal_state_len()`):**
|
||||
/// - `state[offset+0]`: ṁ_suction (kg/s)
|
||||
/// - `state[offset+1]`: ṁ_eco (kg/s)
|
||||
/// - `state[offset+2]`: W_shaft (W)
|
||||
///
|
||||
/// **Equations (5 total):**
|
||||
/// Note: h_suction and h_discharge are read from the connected port enthalpies
|
||||
/// (graph state), not from the component's internal state block.
|
||||
///
|
||||
/// **Equations (5 total, via `n_equations()`):**
|
||||
/// 1. Mass flow suction: ṁ_suc_calc − ṁ_suc_state = 0
|
||||
/// 2. Economizer mass flow: ṁ_eco_calc − ṁ_eco_state = 0
|
||||
/// 3. Energy balance: ṁ_suc×h_suc + ṁ_eco×h_eco + W = ṁ_total×h_dis
|
||||
|
||||
@@ -250,7 +250,19 @@ fn main() {
|
||||
let result = config.solve(&mut system);
|
||||
let mut html = String::new();
|
||||
html.push_str("<html><head><meta charset=\"utf-8\"><title>Cycle Solver Integration Results</title>");
|
||||
html.push_str("<style>body{font-family:'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 40px; background-color: #f4f7f6;} h1{color: #2c3e50;} table {border-collapse: collapse; width: 100%; margin-top:20px;} th, td {border: 1px solid #ddd; padding: 12px; text-align: left;} th {background-color: #3498db; color: white;} tr:nth-child(even){background-color: #f2f2f2;} tr:hover {background-color: #ddd;} .success{color: #27ae60; font-weight:bold;} .error{color: #e74c3c; font-weight:bold;} .info-box {background-color: #ecf0f1; border-left: 5px solid #3498db; padding: 15px; margin-bottom: 20px;}</style>");
|
||||
html.push_str("<style>body{font-family:'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 40px; background-color: #f4f7f6;} h1{color: #2c3e50;} table {border-collapse: collapse; width: 100%; margin-top:20px;} th, td {border: 1px solid #ddd; padding: 12px; text-align: left;} th {background-color: #3498db; color: white;} tr:nth-child(even){background-color: #f2f2f2;} tr:hover {background-color: #ddd;} .success{color: #27ae60; font-weight:bold;} .error{color: #e74c3c; font-weight:bold;} .info-box {background-color: #ecf0f1; border-left: 5px solid #3498db; padding: 15px; margin-bottom: 20px;}");
|
||||
html.push_str(".cycle-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 80px; max-width: 700px; margin: 50px auto; position: relative; }");
|
||||
html.push_str(".cycle-node { background: white; padding: 30px 20px; border-radius: 20px; box-shadow: 0 10px 40px rgba(0,0,0,0.08); text-align: center; position: relative; border: 1px solid #edf2f7; transition: transform 0.3s ease, box-shadow 0.3s ease; }");
|
||||
html.push_str(".cycle-node:hover { transform: translateY(-5px); box-shadow: 0 15px 50px rgba(0,0,0,0.12); }");
|
||||
html.push_str(".node-comp { border-bottom: 8px solid #e53e3e; }");
|
||||
html.push_str(".node-cond { border-bottom: 8px solid #dd6b20; }");
|
||||
html.push_str(".node-valve { border-bottom: 8px solid #38a169; }");
|
||||
html.push_str(".node-evap { border-bottom: 8px solid #3182ce; }");
|
||||
html.push_str(".node-icon { font-size: 40px; margin-bottom: 15px; }");
|
||||
html.push_str(".node-title { font-weight: 800; color: #2d3748; font-size: 20px; letter-spacing: -0.5px; }");
|
||||
html.push_str(".node-subtitle { font-size: 14px; color: #718096; margin-top: 6px; font-weight: 500; }");
|
||||
html.push_str(".state-label { position: absolute; background: #2d3748; color: white; padding: 6px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; box-shadow: 0 4px 10px rgba(0,0,0,0.1); white-space: nowrap; z-index: 10;}");
|
||||
html.push_str("</style>");
|
||||
html.push_str("</head><body>");
|
||||
|
||||
html.push_str("<h1>Résultats de l'Intégration du Cycle Thermodynamique (Contrôle Inverse)</h1>");
|
||||
@@ -263,6 +275,42 @@ fn main() {
|
||||
html.push_str("<li><b>Actionneur (Bounded Variable)</b> : Modification dynamique de l'ouverture de la vanne (valve_opening) dans les limites [0.0 - 1.0].</li>");
|
||||
html.push_str("</ul></div>");
|
||||
|
||||
html.push_str("<div class=\"cycle-grid\">");
|
||||
|
||||
// Compressor (Top Left)
|
||||
html.push_str("<div class=\"cycle-node node-comp\">");
|
||||
html.push_str("<div class=\"state-label\" style=\"top: 20px; right: -70px;\">HP Gaz 🌡️➔</div>");
|
||||
html.push_str("<div class=\"node-icon\">⚙️</div>");
|
||||
html.push_str("<div class=\"node-title\">Compresseur</div>");
|
||||
html.push_str("<div class=\"node-subtitle\">Compression isentropique</div>");
|
||||
html.push_str("</div>");
|
||||
|
||||
// Condenser (Top Right)
|
||||
html.push_str("<div class=\"cycle-node node-cond\">");
|
||||
html.push_str("<div class=\"state-label\" style=\"bottom: -20px; left: 50%; transform: translateX(-50%);\">⬇️ HP Liquide 💧</div>");
|
||||
html.push_str("<div class=\"node-icon\">♨️</div>");
|
||||
html.push_str("<div class=\"node-title\">Condenseur</div>");
|
||||
html.push_str("<div class=\"node-subtitle\">Rejet de chaleur (Désurchauffe/Condensation)</div>");
|
||||
html.push_str("</div>");
|
||||
|
||||
// Evaporator (Bottom Left)
|
||||
html.push_str("<div class=\"cycle-node node-evap\">");
|
||||
html.push_str("<div class=\"state-label\" style=\"top: -20px; left: 50%; transform: translateX(-50%);\">⬆️ BP Gaz 🌀</div>");
|
||||
html.push_str("<div class=\"node-icon\">❄️</div>");
|
||||
html.push_str("<div class=\"node-title\">Évaporateur</div>");
|
||||
html.push_str("<div class=\"node-subtitle\">Absorption chaleur utile (Surchauffe visée)</div>");
|
||||
html.push_str("</div>");
|
||||
|
||||
// Valve (Bottom Right)
|
||||
html.push_str("<div class=\"cycle-node node-valve\">");
|
||||
html.push_str("<div class=\"state-label\" style=\"top: 20px; left: -80px;\">⬅️ BP Mixte 🌫️</div>");
|
||||
html.push_str("<div class=\"node-icon\">🎛️</div>");
|
||||
html.push_str("<div class=\"node-title\">Vanne de Détente</div>");
|
||||
html.push_str("<div class=\"node-subtitle\">Détente isenthalpique (variable)</div>");
|
||||
html.push_str("</div>");
|
||||
|
||||
html.push_str("</div>");
|
||||
|
||||
match result {
|
||||
Ok(converged) => {
|
||||
html.push_str(&format!("<p class='success'>✅ Modèle Résolu Thermodynamiquement avec succès en {} itérations de Newton-Raphson.</p>", converged.iterations));
|
||||
|
||||
1
crates/vendors/Cargo.toml
vendored
1
crates/vendors/Cargo.toml
vendored
@@ -6,6 +6,7 @@ edition.workspace = true
|
||||
description = "Vendor equipment data backends for Entropyk (Copeland, SWEP, Danfoss, Bitzer)"
|
||||
|
||||
[dependencies]
|
||||
csv = "1.3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "1.0"
|
||||
|
||||
2
crates/vendors/data/bitzer/compressors/4HES-5Y.csv
vendored
Normal file
2
crates/vendors/data/bitzer/compressors/4HES-5Y.csv
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
model,manufacturer,refrigerant,c0,c1,c2,c3,c4,c5,c6,c7,c8,c9,p0,p1,p2,p3,p4,p5,p6,p7,p8,p9,t_suction_min,t_suction_max,t_discharge_min,t_discharge_max
|
||||
4HES-5Y,Bitzer,R410A,12000.0,220.0,-65.0,1.8,1.0,-2.2,0.025,0.012,-0.008,0.004,3200.0,75.0,28.0,0.7,0.45,0.7,0.01,0.006,0.004,0.002,-15.0,12.0,30.0,55.0
|
||||
|
2
crates/vendors/data/bitzer/compressors/4NFC-20Y.csv
vendored
Normal file
2
crates/vendors/data/bitzer/compressors/4NFC-20Y.csv
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
model,manufacturer,refrigerant,c0,c1,c2,c3,c4,c5,c6,c7,c8,c9,p0,p1,p2,p3,p4,p5,p6,p7,p8,p9,t_suction_min,t_suction_max,t_discharge_min,t_discharge_max
|
||||
4NFC-20Y,Bitzer,R134a,32000.0,580.0,-150.0,4.2,2.5,-5.0,0.06,0.03,-0.02,0.01,8200.0,180.0,70.0,1.8,1.2,1.8,0.025,0.015,0.01,0.006,-10.0,15.0,25.0,60.0
|
||||
|
418
crates/vendors/src/compressors/bitzer.rs
vendored
Normal file
418
crates/vendors/src/compressors/bitzer.rs
vendored
Normal file
@@ -0,0 +1,418 @@
|
||||
//! Bitzer compressor data backend.
|
||||
//!
|
||||
//! Loads AHRI 540 compressor coefficients from CSV files in the
|
||||
//! `data/bitzer/compressors/` directory. The CSV columns `c0`..`c9` (capacity)
|
||||
//! and `p0`..`p9` (power) are in AHRI 540 standard polynomial order:
|
||||
//! Ts, Td, Ts², Ts·Td, Td², Ts³, Td·Ts², Ts·Td², Td³ (see [`CompressorCoefficients`]).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::error::VendorError;
|
||||
use crate::vendor_api::{
|
||||
BphxParameters, CompressorCoefficients, CompressorValidityRange, UaCalcParams, VendorBackend,
|
||||
};
|
||||
|
||||
/// Backend for Bitzer compressor data.
|
||||
///
|
||||
/// Discovers compressor models by scanning `*.csv` files in `data/bitzer/compressors/`
|
||||
/// and uses the file stem (e.g. `4NFC-20Y`) as the model id. Each CSV row is mapped
|
||||
/// to AHRI 540 `CompressorCoefficients`.
|
||||
///
|
||||
/// # CSV format
|
||||
///
|
||||
/// Header row with columns: `model`, `manufacturer`, `refrigerant`, `c0`..`c9` (capacity),
|
||||
/// `p0`..`p9` (power), `t_suction_min`, `t_suction_max`, `t_discharge_min`, `t_discharge_max`.
|
||||
/// One data row per file (one model per CSV file).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use entropyk_vendors::compressors::bitzer::BitzerBackend;
|
||||
/// use entropyk_vendors::VendorBackend;
|
||||
///
|
||||
/// let backend = BitzerBackend::new().expect("load bitzer data");
|
||||
/// let models = backend.list_compressor_models().unwrap();
|
||||
/// println!("Available: {:?}", models);
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct BitzerBackend {
|
||||
/// Root path to the Bitzer data directory.
|
||||
data_path: PathBuf,
|
||||
/// Pre-loaded compressor coefficients keyed by model name.
|
||||
compressor_cache: HashMap<String, CompressorCoefficients>,
|
||||
/// Sorted list of available models.
|
||||
sorted_models: Vec<String>,
|
||||
}
|
||||
|
||||
impl BitzerBackend {
|
||||
/// Create a new Bitzer backend, loading all compressor models from disk.
|
||||
///
|
||||
/// The data directory is resolved via the `ENTROPYK_DATA` environment variable.
|
||||
/// If unset, it falls back to the compile-time `CARGO_MANIFEST_DIR/data` in debug mode,
|
||||
/// or `./data` in release mode.
|
||||
pub fn new() -> Result<Self, VendorError> {
|
||||
let base_path = std::env::var("ENTROPYK_DATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data")
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
PathBuf::from("data")
|
||||
}
|
||||
});
|
||||
|
||||
let data_path = base_path.join("bitzer");
|
||||
|
||||
let mut backend = Self {
|
||||
data_path,
|
||||
compressor_cache: HashMap::new(),
|
||||
sorted_models: Vec::new(),
|
||||
};
|
||||
|
||||
backend.load_compressors()?;
|
||||
|
||||
Ok(backend)
|
||||
}
|
||||
|
||||
/// Create a new Bitzer backend from a custom data path.
|
||||
///
|
||||
/// Useful for testing with alternative data directories.
|
||||
pub fn from_path(data_path: PathBuf) -> Result<Self, VendorError> {
|
||||
let mut backend = Self {
|
||||
data_path,
|
||||
compressor_cache: HashMap::new(),
|
||||
sorted_models: Vec::new(),
|
||||
};
|
||||
|
||||
backend.load_compressors()?;
|
||||
|
||||
Ok(backend)
|
||||
}
|
||||
|
||||
/// Discover CSV files in `data/bitzer/compressors/` and pre-cache all models.
|
||||
fn load_compressors(&mut self) -> Result<(), VendorError> {
|
||||
let compressors_dir = self.data_path.join("compressors");
|
||||
|
||||
let entries = match std::fs::read_dir(&compressors_dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
return Err(VendorError::IoError {
|
||||
path: compressors_dir.display().to_string(),
|
||||
source: e,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "csv") {
|
||||
if let Some(stem) = path.file_stem() {
|
||||
let model = stem.to_string_lossy().into_owned();
|
||||
match self.load_model(&model) {
|
||||
Ok(coeffs) => {
|
||||
self.compressor_cache.insert(model.clone(), coeffs);
|
||||
self.sorted_models.push(model);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[entropyk-vendors] Skipping Bitzer model {}: {}", model, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.sorted_models.sort();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a single compressor model from its CSV file.
|
||||
fn load_model(&self, model: &str) -> Result<CompressorCoefficients, VendorError> {
|
||||
if model.contains('/') || model.contains('\\') || model.contains("..") {
|
||||
return Err(VendorError::ModelNotFound(model.to_string()));
|
||||
}
|
||||
|
||||
let model_path = self
|
||||
.data_path
|
||||
.join("compressors")
|
||||
.join(format!("{}.csv", model));
|
||||
|
||||
let content = std::fs::read_to_string(&model_path).map_err(|e| VendorError::IoError {
|
||||
path: model_path.display().to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
parse_bitzer_csv(&content, model).map_err(|e| {
|
||||
VendorError::InvalidFormat(format!("Parse error in {}: {}", model_path.display(), e))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a Bitzer CSV string into CompressorCoefficients.
|
||||
///
|
||||
/// CSV must have a header row and at least one data row (one model per file).
|
||||
/// Only the first data row is used; additional rows are ignored.
|
||||
/// Columns `c0`..`c9` and `p0`..`p9` are in AHRI 540 order: Ts, Td, Ts², Ts·Td, Td², Ts³, Td·Ts², Ts·Td², Td³.
|
||||
/// The `model_id` argument is used as fallback when the CSV `model` column is missing.
|
||||
fn parse_bitzer_csv(content: &str, model_id: &str) -> Result<CompressorCoefficients, String> {
|
||||
let mut rdr = csv::Reader::from_reader(content.as_bytes());
|
||||
let headers: Vec<String> = rdr
|
||||
.headers()
|
||||
.map_err(|e| e.to_string())?
|
||||
.iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
let mut record = csv::StringRecord::new();
|
||||
if !rdr.read_record(&mut record).map_err(|e| e.to_string())? {
|
||||
return Err("CSV has no data row".to_string());
|
||||
}
|
||||
|
||||
let get = |name: &str| -> Result<f64, String> {
|
||||
let i = headers
|
||||
.iter()
|
||||
.position(|h| h == name)
|
||||
.ok_or_else(|| format!("missing column {}", name))?;
|
||||
record
|
||||
.get(i)
|
||||
.ok_or_else(|| format!("missing value for {}", name))?
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|e: std::num::ParseFloatError| e.to_string())
|
||||
};
|
||||
|
||||
let get_str = |name: &str| -> Result<String, String> {
|
||||
let i = headers
|
||||
.iter()
|
||||
.position(|h| h == name)
|
||||
.ok_or_else(|| format!("missing column {}", name))?;
|
||||
record
|
||||
.get(i)
|
||||
.map(|s| s.trim().to_string())
|
||||
.ok_or_else(|| format!("missing value for {}", name))
|
||||
};
|
||||
|
||||
let model = get_str("model").unwrap_or_else(|_| model_id.to_string());
|
||||
let manufacturer = get_str("manufacturer").unwrap_or_else(|_| "Bitzer".to_string());
|
||||
let refrigerant = get_str("refrigerant").unwrap_or_else(|_| "R134a".to_string());
|
||||
|
||||
let mut capacity_coeffs = [0.0_f64; 10];
|
||||
for (i, c) in capacity_coeffs.iter_mut().enumerate() {
|
||||
*c = get(&format!("c{}", i))?;
|
||||
}
|
||||
let mut power_coeffs = [0.0_f64; 10];
|
||||
for (i, p) in power_coeffs.iter_mut().enumerate() {
|
||||
*p = get(&format!("p{}", i))?;
|
||||
}
|
||||
|
||||
let validity = CompressorValidityRange {
|
||||
t_suction_min: get("t_suction_min")?,
|
||||
t_suction_max: get("t_suction_max")?,
|
||||
t_discharge_min: get("t_discharge_min")?,
|
||||
t_discharge_max: get("t_discharge_max")?,
|
||||
};
|
||||
|
||||
if validity.t_suction_min > validity.t_suction_max {
|
||||
return Err(format!(
|
||||
"Invalid suction temperature range: min ({}) > max ({})",
|
||||
validity.t_suction_min, validity.t_suction_max
|
||||
));
|
||||
}
|
||||
if validity.t_discharge_min > validity.t_discharge_max {
|
||||
return Err(format!(
|
||||
"Invalid discharge temperature range: min ({}) > max ({})",
|
||||
validity.t_discharge_min, validity.t_discharge_max
|
||||
));
|
||||
}
|
||||
|
||||
Ok(CompressorCoefficients {
|
||||
model,
|
||||
manufacturer,
|
||||
refrigerant,
|
||||
capacity_coeffs,
|
||||
power_coeffs,
|
||||
mass_flow_coeffs: None,
|
||||
validity,
|
||||
})
|
||||
}
|
||||
|
||||
impl VendorBackend for BitzerBackend {
|
||||
fn vendor_name(&self) -> &str {
|
||||
"Bitzer"
|
||||
}
|
||||
|
||||
fn list_compressor_models(&self) -> Result<Vec<String>, VendorError> {
|
||||
Ok(self.sorted_models.clone())
|
||||
}
|
||||
|
||||
fn get_compressor_coefficients(
|
||||
&self,
|
||||
model: &str,
|
||||
) -> Result<CompressorCoefficients, VendorError> {
|
||||
self.compressor_cache
|
||||
.get(model)
|
||||
.cloned()
|
||||
.ok_or_else(|| VendorError::ModelNotFound(model.to_string()))
|
||||
}
|
||||
|
||||
fn list_bphx_models(&self) -> Result<Vec<String>, VendorError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
fn get_bphx_parameters(&self, model: &str) -> Result<BphxParameters, VendorError> {
|
||||
Err(VendorError::InvalidFormat(format!(
|
||||
"Bitzer does not provide BPHX data (requested: {})",
|
||||
model
|
||||
)))
|
||||
}
|
||||
|
||||
fn compute_ua(&self, model: &str, _params: &UaCalcParams) -> Result<f64, VendorError> {
|
||||
Err(VendorError::InvalidFormat(format!(
|
||||
"Bitzer does not provide BPHX/UA data (requested: {})",
|
||||
model
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_bitzer_backend_new() {
|
||||
let backend = BitzerBackend::new();
|
||||
assert!(backend.is_ok(), "BitzerBackend::new() should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitzer_backend_from_path() {
|
||||
let base_path = std::env::var("ENTROPYK_DATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data")
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
PathBuf::from("data")
|
||||
}
|
||||
});
|
||||
|
||||
let backend = BitzerBackend::from_path(base_path.join("bitzer"));
|
||||
assert!(backend.is_ok(), "BitzerBackend::from_path() should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitzer_vendor_name() {
|
||||
let backend = BitzerBackend::new().unwrap();
|
||||
assert_eq!(backend.vendor_name(), "Bitzer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitzer_list_compressor_models() {
|
||||
let backend = BitzerBackend::new().unwrap();
|
||||
let models = backend.list_compressor_models().unwrap();
|
||||
assert_eq!(models.len(), 2);
|
||||
assert!(models.contains(&"4NFC-20Y".to_string()));
|
||||
assert!(models.contains(&"4HES-5Y".to_string()));
|
||||
assert_eq!(models, vec!["4HES-5Y".to_string(), "4NFC-20Y".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitzer_get_compressor_4nfc_20y() {
|
||||
let backend = BitzerBackend::new().unwrap();
|
||||
let coeffs = backend
|
||||
.get_compressor_coefficients("4NFC-20Y")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(coeffs.model, "4NFC-20Y");
|
||||
assert_eq!(coeffs.manufacturer, "Bitzer");
|
||||
assert_eq!(coeffs.refrigerant, "R134a");
|
||||
assert_eq!(coeffs.capacity_coeffs.len(), 10);
|
||||
assert_eq!(coeffs.power_coeffs.len(), 10);
|
||||
assert!((coeffs.capacity_coeffs[0] - 32000.0).abs() < 1e-10);
|
||||
assert!((coeffs.power_coeffs[0] - 8200.0).abs() < 1e-10);
|
||||
assert!((coeffs.capacity_coeffs[9] - 0.01).abs() < 1e-10);
|
||||
assert!((coeffs.power_coeffs[9] - 0.006).abs() < 1e-10);
|
||||
assert!(coeffs.mass_flow_coeffs.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitzer_get_compressor_4hes_5y() {
|
||||
let backend = BitzerBackend::new().unwrap();
|
||||
let coeffs = backend.get_compressor_coefficients("4HES-5Y").unwrap();
|
||||
|
||||
assert_eq!(coeffs.model, "4HES-5Y");
|
||||
assert_eq!(coeffs.manufacturer, "Bitzer");
|
||||
assert_eq!(coeffs.refrigerant, "R410A");
|
||||
assert!((coeffs.capacity_coeffs[0] - 12000.0).abs() < 1e-10);
|
||||
assert!((coeffs.power_coeffs[0] - 3200.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitzer_validity_range() {
|
||||
let backend = BitzerBackend::new().unwrap();
|
||||
let coeffs = backend.get_compressor_coefficients("4NFC-20Y").unwrap();
|
||||
|
||||
assert!((coeffs.validity.t_suction_min - (-10.0)).abs() < 1e-10);
|
||||
assert!((coeffs.validity.t_suction_max - 15.0).abs() < 1e-10);
|
||||
assert!((coeffs.validity.t_discharge_min - 25.0).abs() < 1e-10);
|
||||
assert!((coeffs.validity.t_discharge_max - 60.0).abs() < 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitzer_model_not_found() {
|
||||
let backend = BitzerBackend::new().unwrap();
|
||||
let result = backend.get_compressor_coefficients("NONEXISTENT");
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
VendorError::ModelNotFound(m) => assert_eq!(m, "NONEXISTENT"),
|
||||
other => panic!("Expected ModelNotFound, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitzer_list_bphx_empty() {
|
||||
let backend = BitzerBackend::new().unwrap();
|
||||
let models = backend.list_bphx_models().unwrap();
|
||||
assert!(models.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitzer_get_bphx_returns_error() {
|
||||
let backend = BitzerBackend::new().unwrap();
|
||||
let result = backend.get_bphx_parameters("anything");
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
VendorError::InvalidFormat(msg) => {
|
||||
assert!(msg.contains("Bitzer does not provide BPHX"));
|
||||
}
|
||||
other => panic!("Expected InvalidFormat, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitzer_object_safety() {
|
||||
let backend: Box<dyn VendorBackend> = Box::new(BitzerBackend::new().unwrap());
|
||||
assert_eq!(backend.vendor_name(), "Bitzer");
|
||||
let models = backend.list_compressor_models().unwrap();
|
||||
assert!(!models.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitzer_csv_multiple_rows_first_used() {
|
||||
// When CSV has multiple data rows, only the first is used (one model per file).
|
||||
let csv = "model,manufacturer,refrigerant,c0,c1,c2,c3,c4,c5,c6,c7,c8,c9,p0,p1,p2,p3,p4,p5,p6,p7,p8,p9,t_suction_min,t_suction_max,t_discharge_min,t_discharge_max\n\
|
||||
4NFC-20Y,Bitzer,R134a,32000.0,580.0,-150.0,4.2,2.5,-5.0,0.06,0.03,-0.02,0.01,8200.0,180.0,70.0,1.8,1.2,1.8,0.025,0.015,0.01,0.006,-10.0,15.0,25.0,60.0\n\
|
||||
Other,Bitzer,R410A,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,1.0,-15.0,15.0,30.0,60.0";
|
||||
let result = parse_bitzer_csv(csv, "test");
|
||||
assert!(result.is_ok());
|
||||
let coeffs = result.unwrap();
|
||||
assert_eq!(coeffs.model, "4NFC-20Y");
|
||||
assert!((coeffs.capacity_coeffs[0] - 32000.0).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
4
crates/vendors/src/compressors/mod.rs
vendored
4
crates/vendors/src/compressors/mod.rs
vendored
@@ -6,6 +6,6 @@
|
||||
/// Copeland (Emerson) compressor data backend.
|
||||
pub mod copeland;
|
||||
|
||||
// Future vendor implementations (stories 11.14, 11.15):
|
||||
/// Danfoss (11.14), Bitzer (11.15) compressor data backends.
|
||||
pub mod danfoss;
|
||||
// pub mod bitzer; // Story 11.15
|
||||
pub mod bitzer;
|
||||
|
||||
1
crates/vendors/src/lib.rs
vendored
1
crates/vendors/src/lib.rs
vendored
@@ -21,6 +21,7 @@ pub mod compressors;
|
||||
pub mod heat_exchangers;
|
||||
|
||||
// Public re-exports for convenience
|
||||
pub use compressors::bitzer::BitzerBackend;
|
||||
pub use compressors::copeland::CopelandBackend;
|
||||
pub use compressors::danfoss::DanfossBackend;
|
||||
pub use heat_exchangers::swep::SwepBackend;
|
||||
|
||||
Reference in New Issue
Block a user