Update project structure and configurations

This commit is contained in:
2026-05-23 10:19:55 +02:00
parent ab5dc7e568
commit 62efea0646
1832 changed files with 83568 additions and 51829 deletions

View File

@@ -1,11 +1,18 @@
{
"name": "Water/water BPHX example",
"name": "BPHX Evaporator + Condenser — standalone runnable examples",
"fluid": "R410A",
"circuits": [
{
"id": 0,
"name": "Refrigerant",
"name": "Evaporator circuit (R410A / Water)",
"components": [
{
"type": "RefrigerantSource",
"name": "ref_src_evap",
"fluid": "R410A",
"p_set_bar": 4.0,
"quality": 0.2
},
{
"type": "BphxEvaporator",
"name": "evap",
@@ -21,10 +28,33 @@
"hot_pressure_bar": 2.0,
"hot_mass_flow_kg_s": 0.5,
"cold_fluid": "R410A",
"cold_t_inlet_c": 5.0,
"cold_t_inlet_c": 2.0,
"cold_pressure_bar": 4.0,
"cold_mass_flow_kg_s": 0.1
},
{
"type": "RefrigerantSink",
"name": "ref_snk_evap",
"fluid": "R410A",
"p_back_bar": 4.0
}
],
"edges": [
{ "from": "ref_src_evap:outlet", "to": "evap:inlet" },
{ "from": "evap:outlet", "to": "ref_snk_evap:inlet" }
]
},
{
"id": 1,
"name": "Condenser circuit (R410A / Water)",
"components": [
{
"type": "RefrigerantSource",
"name": "ref_src_cond",
"fluid": "R410A",
"p_set_bar": 18.0,
"quality": 1.0
},
{
"type": "BphxCondenser",
"name": "cond",
@@ -42,13 +72,22 @@
"cold_t_inlet_c": 25.0,
"cold_pressure_bar": 2.0,
"cold_mass_flow_kg_s": 0.5
},
{
"type": "RefrigerantSink",
"name": "ref_snk_cond",
"fluid": "R410A",
"p_back_bar": 18.0
}
],
"edges": []
"edges": [
{ "from": "ref_src_cond:outlet", "to": "cond:inlet" },
{ "from": "cond:outlet", "to": "ref_snk_cond:inlet" }
]
}
],
"solver": {
"strategy": "newton",
"strategy": "fallback",
"max_iterations": 50,
"tolerance": 1e-6
}

View File

@@ -526,6 +526,26 @@ fn resolve_port_index(component_type: &str, port_name: &str, is_source: bool) ->
}
}
},
// BphxEvaporator and BphxCondenser: 2-port refrigerant circuit (inlet=0, outlet=1).
// Secondary-fluid conditions are set via JSON params, not graph edges.
"BphxEvaporator" | "BphxCondenser" => match port_lower.as_str() {
"inlet" | "in" | "refrigerant_in" => 0,
"outlet" | "out" | "refrigerant_out" => 1,
_ => {
tracing::warn!(
port_name,
component_type,
"Unknown port name for {}, defaulting to {}",
component_type,
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() {
@@ -598,21 +618,109 @@ fn parse_side_conditions(
}
/// Build BphxGeometry from JSON params: dh (m), area (m²), n_plates. Defaults: 0.003, 0.5, 20.
///
/// Returns an error if any parameter is physically invalid (≤ 0).
fn bphx_geometry_from_params(
params: &std::collections::HashMap<String, serde_json::Value>,
exchanger_type: entropyk_components::heat_exchanger::BphxType,
) -> entropyk_components::heat_exchanger::BphxGeometry {
) -> CliResult<entropyk_components::heat_exchanger::BphxGeometry> {
use entropyk_components::heat_exchanger::BphxGeometry;
let dh = params
.get("dh_m")
let dh = params.get("dh_m").and_then(|v| v.as_f64()).unwrap_or(0.003);
if dh <= 0.0 {
return Err(CliError::Config(format!(
"BphxGeometry: dh_m must be > 0 (got {:.6} m)",
dh
)));
}
let area = params
.get("area_m2")
.and_then(|v| v.as_f64())
.unwrap_or(0.003);
let area = params.get("area_m2").and_then(|v| v.as_f64()).unwrap_or(0.5);
let n_plates = params
.unwrap_or(0.5);
if area <= 0.0 {
return Err(CliError::Config(format!(
"BphxGeometry: area_m2 must be > 0 (got {:.4} m²)",
area
)));
}
let n_plates_raw = params
.get("n_plates")
.and_then(|v| v.as_u64())
.unwrap_or(20) as u32;
BphxGeometry::from_dh_area(dh, area, n_plates).with_exchanger_type(exchanger_type)
.unwrap_or(20);
if n_plates_raw > u32::MAX as u64 {
return Err(CliError::Config(format!(
"BphxGeometry: n_plates too large (got {}, max {})",
n_plates_raw, u32::MAX
)));
}
let n_plates = n_plates_raw as u32;
if n_plates == 0 {
return Err(CliError::Config(
"BphxGeometry: n_plates must be > 0".into(),
));
}
Ok(BphxGeometry::from_dh_area(dh, area, n_plates).with_exchanger_type(exchanger_type))
}
/// Extract calibration factors for BphxEvaporator/BphxCondenser from JSON params.
///
/// Errors if `ua_nominal == 0` and an explicit `ua` override is provided (geometry is
/// likely invalid). Warns if both `ua` and `f_ua` are provided simultaneously.
fn bphx_calib_from_params(
params: &std::collections::HashMap<String, serde_json::Value>,
ua_nominal: f64,
) -> CliResult<entropyk_core::Calib> {
use entropyk_core::Calib;
let config_ua = params.get("ua").and_then(|v| v.as_f64());
let explicit_f_ua = params.get("f_ua").and_then(|v| v.as_f64());
if config_ua.is_some() && explicit_f_ua.is_some() {
tracing::warn!(
"BphxExchanger: both 'ua' and 'f_ua' provided — 'ua' takes precedence, 'f_ua' ignored"
);
}
let f_ua = match config_ua {
Some(u) => {
if u < 0.0 {
return Err(CliError::Config(format!(
"BphxExchanger: ua must be >= 0 (got {:.2} W/K)",
u
)));
}
if ua_nominal > 0.0 {
u / ua_nominal
} else {
return Err(CliError::Config(
"BphxExchanger: ua_nominal is zero — cannot compute f_ua from explicit 'ua' override. Check geometry parameters.".into(),
));
}
}
None => explicit_f_ua.unwrap_or(1.0),
};
if f_ua <= 0.0 {
return Err(CliError::Config(format!(
"BphxExchanger: f_ua must be > 0 (got {:.4})",
f_ua
)));
}
let f_dp = params.get("f_dp").and_then(|v| v.as_f64()).unwrap_or(1.0);
if f_dp <= 0.0 {
return Err(CliError::Config(format!(
"BphxExchanger: f_dp must be > 0 (got {:.4})",
f_dp
)));
}
Ok(Calib {
f_m: 1.0,
f_dp,
f_ua,
f_power: 1.0,
f_etav: 1.0,
calibration_source: None,
})
}
/// Creates a pair of connected ports for components that need them (screw, MCHX, fan...).
@@ -1230,9 +1338,8 @@ fn create_component(
use entropyk_components::heat_exchanger::{
BphxCorrelation, BphxEvaporator, BphxEvaporatorMode, BphxType,
};
use entropyk_core::Calib;
let geo = bphx_geometry_from_params(params, BphxType::Evaporator);
let geo = bphx_geometry_from_params(params, BphxType::Evaporator)?;
let refrigerant = params
.get("refrigerant")
.and_then(|v| v.as_str())
@@ -1242,29 +1349,64 @@ fn create_component(
.and_then(|v| v.as_str())
.unwrap_or("Water");
let mode = match params.get("mode").and_then(|v| v.as_str()).unwrap_or("dx") {
let mode_str = params
.get("mode")
.and_then(|v| v.as_str())
.unwrap_or("dx")
.to_lowercase();
let mode = match mode_str.as_str() {
"flooded" => {
let target_quality = params
.get("target_quality")
.and_then(|v| v.as_f64())
.unwrap_or(0.7);
if !(0.0..=1.0).contains(&target_quality) {
return Err(CliError::Config(format!(
"BphxEvaporator: target_quality must be in [0, 1] (got {:.4})",
target_quality
)));
}
BphxEvaporatorMode::Flooded { target_quality }
}
_ => {
other => {
if other != "dx" {
tracing::warn!(
mode = other,
"Unknown BphxEvaporator mode '{}', falling back to 'dx'",
other
);
}
let target_superheat = params
.get("target_superheat_k")
.and_then(|v| v.as_f64())
.unwrap_or(5.0);
BphxEvaporatorMode::Dx {
target_superheat,
if target_superheat < 0.0 {
return Err(CliError::Config(format!(
"BphxEvaporator: target_superheat_k must be >= 0 (got {:.2} K)",
target_superheat
)));
}
BphxEvaporatorMode::Dx { target_superheat }
}
};
let correlation = match params.get("correlation").and_then(|v| v.as_str()) {
Some("Shah1979") => BphxCorrelation::Shah1979,
Some("Shah2021") => BphxCorrelation::Shah2021,
_ => BphxCorrelation::Longo2004,
let correlation_str = params
.get("correlation")
.and_then(|v| v.as_str())
.unwrap_or("Longo2004")
.to_lowercase();
let correlation = match correlation_str.as_str() {
"shah1979" => BphxCorrelation::Shah1979,
"shah2021" => BphxCorrelation::Shah2021,
"longo2004" => BphxCorrelation::Longo2004,
other => {
tracing::warn!(
correlation = other,
"Unknown BphxEvaporator correlation '{}', falling back to Longo2004",
other
);
BphxCorrelation::Longo2004
}
};
let mut evap = BphxEvaporator::new(geo)
@@ -1274,6 +1416,9 @@ fn create_component(
.with_fluid_backend(Arc::clone(&backend))
.with_correlation(correlation);
// Convention (Evaporator): hot_fluid = secondary (brine/water), cold_fluid = refrigerant.
// The refrigerant evaporates (absorbs heat from the secondary).
// Note: this is opposite to the Condenser convention — see BphxCondenser.
if params.contains_key("hot_fluid") {
let hot = parse_side_conditions(params, "hot")?;
evap.set_secondary_conditions(hot);
@@ -1283,24 +1428,7 @@ fn create_component(
evap.set_refrigerant_conditions(cold);
}
let ua_nominal = evap.ua();
let config_ua = params.get("ua").and_then(|v| v.as_f64());
let f_ua = config_ua
.map(|u| if ua_nominal > 0.0 { u / ua_nominal } else { 1.0 })
.unwrap_or_else(|| {
params
.get("f_ua")
.and_then(|v| v.as_f64())
.unwrap_or(1.0)
});
let f_dp = params.get("f_dp").and_then(|v| v.as_f64()).unwrap_or(1.0);
evap.set_calib(Calib {
f_m: 1.0,
f_dp,
f_ua,
f_power: 1.0,
f_etav: 1.0,
});
evap.set_calib(bphx_calib_from_params(params, evap.ua())?);
Ok(Box::new(evap))
}
@@ -1310,9 +1438,8 @@ fn create_component(
use entropyk_components::heat_exchanger::{
BphxCondenser, BphxCorrelation, BphxType,
};
use entropyk_core::Calib;
let geo = bphx_geometry_from_params(params, BphxType::Condenser);
let geo = bphx_geometry_from_params(params, BphxType::Condenser)?;
let refrigerant = params
.get("refrigerant")
.and_then(|v| v.as_str())
@@ -1326,10 +1453,23 @@ fn create_component(
.and_then(|v| v.as_f64())
.unwrap_or(3.0);
let correlation = match params.get("correlation").and_then(|v| v.as_str()) {
Some("Shah1979") => BphxCorrelation::Shah1979,
Some("Shah2021") => BphxCorrelation::Shah2021,
_ => BphxCorrelation::Longo2004,
let correlation_str = params
.get("correlation")
.and_then(|v| v.as_str())
.unwrap_or("Longo2004")
.to_lowercase();
let correlation = match correlation_str.as_str() {
"shah1979" => BphxCorrelation::Shah1979,
"shah2021" => BphxCorrelation::Shah2021,
"longo2004" => BphxCorrelation::Longo2004,
other => {
tracing::warn!(
correlation = other,
"Unknown BphxCondenser correlation '{}', falling back to Longo2004",
other
);
BphxCorrelation::Longo2004
}
};
let mut cond = BphxCondenser::new(geo)
@@ -1339,6 +1479,9 @@ fn create_component(
.with_target_subcooling(target_subcooling)
.with_correlation(correlation);
// Convention (Condenser): hot_fluid = refrigerant, cold_fluid = secondary (brine/water).
// The refrigerant condenses (releases heat to the secondary).
// Note: this is opposite to the Evaporator convention — see BphxEvaporator.
if params.contains_key("hot_fluid") {
let hot = parse_side_conditions(params, "hot")?;
cond.set_refrigerant_conditions(hot);
@@ -1348,24 +1491,7 @@ fn create_component(
cond.set_secondary_conditions(cold);
}
let ua_nominal = cond.ua();
let config_ua = params.get("ua").and_then(|v| v.as_f64());
let f_ua = config_ua
.map(|u| if ua_nominal > 0.0 { u / ua_nominal } else { 1.0 })
.unwrap_or_else(|| {
params
.get("f_ua")
.and_then(|v| v.as_f64())
.unwrap_or(1.0)
});
let f_dp = params.get("f_dp").and_then(|v| v.as_f64()).unwrap_or(1.0);
cond.set_calib(Calib {
f_m: 1.0,
f_dp,
f_ua,
f_power: 1.0,
f_etav: 1.0,
});
cond.set_calib(bphx_calib_from_params(params, cond.ua())?);
Ok(Box::new(cond))
}
@@ -1375,8 +1501,48 @@ fn create_component(
Ok(Box::new(SimpleComponent::new("", n_eqs)))
}
"FreeCoolingExchanger" | "FreeCooling" => {
use entropyk::{FreeCoolingConfig, FreeCoolingControlMode, FreeCoolingExchanger, FreeCoolingMode};
use entropyk_components::port::{FluidId, Port};
use entropyk_core::{CircuitId, Enthalpy, Pressure};
let effectiveness = params.get("effectiveness").and_then(|v| v.as_f64()).unwrap_or(0.85);
let ua = params.get("ua").and_then(|v| v.as_f64()).unwrap_or(10_000.0);
let cold_mass_flow = params.get("coldMassFlow").and_then(|v| v.as_f64()).unwrap_or(0.5);
let hot_mass_flow = params.get("hotMassFlow").and_then(|v| v.as_f64()).unwrap_or(0.5);
let cold_cp = params.get("coldCp").and_then(|v| v.as_f64()).unwrap_or(4186.0);
let hot_cp = params.get("hotCp").and_then(|v| v.as_f64()).unwrap_or(4186.0);
let config = FreeCoolingConfig {
effectiveness,
ua,
cold_mass_flow,
hot_mass_flow,
cold_cp,
hot_cp,
..Default::default()
};
let circuit_id = CircuitId(0);
let fluid = FluidId::new("Water");
let p = Pressure::from_pascals(3e5);
let h = Enthalpy::from_joules_per_kg(63_000.0);
let (ci, co) = Port::new(FluidId::new("Water"), p, h)
.connect(Port::new(FluidId::new("Water"), p, h))
.map_err(|e| CliError::Config(format!("Port connect error: {}", e)))?;
let (hi, ho) = Port::new(FluidId::new("Water"), p, h)
.connect(Port::new(FluidId::new("Water"), p, h))
.map_err(|e| CliError::Config(format!("Port connect error: {}", e)))?;
let fc = FreeCoolingExchanger::new("freecooling", circuit_id, config, ci, co, hi, ho)
.map_err(|e| CliError::Config(format!("FreeCoolingExchanger error: {}", e)))?;
Ok(Box::new(fc))
}
_ => Err(CliError::Config(format!(
"Unknown component type: '{}'. Supported: ScrewEconomizerCompressor, MchxCondenserCoil, FloodedEvaporator, BphxEvaporator, BphxCondenser, Condenser, CondenserCoil, Evaporator, EvaporatorCoil, HeatExchanger, Compressor, ExpansionValve, Pump, Placeholder",
"Unknown component type: '{}'. Supported: ScrewEconomizerCompressor, MchxCondenserCoil, FloodedEvaporator, BphxEvaporator, BphxCondenser, FreeCoolingExchanger, Condenser, CondenserCoil, Evaporator, EvaporatorCoil, HeatExchanger, Compressor, ExpansionValve, Pump, Placeholder",
component_type
))),
}

View File

@@ -311,7 +311,10 @@ fn test_screw_compressor_preset_config() {
std::fs::write(&config_path, json).unwrap();
let config = ScenarioConfig::from_file(&config_path);
assert!(config.is_ok(), "Config with preset should parse successfully");
assert!(
config.is_ok(),
"Config with preset should parse successfully"
);
let config = config.unwrap();
let params = &config.circuits[0].components[0].params;
@@ -391,8 +394,8 @@ fn test_screw_compressor_grasso_preset_config() {
fn test_ac2_frequency_ratio_set_correctly_by_cli() {
use entropyk_components::{
polynomials::Polynomial2D,
screw_economizer_compressor::{ScrewEconomizerCompressor, ScrewPerformanceCurves},
port::{FluidId, Port},
screw_economizer_compressor::{ScrewEconomizerCompressor, ScrewPerformanceCurves},
};
use entropyk_core::{Enthalpy, Pressure};
@@ -464,7 +467,11 @@ fn test_ac1_mchx_ua_nominal_parsed_from_config() {
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.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));
}
@@ -472,8 +479,8 @@ fn test_ac1_mchx_ua_nominal_parsed_from_config() {
/// 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;
use entropyk_components::heat_exchanger::MchxCondenserCoil;
let ua_nominal = 8_500.0; // W/K (8.5 kW/K)
let n_air = 0.5;
@@ -485,7 +492,7 @@ fn test_ac2_fan_speed_064_yields_ua_eff_08() {
// 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
// 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);
}
@@ -519,7 +526,10 @@ fn test_ac3_condenser_bank_2x2_generates_4_components() {
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");
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);
@@ -742,11 +752,18 @@ fn test_bphx_evaporator_and_condenser_config_parsing() {
let result = run_simulation(&config_path, None, false).unwrap();
// create_component must accept both types (no "Unknown component type").
// create_component must accept both types. Two distinct assertions:
// (a) no "Unknown component type" — both Bphx types must be registered.
// (b) no "Failed to create component" — construction must succeed, not just be recognised.
if let Some(ref err) = result.error {
assert!(
!err.contains("Unknown component type"),
"BphxEvaporator and BphxCondenser must be supported: {}",
"BphxEvaporator and BphxCondenser must be registered in create_component: {}",
err
);
assert!(
!err.contains("Failed to create component"),
"BphxEvaporator/BphxCondenser construction must not fail: {}",
err
);
}
@@ -754,10 +771,52 @@ fn test_bphx_evaporator_and_condenser_config_parsing() {
// We expect Error or NonConverged (edges empty -> topology/finalization failure), not config parse failure.
match result.status {
SimulationStatus::Error => {
// Failure is expected (e.g. isolated nodes); config parsing succeeded.
// Failure is expected (e.g. isolated nodes); config parsing and construction succeeded.
}
SimulationStatus::NonConverged | SimulationStatus::Converged | SimulationStatus::Timeout => {
SimulationStatus::NonConverged
| SimulationStatus::Converged
| SimulationStatus::Timeout => {
// Also acceptable if we get to solver stage.
}
}
}
/// Story 15-4 — Integration: BphxEvaporator and BphxCondenser in bounded circuits
/// (RefrigerantSource → Bphx → RefrigerantSink) must reach the solver stage.
/// Validates that config parsing, component construction, AND edge routing all succeed.
#[test]
fn test_bphx_bounded_circuit_reaches_solver_stage() {
use entropyk_cli::run::run_simulation;
let example = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples/bphx_evaporator_condenser.json");
if !example.exists() {
panic!(
"Test fixture missing: {} — this test requires the example file to exist",
example.display()
);
}
let result = run_simulation(&example, None, false).unwrap();
// Three-gate assertion: config → construction → edge routing must all succeed.
if let Some(ref err) = result.error {
assert!(
!err.contains("Unknown component type"),
"[Gate 1] Bphx type not registered: {}",
err
);
assert!(
!err.contains("Failed to create component"),
"[Gate 2] Bphx construction failed: {}",
err
);
assert!(
!err.contains("Failed to add edge") && !err.contains("Edge references unknown"),
"[Gate 3] Edge routing failed: {}",
err
);
// Any remaining error (e.g. solver non-convergence) is acceptable.
}
}