Files
Entropyk/crates/solver/tests/calibrated_cycle_integration.rs

297 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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"
);
}