chore: update documentation to reflect recent architectural changes and improve clarity

This commit is contained in:
Sepehr
2026-03-10 22:59:04 +01:00
parent d88914a44f
commit 891c4ba436
530 changed files with 2544 additions and 1513 deletions

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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>,

View File

@@ -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,

View File

@@ -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
);
}
}

View File

@@ -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

View File

@@ -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));

View File

@@ -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"

View 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
1 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
2 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

View 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
1 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
2 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
View 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);
}
}

View File

@@ -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;

View File

@@ -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;