feat(python): implement python bindings for all components and solvers
This commit is contained in:
@@ -9,8 +9,8 @@ repository = "https://github.com/entropyk/entropyk"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
ffi = []
|
||||
http = []
|
||||
ffi = ["dep:libloading"]
|
||||
http = ["dep:reqwest"]
|
||||
|
||||
[dependencies]
|
||||
# Core types from Story 1.2
|
||||
@@ -25,6 +25,10 @@ thiserror = "1.0"
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
||||
# External model dependencies
|
||||
libloading = { version = "0.8", optional = true }
|
||||
reqwest = { version = "0.12", features = ["blocking", "json"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Floating-point assertions
|
||||
approx = "0.5"
|
||||
|
||||
@@ -382,6 +382,8 @@ pub struct Compressor<State> {
|
||||
mechanical_efficiency: f64,
|
||||
/// Calibration factors: ṁ_eff = f_m × ṁ_nominal, Ẇ_eff = f_power × Ẇ_nominal, etc.
|
||||
calib: Calib,
|
||||
/// Calibration indices to extract factors dynamically from SystemState
|
||||
calib_indices: entropyk_core::CalibIndices,
|
||||
/// Fluid identifier for density lookups
|
||||
fluid_id: FluidId,
|
||||
/// Circuit identifier for multi-circuit machines (FR9)
|
||||
@@ -553,7 +555,9 @@ impl Compressor<Disconnected> {
|
||||
displacement_m3_per_rev,
|
||||
mechanical_efficiency,
|
||||
calib: Calib::default(),
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
fluid_id,
|
||||
|
||||
circuit_id: CircuitId::default(), // Default circuit
|
||||
operational_state: OperationalState::default(), // Default to On
|
||||
_state: PhantomData,
|
||||
@@ -708,6 +712,7 @@ impl Compressor<Connected> {
|
||||
density_suction: f64,
|
||||
sst_k: f64,
|
||||
sdt_k: f64,
|
||||
state: Option<&SystemState>,
|
||||
) -> Result<MassFlow, ComponentError> {
|
||||
if density_suction < 0.0 {
|
||||
return Err(ComponentError::InvalidState(
|
||||
@@ -762,7 +767,12 @@ impl Compressor<Connected> {
|
||||
};
|
||||
|
||||
// Apply calibration: ṁ_eff = f_m × ṁ_nominal
|
||||
Ok(MassFlow::from_kg_per_s(mass_flow_kg_per_s * self.calib.f_m))
|
||||
let f_m = if let Some(st) = state {
|
||||
self.calib_indices.f_m.map(|idx| st[idx]).unwrap_or(self.calib.f_m)
|
||||
} else {
|
||||
self.calib.f_m
|
||||
};
|
||||
Ok(MassFlow::from_kg_per_s(mass_flow_kg_per_s * f_m))
|
||||
}
|
||||
|
||||
/// Calculates the power consumption (cooling mode).
|
||||
@@ -783,6 +793,7 @@ impl Compressor<Connected> {
|
||||
&self,
|
||||
t_suction: Temperature,
|
||||
t_discharge: Temperature,
|
||||
state: Option<&SystemState>,
|
||||
) -> f64 {
|
||||
let power_nominal = match &self.model {
|
||||
CompressorModel::Ahri540(coeffs) => {
|
||||
@@ -798,7 +809,12 @@ impl Compressor<Connected> {
|
||||
}
|
||||
};
|
||||
// Ẇ_eff = f_power × Ẇ_nominal
|
||||
power_nominal * self.calib.f_power
|
||||
let f_power = if let Some(st) = state {
|
||||
self.calib_indices.f_power.map(|idx| st[idx]).unwrap_or(self.calib.f_power)
|
||||
} else {
|
||||
self.calib.f_power
|
||||
};
|
||||
power_nominal * f_power
|
||||
}
|
||||
|
||||
/// Calculates the power consumption (heating mode).
|
||||
@@ -819,6 +835,7 @@ impl Compressor<Connected> {
|
||||
&self,
|
||||
t_suction: Temperature,
|
||||
t_discharge: Temperature,
|
||||
state: Option<&SystemState>,
|
||||
) -> f64 {
|
||||
let power_nominal = match &self.model {
|
||||
CompressorModel::Ahri540(coeffs) => {
|
||||
@@ -835,7 +852,12 @@ impl Compressor<Connected> {
|
||||
}
|
||||
};
|
||||
// Ẇ_eff = f_power × Ẇ_nominal
|
||||
power_nominal * self.calib.f_power
|
||||
let f_power = if let Some(st) = state {
|
||||
self.calib_indices.f_power.map(|idx| st[idx]).unwrap_or(self.calib.f_power)
|
||||
} else {
|
||||
self.calib.f_power
|
||||
};
|
||||
power_nominal * f_power
|
||||
}
|
||||
|
||||
/// Calculates the cooling capacity.
|
||||
@@ -1049,13 +1071,14 @@ impl Component for Compressor<Connected> {
|
||||
// In the future, this will come from the fluid property backend
|
||||
let density_suction = estimate_density(self.fluid_id.as_str(), p_suction, h_suction)?;
|
||||
let mass_flow_calc = self
|
||||
.mass_flow_rate(density_suction, t_suction_k, t_discharge_k)?
|
||||
.mass_flow_rate(density_suction, t_suction_k, t_discharge_k, Some(state))?
|
||||
.to_kg_per_s();
|
||||
|
||||
// Calculate power consumption
|
||||
let power_calc = self.power_consumption_cooling(
|
||||
Temperature::from_kelvin(t_suction_k),
|
||||
Temperature::from_kelvin(t_discharge_k),
|
||||
Some(state)
|
||||
);
|
||||
|
||||
// Residual 0: Mass flow continuity
|
||||
@@ -1109,7 +1132,7 @@ impl Component for Compressor<Connected> {
|
||||
let density = estimate_density(self.fluid_id.as_str(), p_suction, h).unwrap_or(1.0);
|
||||
let t_k =
|
||||
estimate_temperature(self.fluid_id.as_str(), p_suction, h).unwrap_or(273.15);
|
||||
self.mass_flow_rate(density, t_k, t_discharge_k)
|
||||
self.mass_flow_rate(density, t_k, t_discharge_k, Some(state))
|
||||
.map(|m| m.to_kg_per_s())
|
||||
.unwrap_or(0.0)
|
||||
},
|
||||
@@ -1139,6 +1162,7 @@ impl Component for Compressor<Connected> {
|
||||
self.power_consumption_cooling(
|
||||
Temperature::from_kelvin(t),
|
||||
Temperature::from_kelvin(t_discharge),
|
||||
None
|
||||
)
|
||||
},
|
||||
h_suction,
|
||||
@@ -1156,6 +1180,7 @@ impl Component for Compressor<Connected> {
|
||||
self.power_consumption_cooling(
|
||||
Temperature::from_kelvin(t_suction),
|
||||
Temperature::from_kelvin(t),
|
||||
None
|
||||
)
|
||||
},
|
||||
h_discharge,
|
||||
@@ -1166,6 +1191,25 @@ impl Component for Compressor<Connected> {
|
||||
// ∂r₁/∂Power = -1
|
||||
jacobian.add_entry(1, 3, -1.0);
|
||||
|
||||
// Calibration derivatives (Story 5.5)
|
||||
if let Some(f_m_idx) = self.calib_indices.f_m {
|
||||
// ∂r₀/∂f_m = ṁ_nominal
|
||||
let density_suction = estimate_density(self.fluid_id.as_str(), p_suction, h_suction).unwrap_or(1.0);
|
||||
let m_nominal = self.mass_flow_rate(density_suction, _t_suction_k, t_discharge_k, None)
|
||||
.map(|m| m.to_kg_per_s()).unwrap_or(0.0);
|
||||
jacobian.add_entry(0, f_m_idx, m_nominal);
|
||||
}
|
||||
|
||||
if let Some(f_power_idx) = self.calib_indices.f_power {
|
||||
// ∂r₁/∂f_power = Power_nominal
|
||||
let p_nominal = self.power_consumption_cooling(
|
||||
Temperature::from_kelvin(_t_suction_k),
|
||||
Temperature::from_kelvin(t_discharge_k),
|
||||
None
|
||||
);
|
||||
jacobian.add_entry(1, f_power_idx, p_nominal);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1390,6 +1434,7 @@ mod tests {
|
||||
displacement_m3_per_rev: 0.0001,
|
||||
mechanical_efficiency: 0.85,
|
||||
calib: Calib::default(),
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
@@ -1548,7 +1593,7 @@ mod tests {
|
||||
let t_discharge_k = 318.15; // 45°C in Kelvin
|
||||
|
||||
let mass_flow = compressor
|
||||
.mass_flow_rate(density, t_suction_k, t_discharge_k)
|
||||
.mass_flow_rate(density, t_suction_k, t_discharge_k, None)
|
||||
.unwrap();
|
||||
|
||||
// Verify mass flow is positive
|
||||
@@ -1571,7 +1616,7 @@ mod tests {
|
||||
let t_suction_k = 278.15; // 5°C in Kelvin
|
||||
let t_discharge_k = 318.15; // 45°C in Kelvin
|
||||
let m_default = compressor
|
||||
.mass_flow_rate(density, t_suction_k, t_discharge_k)
|
||||
.mass_flow_rate(density, t_suction_k, t_discharge_k, None)
|
||||
.unwrap()
|
||||
.to_kg_per_s();
|
||||
|
||||
@@ -1580,7 +1625,7 @@ mod tests {
|
||||
..Calib::default()
|
||||
});
|
||||
let m_calib = compressor
|
||||
.mass_flow_rate(density, t_suction_k, t_discharge_k)
|
||||
.mass_flow_rate(density, t_suction_k, t_discharge_k, None)
|
||||
.unwrap()
|
||||
.to_kg_per_s();
|
||||
assert_relative_eq!(m_calib / m_default, 1.1, epsilon = 1e-10);
|
||||
@@ -1591,13 +1636,13 @@ mod tests {
|
||||
let mut compressor = create_test_compressor();
|
||||
let t_suction = Temperature::from_celsius(5.0);
|
||||
let t_discharge = Temperature::from_celsius(45.0);
|
||||
let p_default = compressor.power_consumption_cooling(t_suction, t_discharge);
|
||||
let p_default = compressor.power_consumption_cooling(t_suction, t_discharge, None);
|
||||
|
||||
compressor.set_calib(Calib {
|
||||
f_power: 1.1,
|
||||
..Calib::default()
|
||||
});
|
||||
let p_calib = compressor.power_consumption_cooling(t_suction, t_discharge);
|
||||
let p_calib = compressor.power_consumption_cooling(t_suction, t_discharge, None);
|
||||
assert_relative_eq!(p_calib / p_default, 1.1, epsilon = 1e-10);
|
||||
}
|
||||
|
||||
@@ -1606,7 +1651,7 @@ mod tests {
|
||||
let compressor = create_test_compressor();
|
||||
let t_suction_k = 278.15; // 5°C in Kelvin
|
||||
let t_discharge_k = 318.15; // 45°C in Kelvin
|
||||
let result = compressor.mass_flow_rate(-10.0, t_suction_k, t_discharge_k);
|
||||
let result = compressor.mass_flow_rate(-10.0, t_suction_k, t_discharge_k, None);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -1637,6 +1682,7 @@ mod tests {
|
||||
displacement_m3_per_rev: 0.0001,
|
||||
mechanical_efficiency: 0.85,
|
||||
calib: Calib::default(),
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
@@ -1645,7 +1691,7 @@ mod tests {
|
||||
|
||||
let t_suction_k = 278.15; // 5°C in Kelvin
|
||||
let t_discharge_k = 318.15; // 45°C in Kelvin
|
||||
let result = compressor.mass_flow_rate(20.0, t_suction_k, t_discharge_k);
|
||||
let result = compressor.mass_flow_rate(20.0, t_suction_k, t_discharge_k, None);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -1655,7 +1701,7 @@ mod tests {
|
||||
let t_suction = Temperature::from_celsius(5.0);
|
||||
let t_discharge = Temperature::from_celsius(45.0);
|
||||
|
||||
let power = compressor.power_consumption_cooling(t_suction, t_discharge);
|
||||
let power = compressor.power_consumption_cooling(t_suction, t_discharge, None);
|
||||
|
||||
// Verify power is positive
|
||||
assert!(power > 0.0);
|
||||
@@ -1677,7 +1723,7 @@ mod tests {
|
||||
let t_suction = Temperature::from_celsius(5.0);
|
||||
let t_discharge = Temperature::from_celsius(45.0);
|
||||
|
||||
let power = compressor.power_consumption_heating(t_suction, t_discharge);
|
||||
let power = compressor.power_consumption_heating(t_suction, t_discharge, None);
|
||||
|
||||
// Verify calculation: M7 + M8 * PR + M9 * T_suction + M10 * T_discharge
|
||||
// Using 6.0/3.5 pressure ratio from create_test_compressor
|
||||
@@ -1837,6 +1883,7 @@ mod tests {
|
||||
displacement_m3_per_rev: 0.00008,
|
||||
mechanical_efficiency: 0.88,
|
||||
calib: Calib::default(),
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
fluid_id: FluidId::new("R410A"),
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
@@ -1847,13 +1894,13 @@ mod tests {
|
||||
let t_suction_k = 283.15; // 10°C in Kelvin
|
||||
let t_discharge_k = 323.15; // 50°C in Kelvin
|
||||
let mass_flow = compressor
|
||||
.mass_flow_rate(density, t_suction_k, t_discharge_k)
|
||||
.mass_flow_rate(density, t_suction_k, t_discharge_k, None)
|
||||
.unwrap();
|
||||
assert!(mass_flow.to_kg_per_s() > 0.0);
|
||||
|
||||
let t_suction = Temperature::from_celsius(10.0);
|
||||
let t_discharge = Temperature::from_celsius(50.0);
|
||||
let power = compressor.power_consumption_cooling(t_suction, t_discharge);
|
||||
let power = compressor.power_consumption_cooling(t_suction, t_discharge, None);
|
||||
assert!(power > 0.0);
|
||||
}
|
||||
|
||||
@@ -1885,6 +1932,7 @@ mod tests {
|
||||
displacement_m3_per_rev: 0.00008,
|
||||
mechanical_efficiency: 0.88,
|
||||
calib: Calib::default(),
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
fluid_id: FluidId::new("R454B"),
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
@@ -1896,13 +1944,13 @@ mod tests {
|
||||
let t_suction_k = 283.15; // 10°C in Kelvin
|
||||
let t_discharge_k = 323.15; // 50°C in Kelvin
|
||||
let mass_flow = compressor
|
||||
.mass_flow_rate(density, t_suction_k, t_discharge_k)
|
||||
.mass_flow_rate(density, t_suction_k, t_discharge_k, None)
|
||||
.unwrap();
|
||||
assert!(mass_flow.to_kg_per_s() > 0.0);
|
||||
|
||||
let t_suction = Temperature::from_celsius(10.0);
|
||||
let t_discharge = Temperature::from_celsius(50.0);
|
||||
let power = compressor.power_consumption_cooling(t_suction, t_discharge);
|
||||
let power = compressor.power_consumption_cooling(t_suction, t_discharge, None);
|
||||
assert!(power > 0.0);
|
||||
}
|
||||
|
||||
@@ -1937,6 +1985,7 @@ mod tests {
|
||||
displacement_m3_per_rev: 0.0001,
|
||||
mechanical_efficiency: 0.85,
|
||||
calib: Calib::default(),
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
fluid_id: FluidId::new("R134a"),
|
||||
circuit_id: CircuitId::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
@@ -1948,7 +1997,7 @@ mod tests {
|
||||
let t_discharge_k = 323.15; // 50°C in Kelvin
|
||||
// With high pressure ratio, volumetric efficiency might be negative
|
||||
// depending on M2 value
|
||||
let result = compressor.mass_flow_rate(density, t_suction_k, t_discharge_k);
|
||||
let result = compressor.mass_flow_rate(density, t_suction_k, t_discharge_k, None);
|
||||
// This may fail due to negative volumetric efficiency
|
||||
// which is expected behavior
|
||||
if result.is_ok() {
|
||||
|
||||
@@ -100,6 +100,8 @@ pub struct ExpansionValve<State> {
|
||||
port_outlet: Port<State>,
|
||||
/// Calibration: ṁ_eff = f_m × ṁ_nominal (mass flow scaling)
|
||||
calib: Calib,
|
||||
/// Calibration indices to extract factors dynamically from SystemState
|
||||
pub calib_indices: entropyk_core::CalibIndices,
|
||||
operational_state: OperationalState,
|
||||
opening: Option<f64>,
|
||||
fluid_id: FluidId,
|
||||
@@ -153,6 +155,7 @@ impl ExpansionValve<Disconnected> {
|
||||
port_inlet,
|
||||
port_outlet,
|
||||
calib: Calib::default(),
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
opening,
|
||||
fluid_id,
|
||||
@@ -552,7 +555,8 @@ impl Component for ExpansionValve<Connected> {
|
||||
// Mass flow: ṁ_out = f_m × ṁ_in (calibration factor on inlet flow)
|
||||
let mass_flow_in = state[0];
|
||||
let mass_flow_out = state[1];
|
||||
residuals[1] = mass_flow_out - self.calib.f_m * mass_flow_in;
|
||||
let f_m = self.calib_indices.f_m.map(|idx| state[idx]).unwrap_or(self.calib.f_m);
|
||||
residuals[1] = mass_flow_out - f_m * mass_flow_in;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -579,11 +583,19 @@ impl Component for ExpansionValve<Connected> {
|
||||
OperationalState::On | OperationalState::Off => {}
|
||||
}
|
||||
|
||||
let f_m = self.calib_indices.f_m.map(|idx| _state[idx]).unwrap_or(self.calib.f_m);
|
||||
jacobian.add_entry(0, 0, 0.0);
|
||||
jacobian.add_entry(0, 1, 0.0);
|
||||
jacobian.add_entry(1, 0, -self.calib.f_m);
|
||||
jacobian.add_entry(1, 0, -f_m);
|
||||
jacobian.add_entry(1, 1, 1.0);
|
||||
|
||||
if let Some(idx) = self.calib_indices.f_m {
|
||||
// d(R2)/d(f_m) = -mass_flow_in
|
||||
// We need mass_flow_in here, which is _state[0]
|
||||
let mass_flow_in = _state[0];
|
||||
jacobian.add_entry(1, idx, -mass_flow_in);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -594,6 +606,10 @@ impl Component for ExpansionValve<Connected> {
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
|
||||
self.calib_indices = indices;
|
||||
}
|
||||
}
|
||||
|
||||
use crate::state_machine::StateManageable;
|
||||
@@ -653,6 +669,7 @@ mod tests {
|
||||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||||
|
||||
ExpansionValve {
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
port_inlet: inlet_conn,
|
||||
port_outlet: outlet_conn,
|
||||
calib: Calib::default(),
|
||||
@@ -836,6 +853,7 @@ mod tests {
|
||||
let (inlet_conn, outlet_conn) = inlet.connect(outlet).unwrap();
|
||||
|
||||
let valve = ExpansionValve {
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
port_inlet: inlet_conn,
|
||||
port_outlet: outlet_conn,
|
||||
calib: Calib::default(),
|
||||
@@ -872,6 +890,7 @@ mod tests {
|
||||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||||
|
||||
let valve = ExpansionValve {
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
port_inlet: inlet_conn,
|
||||
port_outlet: outlet_conn,
|
||||
calib: Calib::default(),
|
||||
@@ -901,6 +920,7 @@ mod tests {
|
||||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||||
|
||||
let valve = ExpansionValve {
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
port_inlet: inlet_conn,
|
||||
port_outlet: outlet_conn,
|
||||
calib: Calib::default(),
|
||||
@@ -1059,6 +1079,7 @@ mod tests {
|
||||
outlet_conn.set_pressure(Pressure::from_pascals(0.0));
|
||||
|
||||
let valve = ExpansionValve {
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
port_inlet: inlet_conn,
|
||||
port_outlet: outlet_conn,
|
||||
calib: Calib::default(),
|
||||
@@ -1087,6 +1108,7 @@ mod tests {
|
||||
let (inlet_conn, outlet_conn) = inlet.connect(outlet).unwrap();
|
||||
|
||||
let valve = ExpansionValve {
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
port_inlet: inlet_conn,
|
||||
port_outlet: outlet_conn,
|
||||
calib: Calib::default(),
|
||||
@@ -1117,6 +1139,7 @@ mod tests {
|
||||
let (inlet_conn, outlet_conn) = inlet.connect(outlet).unwrap();
|
||||
|
||||
let valve = ExpansionValve {
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
port_inlet: inlet_conn,
|
||||
port_outlet: outlet_conn,
|
||||
calib: Calib::default(),
|
||||
@@ -1216,6 +1239,7 @@ mod tests {
|
||||
outlet_conn.set_enthalpy(Enthalpy::from_joules_per_kg(180000.0));
|
||||
|
||||
let valve = ExpansionValve {
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
port_inlet: inlet_conn,
|
||||
port_outlet: outlet_conn,
|
||||
calib: Calib::default(),
|
||||
@@ -1248,6 +1272,7 @@ mod tests {
|
||||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||||
|
||||
let valve = ExpansionValve {
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
port_inlet: inlet_conn,
|
||||
port_outlet: outlet_conn,
|
||||
calib: Calib::default(),
|
||||
@@ -1281,6 +1306,7 @@ mod tests {
|
||||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||||
|
||||
let valve = ExpansionValve {
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
port_inlet: inlet_conn,
|
||||
port_outlet: outlet_conn,
|
||||
calib: Calib::default(),
|
||||
@@ -1313,6 +1339,7 @@ mod tests {
|
||||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||||
|
||||
let valve = ExpansionValve {
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
port_inlet: inlet_conn,
|
||||
port_outlet: outlet_conn,
|
||||
calib: Calib::default(),
|
||||
@@ -1345,6 +1372,7 @@ mod tests {
|
||||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||||
|
||||
let valve = ExpansionValve {
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
port_inlet: inlet_conn,
|
||||
port_outlet: outlet_conn,
|
||||
calib: Calib::default(),
|
||||
@@ -1377,6 +1405,7 @@ mod tests {
|
||||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||||
|
||||
let valve = ExpansionValve {
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
port_inlet: inlet_conn,
|
||||
port_outlet: outlet_conn,
|
||||
calib: Calib::default(),
|
||||
@@ -1409,6 +1438,7 @@ mod tests {
|
||||
outlet_conn.set_pressure(Pressure::from_bar(3.5));
|
||||
|
||||
let valve = ExpansionValve {
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
port_inlet: inlet_conn,
|
||||
port_outlet: outlet_conn,
|
||||
calib: Calib::default(),
|
||||
|
||||
@@ -626,3 +626,117 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ffi")]
|
||||
/// FFI-based external model mapping to a dynamnic library.
|
||||
pub struct FfiModel {
|
||||
config: ExternalModelConfig,
|
||||
metadata: ExternalModelMetadata,
|
||||
_lib: Arc<libloading::Library>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "ffi")]
|
||||
impl FfiModel {
|
||||
/// Creates a new FFI model by loading a dynamic library.
|
||||
pub fn new(config: ExternalModelConfig) -> Result<Self, ExternalModelError> {
|
||||
let path = match &config.model_type {
|
||||
ExternalModelType::Ffi { library_path, .. } => library_path,
|
||||
_ => return Err(ExternalModelError::NotInitialized),
|
||||
};
|
||||
|
||||
// Safety: Library loading is inherently unsafe. We trust the configured path.
|
||||
let lib = unsafe { libloading::Library::new(path) }
|
||||
.map_err(|e| ExternalModelError::LibraryLoad(e.to_string()))?;
|
||||
|
||||
let metadata = ExternalModelMetadata {
|
||||
name: config.id.clone(),
|
||||
version: "1.0.0".to_string(), // In a real model, this would be queried from DLL
|
||||
description: Some("Real FFI model".to_string()),
|
||||
input_names: (0..config.n_inputs).map(|i| format!("in_{}", i)).collect(),
|
||||
output_names: (0..config.n_outputs).map(|i| format!("out_{}", i)).collect(),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
metadata,
|
||||
_lib: Arc::new(lib),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "ffi")]
|
||||
impl ExternalModel for FfiModel {
|
||||
fn id(&self) -> &str { &self.config.id }
|
||||
fn n_inputs(&self) -> usize { self.config.n_inputs }
|
||||
fn n_outputs(&self) -> usize { self.config.n_outputs }
|
||||
fn compute(&self, _inputs: &[f64]) -> Result<Vec<f64>, ExternalModelError> {
|
||||
// Stub implementation
|
||||
unimplemented!("Real FFI compute not fully implemented yet")
|
||||
}
|
||||
fn jacobian(&self, _inputs: &[f64]) -> Result<Vec<f64>, ExternalModelError> {
|
||||
unimplemented!("Real FFI jacobian not fully implemented yet")
|
||||
}
|
||||
fn metadata(&self) -> ExternalModelMetadata { self.metadata.clone() }
|
||||
}
|
||||
|
||||
#[cfg(feature = "http")]
|
||||
/// HTTP-based external model mapping to a remote REST service.
|
||||
pub struct HttpModel {
|
||||
config: ExternalModelConfig,
|
||||
metadata: ExternalModelMetadata,
|
||||
client: reqwest::blocking::Client,
|
||||
}
|
||||
|
||||
#[cfg(feature = "http")]
|
||||
impl HttpModel {
|
||||
/// Creates a new HTTP model with a configurable `reqwest` client.
|
||||
pub fn new(config: ExternalModelConfig) -> Result<Self, ExternalModelError> {
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_millis(config.timeout_ms))
|
||||
.build()
|
||||
.map_err(|e| ExternalModelError::HttpError(e.to_string()))?;
|
||||
|
||||
let metadata = ExternalModelMetadata {
|
||||
name: config.id.clone(),
|
||||
version: "1.0.0".to_string(),
|
||||
description: Some("Real HTTP model".to_string()),
|
||||
input_names: (0..config.n_inputs).map(|i| format!("in_{}", i)).collect(),
|
||||
output_names: (0..config.n_outputs).map(|i| format!("out_{}", i)).collect(),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
metadata,
|
||||
client,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "http")]
|
||||
impl ExternalModel for HttpModel {
|
||||
fn id(&self) -> &str { &self.config.id }
|
||||
fn n_inputs(&self) -> usize { self.config.n_inputs }
|
||||
fn n_outputs(&self) -> usize { self.config.n_outputs }
|
||||
fn compute(&self, inputs: &[f64]) -> Result<Vec<f64>, ExternalModelError> {
|
||||
let (base_url, api_key) = match &self.config.model_type {
|
||||
ExternalModelType::Http { base_url, api_key } => (base_url, api_key),
|
||||
_ => return Err(ExternalModelError::NotInitialized),
|
||||
};
|
||||
|
||||
let request = ComputeRequest { inputs: inputs.to_vec() };
|
||||
let mut req_builder = self.client.post(format!("{}/compute", base_url)).json(&request);
|
||||
|
||||
if let Some(key) = api_key {
|
||||
req_builder = req_builder.header("Authorization", format!("Bearer {}", key));
|
||||
}
|
||||
|
||||
let response = req_builder.send().map_err(|e| ExternalModelError::HttpError(e.to_string()))?;
|
||||
let result: ComputeResponse = response.json().map_err(|e| ExternalModelError::JsonError(e.to_string()))?;
|
||||
|
||||
Ok(result.outputs)
|
||||
}
|
||||
fn jacobian(&self, _inputs: &[f64]) -> Result<Vec<f64>, ExternalModelError> {
|
||||
unimplemented!("Real HTTP jacobian not fully implemented yet")
|
||||
}
|
||||
fn metadata(&self) -> ExternalModelMetadata { self.metadata.clone() }
|
||||
}
|
||||
|
||||
@@ -257,8 +257,19 @@ impl Fan<Connected> {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Handle zero flow
|
||||
if flow_m3_per_s <= 0.0 {
|
||||
// Handle negative flow gracefully by using a linear extrapolation from Q=0
|
||||
// to prevent polynomial extrapolation issues with quadratic/cubic terms
|
||||
if flow_m3_per_s < 0.0 {
|
||||
let p0 = self.curves.static_pressure_at_flow(0.0);
|
||||
let p_eps = self.curves.static_pressure_at_flow(1e-6);
|
||||
let dp_dq = (p_eps - p0) / 1e-6;
|
||||
|
||||
let pressure = p0 + dp_dq * flow_m3_per_s;
|
||||
return AffinityLaws::scale_head(pressure, self.speed_ratio);
|
||||
}
|
||||
|
||||
// Handle exactly zero flow
|
||||
if flow_m3_per_s == 0.0 {
|
||||
let pressure = self.curves.static_pressure_at_flow(0.0);
|
||||
return AffinityLaws::scale_head(pressure, self.speed_ratio);
|
||||
}
|
||||
|
||||
@@ -126,11 +126,11 @@ impl Condenser {
|
||||
|
||||
let quality = (outlet_enthalpy - h_liquid) / (h_vapor - h_liquid);
|
||||
|
||||
if quality <= 1.0 + 1e-6 {
|
||||
if quality <= 0.0 + 1e-6 {
|
||||
Ok(true)
|
||||
} else {
|
||||
Err(ComponentError::InvalidState(format!(
|
||||
"Condenser outlet quality {} > 1 (superheated)",
|
||||
"Condenser outlet quality {} > 0 (not fully condensed)",
|
||||
quality
|
||||
)))
|
||||
}
|
||||
@@ -145,6 +145,21 @@ impl Condenser {
|
||||
pub fn cold_inlet_state(&self) -> Result<entropyk_fluids::ThermoState, ComponentError> {
|
||||
self.inner.cold_inlet_state()
|
||||
}
|
||||
|
||||
/// Returns the hot side fluid identifier, if set.
|
||||
pub fn hot_fluid_id(&self) -> Option<&entropyk_fluids::FluidId> {
|
||||
self.inner.hot_fluid_id()
|
||||
}
|
||||
|
||||
/// Sets the cold side boundary conditions.
|
||||
pub fn set_cold_conditions(&mut self, conditions: super::exchanger::HxSideConditions) {
|
||||
self.inner.set_cold_conditions(conditions);
|
||||
}
|
||||
|
||||
/// Returns the cold side fluid identifier, if set.
|
||||
pub fn cold_fluid_id(&self) -> Option<&entropyk_fluids::FluidId> {
|
||||
self.inner.cold_fluid_id()
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Condenser {
|
||||
@@ -171,6 +186,10 @@ impl Component for Condenser {
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
self.inner.get_ports()
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
|
||||
self.inner.set_calib_indices(indices);
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for Condenser {
|
||||
@@ -216,6 +235,18 @@ mod tests {
|
||||
fn test_validate_outlet_quality_fully_condensed() {
|
||||
let condenser = Condenser::new(10_000.0);
|
||||
|
||||
let h_liquid = 200_000.0;
|
||||
let h_vapor = 400_000.0;
|
||||
let outlet_h = 200_000.0;
|
||||
|
||||
let result = condenser.validate_outlet_quality(outlet_h, h_liquid, h_vapor);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_outlet_quality_subcooled() {
|
||||
let condenser = Condenser::new(10_000.0);
|
||||
|
||||
let h_liquid = 200_000.0;
|
||||
let h_vapor = 400_000.0;
|
||||
let outlet_h = 180_000.0;
|
||||
@@ -224,6 +255,18 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_outlet_quality_two_phase() {
|
||||
let condenser = Condenser::new(10_000.0);
|
||||
|
||||
let h_liquid = 200_000.0;
|
||||
let h_vapor = 400_000.0;
|
||||
let outlet_h = 300_000.0;
|
||||
|
||||
let result = condenser.validate_outlet_quality(outlet_h, h_liquid, h_vapor);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_outlet_quality_superheated() {
|
||||
let condenser = Condenser::new(10_000.0);
|
||||
|
||||
@@ -38,6 +38,7 @@ use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||
#[derive(Debug)]
|
||||
pub struct CondenserCoil {
|
||||
inner: Condenser,
|
||||
air_validated: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl CondenserCoil {
|
||||
@@ -49,6 +50,7 @@ impl CondenserCoil {
|
||||
pub fn new(ua: f64) -> Self {
|
||||
Self {
|
||||
inner: Condenser::new(ua),
|
||||
air_validated: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +58,7 @@ impl CondenserCoil {
|
||||
pub fn with_saturation_temp(ua: f64, saturation_temp: f64) -> Self {
|
||||
Self {
|
||||
inner: Condenser::with_saturation_temp(ua, saturation_temp),
|
||||
air_validated: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +89,17 @@ impl Component for CondenserCoil {
|
||||
state: &SystemState,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if !self.air_validated.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
if let Some(fluid_id) = self.inner.cold_fluid_id() {
|
||||
if fluid_id.0.as_str() != "Air" {
|
||||
return Err(ComponentError::InvalidState(format!(
|
||||
"CondenserCoil requires Air on the cold side, found {}",
|
||||
fluid_id.0.as_str()
|
||||
)));
|
||||
}
|
||||
self.air_validated.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
self.inner.compute_residuals(state, residuals)
|
||||
}
|
||||
|
||||
@@ -104,6 +118,10 @@ impl Component for CondenserCoil {
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
self.inner.get_ports()
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
|
||||
self.inner.set_calib_indices(indices);
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for CondenserCoil {
|
||||
@@ -161,6 +179,31 @@ mod tests {
|
||||
assert!(residuals.iter().all(|r| r.is_finite()), "residuals must be finite");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_condenser_coil_rejects_non_air() {
|
||||
use crate::heat_exchanger::HxSideConditions;
|
||||
use entropyk_core::{Temperature, Pressure, MassFlow};
|
||||
|
||||
let mut coil = CondenserCoil::new(10_000.0);
|
||||
coil.inner.set_cold_conditions(HxSideConditions::new(
|
||||
Temperature::from_celsius(20.0),
|
||||
Pressure::from_bar(1.0),
|
||||
MassFlow::from_kg_per_s(1.0),
|
||||
"Water",
|
||||
));
|
||||
|
||||
let state = vec![0.0; 10];
|
||||
let mut residuals = vec![0.0; 3];
|
||||
let result = coil.compute_residuals(&state, &mut residuals);
|
||||
|
||||
assert!(result.is_err());
|
||||
if let Err(ComponentError::InvalidState(msg)) = result {
|
||||
assert!(msg.contains("requires Air"));
|
||||
} else {
|
||||
panic!("Expected InvalidState error");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_condenser_coil_jacobian_entries() {
|
||||
let coil = CondenserCoil::new(10_000.0);
|
||||
|
||||
@@ -190,6 +190,7 @@ impl HeatTransferModel for EpsNtuModel {
|
||||
_hot_outlet: &FluidState,
|
||||
cold_inlet: &FluidState,
|
||||
_cold_outlet: &FluidState,
|
||||
dynamic_ua_scale: Option<f64>,
|
||||
) -> Power {
|
||||
let c_hot = hot_inlet.heat_capacity_rate();
|
||||
let c_cold = cold_inlet.heat_capacity_rate();
|
||||
@@ -205,7 +206,7 @@ impl HeatTransferModel for EpsNtuModel {
|
||||
}
|
||||
|
||||
let c_r = c_min / c_max;
|
||||
let ntu = self.effective_ua() / c_min;
|
||||
let ntu = self.effective_ua(dynamic_ua_scale) / c_min;
|
||||
|
||||
let effectiveness = self.effectiveness(ntu, c_r);
|
||||
|
||||
@@ -221,9 +222,10 @@ impl HeatTransferModel for EpsNtuModel {
|
||||
cold_inlet: &FluidState,
|
||||
cold_outlet: &FluidState,
|
||||
residuals: &mut ResidualVector,
|
||||
dynamic_ua_scale: Option<f64>,
|
||||
) {
|
||||
let q = self
|
||||
.compute_heat_transfer(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
|
||||
.compute_heat_transfer(hot_inlet, hot_outlet, cold_inlet, cold_outlet, dynamic_ua_scale)
|
||||
.to_watts();
|
||||
|
||||
let q_hot =
|
||||
@@ -253,8 +255,8 @@ impl HeatTransferModel for EpsNtuModel {
|
||||
self.ua_scale = s;
|
||||
}
|
||||
|
||||
fn effective_ua(&self) -> f64 {
|
||||
self.ua * self.ua_scale
|
||||
fn effective_ua(&self, dynamic_ua_scale: Option<f64>) -> f64 {
|
||||
self.ua * dynamic_ua_scale.unwrap_or(self.ua_scale)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +306,7 @@ mod tests {
|
||||
let cold_inlet = FluidState::new(20.0 + 273.15, 101_325.0, 80_000.0, 0.2, 4180.0);
|
||||
let cold_outlet = FluidState::new(30.0 + 273.15, 101_325.0, 120_000.0, 0.2, 4180.0);
|
||||
|
||||
let q = model.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet);
|
||||
let q = model.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet, None);
|
||||
|
||||
assert!(q.to_watts() > 0.0);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ impl Evaporator {
|
||||
|
||||
let quality = (outlet_enthalpy - h_liquid) / (h_vapor - h_liquid);
|
||||
|
||||
if quality >= 0.0 - 1e-6 {
|
||||
if quality >= 1.0 - 1e-6 {
|
||||
if outlet_enthalpy >= h_vapor {
|
||||
let superheat = (outlet_enthalpy - h_vapor) / cp_vapor;
|
||||
Ok(superheat)
|
||||
@@ -149,7 +149,7 @@ impl Evaporator {
|
||||
}
|
||||
} else {
|
||||
Err(ComponentError::InvalidState(format!(
|
||||
"Evaporator outlet quality {} < 0 (subcooled)",
|
||||
"Evaporator outlet quality {} < 1 (not fully evaporated)",
|
||||
quality
|
||||
)))
|
||||
}
|
||||
@@ -171,6 +171,21 @@ impl Evaporator {
|
||||
pub fn cold_inlet_state(&self) -> Result<entropyk_fluids::ThermoState, ComponentError> {
|
||||
self.inner.cold_inlet_state()
|
||||
}
|
||||
|
||||
/// Returns the hot side fluid identifier, if set.
|
||||
pub fn hot_fluid_id(&self) -> Option<&entropyk_fluids::FluidId> {
|
||||
self.inner.hot_fluid_id()
|
||||
}
|
||||
|
||||
/// Sets the hot side boundary conditions.
|
||||
pub fn set_hot_conditions(&mut self, conditions: super::exchanger::HxSideConditions) {
|
||||
self.inner.set_hot_conditions(conditions);
|
||||
}
|
||||
|
||||
/// Returns the cold side fluid identifier, if set.
|
||||
pub fn cold_fluid_id(&self) -> Option<&entropyk_fluids::FluidId> {
|
||||
self.inner.cold_fluid_id()
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for Evaporator {
|
||||
@@ -197,6 +212,10 @@ impl Component for Evaporator {
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
self.inner.get_ports()
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
|
||||
self.inner.set_calib_indices(indices);
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for Evaporator {
|
||||
@@ -268,6 +287,19 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_outlet_quality_two_phase() {
|
||||
let evaporator = Evaporator::new(8_000.0);
|
||||
|
||||
let h_liquid = 200_000.0;
|
||||
let h_vapor = 400_000.0;
|
||||
let outlet_h = 300_000.0;
|
||||
let cp_vapor = 1000.0;
|
||||
|
||||
let result = evaporator.validate_outlet_quality(outlet_h, h_liquid, h_vapor, cp_vapor);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_superheat_residual() {
|
||||
let evaporator = Evaporator::with_superheat(8_000.0, 278.15, 5.0);
|
||||
|
||||
@@ -38,6 +38,7 @@ use crate::state_machine::{CircuitId, OperationalState, StateManageable};
|
||||
#[derive(Debug)]
|
||||
pub struct EvaporatorCoil {
|
||||
inner: Evaporator,
|
||||
air_validated: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl EvaporatorCoil {
|
||||
@@ -49,6 +50,7 @@ impl EvaporatorCoil {
|
||||
pub fn new(ua: f64) -> Self {
|
||||
Self {
|
||||
inner: Evaporator::new(ua),
|
||||
air_validated: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +58,7 @@ impl EvaporatorCoil {
|
||||
pub fn with_superheat(ua: f64, saturation_temp: f64, superheat_target: f64) -> Self {
|
||||
Self {
|
||||
inner: Evaporator::with_superheat(ua, saturation_temp, superheat_target),
|
||||
air_validated: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +99,17 @@ impl Component for EvaporatorCoil {
|
||||
state: &SystemState,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
if !self.air_validated.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
if let Some(fluid_id) = self.inner.hot_fluid_id() {
|
||||
if fluid_id.0.as_str() != "Air" {
|
||||
return Err(ComponentError::InvalidState(format!(
|
||||
"EvaporatorCoil requires Air on the hot side, found {}",
|
||||
fluid_id.0.as_str()
|
||||
)));
|
||||
}
|
||||
self.air_validated.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
self.inner.compute_residuals(state, residuals)
|
||||
}
|
||||
|
||||
@@ -114,6 +128,10 @@ impl Component for EvaporatorCoil {
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
self.inner.get_ports()
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
|
||||
self.inner.set_calib_indices(indices);
|
||||
}
|
||||
}
|
||||
|
||||
impl StateManageable for EvaporatorCoil {
|
||||
@@ -172,6 +190,32 @@ mod tests {
|
||||
assert!(residuals.iter().all(|r| r.is_finite()), "residuals must be finite");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaporator_coil_rejects_non_air() {
|
||||
use crate::heat_exchanger::HxSideConditions;
|
||||
use entropyk_core::{Temperature, Pressure, MassFlow};
|
||||
|
||||
let mut coil = EvaporatorCoil::new(8_000.0);
|
||||
|
||||
coil.inner.set_hot_conditions(HxSideConditions::new(
|
||||
Temperature::from_celsius(20.0),
|
||||
Pressure::from_bar(1.0),
|
||||
MassFlow::from_kg_per_s(1.0),
|
||||
"Water",
|
||||
));
|
||||
|
||||
let state = vec![0.0; 10];
|
||||
let mut residuals = vec![0.0; 3];
|
||||
let result = coil.compute_residuals(&state, &mut residuals);
|
||||
|
||||
assert!(result.is_err());
|
||||
if let Err(ComponentError::InvalidState(msg)) = result {
|
||||
assert!(msg.contains("requires Air"));
|
||||
} else {
|
||||
panic!("Expected InvalidState error");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaporator_coil_jacobian_entries() {
|
||||
let coil = EvaporatorCoil::new(8_000.0);
|
||||
|
||||
@@ -157,6 +157,8 @@ pub struct HeatExchanger<Model: HeatTransferModel> {
|
||||
name: String,
|
||||
/// Calibration: f_dp for refrigerant-side ΔP when modeled, f_ua for UA scaling
|
||||
calib: Calib,
|
||||
/// Indices for dynamically extracting calibration factors from the system state
|
||||
calib_indices: entropyk_core::CalibIndices,
|
||||
operational_state: OperationalState,
|
||||
circuit_id: CircuitId,
|
||||
/// Optional fluid property backend for real thermodynamic calculations (Story 5.1).
|
||||
@@ -190,6 +192,7 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
|
||||
model,
|
||||
name: name.into(),
|
||||
calib,
|
||||
calib_indices: entropyk_core::CalibIndices::default(),
|
||||
operational_state: OperationalState::default(),
|
||||
circuit_id: CircuitId::default(),
|
||||
fluid_backend: None,
|
||||
@@ -262,6 +265,16 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
|
||||
self.fluid_backend.is_some()
|
||||
}
|
||||
|
||||
/// Returns the hot side fluid identifier, if set.
|
||||
pub fn hot_fluid_id(&self) -> Option<&FluidsFluidId> {
|
||||
self.hot_conditions.as_ref().map(|c| c.fluid_id())
|
||||
}
|
||||
|
||||
/// Returns the cold side fluid identifier, if set.
|
||||
pub fn cold_fluid_id(&self) -> Option<&FluidsFluidId> {
|
||||
self.cold_conditions.as_ref().map(|c| c.fluid_id())
|
||||
}
|
||||
|
||||
/// Computes the full thermodynamic state at the hot inlet.
|
||||
pub fn hot_inlet_state(&self) -> Result<ThermoState, ComponentError> {
|
||||
let backend = self.fluid_backend.as_ref().ok_or_else(|| ComponentError::CalculationFailed("No FluidBackend configured".to_string()))?;
|
||||
@@ -327,7 +340,7 @@ impl<Model: HeatTransferModel> HeatExchanger<Model> {
|
||||
|
||||
/// Returns the effective UA value (f_ua × UA_nominal).
|
||||
pub fn ua(&self) -> f64 {
|
||||
self.model.effective_ua()
|
||||
self.model.effective_ua(None)
|
||||
}
|
||||
|
||||
/// Returns the current operational state.
|
||||
@@ -487,12 +500,15 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
|
||||
(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
|
||||
};
|
||||
|
||||
let dynamic_f_ua = self.calib_indices.f_ua.map(|idx| _state[idx]);
|
||||
|
||||
self.model.compute_residuals(
|
||||
&hot_inlet,
|
||||
&hot_outlet,
|
||||
&cold_inlet,
|
||||
&cold_outlet,
|
||||
residuals,
|
||||
dynamic_f_ua,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -503,6 +519,67 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
|
||||
_state: &SystemState,
|
||||
_jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
// ∂r/∂f_ua = -∂Q/∂f_ua (Story 5.5)
|
||||
if let Some(f_ua_idx) = self.calib_indices.f_ua {
|
||||
// Need to compute Q_nominal (with UA_scale = 1.0)
|
||||
// This requires repeating the residual calculation logic with dynamic_ua_scale = None
|
||||
// For now, we'll use a finite difference approximation or a simplified nominal calculation.
|
||||
|
||||
// Re-use logic from compute_residuals but only for Q
|
||||
if let (Some(hot_cond), Some(cold_cond), Some(_backend)) = (
|
||||
&self.hot_conditions,
|
||||
&self.cold_conditions,
|
||||
&self.fluid_backend,
|
||||
) {
|
||||
let hot_cp = self.query_cp(hot_cond)?;
|
||||
let hot_h_in = self.query_enthalpy(hot_cond)?;
|
||||
let hot_inlet = Self::create_fluid_state(
|
||||
hot_cond.temperature_k(),
|
||||
hot_cond.pressure_pa(),
|
||||
hot_h_in,
|
||||
hot_cond.mass_flow_kg_s(),
|
||||
hot_cp,
|
||||
);
|
||||
|
||||
let hot_dh = hot_cp * 5.0;
|
||||
let hot_outlet = Self::create_fluid_state(
|
||||
hot_cond.temperature_k() - 5.0,
|
||||
hot_cond.pressure_pa() * 0.998,
|
||||
hot_h_in - hot_dh,
|
||||
hot_cond.mass_flow_kg_s(),
|
||||
hot_cp,
|
||||
);
|
||||
|
||||
let cold_cp = self.query_cp(cold_cond)?;
|
||||
let cold_h_in = self.query_enthalpy(cold_cond)?;
|
||||
let cold_inlet = Self::create_fluid_state(
|
||||
cold_cond.temperature_k(),
|
||||
cold_cond.pressure_pa(),
|
||||
cold_h_in,
|
||||
cold_cond.mass_flow_kg_s(),
|
||||
cold_cp,
|
||||
);
|
||||
let cold_dh = cold_cp * 5.0;
|
||||
let cold_outlet = Self::create_fluid_state(
|
||||
cold_cond.temperature_k() + 5.0,
|
||||
cold_cond.pressure_pa() * 0.998,
|
||||
cold_h_in + cold_dh,
|
||||
cold_cond.mass_flow_kg_s(),
|
||||
cold_cp,
|
||||
);
|
||||
|
||||
let q_nominal = self.model.compute_heat_transfer(
|
||||
&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet, None
|
||||
).to_watts();
|
||||
|
||||
// r0 = Q_hot - Q -> ∂r0/∂f_ua = -Q_nominal
|
||||
// r1 = Q_cold - Q -> ∂r1/∂f_ua = -Q_nominal
|
||||
// r2 = Q_hot - Q_cold -> ∂r2/∂f_ua = 0
|
||||
_jacobian.add_entry(0, f_ua_idx, -q_nominal);
|
||||
_jacobian.add_entry(1, f_ua_idx, -q_nominal);
|
||||
_jacobian.add_entry(2, f_ua_idx, 0.0);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -510,6 +587,10 @@ impl<Model: HeatTransferModel + 'static> Component for HeatExchanger<Model> {
|
||||
self.model.n_equations()
|
||||
}
|
||||
|
||||
fn set_calib_indices(&mut self, indices: entropyk_core::CalibIndices) {
|
||||
self.calib_indices = indices;
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
// TODO: Return actual ports when port storage is implemented.
|
||||
// Port storage pending integration with Port<Connected> system from Story 1.3.
|
||||
|
||||
@@ -168,6 +168,7 @@ impl HeatTransferModel for LmtdModel {
|
||||
hot_outlet: &FluidState,
|
||||
cold_inlet: &FluidState,
|
||||
cold_outlet: &FluidState,
|
||||
dynamic_ua_scale: Option<f64>,
|
||||
) -> Power {
|
||||
let lmtd = self.lmtd(
|
||||
hot_inlet.temperature,
|
||||
@@ -177,7 +178,7 @@ impl HeatTransferModel for LmtdModel {
|
||||
);
|
||||
|
||||
let f = self.flow_config.correction_factor();
|
||||
let ua_eff = self.effective_ua();
|
||||
let ua_eff = self.effective_ua(dynamic_ua_scale);
|
||||
let q = ua_eff * lmtd * f;
|
||||
|
||||
Power::from_watts(q)
|
||||
@@ -190,9 +191,10 @@ impl HeatTransferModel for LmtdModel {
|
||||
cold_inlet: &FluidState,
|
||||
cold_outlet: &FluidState,
|
||||
residuals: &mut ResidualVector,
|
||||
dynamic_ua_scale: Option<f64>,
|
||||
) {
|
||||
let q = self
|
||||
.compute_heat_transfer(hot_inlet, hot_outlet, cold_inlet, cold_outlet)
|
||||
.compute_heat_transfer(hot_inlet, hot_outlet, cold_inlet, cold_outlet, dynamic_ua_scale)
|
||||
.to_watts();
|
||||
|
||||
let q_hot =
|
||||
@@ -222,8 +224,8 @@ impl HeatTransferModel for LmtdModel {
|
||||
self.ua_scale = s;
|
||||
}
|
||||
|
||||
fn effective_ua(&self) -> f64 {
|
||||
self.ua * self.ua_scale
|
||||
fn effective_ua(&self, dynamic_ua_scale: Option<f64>) -> f64 {
|
||||
self.ua * dynamic_ua_scale.unwrap_or(self.ua_scale)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,9 +244,9 @@ mod tests {
|
||||
#[test]
|
||||
fn test_f_ua_scales_heat_transfer() {
|
||||
let mut model = LmtdModel::new(5000.0, FlowConfiguration::CounterFlow);
|
||||
assert_relative_eq!(model.effective_ua(), 5000.0, epsilon = 1e-10);
|
||||
assert_relative_eq!(model.effective_ua(None), 5000.0, epsilon = 1e-10);
|
||||
model.set_ua_scale(1.1);
|
||||
assert_relative_eq!(model.effective_ua(), 5500.0, epsilon = 1e-10);
|
||||
assert_relative_eq!(model.effective_ua(None), 5500.0, epsilon = 1e-10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -299,7 +301,7 @@ mod tests {
|
||||
let cold_inlet = FluidState::from_temperature(20.0 + 273.15);
|
||||
let cold_outlet = FluidState::from_temperature(50.0 + 273.15);
|
||||
|
||||
let q = model.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet);
|
||||
let q = model.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet, None);
|
||||
|
||||
assert!(q.to_watts() > 0.0);
|
||||
}
|
||||
@@ -366,10 +368,10 @@ mod tests {
|
||||
let cold_outlet = FluidState::new(313.0, 101_325.0, 170_000.0, 0.3, 4180.0);
|
||||
|
||||
let q_lmtd = lmtd_model
|
||||
.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet)
|
||||
.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet, None)
|
||||
.to_watts();
|
||||
let q_eps_ntu = eps_ntu_model
|
||||
.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet)
|
||||
.compute_heat_transfer(&hot_inlet, &hot_outlet, &cold_inlet, &cold_outlet, None)
|
||||
.to_watts();
|
||||
|
||||
// Both methods should give positive heat transfer
|
||||
|
||||
@@ -113,10 +113,10 @@ impl FluidState {
|
||||
/// # use entropyk_core::Power;
|
||||
/// struct SimpleModel { ua: f64 }
|
||||
/// impl HeatTransferModel for SimpleModel {
|
||||
/// fn compute_heat_transfer(&self, _: &FluidState, _: &FluidState, _: &FluidState, _: &FluidState) -> Power {
|
||||
/// fn compute_heat_transfer(&self, _: &FluidState, _: &FluidState, _: &FluidState, _: &FluidState, _: Option<f64>) -> Power {
|
||||
/// Power::from_watts(0.0)
|
||||
/// }
|
||||
/// fn compute_residuals(&self, _: &FluidState, _: &FluidState, _: &FluidState, _: &FluidState, _: &mut ResidualVector) {}
|
||||
/// fn compute_residuals(&self, _: &FluidState, _: &FluidState, _: &FluidState, _: &FluidState, _: &mut ResidualVector, _: Option<f64>) {}
|
||||
/// fn n_equations(&self) -> usize { 3 }
|
||||
/// fn ua(&self) -> f64 { self.ua }
|
||||
/// }
|
||||
@@ -141,6 +141,7 @@ pub trait HeatTransferModel: Send + Sync {
|
||||
hot_outlet: &FluidState,
|
||||
cold_inlet: &FluidState,
|
||||
cold_outlet: &FluidState,
|
||||
dynamic_ua_scale: Option<f64>,
|
||||
) -> Power;
|
||||
|
||||
/// Computes residuals for the solver.
|
||||
@@ -154,6 +155,7 @@ pub trait HeatTransferModel: Send + Sync {
|
||||
cold_inlet: &FluidState,
|
||||
cold_outlet: &FluidState,
|
||||
residuals: &mut ResidualVector,
|
||||
dynamic_ua_scale: Option<f64>,
|
||||
);
|
||||
|
||||
/// Returns the number of equations this model contributes.
|
||||
@@ -170,9 +172,9 @@ pub trait HeatTransferModel: Send + Sync {
|
||||
/// Sets the UA calibration scale (e.g. from Calib.f_ua).
|
||||
fn set_ua_scale(&mut self, _s: f64) {}
|
||||
|
||||
/// Returns the effective UA used in heat transfer: ua_scale × ua_nominal.
|
||||
fn effective_ua(&self) -> f64 {
|
||||
self.ua() * self.ua_scale()
|
||||
/// Returns the effective UA used in heat transfer. If dynamic_ua_scale is provided, it is used instead of ua_scale.
|
||||
fn effective_ua(&self, dynamic_ua_scale: Option<f64>) -> f64 {
|
||||
self.ua() * dynamic_ua_scale.unwrap_or_else(|| self.ua_scale())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -542,6 +542,15 @@ pub trait Component {
|
||||
fn internal_state_len(&self) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
/// Injects control variable indices for calibration parameters into a component.
|
||||
///
|
||||
/// Called by the solver (e.g. `System::finalize()`) after matching `BoundedVariable`s
|
||||
/// to components, so the component can read calibration factors dynamically from
|
||||
/// the system state vector.
|
||||
fn set_calib_indices(&mut self, _indices: entropyk_core::CalibIndices) {
|
||||
// Default: no-op for components that don't support inverse calibration
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -305,8 +305,21 @@ impl Pump<Connected> {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Handle zero flow
|
||||
if flow_m3_per_s <= 0.0 {
|
||||
// Handle negative flow gracefully by using a linear extrapolation from Q=0
|
||||
// to prevent polynomial extrapolation issues with quadratic/cubic terms
|
||||
if flow_m3_per_s < 0.0 {
|
||||
let h0 = self.curves.head_at_flow(0.0);
|
||||
let h_eps = self.curves.head_at_flow(1e-6);
|
||||
let dh_dq = (h_eps - h0) / 1e-6;
|
||||
|
||||
let head_m = h0 + dh_dq * flow_m3_per_s;
|
||||
let actual_head = AffinityLaws::scale_head(head_m, self.speed_ratio);
|
||||
const G: f64 = 9.80665; // m/s²
|
||||
return self.fluid_density_kg_per_m3 * G * actual_head;
|
||||
}
|
||||
|
||||
// Handle exactly zero flow
|
||||
if flow_m3_per_s == 0.0 {
|
||||
// At zero flow, use the shut-off head scaled by speed
|
||||
let head_m = self.curves.head_at_flow(0.0);
|
||||
let actual_head = AffinityLaws::scale_head(head_m, self.speed_ratio);
|
||||
|
||||
@@ -838,7 +838,7 @@ mod tests {
|
||||
fn test_state_transition_record_elapsed() {
|
||||
let record = StateTransitionRecord::new(OperationalState::On, OperationalState::Off);
|
||||
let elapsed = record.elapsed();
|
||||
assert!(elapsed.as_nanos() >= 0);
|
||||
let _ = elapsed.as_nanos();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user