Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,7 +13,7 @@ categories = ["science", "simulation"]
|
||||
[dependencies]
|
||||
entropyk-core = { path = "../core" }
|
||||
entropyk-components = { path = "../components" }
|
||||
entropyk-fluids = { path = "../fluids" }
|
||||
entropyk-fluids = { path = "../fluids", features = ["coolprop"] }
|
||||
entropyk-solver = { path = "../solver" }
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use petgraph::visit::EdgeRef;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use petgraph::visit::EdgeRef;
|
||||
use thiserror::Error;
|
||||
|
||||
use entropyk_core::{CircuitId, ThermalConductance};
|
||||
@@ -577,9 +577,7 @@ impl SystemBuilder {
|
||||
) -> Result<Self, SystemBuilderError> {
|
||||
// AC3: Reject same-circuit coupling
|
||||
if circuit_a == circuit_b {
|
||||
return Err(SystemBuilderError::SameCircuitCoupling {
|
||||
circuit: circuit_a,
|
||||
});
|
||||
return Err(SystemBuilderError::SameCircuitCoupling { circuit: circuit_a });
|
||||
}
|
||||
|
||||
// AC5: Validate UA > 0
|
||||
@@ -596,9 +594,7 @@ impl SystemBuilder {
|
||||
.next()
|
||||
.is_none()
|
||||
{
|
||||
return Err(SystemBuilderError::EmptyCircuitCoupling {
|
||||
circuit: circuit_a,
|
||||
});
|
||||
return Err(SystemBuilderError::EmptyCircuitCoupling { circuit: circuit_a });
|
||||
}
|
||||
if self
|
||||
.system
|
||||
@@ -606,15 +602,12 @@ impl SystemBuilder {
|
||||
.next()
|
||||
.is_none()
|
||||
{
|
||||
return Err(SystemBuilderError::EmptyCircuitCoupling {
|
||||
circuit: circuit_b,
|
||||
});
|
||||
return Err(SystemBuilderError::EmptyCircuitCoupling { circuit: circuit_b });
|
||||
}
|
||||
|
||||
// AC1 & AC5: Create coupling with kW→W conversion
|
||||
let ua = ThermalConductance::from_kilowatts_per_kelvin(ua_kw_per_k);
|
||||
let coupling =
|
||||
ThermalCoupling::new(CircuitId(circuit_a), CircuitId(circuit_b), ua);
|
||||
let coupling = ThermalCoupling::new(CircuitId(circuit_a), CircuitId(circuit_b), ua);
|
||||
|
||||
// Defer to build time
|
||||
self.thermal_couplings.push(coupling);
|
||||
@@ -746,7 +739,12 @@ impl SystemBuilder {
|
||||
.component_names
|
||||
.iter()
|
||||
.map(|(name, &node_idx)| {
|
||||
let cid = self.system.node_to_circuit().get(&node_idx).map(|c| c.0).unwrap_or(0);
|
||||
let cid = self
|
||||
.system
|
||||
.node_to_circuit()
|
||||
.get(&node_idx)
|
||||
.map(|c| c.0)
|
||||
.unwrap_or(0);
|
||||
(name.clone(), cid)
|
||||
})
|
||||
.collect();
|
||||
@@ -779,7 +777,10 @@ impl SystemBuilder {
|
||||
|
||||
let mut metadata = HashMap::new();
|
||||
if let Some(ref fluid) = self.fluid_name {
|
||||
metadata.insert("fluidName".to_string(), serde_json::Value::String(fluid.clone()));
|
||||
metadata.insert(
|
||||
"fluidName".to_string(),
|
||||
serde_json::Value::String(fluid.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
let snapshot = SystemSnapshot {
|
||||
@@ -823,18 +824,14 @@ impl SystemBuilder {
|
||||
};
|
||||
use entropyk_solver::snapshot::SystemSnapshot;
|
||||
|
||||
let snapshot: SystemSnapshot = serde_json::from_str(json).map_err(|e| {
|
||||
SystemBuilderError::ConfigJsonError {
|
||||
let snapshot: SystemSnapshot =
|
||||
serde_json::from_str(json).map_err(|e| SystemBuilderError::ConfigJsonError {
|
||||
reason: format!("Invalid JSON: {}", e),
|
||||
}
|
||||
})?;
|
||||
})?;
|
||||
|
||||
if snapshot.version != "1.0" {
|
||||
return Err(SystemBuilderError::ConfigJsonError {
|
||||
reason: format!(
|
||||
"Unsupported version '{}', expected '1.0'",
|
||||
snapshot.version
|
||||
),
|
||||
reason: format!("Unsupported version '{}', expected '1.0'", snapshot.version),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -860,27 +857,18 @@ impl SystemBuilder {
|
||||
}
|
||||
})?;
|
||||
|
||||
let component = create_component(params).map_err(|e| {
|
||||
SystemBuilderError::ConfigJsonError {
|
||||
let component =
|
||||
create_component(params).map_err(|e| SystemBuilderError::ConfigJsonError {
|
||||
reason: format!(
|
||||
"Failed to reconstruct component '{}' (type '{}'): {}",
|
||||
name, params.component_type, e
|
||||
),
|
||||
}
|
||||
})?;
|
||||
})?;
|
||||
|
||||
let circuit_id = snapshot
|
||||
.circuit_assignments
|
||||
.get(name)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
let circuit_id = snapshot.circuit_assignments.get(name).copied().unwrap_or(0);
|
||||
|
||||
builder = builder
|
||||
.component_in_circuit(
|
||||
name,
|
||||
component,
|
||||
entropyk_core::CircuitId(circuit_id),
|
||||
)
|
||||
.component_in_circuit(name, component, entropyk_core::CircuitId(circuit_id))
|
||||
.map_err(|e| SystemBuilderError::ConfigJsonError {
|
||||
reason: format!("Failed to add component '{}': {}", name, e),
|
||||
})?;
|
||||
@@ -909,14 +897,14 @@ impl SystemBuilder {
|
||||
),
|
||||
})?;
|
||||
} else {
|
||||
builder = builder
|
||||
.edge(&edge.source, &edge.target)
|
||||
.map_err(|e| SystemBuilderError::ConfigJsonError {
|
||||
builder = builder.edge(&edge.source, &edge.target).map_err(|e| {
|
||||
SystemBuilderError::ConfigJsonError {
|
||||
reason: format!(
|
||||
"Failed to create edge '{}' -> '{}': {}",
|
||||
edge.source, edge.target, e
|
||||
),
|
||||
})?;
|
||||
}
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,11 +915,11 @@ impl SystemBuilder {
|
||||
for cs in &snapshot.constraints {
|
||||
let output = parse_component_output(&cs.output_type, &cs.component);
|
||||
let constraint = Constraint::new(ConstraintId::new(&cs.id), output, cs.target);
|
||||
builder = builder
|
||||
.with_constraint(constraint)
|
||||
.map_err(|e| SystemBuilderError::ConfigJsonError {
|
||||
builder = builder.with_constraint(constraint).map_err(|e| {
|
||||
SystemBuilderError::ConfigJsonError {
|
||||
reason: format!("Failed to restore constraint '{}': {}", cs.id, e),
|
||||
})?;
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
// Reconstruct bounded variables
|
||||
@@ -945,11 +933,11 @@ impl SystemBuilder {
|
||||
.map_err(|e| SystemBuilderError::ConfigJsonError {
|
||||
reason: format!("Failed to restore bounded variable '{}': {}", bvs.id, e),
|
||||
})?;
|
||||
builder = builder
|
||||
.with_bounded_variable(var)
|
||||
.map_err(|e| SystemBuilderError::ConfigJsonError {
|
||||
builder = builder.with_bounded_variable(var).map_err(|e| {
|
||||
SystemBuilderError::ConfigJsonError {
|
||||
reason: format!("Failed to add bounded variable '{}': {}", bvs.id, e),
|
||||
})?;
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(builder)
|
||||
@@ -965,9 +953,10 @@ impl SystemBuilder {
|
||||
|
||||
/// Loads a builder configuration from a JSON file.
|
||||
pub fn load_config_json(path: &std::path::Path) -> Result<Self, SystemBuilderError> {
|
||||
let json = std::fs::read_to_string(path).map_err(|e| SystemBuilderError::ConfigJsonError {
|
||||
reason: format!("Failed to read file '{}': {}", path.display(), e),
|
||||
})?;
|
||||
let json =
|
||||
std::fs::read_to_string(path).map_err(|e| SystemBuilderError::ConfigJsonError {
|
||||
reason: format!("Failed to read file '{}': {}", path.display(), e),
|
||||
})?;
|
||||
Self::from_config_json(&json)
|
||||
}
|
||||
|
||||
@@ -1031,18 +1020,39 @@ impl SystemBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_component_output(type_name: &str, component_id: &str) -> entropyk_solver::inverse::ComponentOutput {
|
||||
fn parse_component_output(
|
||||
type_name: &str,
|
||||
component_id: &str,
|
||||
) -> entropyk_solver::inverse::ComponentOutput {
|
||||
use entropyk_solver::inverse::ComponentOutput;
|
||||
match type_name {
|
||||
"saturationTemperature" => ComponentOutput::SaturationTemperature { component_id: component_id.to_string() },
|
||||
"superheat" => ComponentOutput::Superheat { component_id: component_id.to_string() },
|
||||
"subcooling" => ComponentOutput::Subcooling { component_id: component_id.to_string() },
|
||||
"heatTransferRate" => ComponentOutput::HeatTransferRate { component_id: component_id.to_string() },
|
||||
"capacity" => ComponentOutput::Capacity { component_id: component_id.to_string() },
|
||||
"massFlowRate" => ComponentOutput::MassFlowRate { component_id: component_id.to_string() },
|
||||
"pressure" => ComponentOutput::Pressure { component_id: component_id.to_string() },
|
||||
"temperature" => ComponentOutput::Temperature { component_id: component_id.to_string() },
|
||||
_ => ComponentOutput::Superheat { component_id: component_id.to_string() },
|
||||
"saturationTemperature" => ComponentOutput::SaturationTemperature {
|
||||
component_id: component_id.to_string(),
|
||||
},
|
||||
"superheat" => ComponentOutput::Superheat {
|
||||
component_id: component_id.to_string(),
|
||||
},
|
||||
"subcooling" => ComponentOutput::Subcooling {
|
||||
component_id: component_id.to_string(),
|
||||
},
|
||||
"heatTransferRate" => ComponentOutput::HeatTransferRate {
|
||||
component_id: component_id.to_string(),
|
||||
},
|
||||
"capacity" => ComponentOutput::Capacity {
|
||||
component_id: component_id.to_string(),
|
||||
},
|
||||
"massFlowRate" => ComponentOutput::MassFlowRate {
|
||||
component_id: component_id.to_string(),
|
||||
},
|
||||
"pressure" => ComponentOutput::Pressure {
|
||||
component_id: component_id.to_string(),
|
||||
},
|
||||
"temperature" => ComponentOutput::Temperature {
|
||||
component_id: component_id.to_string(),
|
||||
},
|
||||
_ => ComponentOutput::Superheat {
|
||||
component_id: component_id.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1302,7 +1312,10 @@ mod tests {
|
||||
}) = result
|
||||
{
|
||||
assert_eq!(component, "a");
|
||||
assert!(port_name.starts_with("totally_invalid_port"), "port_name should start with the port name, got: {port_name}");
|
||||
assert!(
|
||||
port_name.starts_with("totally_invalid_port"),
|
||||
"port_name should start with the port name, got: {port_name}"
|
||||
);
|
||||
} else {
|
||||
panic!("Expected PortNotFound error");
|
||||
}
|
||||
@@ -1562,13 +1575,11 @@ mod tests {
|
||||
.expect("build should succeed");
|
||||
|
||||
assert_eq!(system.thermal_coupling_count(), 1);
|
||||
let coupling = system.get_thermal_coupling(0).expect("coupling should exist");
|
||||
let coupling = system
|
||||
.get_thermal_coupling(0)
|
||||
.expect("coupling should exist");
|
||||
// AC4 & AC5: 5.0 kW/K = 5000 W/K
|
||||
approx::assert_relative_eq!(
|
||||
coupling.ua.to_watts_per_kelvin(),
|
||||
5000.0,
|
||||
epsilon = 1e-10
|
||||
);
|
||||
approx::assert_relative_eq!(coupling.ua.to_watts_per_kelvin(), 5000.0, epsilon = 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1592,12 +1603,10 @@ mod tests {
|
||||
.build()
|
||||
.expect("build should succeed");
|
||||
|
||||
let coupling = system.get_thermal_coupling(0).expect("coupling should exist");
|
||||
approx::assert_relative_eq!(
|
||||
coupling.ua.to_watts_per_kelvin(),
|
||||
2500.0,
|
||||
epsilon = 1e-10
|
||||
);
|
||||
let coupling = system
|
||||
.get_thermal_coupling(0)
|
||||
.expect("coupling should exist");
|
||||
approx::assert_relative_eq!(coupling.ua.to_watts_per_kelvin(), 2500.0, epsilon = 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1682,8 +1691,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_build_propagates_default_backend() {
|
||||
use entropyk_components::Component;
|
||||
|
||||
let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
|
||||
|
||||
// Use a MockComponent — set_fluid_backend_from_builder is a no-op on it,
|
||||
@@ -1749,8 +1756,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_build_propagates_backend_to_real_node() {
|
||||
use entropyk_components::{Node, port::Port};
|
||||
use entropyk_core::{Pressure, Enthalpy};
|
||||
use entropyk_components::{port::Port, Node};
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
use entropyk_fluids::FluidId;
|
||||
|
||||
let backend: Arc<dyn FluidBackend> = Arc::new(entropyk_fluids::TestBackend::new());
|
||||
@@ -1803,7 +1810,9 @@ mod tests {
|
||||
|
||||
// The real Node's set_fluid_backend_from_builder was called (not no-op like MockComponent).
|
||||
// Build succeeded without panic = backend was accepted.
|
||||
let node_idx = system.get_component_node("node_a").expect("node should exist");
|
||||
let node_idx = system
|
||||
.get_component_node("node_a")
|
||||
.expect("node should exist");
|
||||
let comp = system.component(node_idx);
|
||||
assert_eq!(comp.n_equations(), 0, "Node is passive (0 equations)");
|
||||
assert_eq!(system.node_count(), 2);
|
||||
@@ -1812,7 +1821,9 @@ mod tests {
|
||||
#[test]
|
||||
fn test_to_config_json_empty_builder() {
|
||||
let builder = SystemBuilder::new();
|
||||
let json = builder.to_config_json().expect("empty builder should serialize");
|
||||
let json = builder
|
||||
.to_config_json()
|
||||
.expect("empty builder should serialize");
|
||||
assert!(json.contains("\"version\": \"1.0\""));
|
||||
assert!(json.contains("\"parameters\": {}"));
|
||||
}
|
||||
@@ -1823,7 +1834,9 @@ mod tests {
|
||||
.component("a", Box::new(MockComponent { n_eqs: 1 }))
|
||||
.unwrap()
|
||||
.with_fluid("R134a");
|
||||
let json = builder.to_config_json().expect("should serialize with fluid");
|
||||
let json = builder
|
||||
.to_config_json()
|
||||
.expect("should serialize with fluid");
|
||||
assert!(json.contains("\"fluidName\": \"R134a\""));
|
||||
}
|
||||
|
||||
@@ -1885,30 +1898,52 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_round_trip_with_constraints_and_bounded_vars() {
|
||||
use entropyk_components::port::{FluidId, Port};
|
||||
use entropyk_components::Node;
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
use entropyk_solver::inverse::{
|
||||
BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId,
|
||||
};
|
||||
use entropyk_components::Node;
|
||||
use entropyk_components::port::{FluidId, Port};
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
|
||||
let in_p = Port::new(FluidId::new("R134a"), Pressure::from_pascals(300_000.0), Enthalpy::from_joules_per_kg(400_000.0));
|
||||
let out_p = Port::new(FluidId::new("R134a"), Pressure::from_pascals(290_000.0), Enthalpy::from_joules_per_kg(410_000.0));
|
||||
let node = Node::new("probe", in_p, out_p).connect(
|
||||
Port::new(FluidId::new("R134a"), Pressure::from_pascals(300_000.0), Enthalpy::from_joules_per_kg(400_000.0)),
|
||||
Port::new(FluidId::new("R134a"), Pressure::from_pascals(290_000.0), Enthalpy::from_joules_per_kg(410_000.0)),
|
||||
).expect("connect");
|
||||
let in_p = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_pascals(300_000.0),
|
||||
Enthalpy::from_joules_per_kg(400_000.0),
|
||||
);
|
||||
let out_p = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_pascals(290_000.0),
|
||||
Enthalpy::from_joules_per_kg(410_000.0),
|
||||
);
|
||||
let node = Node::new("probe", in_p, out_p)
|
||||
.connect(
|
||||
Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_pascals(300_000.0),
|
||||
Enthalpy::from_joules_per_kg(400_000.0),
|
||||
),
|
||||
Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_pascals(290_000.0),
|
||||
Enthalpy::from_joules_per_kg(410_000.0),
|
||||
),
|
||||
)
|
||||
.expect("connect");
|
||||
|
||||
let original = SystemBuilder::new()
|
||||
.component("evap", Box::new(node))
|
||||
.unwrap()
|
||||
.with_constraint(Constraint::new(
|
||||
ConstraintId::new("sh"),
|
||||
ComponentOutput::Superheat { component_id: "evap".to_string() },
|
||||
ComponentOutput::Superheat {
|
||||
component_id: "evap".to_string(),
|
||||
},
|
||||
5.0,
|
||||
))
|
||||
.unwrap()
|
||||
.with_bounded_variable(BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap())
|
||||
.with_bounded_variable(
|
||||
BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let json = original.to_config_json().expect("serialize");
|
||||
@@ -1922,19 +1957,37 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_round_trip_multi_circuit_with_thermal_coupling() {
|
||||
use entropyk_core::CircuitId;
|
||||
use entropyk_components::Node;
|
||||
use entropyk_components::port::{FluidId, Port};
|
||||
use entropyk_components::Node;
|
||||
use entropyk_core::CircuitId;
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
|
||||
let in_p = Port::new(FluidId::new("R134a"), Pressure::from_pascals(300_000.0), Enthalpy::from_joules_per_kg(400_000.0));
|
||||
let out_p = Port::new(FluidId::new("R134a"), Pressure::from_pascals(290_000.0), Enthalpy::from_joules_per_kg(410_000.0));
|
||||
let in_p = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_pascals(300_000.0),
|
||||
Enthalpy::from_joules_per_kg(400_000.0),
|
||||
);
|
||||
let out_p = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_pascals(290_000.0),
|
||||
Enthalpy::from_joules_per_kg(410_000.0),
|
||||
);
|
||||
|
||||
let make_node = |name: &str| {
|
||||
let n = Node::new(name, in_p.clone(), out_p.clone()).connect(
|
||||
Port::new(FluidId::new("R134a"), Pressure::from_pascals(300_000.0), Enthalpy::from_joules_per_kg(400_000.0)),
|
||||
Port::new(FluidId::new("R134a"), Pressure::from_pascals(290_000.0), Enthalpy::from_joules_per_kg(410_000.0)),
|
||||
).expect("connect");
|
||||
let n = Node::new(name, in_p.clone(), out_p.clone())
|
||||
.connect(
|
||||
Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_pascals(300_000.0),
|
||||
Enthalpy::from_joules_per_kg(400_000.0),
|
||||
),
|
||||
Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_pascals(290_000.0),
|
||||
Enthalpy::from_joules_per_kg(410_000.0),
|
||||
),
|
||||
)
|
||||
.expect("connect");
|
||||
Box::new(n) as Box<dyn entropyk_components::Component>
|
||||
};
|
||||
|
||||
@@ -1963,15 +2016,31 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_save_load_config_json_file() {
|
||||
use entropyk_components::Node;
|
||||
use entropyk_components::port::{FluidId, Port};
|
||||
use entropyk_components::Node;
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
|
||||
let internal_in = Port::new(FluidId::new("R134a"), Pressure::from_pascals(300_000.0), Enthalpy::from_joules_per_kg(400_000.0));
|
||||
let internal_out = Port::new(FluidId::new("R134a"), Pressure::from_pascals(290_000.0), Enthalpy::from_joules_per_kg(410_000.0));
|
||||
let internal_in = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_pascals(300_000.0),
|
||||
Enthalpy::from_joules_per_kg(400_000.0),
|
||||
);
|
||||
let internal_out = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_pascals(290_000.0),
|
||||
Enthalpy::from_joules_per_kg(410_000.0),
|
||||
);
|
||||
let node_disconnected = Node::new("probe", internal_in, internal_out);
|
||||
let ext_in = Port::new(FluidId::new("R134a"), Pressure::from_pascals(300_000.0), Enthalpy::from_joules_per_kg(400_000.0));
|
||||
let ext_out = Port::new(FluidId::new("R134a"), Pressure::from_pascals(290_000.0), Enthalpy::from_joules_per_kg(410_000.0));
|
||||
let ext_in = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_pascals(300_000.0),
|
||||
Enthalpy::from_joules_per_kg(400_000.0),
|
||||
);
|
||||
let ext_out = Port::new(
|
||||
FluidId::new("R134a"),
|
||||
Pressure::from_pascals(290_000.0),
|
||||
Enthalpy::from_joules_per_kg(410_000.0),
|
||||
);
|
||||
let node = node_disconnected.connect(ext_in, ext_out).expect("connect");
|
||||
|
||||
let dir = std::env::temp_dir().join("entropyk_test_config.json");
|
||||
|
||||
@@ -87,9 +87,10 @@
|
||||
// =============================================================================
|
||||
|
||||
pub use entropyk_core::{
|
||||
Calib, CalibIndices, CalibValidationError, Enthalpy, MassFlow, Power, Pressure, Temperature,
|
||||
ThermalConductance, Concentration, RelativeHumidity, VaporQuality, VolumeFlow,
|
||||
MIN_MASS_FLOW_REGULARIZATION_KG_S,
|
||||
id_ends_with_calib_suffix, normalize_factor_name, Calib, CalibIndices, CalibValidationError,
|
||||
Concentration, Enthalpy, MassFlow, Power, Pressure, RelativeHumidity, Temperature,
|
||||
ThermalConductance, VaporQuality, VolumeFlow, MIN_MASS_FLOW_REGULARIZATION_KG_S, Z_DP, Z_ETAV,
|
||||
Z_FLOW, Z_POWER, Z_UA,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
@@ -97,25 +98,28 @@ pub use entropyk_core::{
|
||||
// =============================================================================
|
||||
|
||||
pub use entropyk_components::{
|
||||
create_component, friction_factor, roughness, AffinityLaws, Ahri540Coefficients, AirSink, AirSource,
|
||||
BoundedCurve, BrineSink, BrineSource, BypassValve, BypassValveConfig, CircuitId, Component,
|
||||
ComponentError, CompressibleMerger, CompressibleSplitter,
|
||||
Compressor, CompressorModel, Condenser, CondenserCoil, ConnectedPort, ConnectionError,
|
||||
CurveEngine, CurveEval, CurveResult, CurveSet, CurveWarning,
|
||||
Economizer, EpsNtuModel, Evaporator, EvaporatorCoil, ExchangerType, ExpansionValve,
|
||||
ExternalModel, ExternalModelConfig, ExternalModelError, ExternalModelMetadata,
|
||||
ExternalModelType, Fan, FanCurves, FloodedEvaporator, FlowConfiguration, FlowMerger,
|
||||
FlowSplitter, FluidKind, FreeCoolingConfig, FreeCoolingControlMode, FreeCoolingExchanger,
|
||||
FreeCoolingMode, HeatExchanger, HeatExchangerBuilder, HeatTransferModel,
|
||||
HxSideConditions, IncompressibleMerger,
|
||||
IncompressibleSplitter, JacobianBuilder, LmtdModel, MchxCondenserCoil, MockExternalModel,
|
||||
Node, NodeMeasurements, NodePhase, OperationalState, PerformanceCurves, PhaseRegion,
|
||||
Pipe, PipeGeometry, Polynomial1D,
|
||||
Polynomial2D, Pump, PumpCurves, RegistryError, RefrigerantSink, RefrigerantSource,
|
||||
ResidualVector, ScrewEconomizerCompressor,
|
||||
ScrewPerformanceCurves, SstSdtCoefficients, StateHistory, StateManageable,
|
||||
StateTransitionError, SystemState, ThreadSafeExternalModel, ValveCharacteristics,
|
||||
create_component, friction_factor, roughness, AffinityLaws, Ahri540Coefficients,
|
||||
AirCooledCondenser, AirSink, AirSource, Anchor, AnchorConstraint, BoundedCurve, BrineSink,
|
||||
BrineSource, BypassValve, BypassValveConfig, CapillaryGeometry, CapillaryTube,
|
||||
CentrifugalCompressor, CentrifugalMap, CentrifugalMapPoint, CircuitId, CoilGeometry, Component,
|
||||
ComponentError, CompressibleMerger, CompressibleSplitter, Compressor, CompressorModel,
|
||||
Condenser, CondenserCoil, CondenserRating, ConnectedPort, ConnectionError, CurveEngine,
|
||||
CurveEval, CurveResult, CurveSet, CurveWarning, Drum, Economizer, EpsNtuModel, Evaporator,
|
||||
EvaporatorCoil, EvaporatorRating, ExchangerType, ExpansionValve, ExternalModel,
|
||||
ExternalModelConfig, ExternalModelError, ExternalModelMetadata, ExternalModelType, Fan,
|
||||
FanCoilUnit, FanCurves, FinCoilCondenser, FinType, FloodedEvaporator, FloodedPoolBoilingConfig,
|
||||
FlowConfiguration, FlowMerger, FlowSplitter, FluidKind, FreeCoolingConfig,
|
||||
FreeCoolingControlMode, FreeCoolingExchanger, FreeCoolingMode, GasCooler, HeatExchanger,
|
||||
HeatExchangerBuilder, HeatSource, HeatTransferModel, HxSideConditions, IncompressibleMerger,
|
||||
IncompressibleSplitter, IsenthalpicExpansionValve, IsentropicCompressor, JacobianBuilder,
|
||||
LmtdModel, MchxCondenserCoil, MockExternalModel, Node, NodeMeasurements, NodePhase,
|
||||
OperationalState, PerformanceCurves, PhaseRegion, Pipe, PipeGeometry, Polynomial1D,
|
||||
Polynomial2D, Pump, PumpCurves, RefrigerantSink, RefrigerantSource, RegistryError,
|
||||
ResidualVector, ScrewEconomizerCompressor, ScrewPerformanceCurves, ShellAndTubeHx,
|
||||
SstSdtCoefficients, StateHistory, StateManageable, StateTransitionError, SystemState,
|
||||
ThermalLoad, ThreadSafeExternalModel, UaMode, ValveCharacteristics, ValveFlowModel,
|
||||
};
|
||||
pub use entropyk_components::{ReversingMode, ReversingValve};
|
||||
|
||||
pub use entropyk_components::port::{Connected, Disconnected, FluidId as ComponentFluidId, Port};
|
||||
|
||||
@@ -134,18 +138,18 @@ pub use entropyk_fluids::{
|
||||
// Solver Re-exports
|
||||
// =============================================================================
|
||||
|
||||
pub use entropyk_solver::inverse::{
|
||||
BoundedVariable, BoundedVariableError, BoundedVariableId, DoFError,
|
||||
};
|
||||
pub use entropyk_solver::{
|
||||
antoine_pressure, compute_coupling_heat, coupling_groups, has_circular_dependencies,
|
||||
AddEdgeError, AntoineCoefficients, CircuitConvergence, CircuitId as SolverCircuitId,
|
||||
ComponentOutput, Constraint, ConstraintError, ConstraintId, ConvergedState,
|
||||
ConvergenceCriteria, ConvergenceReport, ConvergenceStatus, FallbackConfig, FallbackSolver,
|
||||
FlowEdge, InitializerConfig, InitializerError, JacobianFreezingConfig, JacobianMatrix,
|
||||
MacroComponent, MacroComponentSnapshot, NewtonConfig, PicardConfig, PortMapping,
|
||||
SmartInitializer, Solver, SolverError, SolverStrategy, System, ThermalCoupling, TimeoutConfig,
|
||||
TopologyError,
|
||||
};
|
||||
pub use entropyk_solver::inverse::{
|
||||
BoundedVariable, BoundedVariableError, BoundedVariableId, DoFError,
|
||||
ConvergenceCriteria, ConvergenceReport, ConvergenceStatus, CyclePerformance, FallbackConfig,
|
||||
FallbackSolver, FlowEdge, HomotopyConfig, InitializerConfig, InitializerError,
|
||||
JacobianFreezingConfig, JacobianMatrix, MacroComponent, MacroComponentSnapshot, NewtonConfig,
|
||||
PicardConfig, PortMapping, SmartInitializer, Solver, SolverError, SolverStrategy, System,
|
||||
ThermalCoupling, TimeoutConfig, TopologyError,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
@@ -172,6 +176,16 @@ pub use result::{
|
||||
PortState, SimulationOutcome, SimulationResult, SystemSummary,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Standardized ratings (IPLV / ESEER / SCOP)
|
||||
// =============================================================================
|
||||
|
||||
pub mod rating;
|
||||
pub use rating::{
|
||||
BinClimateStandard, BinPerformance, PartLoadEfficiencies, PartLoadStandard, RatingCondition,
|
||||
RatingError, TemperatureBin,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Prelude
|
||||
// =============================================================================
|
||||
@@ -185,8 +199,8 @@ pub use result::{
|
||||
/// use entropyk::prelude::*;
|
||||
/// ```
|
||||
pub mod prelude {
|
||||
pub use crate::ThermoError;
|
||||
pub use crate::result::SimulationResult;
|
||||
pub use crate::ThermoError;
|
||||
pub use entropyk_components::Component;
|
||||
pub use entropyk_core::{Enthalpy, MassFlow, Power, Pressure, Temperature};
|
||||
pub use entropyk_solver::{NewtonConfig, Solver, System};
|
||||
|
||||
921
crates/entropyk/src/rating.rs
Normal file
921
crates/entropyk/src/rating.rs
Normal file
@@ -0,0 +1,921 @@
|
||||
//! Standardized part-load and seasonal performance ratings.
|
||||
//!
|
||||
//! This module turns a set of part-load operating points (each characterised by a
|
||||
//! load fraction and an efficiency figure — EER for cooling, COP for heating) into
|
||||
//! the standardized seasonal metrics used to *qualify* chillers and heat pumps:
|
||||
//!
|
||||
//! - **IPLV / NPLV** — Integrated / Non-standard Part Load Value, per
|
||||
//! *AHRI Standard 550/590* (I-P and SI editions). Four-point weighted average at
|
||||
//! 100 / 75 / 50 / 25 % load.
|
||||
//! - **ESEER** — European Seasonal Energy Efficiency Ratio, per *Eurovent*. Same
|
||||
//! four load points, different weights.
|
||||
//! - **SCOP / SEER** — Seasonal Coefficient Of Performance / Seasonal Energy
|
||||
//! Efficiency Ratio, per *EN 14825*, computed by a temperature-bin method. A
|
||||
//! reference "average" climate bin table is provided.
|
||||
//!
|
||||
//! All formulas take *already-solved* efficiency values as input — computing the
|
||||
//! part-load operating points themselves (by re-solving the cycle at each rating
|
||||
//! condition) is the caller's responsibility. This keeps the metric math pure,
|
||||
//! deterministic and trivially unit-testable.
|
||||
//!
|
||||
//! # Modular, data-driven standards
|
||||
//!
|
||||
//! Regulatory rating standards are periodically revised (AHRI and Eurovent re-fit
|
||||
//! their part-load weights; EN 14825 updates its climate bins). To keep pace
|
||||
//! **without changing code**, the weighting schemes are expressed as *data*, not
|
||||
//! hard-coded arithmetic:
|
||||
//!
|
||||
//! - [`PartLoadStandard`] — a named `{ load_fractions, weights }` table driving any
|
||||
//! weighted part-load metric (IPLV, NPLV, ESEER, and user-defined variants such
|
||||
//! as SEER weightings). Built-in presets: [`PartLoadStandard::ahri_550_590_iplv`],
|
||||
//! [`PartLoadStandard::eurovent_eseer`]. Look one up by id with
|
||||
//! [`PartLoadStandard::builtin`], or deserialize a custom one from JSON and call
|
||||
//! [`PartLoadStandard::validate`].
|
||||
//! - [`BinClimateStandard`] — a named temperature-bin table (hours per bin) driving
|
||||
//! the SCOP/SEER bin method. Built-in preset:
|
||||
//! [`BinClimateStandard::en_14825_average`].
|
||||
//!
|
||||
//! When a standard changes, update the preset here or ship a JSON file — callers
|
||||
//! select the standard by name/file at run time, so the surrounding solve and CLI
|
||||
//! stay untouched. The legacy `IPLV_WEIGHTS` / `ESEER_WEIGHTS` constants and the
|
||||
//! `PartLoadEfficiencies::iplv` / `eseer` helpers are retained as thin wrappers
|
||||
//! over the corresponding presets for backward compatibility.
|
||||
//!
|
||||
//! # References
|
||||
//! - AHRI Standard 550/590 (2023): *Performance Rating of Water-Chilling and Heat
|
||||
//! Pump Water-Heating Packages Using the Vapor Compression Cycle.*
|
||||
//! - AHRI Standard 551/591 (SI): metric counterpart of 550/590.
|
||||
//! - Eurovent: ESEER definition for liquid chilling packages.
|
||||
//! - EN 14825:2018: *Air conditioners, liquid chilling packages and heat pumps …
|
||||
//! Testing and rating at part load conditions and calculation of seasonal
|
||||
//! performance.*
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The four standardized part-load fractions used by AHRI 550/590 and Eurovent.
|
||||
pub const STANDARD_LOAD_FRACTIONS: [f64; 4] = [1.0, 0.75, 0.50, 0.25];
|
||||
|
||||
/// AHRI 550/590 IPLV weighting coefficients for the 100/75/50/25 % load points.
|
||||
///
|
||||
/// `IPLV = 0.01·A + 0.42·B + 0.45·C + 0.12·D`, where A/B/C/D are the efficiencies
|
||||
/// at 100/75/50/25 % load respectively.
|
||||
pub const IPLV_WEIGHTS: [f64; 4] = [0.01, 0.42, 0.45, 0.12];
|
||||
|
||||
/// Eurovent ESEER weighting coefficients for the 100/75/50/25 % load points.
|
||||
///
|
||||
/// `ESEER = 0.03·EER₁₀₀ + 0.33·EER₇₅ + 0.41·EER₅₀ + 0.23·EER₂₅`.
|
||||
pub const ESEER_WEIGHTS: [f64; 4] = [0.03, 0.33, 0.41, 0.23];
|
||||
|
||||
/// Tolerance applied when checking that a standard's weights sum to 1.0.
|
||||
const WEIGHT_SUM_TOL: f64 = 1e-6;
|
||||
|
||||
/// Error produced when constructing or applying a rating standard with
|
||||
/// inconsistent data.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RatingError {
|
||||
/// The standard defines no load points.
|
||||
Empty,
|
||||
/// `load_fractions` and `weights` have mismatched lengths.
|
||||
LengthMismatch {
|
||||
/// Number of load fractions supplied.
|
||||
fractions: usize,
|
||||
/// Number of weights supplied.
|
||||
weights: usize,
|
||||
},
|
||||
/// The weights do not sum to 1.0 within [`WEIGHT_SUM_TOL`].
|
||||
WeightsNotNormalized {
|
||||
/// The actual (out-of-range) sum.
|
||||
sum: f64,
|
||||
},
|
||||
/// The number of supplied efficiencies does not match the number of load
|
||||
/// points in the standard.
|
||||
EfficiencyCountMismatch {
|
||||
/// Load points the standard expects.
|
||||
expected: usize,
|
||||
/// Efficiencies actually supplied.
|
||||
got: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RatingError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RatingError::Empty => write!(f, "rating standard defines no load points"),
|
||||
RatingError::LengthMismatch { fractions, weights } => write!(
|
||||
f,
|
||||
"rating standard has {fractions} load fractions but {weights} weights"
|
||||
),
|
||||
RatingError::WeightsNotNormalized { sum } => {
|
||||
write!(f, "rating standard weights sum to {sum}, expected 1.0")
|
||||
}
|
||||
RatingError::EfficiencyCountMismatch { expected, got } => write!(
|
||||
f,
|
||||
"expected {expected} efficiencies for this standard, got {got}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RatingError {}
|
||||
|
||||
/// A data-driven part-load weighting standard (IPLV, NPLV, ESEER, SEER, …).
|
||||
///
|
||||
/// A weighted seasonal metric is fully described by *which* part-load points are
|
||||
/// measured (`load_fractions`) and *how* they are weighted (`weights`). Encoding
|
||||
/// the standard as data — rather than hard-coding the coefficients — means that
|
||||
/// when a standard is revised you update a table or ship a JSON file instead of
|
||||
/// changing code. The number of load points is arbitrary (four for AHRI/Eurovent,
|
||||
/// but any N is accepted), so a future standard with more or fewer points needs
|
||||
/// no code change.
|
||||
///
|
||||
/// # Adding a new standard without recompiling
|
||||
///
|
||||
/// Author a JSON file and deserialize it, then validate:
|
||||
///
|
||||
/// ```
|
||||
/// use entropyk::rating::PartLoadStandard;
|
||||
/// let json = r#"{
|
||||
/// "name": "Custom SEER weighting",
|
||||
/// "reference": "EN 14825 moderate cooling season (illustrative)",
|
||||
/// "load_fractions": [1.0, 0.74, 0.47, 0.21],
|
||||
/// "weights": [0.03, 0.27, 0.41, 0.29]
|
||||
/// }"#;
|
||||
/// let std: PartLoadStandard = serde_json::from_str(json).unwrap();
|
||||
/// std.validate().unwrap();
|
||||
/// let seer = std.integrate(&[3.0, 4.0, 5.0, 4.5]).unwrap();
|
||||
/// # assert!(seer > 0.0);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PartLoadStandard {
|
||||
/// Human-readable name, e.g. "AHRI 550/590 IPLV".
|
||||
pub name: String,
|
||||
/// Citation / provenance of the coefficients.
|
||||
#[serde(default)]
|
||||
pub reference: String,
|
||||
/// Part-load fractions the points are measured at, e.g. `[1.0, 0.75, 0.5, 0.25]`.
|
||||
pub load_fractions: Vec<f64>,
|
||||
/// Weight applied to each load fraction; must have the same length as
|
||||
/// `load_fractions` and sum to 1.0.
|
||||
pub weights: Vec<f64>,
|
||||
}
|
||||
|
||||
impl PartLoadStandard {
|
||||
/// Construct and validate a standard from its raw data.
|
||||
pub fn new(
|
||||
name: impl Into<String>,
|
||||
reference: impl Into<String>,
|
||||
load_fractions: Vec<f64>,
|
||||
weights: Vec<f64>,
|
||||
) -> Result<Self, RatingError> {
|
||||
let std = Self {
|
||||
name: name.into(),
|
||||
reference: reference.into(),
|
||||
load_fractions,
|
||||
weights,
|
||||
};
|
||||
std.validate()?;
|
||||
Ok(std)
|
||||
}
|
||||
|
||||
/// Check the standard is internally consistent: non-empty, equal-length
|
||||
/// fractions/weights, and weights that sum to 1.0.
|
||||
pub fn validate(&self) -> Result<(), RatingError> {
|
||||
if self.load_fractions.is_empty() || self.weights.is_empty() {
|
||||
return Err(RatingError::Empty);
|
||||
}
|
||||
if self.load_fractions.len() != self.weights.len() {
|
||||
return Err(RatingError::LengthMismatch {
|
||||
fractions: self.load_fractions.len(),
|
||||
weights: self.weights.len(),
|
||||
});
|
||||
}
|
||||
let sum: f64 = self.weights.iter().sum();
|
||||
if (sum - 1.0).abs() > WEIGHT_SUM_TOL {
|
||||
return Err(RatingError::WeightsNotNormalized { sum });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Number of part-load points this standard weights.
|
||||
pub fn len(&self) -> usize {
|
||||
self.load_fractions.len()
|
||||
}
|
||||
|
||||
/// Whether the standard defines no load points.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.load_fractions.is_empty()
|
||||
}
|
||||
|
||||
/// Integrate the seasonal metric: the weighted sum of `efficiencies`, which
|
||||
/// must be ordered to match `load_fractions`.
|
||||
pub fn integrate(&self, efficiencies: &[f64]) -> Result<f64, RatingError> {
|
||||
if efficiencies.len() != self.weights.len() {
|
||||
return Err(RatingError::EfficiencyCountMismatch {
|
||||
expected: self.weights.len(),
|
||||
got: efficiencies.len(),
|
||||
});
|
||||
}
|
||||
Ok(efficiencies
|
||||
.iter()
|
||||
.zip(self.weights.iter())
|
||||
.map(|(e, w)| e * w)
|
||||
.sum())
|
||||
}
|
||||
|
||||
/// **AHRI 550/590** IPLV/NPLV preset (four points, weights
|
||||
/// `[0.01, 0.42, 0.45, 0.12]`).
|
||||
pub fn ahri_550_590_iplv() -> Self {
|
||||
Self {
|
||||
name: "AHRI 550/590 IPLV".to_string(),
|
||||
reference: "AHRI Standard 550/590 — Integrated Part Load Value".to_string(),
|
||||
load_fractions: STANDARD_LOAD_FRACTIONS.to_vec(),
|
||||
weights: IPLV_WEIGHTS.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// **Eurovent** ESEER preset (four points, weights `[0.03, 0.33, 0.41, 0.23]`).
|
||||
pub fn eurovent_eseer() -> Self {
|
||||
Self {
|
||||
name: "Eurovent ESEER".to_string(),
|
||||
reference: "Eurovent — European Seasonal Energy Efficiency Ratio".to_string(),
|
||||
load_fractions: STANDARD_LOAD_FRACTIONS.to_vec(),
|
||||
weights: ESEER_WEIGHTS.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a built-in standard by a case-insensitive identifier.
|
||||
///
|
||||
/// Recognised: `"iplv"`, `"nplv"`, `"ahri_550_590"` → AHRI IPLV;
|
||||
/// `"eseer"`, `"eurovent"` → Eurovent ESEER. Returns `None` for unknown ids
|
||||
/// (the caller should then try loading a custom standard from a file).
|
||||
pub fn builtin(id: &str) -> Option<Self> {
|
||||
match id
|
||||
.to_ascii_lowercase()
|
||||
.replace([' ', '-', '/'], "_")
|
||||
.as_str()
|
||||
{
|
||||
"iplv" | "nplv" | "ahri" | "ahri_550_590" | "ahri_551_591" => {
|
||||
Some(Self::ahri_550_590_iplv())
|
||||
}
|
||||
"eseer" | "eurovent" => Some(Self::eurovent_eseer()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ids of all built-in part-load standards (for help/discovery).
|
||||
pub fn builtin_ids() -> &'static [&'static str] {
|
||||
&["iplv", "nplv", "eseer"]
|
||||
}
|
||||
}
|
||||
|
||||
/// Efficiency figures at the four standardized part-load points.
|
||||
///
|
||||
/// The values are EER (cooling) or COP (heating), consistently one or the other.
|
||||
/// Fields are named by the fraction of full load they correspond to.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PartLoadEfficiencies {
|
||||
/// Efficiency at 100 % load (point A).
|
||||
pub at_100: f64,
|
||||
/// Efficiency at 75 % load (point B).
|
||||
pub at_75: f64,
|
||||
/// Efficiency at 50 % load (point C).
|
||||
pub at_50: f64,
|
||||
/// Efficiency at 25 % load (point D).
|
||||
pub at_25: f64,
|
||||
}
|
||||
|
||||
impl PartLoadEfficiencies {
|
||||
/// Create part-load efficiencies from the four values, ordered
|
||||
/// `[100 %, 75 %, 50 %, 25 %]`.
|
||||
pub fn new(at_100: f64, at_75: f64, at_50: f64, at_25: f64) -> Self {
|
||||
Self {
|
||||
at_100,
|
||||
at_75,
|
||||
at_50,
|
||||
at_25,
|
||||
}
|
||||
}
|
||||
|
||||
/// The four efficiencies as an array ordered `[100 %, 75 %, 50 %, 25 %]`.
|
||||
pub fn as_array(&self) -> [f64; 4] {
|
||||
[self.at_100, self.at_75, self.at_50, self.at_25]
|
||||
}
|
||||
|
||||
/// Weighted sum of the four efficiencies with the supplied weights (which are
|
||||
/// expected to sum to 1.0).
|
||||
fn weighted(&self, weights: &[f64; 4]) -> f64 {
|
||||
self.as_array()
|
||||
.iter()
|
||||
.zip(weights.iter())
|
||||
.map(|(e, w)| e * w)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Integrate these efficiencies against an arbitrary [`PartLoadStandard`].
|
||||
///
|
||||
/// This is the modular entry point: pass any built-in or custom standard
|
||||
/// (four load points, in the canonical `[100, 75, 50, 25] %` order these
|
||||
/// efficiencies are stored in) to obtain its weighted seasonal value.
|
||||
///
|
||||
/// Returns an error if the standard does not define exactly four load points.
|
||||
pub fn integrate(&self, standard: &PartLoadStandard) -> Result<f64, RatingError> {
|
||||
standard.integrate(&self.as_array())
|
||||
}
|
||||
|
||||
/// Integrated Part Load Value per **AHRI 550/590**.
|
||||
///
|
||||
/// When the part-load points are measured at the *standard* rating conditions
|
||||
/// this is the IPLV; measured at any other condition set it is the NPLV
|
||||
/// (Non-standard Part Load Value) — the arithmetic is identical.
|
||||
///
|
||||
/// ```
|
||||
/// use entropyk::rating::PartLoadEfficiencies;
|
||||
/// let eff = PartLoadEfficiencies::new(4.0, 5.0, 6.0, 5.5);
|
||||
/// let iplv = eff.iplv();
|
||||
/// assert!((iplv - (0.01*4.0 + 0.42*5.0 + 0.45*6.0 + 0.12*5.5)).abs() < 1e-12);
|
||||
/// ```
|
||||
pub fn iplv(&self) -> f64 {
|
||||
self.weighted(&IPLV_WEIGHTS)
|
||||
}
|
||||
|
||||
/// Non-standard Part Load Value (alias of [`Self::iplv`]; identical formula,
|
||||
/// used when points are taken at non-standard conditions).
|
||||
pub fn nplv(&self) -> f64 {
|
||||
self.iplv()
|
||||
}
|
||||
|
||||
/// European Seasonal Energy Efficiency Ratio per **Eurovent**.
|
||||
///
|
||||
/// ```
|
||||
/// use entropyk::rating::PartLoadEfficiencies;
|
||||
/// let eff = PartLoadEfficiencies::new(3.0, 4.0, 5.0, 4.5);
|
||||
/// let eseer = eff.eseer();
|
||||
/// assert!((eseer - (0.03*3.0 + 0.33*4.0 + 0.41*5.0 + 0.23*4.5)).abs() < 1e-12);
|
||||
/// ```
|
||||
pub fn eseer(&self) -> f64 {
|
||||
self.weighted(&ESEER_WEIGHTS)
|
||||
}
|
||||
}
|
||||
|
||||
/// A standardized full-load rating condition (secondary-fluid temperatures).
|
||||
///
|
||||
/// Temperatures are the *secondary* (heat-transfer-fluid) side conditions that
|
||||
/// define the operating envelope. Refrigerant regimes emerge from the coupled
|
||||
/// heat-exchanger solve, so only the secondary conditions are prescribed here.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RatingCondition {
|
||||
/// Human-readable standard/condition name.
|
||||
pub name: &'static str,
|
||||
/// Evaporator-side secondary fluid leaving (supply) temperature [°C].
|
||||
pub evap_secondary_out_c: f64,
|
||||
/// Evaporator-side secondary fluid entering (return) temperature [°C].
|
||||
pub evap_secondary_in_c: f64,
|
||||
/// Condenser / gas-cooler side secondary fluid entering temperature [°C].
|
||||
pub cond_secondary_in_c: f64,
|
||||
}
|
||||
|
||||
impl RatingCondition {
|
||||
/// **AHRI 550/590** water-cooled chiller full-load condition:
|
||||
/// chilled-water 6.7 °C supply / 12.2 °C return, condenser water 29.4 °C entering.
|
||||
pub const AHRI_550_590_WATER_COOLED: RatingCondition = RatingCondition {
|
||||
name: "AHRI 550/590 water-cooled full load",
|
||||
evap_secondary_out_c: 6.7,
|
||||
evap_secondary_in_c: 12.2,
|
||||
cond_secondary_in_c: 29.4,
|
||||
};
|
||||
|
||||
/// **AHRI 550/590** air-cooled chiller full-load condition:
|
||||
/// chilled-water 6.7 °C supply / 12.2 °C return, ambient air 35.0 °C entering.
|
||||
pub const AHRI_550_590_AIR_COOLED: RatingCondition = RatingCondition {
|
||||
name: "AHRI 550/590 air-cooled full load",
|
||||
evap_secondary_out_c: 6.7,
|
||||
evap_secondary_in_c: 12.2,
|
||||
cond_secondary_in_c: 35.0,
|
||||
};
|
||||
|
||||
/// **EN 14511** water-cooled chiller condition A:
|
||||
/// chilled-water 7 °C supply / 12 °C return, condenser water 30 °C entering.
|
||||
pub const EN_14511_WATER_COOLED_A: RatingCondition = RatingCondition {
|
||||
name: "EN 14511 water-cooled condition A",
|
||||
evap_secondary_out_c: 7.0,
|
||||
evap_secondary_in_c: 12.0,
|
||||
cond_secondary_in_c: 30.0,
|
||||
};
|
||||
|
||||
/// **EN 14511** air-cooled chiller condition A:
|
||||
/// chilled-water 7 °C supply / 12 °C return, ambient air 35 °C entering.
|
||||
pub const EN_14511_AIR_COOLED_A: RatingCondition = RatingCondition {
|
||||
name: "EN 14511 air-cooled condition A",
|
||||
evap_secondary_out_c: 7.0,
|
||||
evap_secondary_in_c: 12.0,
|
||||
cond_secondary_in_c: 35.0,
|
||||
};
|
||||
}
|
||||
|
||||
/// A single temperature bin for the EN 14825 seasonal (SCOP) bin method.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TemperatureBin {
|
||||
/// Outdoor dry-bulb bin temperature [°C].
|
||||
pub temperature_c: f64,
|
||||
/// Number of hours per year spent in this bin.
|
||||
pub hours: f64,
|
||||
}
|
||||
|
||||
/// EN 14825 **average** heating-season reference bin table (Strasbourg reference).
|
||||
///
|
||||
/// This is Table 5 of Annex III to Commission Regulation (EU) No 813/2013
|
||||
/// ("European reference heating season under average climate conditions"),
|
||||
/// reproduced verbatim as Table A.4 of EN 14825:2018. The 26 bins with non-zero
|
||||
/// hours (Tj = −10 °C … +15 °C) are listed; the standard's all-zero bins below
|
||||
/// −10 °C are omitted. Hours sum to exactly 4910 h.
|
||||
pub const EN_14825_AVERAGE_BINS: [TemperatureBin; 26] = [
|
||||
TemperatureBin {
|
||||
temperature_c: -10.0,
|
||||
hours: 1.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: -9.0,
|
||||
hours: 25.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: -8.0,
|
||||
hours: 23.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: -7.0,
|
||||
hours: 24.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: -6.0,
|
||||
hours: 27.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: -5.0,
|
||||
hours: 68.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: -4.0,
|
||||
hours: 91.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: -3.0,
|
||||
hours: 89.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: -2.0,
|
||||
hours: 165.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: -1.0,
|
||||
hours: 173.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: 0.0,
|
||||
hours: 240.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: 1.0,
|
||||
hours: 280.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: 2.0,
|
||||
hours: 320.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: 3.0,
|
||||
hours: 357.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: 4.0,
|
||||
hours: 356.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: 5.0,
|
||||
hours: 303.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: 6.0,
|
||||
hours: 330.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: 7.0,
|
||||
hours: 326.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: 8.0,
|
||||
hours: 348.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: 9.0,
|
||||
hours: 335.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: 10.0,
|
||||
hours: 315.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: 11.0,
|
||||
hours: 215.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: 12.0,
|
||||
hours: 169.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: 13.0,
|
||||
hours: 151.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: 14.0,
|
||||
hours: 105.0,
|
||||
},
|
||||
TemperatureBin {
|
||||
temperature_c: 15.0,
|
||||
hours: 74.0,
|
||||
},
|
||||
];
|
||||
|
||||
/// A data-driven climate bin standard for the SCOP/SEER bin method.
|
||||
///
|
||||
/// The bin *set* (which outdoor temperatures, and how many hours per year at
|
||||
/// each) is defined by the applicable standard and climate zone — EN 14825
|
||||
/// specifies average / warmer / colder heating reference seasons, and separate
|
||||
/// cooling seasons for SEER, and these tables are revised over time. Holding the
|
||||
/// bins as data means a new climate or a revised table is just another
|
||||
/// [`BinClimateStandard`] value (a preset here or a JSON file), with the SCOP/SEER
|
||||
/// arithmetic unchanged.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BinClimateStandard {
|
||||
/// Human-readable name, e.g. "EN 14825 average heating season".
|
||||
pub name: String,
|
||||
/// Citation / provenance of the bin table.
|
||||
#[serde(default)]
|
||||
pub reference: String,
|
||||
/// The temperature bins (outdoor temperature + annual hours).
|
||||
pub bins: Vec<TemperatureBin>,
|
||||
}
|
||||
|
||||
impl BinClimateStandard {
|
||||
/// **EN 14825** average heating-season reference climate (Strasbourg, 4910 h).
|
||||
pub fn en_14825_average() -> Self {
|
||||
Self {
|
||||
name: "EN 14825 average heating season".to_string(),
|
||||
reference: "EN 14825:2018 Table A.4 / EU 813/2013 Annex III Table 5".to_string(),
|
||||
bins: EN_14825_AVERAGE_BINS.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a built-in climate by a case-insensitive identifier.
|
||||
///
|
||||
/// Recognised: `"en_14825_average"`, `"average"` → EN 14825 average season.
|
||||
pub fn builtin(id: &str) -> Option<Self> {
|
||||
match id
|
||||
.to_ascii_lowercase()
|
||||
.replace([' ', '-', '/'], "_")
|
||||
.as_str()
|
||||
{
|
||||
"en_14825_average" | "average" | "en14825" => Some(Self::en_14825_average()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ids of all built-in climate standards (for help/discovery).
|
||||
pub fn builtin_ids() -> &'static [&'static str] {
|
||||
&["en_14825_average"]
|
||||
}
|
||||
|
||||
/// Total annual hours across all bins.
|
||||
pub fn total_hours(&self) -> f64 {
|
||||
self.bins.iter().map(|b| b.hours).sum()
|
||||
}
|
||||
|
||||
/// Check the climate defines at least one bin.
|
||||
pub fn validate(&self) -> Result<(), RatingError> {
|
||||
if self.bins.is_empty() {
|
||||
return Err(RatingError::Empty);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A bin paired with the seasonal building heating demand and the machine COP at
|
||||
/// that bin's outdoor temperature.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BinPerformance {
|
||||
/// The temperature bin (outdoor temperature + annual hours).
|
||||
pub bin: TemperatureBin,
|
||||
/// Building heating demand at this bin temperature [W] (part-load ratio × design load).
|
||||
pub demand_w: f64,
|
||||
/// Machine COP at this bin temperature (including any degradation/backup effect).
|
||||
pub cop: f64,
|
||||
}
|
||||
|
||||
/// Seasonal Coefficient Of Performance per the **EN 14825** bin method.
|
||||
///
|
||||
/// `SCOP = Σ (hours·demand) / Σ (hours·demand / COP)` — i.e. the ratio of the total
|
||||
/// seasonal heating energy delivered to the total electrical energy consumed,
|
||||
/// summed over all temperature bins. Bins with zero demand or zero hours are
|
||||
/// ignored.
|
||||
///
|
||||
/// Returns `None` if the total electrical energy works out to zero (no valid bins
|
||||
/// with positive demand and COP).
|
||||
pub fn scop(bins: &[BinPerformance]) -> Option<f64> {
|
||||
let mut heat_energy = 0.0;
|
||||
let mut elec_energy = 0.0;
|
||||
for b in bins {
|
||||
if b.bin.hours <= 0.0 || b.demand_w <= 0.0 || b.cop <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let heat = b.bin.hours * b.demand_w;
|
||||
heat_energy += heat;
|
||||
elec_energy += heat / b.cop;
|
||||
}
|
||||
if elec_energy > 0.0 {
|
||||
Some(heat_energy / elec_energy)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn iplv_weights_sum_to_one() {
|
||||
assert_relative_eq!(IPLV_WEIGHTS.iter().sum::<f64>(), 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eseer_weights_sum_to_one() {
|
||||
assert_relative_eq!(ESEER_WEIGHTS.iter().sum::<f64>(), 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iplv_matches_ahri_formula() {
|
||||
let eff = PartLoadEfficiencies::new(4.0, 5.2, 6.1, 5.4);
|
||||
let expected = 0.01 * 4.0 + 0.42 * 5.2 + 0.45 * 6.1 + 0.12 * 5.4;
|
||||
assert_relative_eq!(eff.iplv(), expected, epsilon = 1e-12);
|
||||
// NPLV is the same arithmetic.
|
||||
assert_relative_eq!(eff.nplv(), expected, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eseer_matches_eurovent_formula() {
|
||||
let eff = PartLoadEfficiencies::new(3.1, 4.2, 5.3, 4.7);
|
||||
let expected = 0.03 * 3.1 + 0.33 * 4.2 + 0.41 * 5.3 + 0.23 * 4.7;
|
||||
assert_relative_eq!(eff.eseer(), expected, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_efficiency_gives_same_iplv_and_eseer() {
|
||||
// If efficiency is identical at every load, both seasonal metrics equal it
|
||||
// (weights sum to 1).
|
||||
let eff = PartLoadEfficiencies::new(5.0, 5.0, 5.0, 5.0);
|
||||
assert_relative_eq!(eff.iplv(), 5.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(eff.eseer(), 5.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iplv_weights_part_load_most_heavily() {
|
||||
// A machine that is much better at 50 % load should see a big IPLV lift,
|
||||
// because the 50 % point carries 45 % weight.
|
||||
let base = PartLoadEfficiencies::new(4.0, 4.0, 4.0, 4.0);
|
||||
let good_partload = PartLoadEfficiencies::new(4.0, 4.0, 8.0, 4.0);
|
||||
let lift = good_partload.iplv() - base.iplv();
|
||||
assert_relative_eq!(lift, 0.45 * 4.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scop_of_constant_cop_equals_cop() {
|
||||
let bins: Vec<BinPerformance> = EN_14825_AVERAGE_BINS
|
||||
.iter()
|
||||
.map(|&bin| BinPerformance {
|
||||
bin,
|
||||
demand_w: 5000.0,
|
||||
cop: 3.5,
|
||||
})
|
||||
.collect();
|
||||
assert_relative_eq!(scop(&bins).unwrap(), 3.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scop_is_hours_and_demand_weighted() {
|
||||
// Two bins: cold bin (few hours, low COP) + mild bin (many hours, high COP).
|
||||
// SCOP must be pulled toward the mild bin because it carries far more
|
||||
// heating energy.
|
||||
let bins = [
|
||||
BinPerformance {
|
||||
bin: TemperatureBin {
|
||||
temperature_c: -7.0,
|
||||
hours: 10.0,
|
||||
},
|
||||
demand_w: 8000.0,
|
||||
cop: 2.0,
|
||||
},
|
||||
BinPerformance {
|
||||
bin: TemperatureBin {
|
||||
temperature_c: 7.0,
|
||||
hours: 1000.0,
|
||||
},
|
||||
demand_w: 3000.0,
|
||||
cop: 4.5,
|
||||
},
|
||||
];
|
||||
let s = scop(&bins).unwrap();
|
||||
// Manual: heat = 10*8000 + 1000*3000 = 80_000 + 3_000_000 = 3_080_000
|
||||
// elec = 80_000/2.0 + 3_000_000/4.5 = 40_000 + 666_666.67
|
||||
let heat = 10.0 * 8000.0 + 1000.0 * 3000.0;
|
||||
let elec = 10.0 * 8000.0 / 2.0 + 1000.0 * 3000.0 / 4.5;
|
||||
assert_relative_eq!(s, heat / elec, epsilon = 1e-9);
|
||||
assert!(s > 4.0, "SCOP should be dominated by the mild high-COP bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scop_ignores_invalid_bins_and_handles_empty() {
|
||||
assert!(scop(&[]).is_none());
|
||||
let bins = [BinPerformance {
|
||||
bin: TemperatureBin {
|
||||
temperature_c: 0.0,
|
||||
hours: 0.0,
|
||||
},
|
||||
demand_w: 5000.0,
|
||||
cop: 3.0,
|
||||
}];
|
||||
assert!(scop(&bins).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn en14825_average_bins_hours_sum() {
|
||||
let total: f64 = EN_14825_AVERAGE_BINS.iter().map(|b| b.hours).sum();
|
||||
assert_relative_eq!(total, 4910.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_conditions_are_ordered_physically() {
|
||||
// Evaporator supply must be colder than return (chiller extracts heat).
|
||||
for c in [
|
||||
RatingCondition::AHRI_550_590_WATER_COOLED,
|
||||
RatingCondition::AHRI_550_590_AIR_COOLED,
|
||||
RatingCondition::EN_14511_WATER_COOLED_A,
|
||||
RatingCondition::EN_14511_AIR_COOLED_A,
|
||||
] {
|
||||
assert!(
|
||||
c.evap_secondary_out_c < c.evap_secondary_in_c,
|
||||
"{}: supply must be colder than return",
|
||||
c.name
|
||||
);
|
||||
assert!(
|
||||
c.cond_secondary_in_c > c.evap_secondary_in_c,
|
||||
"{}: condenser side must be warmer than evaporator side",
|
||||
c.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Modular, data-driven standards ----
|
||||
|
||||
#[test]
|
||||
fn partload_standard_presets_reproduce_legacy_constants() {
|
||||
let iplv_std = PartLoadStandard::ahri_550_590_iplv();
|
||||
let eseer_std = PartLoadStandard::eurovent_eseer();
|
||||
iplv_std.validate().unwrap();
|
||||
eseer_std.validate().unwrap();
|
||||
assert_eq!(iplv_std.weights, IPLV_WEIGHTS.to_vec());
|
||||
assert_eq!(eseer_std.weights, ESEER_WEIGHTS.to_vec());
|
||||
assert_eq!(iplv_std.load_fractions, STANDARD_LOAD_FRACTIONS.to_vec());
|
||||
|
||||
// The generic integrate() must agree with the legacy helpers bit-for-bit.
|
||||
let eff = PartLoadEfficiencies::new(4.0, 5.2, 6.1, 5.4);
|
||||
assert_relative_eq!(
|
||||
eff.integrate(&iplv_std).unwrap(),
|
||||
eff.iplv(),
|
||||
epsilon = 1e-12
|
||||
);
|
||||
assert_relative_eq!(
|
||||
eff.integrate(&eseer_std).unwrap(),
|
||||
eff.eseer(),
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partload_standard_builtin_lookup_is_case_and_separator_insensitive() {
|
||||
for id in ["iplv", "IPLV", "nplv", "AHRI-550/590", "ahri 550 590"] {
|
||||
let s = PartLoadStandard::builtin(id).unwrap_or_else(|| panic!("id {id} not found"));
|
||||
assert_eq!(s.weights, IPLV_WEIGHTS.to_vec());
|
||||
}
|
||||
for id in ["eseer", "Eurovent"] {
|
||||
assert_eq!(
|
||||
PartLoadStandard::builtin(id).unwrap().weights,
|
||||
ESEER_WEIGHTS.to_vec()
|
||||
);
|
||||
}
|
||||
assert!(PartLoadStandard::builtin("does-not-exist").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partload_standard_custom_from_json_round_trips_and_integrates() {
|
||||
// A user-supplied SEER-style weighting with four points but different
|
||||
// fractions and weights — no code change required.
|
||||
let json = r#"{
|
||||
"name": "Custom SEER",
|
||||
"reference": "illustrative",
|
||||
"load_fractions": [1.0, 0.74, 0.47, 0.21],
|
||||
"weights": [0.03, 0.27, 0.41, 0.29]
|
||||
}"#;
|
||||
let std: PartLoadStandard = serde_json::from_str(json).unwrap();
|
||||
std.validate().unwrap();
|
||||
let value = std.integrate(&[3.0, 4.0, 5.0, 4.5]).unwrap();
|
||||
let expected = 0.03 * 3.0 + 0.27 * 4.0 + 0.41 * 5.0 + 0.29 * 4.5;
|
||||
assert_relative_eq!(value, expected, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partload_standard_supports_arbitrary_point_counts() {
|
||||
// Three points, not four — accepted as long as it is internally consistent.
|
||||
let std =
|
||||
PartLoadStandard::new("3-point", "test", vec![1.0, 0.5, 0.25], vec![0.2, 0.5, 0.3])
|
||||
.unwrap();
|
||||
assert_eq!(std.len(), 3);
|
||||
let value = std.integrate(&[4.0, 6.0, 5.0]).unwrap();
|
||||
assert_relative_eq!(value, 0.2 * 4.0 + 0.5 * 6.0 + 0.3 * 5.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partload_standard_validation_rejects_bad_data() {
|
||||
// Length mismatch.
|
||||
assert_eq!(
|
||||
PartLoadStandard::new("bad", "", vec![1.0, 0.5], vec![1.0]).unwrap_err(),
|
||||
RatingError::LengthMismatch {
|
||||
fractions: 2,
|
||||
weights: 1
|
||||
}
|
||||
);
|
||||
// Weights that do not sum to 1.
|
||||
match PartLoadStandard::new("bad", "", vec![1.0, 0.5], vec![0.3, 0.3]).unwrap_err() {
|
||||
RatingError::WeightsNotNormalized { sum } => {
|
||||
assert_relative_eq!(sum, 0.6, epsilon = 1e-12)
|
||||
}
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
// Empty.
|
||||
assert_eq!(
|
||||
PartLoadStandard::new("bad", "", vec![], vec![]).unwrap_err(),
|
||||
RatingError::Empty
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partload_standard_integrate_rejects_wrong_efficiency_count() {
|
||||
let std = PartLoadStandard::ahri_550_590_iplv();
|
||||
assert_eq!(
|
||||
std.integrate(&[4.0, 5.0, 6.0]).unwrap_err(),
|
||||
RatingError::EfficiencyCountMismatch {
|
||||
expected: 4,
|
||||
got: 3
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bin_climate_standard_preset_matches_reference_table() {
|
||||
let climate = BinClimateStandard::en_14825_average();
|
||||
climate.validate().unwrap();
|
||||
assert_eq!(climate.bins, EN_14825_AVERAGE_BINS.to_vec());
|
||||
assert_relative_eq!(climate.total_hours(), 4910.0, epsilon = 1e-9);
|
||||
|
||||
assert_eq!(
|
||||
BinClimateStandard::builtin("average").unwrap().bins.len(),
|
||||
EN_14825_AVERAGE_BINS.len()
|
||||
);
|
||||
assert!(BinClimateStandard::builtin("unknown").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bin_climate_standard_custom_from_json_drives_scop() {
|
||||
// Swap in a small custom climate; SCOP must use exactly those bins.
|
||||
let json = r#"{
|
||||
"name": "Tiny climate",
|
||||
"bins": [
|
||||
{ "temperature_c": -5.0, "hours": 100.0 },
|
||||
{ "temperature_c": 5.0, "hours": 900.0 }
|
||||
]
|
||||
}"#;
|
||||
let climate: BinClimateStandard = serde_json::from_str(json).unwrap();
|
||||
climate.validate().unwrap();
|
||||
assert_relative_eq!(climate.total_hours(), 1000.0, epsilon = 1e-12);
|
||||
|
||||
let bins: Vec<BinPerformance> = climate
|
||||
.bins
|
||||
.iter()
|
||||
.map(|&bin| BinPerformance {
|
||||
bin,
|
||||
demand_w: 4000.0,
|
||||
cop: 3.0,
|
||||
})
|
||||
.collect();
|
||||
assert_relative_eq!(scop(&bins).unwrap(), 3.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use entropyk_solver::{
|
||||
ConvergenceStatus, ConvergedState, SimulationMetadata, System,
|
||||
};
|
||||
use entropyk_solver::{ConvergedState, ConvergenceStatus, SimulationMetadata, System};
|
||||
use petgraph::graph::{EdgeIndex, NodeIndex};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -223,19 +221,17 @@ impl SimulationResult {
|
||||
///
|
||||
/// * `system` - The solved system (must be finalized).
|
||||
/// * `converged` - The converged state returned by the solver.
|
||||
pub fn extract_simulation_result(
|
||||
system: &System,
|
||||
converged: &ConvergedState,
|
||||
) -> SimulationResult {
|
||||
pub fn extract_simulation_result(system: &System, converged: &ConvergedState) -> SimulationResult {
|
||||
let state = &converged.state;
|
||||
|
||||
// Validate state vector length matches system topology
|
||||
let expected_len = system.edge_count() * 2;
|
||||
// Validate state vector length matches system topology.
|
||||
// CM1.4 layout: |branches| + 2*|edges| + internal_component_state.
|
||||
let expected_len = system.state_vector_len();
|
||||
if state.len() != expected_len {
|
||||
tracing::warn!(
|
||||
state_len = state.len(),
|
||||
expected_len,
|
||||
"State vector length does not match system edge count; results may be incomplete"
|
||||
"State vector length does not match system state length; results may be incomplete"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -279,56 +275,54 @@ pub fn extract_simulation_result(
|
||||
let circuit = system.node_circuit(node).0;
|
||||
|
||||
// Energy transfers
|
||||
let energy = comp
|
||||
.energy_transfers(state)
|
||||
.map(|(heat, work)| {
|
||||
let q = heat.to_watts();
|
||||
let w = work.to_watts();
|
||||
let energy = comp.energy_transfers(state).map(|(heat, work)| {
|
||||
let q = heat.to_watts();
|
||||
let w = work.to_watts();
|
||||
|
||||
// Guard against NaN/Inf propagating into system totals
|
||||
let q = if q.is_finite() { q } else { 0.0 };
|
||||
let w = if w.is_finite() { w } else { 0.0 };
|
||||
// Guard against NaN/Inf propagating into system totals
|
||||
let q = if q.is_finite() { q } else { 0.0 };
|
||||
let w = if w.is_finite() { w } else { 0.0 };
|
||||
|
||||
// Accumulate system totals based on sign conventions
|
||||
// Q > 0 = heat into component (evaporator absorbs heat = cooling)
|
||||
// Q < 0 = heat out of component (condenser rejects heat = heating)
|
||||
if q > 0.0 {
|
||||
total_cooling_w += q;
|
||||
has_cooling = true;
|
||||
} else if q < 0.0 {
|
||||
total_heating_w += q.abs();
|
||||
has_heating = true;
|
||||
// Accumulate system totals based on sign conventions
|
||||
// Q > 0 = heat into component (evaporator absorbs heat = cooling)
|
||||
// Q < 0 = heat out of component (condenser rejects heat = heating)
|
||||
if q > 0.0 {
|
||||
total_cooling_w += q;
|
||||
has_cooling = true;
|
||||
} else if q < 0.0 {
|
||||
total_heating_w += q.abs();
|
||||
has_heating = true;
|
||||
}
|
||||
|
||||
// W > 0 = work by component (compressors, pumps consume power)
|
||||
if w > 0.0 {
|
||||
// Best-effort classification by signature string.
|
||||
// Known work-producing components: Compressor, ScrewEconomizerCompressor,
|
||||
// Pump, Fan. Unknown work producers are logged and default to compressor.
|
||||
let sig = comp.signature().to_lowercase();
|
||||
if sig.contains("compressor") || sig.contains("screw") {
|
||||
total_compressor_power_w += w;
|
||||
has_compressor_power = true;
|
||||
} else if sig.contains("pump") || sig.contains("fan") {
|
||||
total_pump_power_w += w;
|
||||
has_pump_power = true;
|
||||
} else {
|
||||
tracing::debug!(
|
||||
component = name,
|
||||
signature = %comp.signature(),
|
||||
work_w = w,
|
||||
"Unknown work-producing component classified as compressor"
|
||||
);
|
||||
total_compressor_power_w += w;
|
||||
has_compressor_power = true;
|
||||
}
|
||||
}
|
||||
|
||||
// W > 0 = work by component (compressors, pumps consume power)
|
||||
if w > 0.0 {
|
||||
// Best-effort classification by signature string.
|
||||
// Known work-producing components: Compressor, ScrewEconomizerCompressor,
|
||||
// Pump, Fan. Unknown work producers are logged and default to compressor.
|
||||
let sig = comp.signature().to_lowercase();
|
||||
if sig.contains("compressor") || sig.contains("screw") {
|
||||
total_compressor_power_w += w;
|
||||
has_compressor_power = true;
|
||||
} else if sig.contains("pump") || sig.contains("fan") {
|
||||
total_pump_power_w += w;
|
||||
has_pump_power = true;
|
||||
} else {
|
||||
tracing::debug!(
|
||||
component = name,
|
||||
signature = %comp.signature(),
|
||||
work_w = w,
|
||||
"Unknown work-producing component classified as compressor"
|
||||
);
|
||||
total_compressor_power_w += w;
|
||||
has_compressor_power = true;
|
||||
}
|
||||
}
|
||||
|
||||
EnergyResult {
|
||||
heat_transfer_w: q,
|
||||
work_w: w,
|
||||
}
|
||||
});
|
||||
EnergyResult {
|
||||
heat_transfer_w: q,
|
||||
work_w: w,
|
||||
}
|
||||
});
|
||||
|
||||
// Mass flow from port_mass_flows (graceful fallback on Err/empty)
|
||||
let mass_flows: Vec<f64> = comp
|
||||
@@ -358,7 +352,10 @@ pub fn extract_simulation_result(
|
||||
Some(PortState {
|
||||
pressure_pa: state.get(p_idx).copied()?,
|
||||
enthalpy_j_kg: state.get(h_idx).copied()?,
|
||||
mass_flow_kg_s: mass_flows.get(1).copied().or_else(|| mass_flows.first().copied()),
|
||||
mass_flow_kg_s: mass_flows
|
||||
.get(1)
|
||||
.copied()
|
||||
.or_else(|| mass_flows.first().copied()),
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
//! API ergonomics using real component types.
|
||||
|
||||
use entropyk::{System, SystemBuilder, ThermoError};
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, JacobianBuilder, ResidualVector,
|
||||
};
|
||||
use entropyk_components::{Component, ComponentError, JacobianBuilder, ResidualVector};
|
||||
|
||||
struct MockComponent {
|
||||
#[allow(dead_code)] // Identifies the fixture instance; not asserted in these tests.
|
||||
name: &'static str,
|
||||
n_eqs: usize,
|
||||
}
|
||||
@@ -143,7 +142,7 @@ fn test_builder_into_inner() {
|
||||
#[test]
|
||||
fn test_direct_system_api() {
|
||||
let mut system = System::new();
|
||||
let idx = system.add_component(Box::new(MockComponent {
|
||||
let _idx = system.add_component(Box::new(MockComponent {
|
||||
name: "test",
|
||||
n_eqs: 2,
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use entropyk::{
|
||||
BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId, SystemBuilder,
|
||||
ThermoError, TopologyError,
|
||||
};
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector,
|
||||
@@ -49,18 +50,13 @@ fn test_builder_constraints_link_and_validate_dof() {
|
||||
},
|
||||
5.0,
|
||||
);
|
||||
let valve = BoundedVariable::new(
|
||||
BoundedVariableId::new("valve"),
|
||||
0.5,
|
||||
0.0,
|
||||
1.0,
|
||||
)
|
||||
.expect("valid bounds");
|
||||
let valve =
|
||||
BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).expect("valid bounds");
|
||||
|
||||
// Minimal topology: 2 nodes, 1 edge → 2 edge unknowns (P,h). With 1 constraint and 1 control
|
||||
// we need 3 equations total: 2 component eqs + 1 constraint = 3 = 2 + 1 unknowns.
|
||||
// Minimal topology: 2 nodes, 1 edge → 3 edge unknowns (ṁ,P,h). With 1 constraint and 1 control
|
||||
// we need 4 equations total: 3 component eqs + 1 constraint = 4 = 3 + 1 unknowns.
|
||||
let system = SystemBuilder::new()
|
||||
.component("evap", Box::new(MockComponent { n_eqs: 1 }))
|
||||
.component("evap", Box::new(MockComponent { n_eqs: 2 }))
|
||||
.unwrap()
|
||||
.component("other", Box::new(MockComponent { n_eqs: 1 }))
|
||||
.unwrap()
|
||||
@@ -103,16 +99,16 @@ fn test_builder_dof_imbalance_two_constraints_one_control() {
|
||||
},
|
||||
3.0,
|
||||
);
|
||||
let valve = BoundedVariable::new(
|
||||
BoundedVariableId::new("valve"),
|
||||
0.5,
|
||||
0.0,
|
||||
1.0,
|
||||
)
|
||||
.expect("valid bounds");
|
||||
let valve =
|
||||
BoundedVariable::new(BoundedVariableId::new("valve"), 0.5, 0.0, 1.0).expect("valid bounds");
|
||||
|
||||
let system = SystemBuilder::new()
|
||||
.component("evap", Box::new(MockComponent { n_eqs: 1 }))
|
||||
// Same topology as test 1 (3 component eqs total), but 2 constraints and 1 control →
|
||||
// 3+2=5 equations vs 3+1=4 unknowns → over-constrained. Since `System::finalize()`
|
||||
// enforces the DoF gate, `build()` itself now rejects the system with
|
||||
// `TopologyError::DofImbalance` (the post-build `validate_inverse_control_dof()`
|
||||
// check is unreachable for over-constrained systems).
|
||||
let result = SystemBuilder::new()
|
||||
.component("evap", Box::new(MockComponent { n_eqs: 2 }))
|
||||
.unwrap()
|
||||
.component("other", Box::new(MockComponent { n_eqs: 1 }))
|
||||
.unwrap()
|
||||
@@ -129,14 +125,17 @@ fn test_builder_dof_imbalance_two_constraints_one_control() {
|
||||
&BoundedVariableId::new("valve"),
|
||||
)
|
||||
.unwrap()
|
||||
.build()
|
||||
.expect("build should succeed");
|
||||
.build();
|
||||
|
||||
// DoF validation should fail: 2 constraints but only 1 control (unbalanced).
|
||||
let dof_result = system.validate_inverse_control_dof();
|
||||
assert!(
|
||||
dof_result.is_err(),
|
||||
"validate_inverse_control_dof should fail with 2 constraints and 1 control, got: {:?}",
|
||||
dof_result
|
||||
);
|
||||
// DoF gate should reject the build: 2 constraints but only 1 control (unbalanced).
|
||||
match result {
|
||||
Err(ThermoError::Topology(TopologyError::DofImbalance { message })) => {
|
||||
assert!(
|
||||
message.contains("over-constrained"),
|
||||
"expected an over-constrained DoF report, got: {message}"
|
||||
);
|
||||
}
|
||||
Err(other) => panic!("expected DofImbalance error from build(), got: {other}"),
|
||||
Ok(_) => panic!("build() should fail with DofImbalance (2 constraints, 1 control)"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
//! finalized, and exposes the expected circuit topology.
|
||||
|
||||
use entropyk::{CircuitId, SystemBuilder};
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, JacobianBuilder, ResidualVector,
|
||||
};
|
||||
use entropyk_components::{Component, ComponentError, JacobianBuilder, ResidualVector};
|
||||
|
||||
struct MockComponent {
|
||||
n_eqs: usize,
|
||||
|
||||
@@ -152,7 +152,10 @@ fn test_edge_with_ports_unknown_port_name_error() {
|
||||
}) = result
|
||||
{
|
||||
assert_eq!(component, "a");
|
||||
assert!(port_name.starts_with("bogus_port"), "port_name should start with the port name, got: {port_name}");
|
||||
assert!(
|
||||
port_name.starts_with("bogus_port"),
|
||||
"port_name should start with the port name, got: {port_name}"
|
||||
);
|
||||
} else {
|
||||
panic!("Expected PortNotFound error");
|
||||
}
|
||||
@@ -183,12 +186,15 @@ fn test_edge_with_ports_same_circuit_succeeds() {
|
||||
|
||||
#[test]
|
||||
fn test_build_system_with_port_validated_edges() {
|
||||
// DoF ledger post-CM1.4: 2-edge chain → 1 branch ṁ + 2×(P,h) = 5 unknowns,
|
||||
// so component equations must total 5 for the finalize DoF gate (2+2+1).
|
||||
// The last mock contributes a single equation to keep the system square.
|
||||
let system = SystemBuilder::new()
|
||||
.component("a", Box::new(MockComponentWithPorts::new(2)))
|
||||
.unwrap()
|
||||
.component("b", Box::new(MockComponentWithPorts::new(2)))
|
||||
.unwrap()
|
||||
.component("c", Box::new(MockComponentWithPorts::new(2)))
|
||||
.component("c", Box::new(MockComponentWithPorts::new(1)))
|
||||
.unwrap()
|
||||
.edge_with_ports("a", "outlet", "b", "inlet")
|
||||
.unwrap()
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
//! Integration tests for structured simulation result extraction.
|
||||
|
||||
use entropyk::{
|
||||
extract_simulation_result, SimulationOutcome, SimulationResult, SystemBuilder,
|
||||
};
|
||||
use entropyk::{extract_simulation_result, SimulationOutcome, SimulationResult, SystemBuilder};
|
||||
use entropyk_components::expansion_valve::ExpansionValve;
|
||||
use entropyk_components::heat_exchanger::{Condenser, Evaporator};
|
||||
use entropyk_components::heat_exchanger::Evaporator;
|
||||
use entropyk_components::port::{Disconnected, FluidId, Port};
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, MchxCondenserCoil, Polynomial2D,
|
||||
ResidualVector, ScrewEconomizerCompressor, ScrewPerformanceCurves, StateSlice,
|
||||
ResidualVector, ScrewEconomizerCompressor, ScrewPerformanceCurves,
|
||||
};
|
||||
use entropyk_core::{Enthalpy, MassFlow, Power, Pressure};
|
||||
use entropyk_core::{Enthalpy, Power, Pressure};
|
||||
use entropyk_solver::{ConvergedState, ConvergenceStatus, SimulationMetadata};
|
||||
|
||||
use approx::assert_relative_eq;
|
||||
@@ -53,8 +51,9 @@ fn build_real_r134a_cycle() -> (entropyk_solver::System, ConvergedState) {
|
||||
let suc = make_connected_port("R134a", 2.93, 405.0);
|
||||
let dis = make_connected_port("R134a", 10.17, 440.0);
|
||||
let eco = make_connected_port("R134a", 5.5, 250.0);
|
||||
let comp = ScrewEconomizerCompressor::new(make_screw_curves(), "R134a", 50.0, 0.92, suc, dis, eco)
|
||||
.expect("compressor");
|
||||
let comp =
|
||||
ScrewEconomizerCompressor::new(make_screw_curves(), "R134a", 50.0, 0.92, suc, dis, eco)
|
||||
.expect("compressor");
|
||||
|
||||
// --- Condenser (air-cooled coil at 35°C ambient) ---
|
||||
let condenser = MchxCondenserCoil::for_35c_ambient(15_000.0, 0);
|
||||
@@ -62,19 +61,31 @@ fn build_real_r134a_cycle() -> (entropyk_solver::System, ConvergedState) {
|
||||
// --- Expansion valve (fully open) ---
|
||||
let exv_in = make_disconnected_port("R134a", 10.17, 253.4);
|
||||
let exv_out = make_disconnected_port("R134a", 2.93, 253.4);
|
||||
let exv_disconnected = ExpansionValve::new(exv_in, exv_out, Some(1.0)).expect("exv disconnected");
|
||||
let exv_disconnected =
|
||||
ExpansionValve::new(exv_in, exv_out, Some(1.0)).expect("exv disconnected");
|
||||
let exv = exv_disconnected
|
||||
.connect(make_disconnected_port("R134a", 10.17, 253.4), make_disconnected_port("R134a", 2.93, 253.4))
|
||||
.connect(
|
||||
make_disconnected_port("R134a", 10.17, 253.4),
|
||||
make_disconnected_port("R134a", 2.93, 253.4),
|
||||
)
|
||||
.expect("exv connect");
|
||||
|
||||
// --- Evaporator (BPHE, T_sat=278.15K, SH=5K) ---
|
||||
let evaporator = Evaporator::with_superheat(8000.0, 278.15, 5.0);
|
||||
|
||||
// Add to circuit 0
|
||||
let n_comp = sys.add_component_to_circuit(Box::new(comp), CircuitId::ZERO).unwrap();
|
||||
let n_cond = sys.add_component_to_circuit(Box::new(condenser), CircuitId::ZERO).unwrap();
|
||||
let n_exv = sys.add_component_to_circuit(Box::new(exv), CircuitId::ZERO).unwrap();
|
||||
let n_evap = sys.add_component_to_circuit(Box::new(evaporator), CircuitId::ZERO).unwrap();
|
||||
let n_comp = sys
|
||||
.add_component_to_circuit(Box::new(comp), CircuitId::ZERO)
|
||||
.unwrap();
|
||||
let n_cond = sys
|
||||
.add_component_to_circuit(Box::new(condenser), CircuitId::ZERO)
|
||||
.unwrap();
|
||||
let n_exv = sys
|
||||
.add_component_to_circuit(Box::new(exv), CircuitId::ZERO)
|
||||
.unwrap();
|
||||
let n_evap = sys
|
||||
.add_component_to_circuit(Box::new(evaporator), CircuitId::ZERO)
|
||||
.unwrap();
|
||||
|
||||
// Register names for extract_simulation_result
|
||||
sys.register_component_name("compressor", n_comp);
|
||||
@@ -88,6 +99,12 @@ fn build_real_r134a_cycle() -> (entropyk_solver::System, ConvergedState) {
|
||||
sys.add_edge(n_exv, n_evap).unwrap();
|
||||
sys.add_edge(n_evap, n_comp).unwrap();
|
||||
|
||||
// DoF gate escape hatch: the real components contribute 6+2+2+2 = 12 equations
|
||||
// vs 10 unknowns (1 branch ṁ + 4×(P,h) + compressor internal W_shaft) because no
|
||||
// free actuators (compressor speed, EXV opening) are registered here. This test
|
||||
// exercises result extraction/JSON serialization, not DoF balancing, so the
|
||||
// over-constrained system is accepted deliberately (test-only escape hatch).
|
||||
sys.set_enforce_dof_gate(false);
|
||||
sys.finalize().expect("system finalize");
|
||||
|
||||
// ConvergedState from NIST R134a reference data:
|
||||
@@ -95,11 +112,14 @@ fn build_real_r134a_cycle() -> (entropyk_solver::System, ConvergedState) {
|
||||
// T_cond_sat = 40°C → P_sat ≈ 1017000 Pa (10.17 bar)
|
||||
// h_g(0°C) ≈ 398600 J/kg, h_f(40°C) ≈ 256400 J/kg
|
||||
// With SH=5K and SC=3K
|
||||
// CM1.4: 1 series branch + 4 × (P, h) = 9 elements.
|
||||
// [ṁ_branch, P_e0, h_e0, P_e1, h_e1, P_e2, h_e2, P_e3, h_e3]
|
||||
let state = vec![
|
||||
0.05, // ṁ branch (shared, ~0.05 kg/s)
|
||||
1017000.0, 440000.0, // edge 0: comp→cond (discharge, superheated ~440 kJ/kg)
|
||||
1000000.0, 250000.0, // edge 1: cond→exv (subcooled liquid ~250 kJ/kg, ~3K SC)
|
||||
292800.0, 250000.0, // edge 2: exv→evap (isenthalpic expansion, same h)
|
||||
285000.0, 405000.0, // edge 3: evap→comp (superheated ~5K above sat)
|
||||
292800.0, 250000.0, // edge 2: exv→evap (isenthalpic expansion, same h)
|
||||
285000.0, 405000.0, // edge 3: evap→comp (superheated ~5K above sat)
|
||||
];
|
||||
|
||||
let converged = ConvergedState::new(
|
||||
@@ -217,6 +237,7 @@ impl Component for MockPipe {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Helper: build a realistic 4-component vapor compression cycle with mock components.
|
||||
#[allow(dead_code)] // Reusable cycle fixture for result-serialization tests.
|
||||
fn build_realistic_cycle() -> (entropyk_solver::System, ConvergedState) {
|
||||
let system = SystemBuilder::new()
|
||||
.component("compressor", Box::new(MockCompressor))
|
||||
@@ -238,13 +259,16 @@ fn build_realistic_cycle() -> (entropyk_solver::System, ConvergedState) {
|
||||
.build()
|
||||
.expect("build system");
|
||||
|
||||
// R410A-like state vector: 4 edges × 2 (P, h)
|
||||
// Realistic values: high side ~24 bar, low side ~8 bar
|
||||
// CM1.4 layout: 1 series branch + 4 × (P, h) = 9 elements
|
||||
// [ṁ_branch, P_e0, h_e0, P_e1, h_e1, P_e2, h_e2, P_e3, h_e3]
|
||||
// Realistic R410A values: high side ~24 bar, low side ~8 bar
|
||||
let state = vec![
|
||||
2400000.0, 440000.0, // edge 0: compressor → condenser (discharge, high P, superheated)
|
||||
0.05, // ṁ branch (shared)
|
||||
2400000.0,
|
||||
440000.0, // edge 0: compressor → condenser (discharge, high P, superheated)
|
||||
2350000.0, 280000.0, // edge 1: condenser → expansion (subcooled liquid)
|
||||
800000.0, 260000.0, // edge 2: expansion → evaporator (two-phase, low P)
|
||||
780000.0, 400000.0, // edge 3: evaporator → compressor (superheated vapor, low P)
|
||||
800000.0, 260000.0, // edge 2: expansion → evaporator (two-phase, low P)
|
||||
780000.0, 400000.0, // edge 3: evaporator → compressor (superheated vapor, low P)
|
||||
];
|
||||
|
||||
let converged = ConvergedState::new(
|
||||
@@ -280,9 +304,10 @@ fn build_test_system() -> (entropyk_solver::System, ConvergedState) {
|
||||
.build()
|
||||
.expect("build system");
|
||||
|
||||
// Create a fake converged state with 4 edges = 8 state variables
|
||||
// [P0, h0, P1, h1, P2, h2, P3, h3]
|
||||
// CM1.4 layout: 1 series branch + 4 × (P, h) = 9 elements
|
||||
// [ṁ_branch, P_e0, h_e0, P_e1, h_e1, P_e2, h_e2, P_e3, h_e3]
|
||||
let state = vec![
|
||||
0.05, // ṁ branch (shared)
|
||||
500000.0, 450000.0, // edge 0: comp -> pipe1 (high pressure)
|
||||
490000.0, 440000.0, // edge 1: pipe1 -> evap
|
||||
200000.0, 250000.0, // edge 2: evap -> pipe2 (low pressure)
|
||||
@@ -360,14 +385,22 @@ fn test_extract_per_edge_results() {
|
||||
let result = extract_simulation_result(&system, &converged);
|
||||
|
||||
// Edge 0: comp -> pipe1 (high pressure side)
|
||||
let edge0 = result.edges.iter().find(|e| e.edge_id == 0).expect("edge 0");
|
||||
let edge0 = result
|
||||
.edges
|
||||
.iter()
|
||||
.find(|e| e.edge_id == 0)
|
||||
.expect("edge 0");
|
||||
assert_relative_eq!(edge0.pressure_pa, 500000.0);
|
||||
assert_relative_eq!(edge0.enthalpy_j_kg, 450000.0);
|
||||
assert_eq!(edge0.source.as_deref(), Some("comp"));
|
||||
assert_eq!(edge0.target.as_deref(), Some("pipe1"));
|
||||
|
||||
// Edge 2: evap -> pipe2 (low pressure side)
|
||||
let edge2 = result.edges.iter().find(|e| e.edge_id == 2).expect("edge 2");
|
||||
let edge2 = result
|
||||
.edges
|
||||
.iter()
|
||||
.find(|e| e.edge_id == 2)
|
||||
.expect("edge 2");
|
||||
assert_relative_eq!(edge2.pressure_pa, 200000.0);
|
||||
assert_relative_eq!(edge2.enthalpy_j_kg, 250000.0);
|
||||
assert_eq!(edge2.source.as_deref(), Some("evap"));
|
||||
@@ -381,15 +414,9 @@ fn test_system_summary() {
|
||||
|
||||
// Evaporator absorbs 10000W (cooling), compressor uses 3000W
|
||||
assert!(result.summary.total_cooling_capacity_w.is_some());
|
||||
assert_relative_eq!(
|
||||
result.summary.total_cooling_capacity_w.unwrap(),
|
||||
10000.0
|
||||
);
|
||||
assert_relative_eq!(result.summary.total_cooling_capacity_w.unwrap(), 10000.0);
|
||||
assert!(result.summary.total_compressor_power_w.is_some());
|
||||
assert_relative_eq!(
|
||||
result.summary.total_compressor_power_w.unwrap(),
|
||||
3000.0
|
||||
);
|
||||
assert_relative_eq!(result.summary.total_compressor_power_w.unwrap(), 3000.0);
|
||||
|
||||
// COP_cooling = 10000 / 3000
|
||||
assert!(result.summary.cop_cooling.is_some());
|
||||
@@ -415,13 +442,19 @@ fn test_simulation_result_json_roundtrip() {
|
||||
let deserialized: SimulationResult =
|
||||
serde_json::from_str(&json).expect("deserialize should succeed");
|
||||
assert_eq!(result.status, deserialized.status);
|
||||
assert_eq!(result.convergence.iterations, deserialized.convergence.iterations);
|
||||
assert_eq!(
|
||||
result.convergence.iterations,
|
||||
deserialized.convergence.iterations
|
||||
);
|
||||
assert_relative_eq!(
|
||||
result.convergence.final_residual,
|
||||
deserialized.convergence.final_residual,
|
||||
epsilon = 1e-15
|
||||
);
|
||||
assert_eq!(result.convergence.converged, deserialized.convergence.converged);
|
||||
assert_eq!(
|
||||
result.convergence.converged,
|
||||
deserialized.convergence.converged
|
||||
);
|
||||
assert_eq!(result.convergence.status, deserialized.convergence.status);
|
||||
assert_eq!(result.components.len(), deserialized.components.len());
|
||||
assert_eq!(result.edges.len(), deserialized.edges.len());
|
||||
@@ -485,20 +518,52 @@ fn test_realistic_cycle_json_output() {
|
||||
assert!(json.contains("\"expansion_valve\""));
|
||||
|
||||
// Compressor should have real component type (not Mock)
|
||||
let comp = result.components.iter().find(|c| c.name == "compressor").expect("comp");
|
||||
assert!(comp.component_type.contains("Screw"), "expected ScrewEconomizer, got {}", comp.component_type);
|
||||
let comp = result
|
||||
.components
|
||||
.iter()
|
||||
.find(|c| c.name == "compressor")
|
||||
.expect("comp");
|
||||
assert!(
|
||||
comp.component_type.contains("Screw"),
|
||||
"expected ScrewEconomizer, got {}",
|
||||
comp.component_type
|
||||
);
|
||||
|
||||
// Condenser should be MchxCondenserCoil
|
||||
let cond = result.components.iter().find(|c| c.name == "condenser").expect("cond");
|
||||
assert!(cond.component_type.contains("Mchx"), "expected MchxCondenserCoil, got {}", cond.component_type);
|
||||
let cond = result
|
||||
.components
|
||||
.iter()
|
||||
.find(|c| c.name == "condenser")
|
||||
.expect("cond");
|
||||
assert!(
|
||||
cond.component_type.contains("Mchx"),
|
||||
"expected MchxCondenserCoil, got {}",
|
||||
cond.component_type
|
||||
);
|
||||
|
||||
// Expansion valve should have real type
|
||||
let exv = result.components.iter().find(|c| c.name == "expansion_valve").expect("exv");
|
||||
assert!(exv.component_type.contains("ExpansionValve"), "expected ExpansionValve, got {}", exv.component_type);
|
||||
let exv = result
|
||||
.components
|
||||
.iter()
|
||||
.find(|c| c.name == "expansion_valve")
|
||||
.expect("exv");
|
||||
assert!(
|
||||
exv.component_type.contains("ExpansionValve"),
|
||||
"expected ExpansionValve, got {}",
|
||||
exv.component_type
|
||||
);
|
||||
|
||||
// Evaporator
|
||||
let evap = result.components.iter().find(|c| c.name == "evaporator").expect("evap");
|
||||
assert!(evap.component_type.contains("Evaporator"), "expected Evaporator, got {}", evap.component_type);
|
||||
let evap = result
|
||||
.components
|
||||
.iter()
|
||||
.find(|c| c.name == "evaporator")
|
||||
.expect("evap");
|
||||
assert!(
|
||||
evap.component_type.contains("Evaporator"),
|
||||
"expected Evaporator, got {}",
|
||||
evap.component_type
|
||||
);
|
||||
|
||||
// Check edge pressures are from NIST data
|
||||
let edge0 = result.edges.iter().find(|e| e.edge_id == 0).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user