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:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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)"),
}
}

View File

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

View File

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

View File

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