297 lines
11 KiB
Rust
297 lines
11 KiB
Rust
/// Integration test: calibrated refrigeration cycle vs synthetic test data.
|
||
///
|
||
/// Validates that Calib factors correctly scale component outputs and that
|
||
/// the solver converges on a calibrated cycle matching expected targets
|
||
/// within configurable tolerances (capacity ±2%, power ±3%).
|
||
///
|
||
/// The mock components form a self-consistent cycle for any Calib values:
|
||
/// Compressor : dp = +1 MPa, dh = +75kJ × f_m × f_power
|
||
/// Condenser : dp = -20kPa×f_dp, dh = -(75kJ×f_m×f_power + 150kJ×f_ua)
|
||
/// Valve : dp = -(1MPa - 20kPa×f_dp), dh = 0 (isenthalpic)
|
||
/// Evaporator : dp = 0, dh = +150kJ × f_ua
|
||
///
|
||
/// Energy balance: compressor_work + evaporator_absorption = condenser_rejection ✓
|
||
/// Pressure balance: closes for any f_dp ✓
|
||
|
||
use entropyk_components::{
|
||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
|
||
};
|
||
use entropyk_core::{Calib, MassFlow};
|
||
use entropyk_solver::{
|
||
solver::{NewtonConfig, Solver},
|
||
system::System,
|
||
};
|
||
use entropyk_components::port::{Connected, FluidId, Port};
|
||
use entropyk_core::{Enthalpy, Pressure};
|
||
|
||
type CP = Port<Connected>;
|
||
|
||
// ─── Calibrated mock components ────────────────────────────────────────────────
|
||
|
||
struct CalibCompressor { port_suc: CP, port_disc: CP, calib: Calib }
|
||
impl Component for CalibCompressor {
|
||
fn compute_residuals(&self, _s: &StateSlice, r: &mut ResidualVector) -> Result<(), ComponentError> {
|
||
let dh_eff = 75_000.0 * self.calib.f_m * self.calib.f_power;
|
||
r[0] = self.port_disc.pressure().to_pascals() - (self.port_suc.pressure().to_pascals() + 1_000_000.0);
|
||
r[1] = self.port_disc.enthalpy().to_joules_per_kg() - (self.port_suc.enthalpy().to_joules_per_kg() + dh_eff);
|
||
Ok(())
|
||
}
|
||
fn jacobian_entries(&self, _s: &StateSlice, _j: &mut JacobianBuilder) -> Result<(), ComponentError> { Ok(()) }
|
||
fn n_equations(&self) -> usize { 2 }
|
||
fn get_ports(&self) -> &[ConnectedPort] { &[] }
|
||
fn port_mass_flows(&self, _: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
|
||
Ok(vec![MassFlow::from_kg_per_s(0.05), MassFlow::from_kg_per_s(-0.05)])
|
||
}
|
||
}
|
||
|
||
struct CalibCondenser { port_in: CP, port_out: CP, calib: Calib }
|
||
impl Component for CalibCondenser {
|
||
fn compute_residuals(&self, _s: &StateSlice, r: &mut ResidualVector) -> Result<(), ComponentError> {
|
||
let dp_eff = 20_000.0 * self.calib.f_dp;
|
||
// Condenser rejects compressor work + evaporator load (energy balance)
|
||
let dh_reject = 75_000.0 * self.calib.f_m * self.calib.f_power + 150_000.0 * self.calib.f_ua;
|
||
r[0] = self.port_out.pressure().to_pascals() - (self.port_in.pressure().to_pascals() - dp_eff);
|
||
r[1] = self.port_out.enthalpy().to_joules_per_kg() - (self.port_in.enthalpy().to_joules_per_kg() - dh_reject);
|
||
Ok(())
|
||
}
|
||
fn jacobian_entries(&self, _s: &StateSlice, _j: &mut JacobianBuilder) -> Result<(), ComponentError> { Ok(()) }
|
||
fn n_equations(&self) -> usize { 2 }
|
||
fn get_ports(&self) -> &[ConnectedPort] { &[] }
|
||
fn port_mass_flows(&self, _: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
|
||
Ok(vec![MassFlow::from_kg_per_s(0.05), MassFlow::from_kg_per_s(-0.05)])
|
||
}
|
||
}
|
||
|
||
struct CalibValve { port_in: CP, port_out: CP, calib: Calib }
|
||
impl Component for CalibValve {
|
||
fn compute_residuals(&self, _s: &StateSlice, r: &mut ResidualVector) -> Result<(), ComponentError> {
|
||
let dp_eff = 1_000_000.0 - 20_000.0 * self.calib.f_dp;
|
||
r[0] = self.port_out.pressure().to_pascals() - (self.port_in.pressure().to_pascals() - dp_eff);
|
||
r[1] = self.port_out.enthalpy().to_joules_per_kg() - self.port_in.enthalpy().to_joules_per_kg();
|
||
Ok(())
|
||
}
|
||
fn jacobian_entries(&self, _s: &StateSlice, _j: &mut JacobianBuilder) -> Result<(), ComponentError> { Ok(()) }
|
||
fn n_equations(&self) -> usize { 2 }
|
||
fn get_ports(&self) -> &[ConnectedPort] { &[] }
|
||
fn port_mass_flows(&self, _: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
|
||
Ok(vec![MassFlow::from_kg_per_s(0.05), MassFlow::from_kg_per_s(-0.05)])
|
||
}
|
||
}
|
||
|
||
struct CalibEvaporator { port_in: CP, port_out: CP, calib: Calib }
|
||
impl Component for CalibEvaporator {
|
||
fn compute_residuals(&self, _s: &StateSlice, r: &mut ResidualVector) -> Result<(), ComponentError> {
|
||
let dh_eff = 150_000.0 * self.calib.f_ua;
|
||
r[0] = self.port_out.pressure().to_pascals() - self.port_in.pressure().to_pascals();
|
||
r[1] = self.port_out.enthalpy().to_joules_per_kg() - (self.port_in.enthalpy().to_joules_per_kg() + dh_eff);
|
||
Ok(())
|
||
}
|
||
fn jacobian_entries(&self, _s: &StateSlice, _j: &mut JacobianBuilder) -> Result<(), ComponentError> { Ok(()) }
|
||
fn n_equations(&self) -> usize { 2 }
|
||
fn get_ports(&self) -> &[ConnectedPort] { &[] }
|
||
fn port_mass_flows(&self, _: &StateSlice) -> Result<Vec<MassFlow>, ComponentError> {
|
||
Ok(vec![MassFlow::from_kg_per_s(0.05), MassFlow::from_kg_per_s(-0.05)])
|
||
}
|
||
}
|
||
|
||
fn port(p_pa: f64, h_j_kg: f64) -> CP {
|
||
let (connected, _) = Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_pascals(p_pa),
|
||
Enthalpy::from_joules_per_kg(h_j_kg),
|
||
).connect(Port::new(
|
||
FluidId::new("R134a"),
|
||
Pressure::from_pascals(p_pa),
|
||
Enthalpy::from_joules_per_kg(h_j_kg),
|
||
)).unwrap();
|
||
connected
|
||
}
|
||
|
||
fn make_calib() -> Calib {
|
||
Calib {
|
||
f_m: 1.0,
|
||
f_dp: 1.0,
|
||
f_ua: 1.0,
|
||
f_power: 1.0,
|
||
f_etav: 1.0,
|
||
calibration_source: None,
|
||
}
|
||
}
|
||
|
||
/// Compute the analytical solution for the calibrated cycle.
|
||
fn analytical_solution(calib: &Calib) -> [f64; 8] {
|
||
let p3 = 350_000.0;
|
||
let h3 = 410_000.0;
|
||
let p0 = p3 + 1_000_000.0;
|
||
let h0 = h3 + 75_000.0 * calib.f_m * calib.f_power;
|
||
let p1 = p0 - 20_000.0 * calib.f_dp;
|
||
let h1 = h0 - 75_000.0 * calib.f_m * calib.f_power - 150_000.0 * calib.f_ua;
|
||
let p2 = p3;
|
||
let h2 = h1;
|
||
[p0, h0, p1, h1, p2, h2, p3, h3]
|
||
}
|
||
|
||
fn solve_calibrated_cycle(calib: &Calib) -> Vec<f64> {
|
||
let sol = analytical_solution(calib);
|
||
let comp = Box::new(CalibCompressor {
|
||
port_suc: port(sol[6], sol[7]),
|
||
port_disc: port(sol[0], sol[1]),
|
||
calib: calib.clone(),
|
||
});
|
||
let cond = Box::new(CalibCondenser {
|
||
port_in: port(sol[0], sol[1]),
|
||
port_out: port(sol[2], sol[3]),
|
||
calib: calib.clone(),
|
||
});
|
||
let valv = Box::new(CalibValve {
|
||
port_in: port(sol[2], sol[3]),
|
||
port_out: port(sol[4], sol[5]),
|
||
calib: calib.clone(),
|
||
});
|
||
let evap = Box::new(CalibEvaporator {
|
||
port_in: port(sol[4], sol[5]),
|
||
port_out: port(sol[6], sol[7]),
|
||
calib: calib.clone(),
|
||
});
|
||
|
||
let mut system = System::new();
|
||
let n_comp = system.add_component(comp);
|
||
let n_cond = system.add_component(cond);
|
||
let n_valv = system.add_component(valv);
|
||
let n_evap = system.add_component(evap);
|
||
|
||
system.add_edge(n_comp, n_cond).unwrap();
|
||
system.add_edge(n_cond, n_valv).unwrap();
|
||
system.add_edge(n_valv, n_evap).unwrap();
|
||
system.add_edge(n_evap, n_comp).unwrap();
|
||
system.finalize().unwrap();
|
||
|
||
let mut config = NewtonConfig {
|
||
max_iterations: 100,
|
||
tolerance: 1e-8,
|
||
line_search: false,
|
||
use_numerical_jacobian: true,
|
||
initial_state: Some(sol.to_vec()),
|
||
..NewtonConfig::default()
|
||
};
|
||
|
||
config.solve(&mut system).unwrap().state
|
||
}
|
||
|
||
/// Baseline: all Calib = 1.0 → results match nominal analytical solution.
|
||
#[test]
|
||
fn test_calibrated_cycle_nominal_baseline() {
|
||
let calib = make_calib();
|
||
let sv = solve_calibrated_cycle(&calib);
|
||
let expected = analytical_solution(&calib);
|
||
|
||
for i in 0..8 {
|
||
let diff = (sv[i] - expected[i]).abs();
|
||
assert!(diff < 10.0, "sv[{}]: got {}, expected {}, diff {}", i, sv[i], expected[i], diff);
|
||
}
|
||
|
||
// Energy balance check
|
||
let dh_comp = sv[1] - sv[7];
|
||
let dh_cond = sv[3] - sv[1];
|
||
let dh_valve = sv[5] - sv[3];
|
||
let dh_evap = sv[7] - sv[5];
|
||
let imbalance = dh_comp + dh_cond + dh_valve + dh_evap;
|
||
assert!(imbalance.abs() < 10.0, "Energy imbalance: {imbalance}");
|
||
}
|
||
|
||
/// f_ua = 1.1 on evaporator → capacity increases by 10% (±2% tolerance).
|
||
#[test]
|
||
fn test_calibrated_cycle_fua_increases_capacity() {
|
||
let nom = make_calib();
|
||
let cal = Calib { f_ua: 1.1, calibration_source: Some("synthetic-fua".into()), ..make_calib() };
|
||
|
||
let sv_nom = solve_calibrated_cycle(&nom);
|
||
let sv_cal = solve_calibrated_cycle(&cal);
|
||
|
||
let dh_evap_nom = sv_nom[7] - sv_nom[5];
|
||
let dh_evap_cal = sv_cal[7] - sv_cal[5];
|
||
let capacity_ratio = dh_evap_cal / dh_evap_nom;
|
||
|
||
assert!(
|
||
(capacity_ratio - 1.10).abs() < 0.02,
|
||
"Capacity ratio: {capacity_ratio:.4}, expected ~1.10 ±2%"
|
||
);
|
||
}
|
||
|
||
/// f_m * f_power on compressor → compressor work scales accordingly (±3% tolerance).
|
||
#[test]
|
||
fn test_calibrated_cycle_fm_fpower_scales_compressor_work() {
|
||
let nom = make_calib();
|
||
let cal = Calib {
|
||
f_m: 1.05,
|
||
f_power: 1.03,
|
||
calibration_source: Some("test-bench-2024-A".into()),
|
||
..make_calib()
|
||
};
|
||
|
||
let sv_nom = solve_calibrated_cycle(&nom);
|
||
let sv_cal = solve_calibrated_cycle(&cal);
|
||
|
||
let dh_comp_nom = sv_nom[1] - sv_nom[7];
|
||
let dh_comp_cal = sv_cal[1] - sv_cal[7];
|
||
let power_ratio = dh_comp_cal / dh_comp_nom;
|
||
let expected = 1.05 * 1.03;
|
||
|
||
assert!(
|
||
(power_ratio - expected).abs() < 0.03,
|
||
"Power ratio: {power_ratio:.4}, expected ~{expected:.4} ±3%"
|
||
);
|
||
}
|
||
|
||
/// f_dp on condenser → pressure drop scales by f_dp factor.
|
||
#[test]
|
||
fn test_calibrated_cycle_fdp_scales_pressure_drop() {
|
||
let nom = make_calib();
|
||
let cal = Calib {
|
||
f_dp: 1.5,
|
||
calibration_source: Some("dp-test-synthetic".into()),
|
||
..make_calib()
|
||
};
|
||
|
||
let sv_nom = solve_calibrated_cycle(&nom);
|
||
let sv_cal = solve_calibrated_cycle(&cal);
|
||
|
||
let dp_nom = sv_nom[2] - sv_nom[0]; // negative (pressure drop)
|
||
let dp_cal = sv_cal[2] - sv_cal[0];
|
||
let dp_ratio = dp_cal / dp_nom;
|
||
|
||
assert!(
|
||
(dp_ratio - 1.5).abs() < 0.05,
|
||
"Pressure drop ratio: {dp_ratio:.4}, expected ~1.50 ±5%"
|
||
);
|
||
}
|
||
|
||
/// Calib with calibration_source roundtrips through JSON and still produces correct results.
|
||
#[test]
|
||
fn test_calibrated_cycle_with_calibration_source_metadata() {
|
||
let calib_json = r#"{
|
||
"f_m": 1.0,
|
||
"f_dp": 1.0,
|
||
"f_ua": 1.1,
|
||
"f_power": 1.0,
|
||
"f_etav": 1.0,
|
||
"calibration_source": "manufacturer-test-report-2024-TR-001"
|
||
}"#;
|
||
|
||
let calib: Calib = serde_json::from_str(calib_json).unwrap();
|
||
assert_eq!(
|
||
calib.calibration_source.as_deref(),
|
||
Some("manufacturer-test-report-2024-TR-001")
|
||
);
|
||
assert_eq!(calib.f_ua, 1.1);
|
||
|
||
let sv = solve_calibrated_cycle(&calib);
|
||
|
||
// f_ua=1.1 → evaporator Δh = 150kJ × 1.1 = 165 kJ/kg
|
||
let dh_evap = sv[7] - sv[5];
|
||
assert!(
|
||
(dh_evap - 165_000.0).abs() < 1_000.0,
|
||
"Evaporator Δh with f_ua=1.1: {dh_evap:.0}, expected ~165000"
|
||
);
|
||
}
|