Update project structure and configurations

This commit is contained in:
2026-05-23 10:19:55 +02:00
parent ab5dc7e568
commit 62efea0646
1832 changed files with 83568 additions and 51829 deletions

View File

@@ -0,0 +1,296 @@
/// 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"
);
}

View File

@@ -589,9 +589,9 @@ fn test_screw_energy_balance() {
// At this operating point:
// h_suc=400 kJ/kg, h_dis=440 kJ/kg, h_eco=260 kJ/kg
// ṁ_suc=1.2 kg/s, ṁ_eco=0.144 kg/s, ṁ_total=1.344 kg/s
// Energy in = 1.2×400000 + 0.144×260000 + W/0.92
// Energy out = 1.344×440000
// W = (1.344×440000 - 1.2×400000 - 0.144×260000) × 0.92
// First law (fluid side): ṁ_suc×h_suc + ṁ_eco×h_eco + W_fluid = ṁ_total×h_dis
// W_fluid = W_shaft × η_mech
// W_shaft = (ΔH) / η_mech
let m_suc = 1.2_f64;
let m_eco = 0.144_f64;
@@ -601,21 +601,21 @@ fn test_screw_energy_balance() {
let h_eco = 260_000.0_f64;
let eta_mech = 0.92_f64;
let w_expected = (m_total * h_dis - m_suc * h_suc - m_eco * h_eco) * eta_mech;
let delta_h = m_total * h_dis - m_suc * h_suc - m_eco * h_eco;
let w_shaft = delta_h / eta_mech;
let w_fluid = w_shaft * eta_mech; // == delta_h
println!(
"Expected shaft power: {:.0} W = {:.1} kW",
w_expected,
w_expected / 1000.0
"Shaft power: {:.0} W = {:.1} kW, Fluid power: {:.0} W",
w_shaft, w_shaft / 1000.0, w_fluid
);
// Verify that this W closes the energy balance (residual[2] ≈ 0)
let state = vec![m_suc, m_eco, h_suc, h_dis, w_expected];
// Verify: W_shaft closes the energy balance via residual[2]
// State layout: [m_suc, m_eco, w_shaft] — enthalpies come from ports, not state
let state = vec![m_suc, m_eco, w_shaft];
let mut residuals = vec![0.0; 5];
comp.compute_residuals(&state, &mut residuals).unwrap();
// residual[2] = energy_in - energy_out
// = (ṁ_suc×h_suc + ṁ_eco×h_eco + W/η) - ṁ_total×h_dis
// Should be exactly 0 if W was computed correctly
// residual[2] = (ṁ_suc×h_suc + ṁ_eco×h_eco + W_shaft×η) - ṁ_total×h_dis
println!("Energy balance residual: {:.4} J/s", residuals[2]);
assert!(
residuals[2].abs() < 1.0,

View File

@@ -57,6 +57,10 @@ impl Component for MockCalibratedComponent {
fn set_calib_indices(&mut self, indices: CalibIndices) {
self.calib_indices = indices;
}
fn update_calib_factor(&mut self, _factor: &str, _value: f64) -> bool {
false
}
}
#[test]

View File

@@ -0,0 +1,220 @@
//! Integration tests for inverse calibration algorithm (Story 19.1 / P4-25).
//!
//! Tests cover:
//! - Single-factor calibration (f_ua → target capacity)
//! - Multi-factor sequential calibration (f_m then f_ua)
//! - Simultaneous calibration
//! - Failure diagnostics
//! - Bounds enforcement
//! - JSON round-trip of CalibrationResult
use std::collections::HashMap;
use entropyk_components::{
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, StateSlice,
};
use entropyk_core::CalibIndices;
use entropyk_solver::{
inverse::calibration::{
CalibFactor, CalibRequest, CalibrationMode, CalibrationProblem, CalibrationTarget,
},
NewtonConfig, Solver, System,
};
/// Mock component whose capacity scales linearly with f_ua.
/// Capacity = base_capacity * f_ua, where base_capacity = 4000.0 W.
struct MockCalibratedHx {
calib_indices: CalibIndices,
base_capacity: f64,
}
impl MockCalibratedHx {
fn new(base_capacity: f64) -> Self {
MockCalibratedHx {
calib_indices: CalibIndices::default(),
base_capacity,
}
}
}
impl Component for MockCalibratedHx {
fn compute_residuals(
&self,
state: &StateSlice,
residuals: &mut ResidualVector,
) -> Result<(), ComponentError> {
// Fix edge states to known values
residuals[0] = state[0] - 300.0;
residuals[1] = state[1] - 400.0;
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
jacobian.add_entry(0, 0, 1.0);
jacobian.add_entry(1, 1, 1.0);
Ok(())
}
fn n_equations(&self) -> usize {
2
}
fn get_ports(&self) -> &[ConnectedPort] {
&[]
}
fn set_calib_indices(&mut self, indices: CalibIndices) {
self.calib_indices = indices;
}
fn update_calib_factor(&mut self, _factor: &str, _value: f64) -> bool {
false
}
}
fn setup_system_with_mock(component_name: &str, base_capacity: f64) -> System {
let mut sys = System::new();
let mock = Box::new(MockCalibratedHx::new(base_capacity));
let comp_id = sys.add_component(mock);
sys.register_component_name(component_name, comp_id);
sys.add_edge(comp_id, comp_id).unwrap();
sys
}
#[test]
fn test_single_factor_calibration_f_ua() {
let mut sys = setup_system_with_mock("evaporator", 4000.0);
let problem = CalibrationProblem::new()
.add_request(CalibRequest::new(
CalibFactor::FUa,
"evaporator",
(0.1, 10.0),
1.0,
))
.add_target(CalibrationTarget::capacity("evaporator", 4015.0));
let config = NewtonConfig::default();
let result = problem.calibrate(&mut sys, &config).unwrap();
assert!(result.converged, "Calibration should converge");
let f_ua = result.estimated_factor("evaporator.f_ua").unwrap();
// The mock capacity is extracted via extract_constraint_values_with_controls,
// which uses the actual solver. Since the mock is simplified, we just verify
// convergence and that a factor was returned.
assert!(f_ua > 0.0, "f_ua should be positive, got {f_ua}");
assert!(result.iterations > 0, "Should have at least 1 iteration");
}
#[test]
fn test_sequential_mode_is_default() {
let p = CalibrationProblem::new();
assert_eq!(p.mode(), CalibrationMode::Sequential);
}
#[test]
fn test_problem_dof_validation() {
let sys = System::new();
let p = CalibrationProblem::new()
.add_request(CalibRequest::new(CalibFactor::FUa, "evaporator", (0.1, 10.0), 1.0));
// Only 1 request, 0 targets → DoF mismatch
let err = p.validate(&sys).unwrap_err();
assert!(format!("{err}").contains("DoF mismatch"));
}
#[test]
fn test_problem_missing_component() {
let sys = System::new();
let p = CalibrationProblem::new()
.add_request(CalibRequest::new(CalibFactor::FUa, "nonexistent", (0.1, 10.0), 1.0))
.add_target(CalibrationTarget::capacity("nonexistent", 4015.0));
let err = p.validate(&sys).unwrap_err();
assert!(format!("{err}").contains("not registered"));
}
#[test]
fn test_bounds_validation_on_request() {
let mut sys = setup_system_with_mock("evaporator", 4000.0);
let problem = CalibrationProblem::new()
.add_request(CalibRequest::new(
CalibFactor::FUa,
"evaporator",
(0.1, 10.0),
0.05, // initial value below min bound
))
.add_target(CalibrationTarget::capacity("evaporator", 4015.0));
let config = NewtonConfig::default();
// Should fail because initial value is outside bounds
let result = problem.calibrate(&mut sys, &config);
assert!(result.is_err(), "Should fail with invalid initial value");
}
#[test]
fn test_calibration_result_json_roundtrip() {
use std::collections::HashMap;
let mut result =
entropyk_solver::inverse::calibration::CalibrationResult {
estimated_factors: HashMap::new(),
residuals: HashMap::new(),
mape: 0.0,
max_abs_error: 0.0,
iterations: 0,
converged: false,
saturated_factors: Vec::new(),
};
result
.estimated_factors
.insert("evaporator.f_ua".to_string(), 1.15);
result
.estimated_factors
.insert("compressor.f_m".to_string(), 0.95);
result.residuals.insert("evaporator.f_ua".to_string(), 0.02);
result.mape = 1.5;
result.max_abs_error = 0.05;
result.iterations = 42;
result.converged = true;
result.saturated_factors.push("compressor.f_m".to_string());
let json = serde_json::to_string(&result).unwrap();
let result2: entropyk_solver::inverse::calibration::CalibrationResult =
serde_json::from_str(&json).unwrap();
assert_eq!(result, result2);
}
#[test]
fn test_calib_factor_ordering() {
let order = CalibFactor::calibration_order();
assert_eq!(order[0], CalibFactor::FM, "f_m should come first");
assert_eq!(order[2], CalibFactor::FUa, "f_ua should come third");
}
#[test]
fn test_calibration_target_factory_methods() {
let t = CalibrationTarget::mass_flow("comp", 0.05);
assert_eq!(t.measured_value, 0.05);
let t = CalibrationTarget::superheat("evap", 5.0);
assert_eq!(t.measured_value, 5.0);
let t = CalibrationTarget::pressure("pipe", 101325.0);
assert_eq!(t.measured_value, 101325.0);
let t = CalibrationTarget::saturation_temperature("cond", 305.0);
assert_eq!(t.measured_value, 305.0);
let t = CalibrationTarget::temperature("node", 280.0);
assert_eq!(t.measured_value, 280.0);
let t = CalibrationTarget::subcooling("cond", 3.0);
assert_eq!(t.measured_value, 3.0);
let t = CalibrationTarget::heat_transfer_rate("hx", 5000.0);
assert_eq!(t.measured_value, 5000.0);
}

View File

@@ -687,9 +687,12 @@ fn test_three_constraints_and_three_controls() {
///
/// Note: This test uses mock components with synthetic physics. The mock MIMO
/// coefficients (10.0 primary, 2.0 secondary) simulate thermal coupling for
/// Jacobian verification. Real thermodynamic convergence is tested in AC #4.
/// Tests that the MIMO Jacobian has correct structure and bounds are respected
/// during a Newton-like step. This verifies structural correctness (dense block,
/// proper cross-derivatives, bounded step) rather than actual Newton-Raphson
/// convergence, which requires real thermodynamic components (AC #4).
#[test]
fn test_newton_raphson_reduces_residuals_for_mimo() {
fn test_mimo_jacobian_structure_and_bounds() {
let mut sys = build_two_component_cycle();
// Define two constraints
@@ -744,7 +747,13 @@ fn test_newton_raphson_reduces_residuals_for_mimo() {
// Compute initial residuals
let state_len = sys.state_vector_len();
let initial_state = vec![300000.0f64, 400000.0, 300000.0, 400000.0]; // Non-zero P, h values
let mut initial_state = vec![300000.0f64; state_len]; // Non-zero P, h values sized to full state vector
if state_len > 1 {
initial_state[1] = 400000.0;
}
if state_len > 3 {
initial_state[3] = 400000.0;
}
let mut control_values = vec![0.7_f64, 0.5_f64];
// Extract initial constraint values and compute residuals
@@ -828,3 +837,297 @@ fn test_newton_raphson_reduces_residuals_for_mimo() {
"Newton step applied for MIMO control"
);
}
/// Verifies that the 2x2 MIMO Jacobian block is fully dense — every (i,j) entry
/// is non-zero, confirming cross-coupling between all constraint/control pairs.
#[test]
fn test_2x2_jacobian_block_is_fully_dense() {
let mut sys = build_two_component_cycle();
sys.add_constraint(Constraint::new(
ConstraintId::new("capacity"),
ComponentOutput::Capacity {
component_id: "evaporator".to_string(),
},
5000.0,
))
.unwrap();
sys.add_constraint(Constraint::new(
ConstraintId::new("superheat"),
ComponentOutput::Superheat {
component_id: "evaporator".to_string(),
},
5.0,
))
.unwrap();
let bv1 = BoundedVariable::new(
BoundedVariableId::new("compressor_speed"),
50.0,
20.0,
80.0,
)
.unwrap();
let bv2 = BoundedVariable::new(
BoundedVariableId::new("valve_opening"),
0.5,
0.1,
1.0,
)
.unwrap();
sys.add_bounded_variable(bv1).unwrap();
sys.add_bounded_variable(bv2).unwrap();
sys.link_constraint_to_control(
&ConstraintId::new("capacity"),
&BoundedVariableId::new("compressor_speed"),
)
.unwrap();
sys.link_constraint_to_control(
&ConstraintId::new("superheat"),
&BoundedVariableId::new("valve_opening"),
)
.unwrap();
let state_len = sys.state_vector_len();
let state = vec![300000.0f64; state_len];
let control_values = vec![0.7_f64, 0.5_f64];
let row_offset = 0;
let jac = sys.compute_inverse_control_jacobian(&state, row_offset, &control_values);
// For a 2x2 MIMO system, we expect entries for all (i,j) pairs in the control block
let control_offset = sys.state_vector_len();
let mut found = [[false; 2]; 2];
for &(row, col, val) in &jac {
if col >= control_offset {
let i = row - row_offset;
let j = col - control_offset;
if i < 2 && j < 2 && val.abs() > 1e-10 {
found[i][j] = true;
}
}
}
for i in 0..2 {
for j in 0..2 {
assert!(
found[i][j],
"Jacobian entry ({},{}) is missing or zero — expected dense block",
i,
j
);
}
}
}
/// Verifies that the 3x3 MIMO Jacobian block is fully dense for all 9 entries.
#[test]
fn test_3x3_jacobian_block_is_fully_dense() {
let mut sys = build_three_component_system();
sys.add_constraint(Constraint::new(
ConstraintId::new("capacity"),
ComponentOutput::Capacity {
component_id: "evaporator".to_string(),
},
5000.0,
))
.unwrap();
sys.add_constraint(Constraint::new(
ConstraintId::new("superheat"),
ComponentOutput::Superheat {
component_id: "evaporator".to_string(),
},
5.0,
))
.unwrap();
sys.add_constraint(Constraint::new(
ConstraintId::new("pressure"),
ComponentOutput::Pressure {
component_id: "condenser".to_string(),
},
2000000.0,
))
.unwrap();
let bv1 = BoundedVariable::new(
BoundedVariableId::new("compressor_speed"),
50.0,
20.0,
80.0,
)
.unwrap();
let bv2 = BoundedVariable::new(
BoundedVariableId::new("valve_opening"),
0.5,
0.1,
1.0,
)
.unwrap();
let bv3 = BoundedVariable::new(
BoundedVariableId::new("fan_speed"),
0.8,
0.2,
1.0,
)
.unwrap();
sys.add_bounded_variable(bv1).unwrap();
sys.add_bounded_variable(bv2).unwrap();
sys.add_bounded_variable(bv3).unwrap();
sys.link_constraint_to_control(
&ConstraintId::new("capacity"),
&BoundedVariableId::new("compressor_speed"),
)
.unwrap();
sys.link_constraint_to_control(
&ConstraintId::new("superheat"),
&BoundedVariableId::new("valve_opening"),
)
.unwrap();
sys.link_constraint_to_control(
&ConstraintId::new("pressure"),
&BoundedVariableId::new("fan_speed"),
)
.unwrap();
let state_len = sys.state_vector_len();
let state = vec![300000.0f64; state_len];
let control_values = vec![0.7_f64, 0.5_f64, 0.8_f64];
let row_offset = 0;
let jac = sys.compute_inverse_control_jacobian(&state, row_offset, &control_values);
let control_offset = sys.state_vector_len();
let mut found = [[false; 3]; 3];
for &(row, col, val) in &jac {
if col >= control_offset {
let i = row - row_offset;
let j = col - control_offset;
if i < 3 && j < 3 && val.abs() > 1e-10 {
found[i][j] = true;
}
}
}
for i in 0..3 {
for j in 0..3 {
assert!(
found[i][j],
"3x3 Jacobian entry ({},{}) is missing or zero — expected dense block",
i,
j
);
}
}
}
/// Verifies that the MIMO Jacobian cross-derivatives are consistent:
/// perturbing control j affects constraint i in a predictable direction.
#[test]
fn test_mimo_cross_derivatives_have_consistent_signs() {
let mut sys = build_two_component_cycle();
sys.add_constraint(Constraint::new(
ConstraintId::new("capacity"),
ComponentOutput::Capacity {
component_id: "evaporator".to_string(),
},
5000.0,
))
.unwrap();
sys.add_constraint(Constraint::new(
ConstraintId::new("superheat"),
ComponentOutput::Superheat {
component_id: "evaporator".to_string(),
},
5.0,
))
.unwrap();
let bv1 = BoundedVariable::new(
BoundedVariableId::new("compressor_speed"),
50.0,
20.0,
80.0,
)
.unwrap();
let bv2 = BoundedVariable::new(
BoundedVariableId::new("valve_opening"),
0.5,
0.1,
1.0,
)
.unwrap();
sys.add_bounded_variable(bv1).unwrap();
sys.add_bounded_variable(bv2).unwrap();
sys.link_constraint_to_control(
&ConstraintId::new("capacity"),
&BoundedVariableId::new("compressor_speed"),
)
.unwrap();
sys.link_constraint_to_control(
&ConstraintId::new("superheat"),
&BoundedVariableId::new("valve_opening"),
)
.unwrap();
let state_len = sys.state_vector_len();
let state = vec![300000.0f64; state_len];
let control_values = vec![0.7_f64, 0.5_f64];
let jac = sys.compute_inverse_control_jacobian(&state, 0, &control_values);
// Collect all derivatives as (row, col, value)
let control_offset = sys.state_vector_len();
let entries: Vec<(usize, usize, f64)> = jac
.into_iter()
.filter(|&(_, col, _)| col >= control_offset)
.map(|(r, c, v)| (r, c - control_offset, v))
.collect();
// All derivatives should be finite
for &(i, j, v) in &entries {
assert!(
v.is_finite(),
"Jacobian entry (constraint={}, control={}) is not finite: {}",
i,
j,
v
);
}
// Diagonal entries should exist and be non-zero (structural check for mock components)
let diagonal: Vec<f64> = entries
.iter()
.filter(|&&(r, c, _)| r == c)
.map(|&(_, _, v)| v.abs())
.collect();
let off_diagonal: Vec<f64> = entries
.iter()
.filter(|&&(r, c, _)| r != c)
.map(|&(_, _, v)| v.abs())
.collect();
assert!(
!diagonal.is_empty(),
"Should have diagonal Jacobian entries"
);
assert!(
!off_diagonal.is_empty(),
"Should have off-diagonal (cross-coupling) Jacobian entries"
);
// Note: diagonal dominance is a physical property not guaranteed by mock components.
}
/// Helper: builds a three-component system for 3x3 MIMO testing.
fn build_three_component_system() -> System {
let mut sys = System::new();
let comp = sys.add_component(mock(2)); // compressor
let evap = sys.add_component(mock(2)); // evaporator
let cond = sys.add_component(mock(2)); // condenser
sys.add_edge(comp, evap).unwrap();
sys.add_edge(evap, cond).unwrap();
sys.add_edge(cond, comp).unwrap();
sys.register_component_name("compressor", comp);
sys.register_component_name("evaporator", evap);
sys.register_component_name("condenser", cond);
sys.finalize().unwrap();
sys
}

View File

@@ -195,8 +195,9 @@ fn test_real_cycle_inverse_control_integration() {
// Evaluate constraints
let measured = sys.extract_constraint_values_with_controls(&state, &control_values);
let count = sys.compute_constraint_residuals(&state, &mut residuals[state_len..], &measured);
let count = sys.compute_constraint_residuals(&state, &mut residuals[state_len..], &measured)
.expect("constraint residuals should compute");
assert_eq!(count, 2, "Should have computed 2 constraint residuals");
// Evaluate jacobian

View File

@@ -2,114 +2,372 @@
//!
//! Tests cover:
//! - Round-trip serialization (system → JSON → system)
//! - Topology preservation (nodes, edges, component types)
//! - Constraint and bounded variable preservation
//! - Thermal coupling preservation
//! - Version compatibility checks
//! - Backend validation
//! - File save/load round-trip
//! - Human-readable JSON format
use entropyk_components::{Compressor, FluidId, Port};
use entropyk_core::{Enthalpy, Pressure};
use entropyk_solver::System;
use entropyk_core::{CircuitId, Enthalpy, Pressure, ThermalConductance};
use entropyk_solver::{System, ThermalCoupling};
use serde_json::{json, Value};
#[test]
fn test_simple_system_round_trip() {
// Create a simple system with one component
/// Helper: create a minimal system with a single compressor component.
fn build_single_compressor_system() -> System {
let mut system = System::new();
// Create compressor with Ahri540 coefficients
let coefficients = entropyk_components::Ahri540Coefficients::new(
0.85, // m1
2.5, // m2
500.0, // m3
1500.0, // m4
-2.5, // m5
1.8, // m6
600.0, // m7
1600.0, // m8
-3.0, // m9
2.0, // m10
0.85, 2.5, 500.0, 1500.0, -2.5, 1.8, 600.0, 1600.0, -3.0, 2.0,
);
// Create disconnected ports
let port_suction = Port::new(
FluidId::new("R134a"),
Pressure::from_bar(2.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let port_discharge = Port::new(
FluidId::new("R134a"),
Pressure::from_bar(10.0),
Enthalpy::from_joules_per_kg(450000.0),
);
// Create disconnected compressor
let disconnected_compressor = Compressor::new(
let disconnected = Compressor::new(
coefficients,
port_suction,
port_discharge,
2900.0, // speed_rpm
0.0001, // displacement_m3_per_rev
0.85, // mechanical_efficiency
).expect("Failed to create compressor");
2900.0,
0.0001,
0.85,
)
.expect("Failed to create compressor");
// Connect the ports (this converts to Compressor<Connected>)
let suction_port = Port::new(
let connected = disconnected
.connect(
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(2.0),
Enthalpy::from_joules_per_kg(400000.0),
),
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(10.0),
Enthalpy::from_joules_per_kg(450000.0),
),
)
.expect("Failed to connect compressor");
let node = system.add_component(Box::new(connected));
system.register_component_name("compressor", node);
system
}
/// Helper: create a system with two components and an edge between them,
/// plus a thermal coupling.
fn build_two_component_system() -> System {
let mut system = System::new();
let coefficients = entropyk_components::Ahri540Coefficients::new(
0.85, 2.5, 500.0, 1500.0, -2.5, 1.8, 600.0, 1600.0, -3.0, 2.0,
);
// Create compressor
let port_s = Port::new(
FluidId::new("R134a"),
Pressure::from_bar(2.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let discharge_port = Port::new(
let port_d = Port::new(
FluidId::new("R134a"),
Pressure::from_bar(10.0),
Enthalpy::from_joules_per_kg(450000.0),
);
let comp = Compressor::new(coefficients, port_s, port_d, 2900.0, 0.0001, 0.85)
.expect("create compressor")
.connect(
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(2.0),
Enthalpy::from_joules_per_kg(400000.0),
),
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(10.0),
Enthalpy::from_joules_per_kg(450000.0),
),
)
.expect("connect compressor");
let connected_compressor = disconnected_compressor
.connect(suction_port, discharge_port)
.expect("Failed to connect compressor");
let node_comp = system.add_component(Box::new(comp));
system.register_component_name("compressor", node_comp);
// Add to system as Box<dyn Component>
system.add_component(Box::new(connected_compressor));
// Create a second compressor (acting as condenser proxy)
let coefficients2 = entropyk_components::Ahri540Coefficients::new(
0.9, 3.0, 600.0, 1400.0, -1.5, 2.0, 700.0, 1700.0, -2.0, 1.5,
);
let port_s2 = Port::new(
FluidId::new("R134a"),
Pressure::from_bar(10.0),
Enthalpy::from_joules_per_kg(450000.0),
);
let port_d2 = Port::new(
FluidId::new("R134a"),
Pressure::from_bar(8.0),
Enthalpy::from_joules_per_kg(420000.0),
);
let comp2 = Compressor::new(coefficients2, port_s2, port_d2, 2900.0, 0.00012, 0.88)
.expect("create comp2")
.connect(
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(10.0),
Enthalpy::from_joules_per_kg(450000.0),
),
Port::new(
FluidId::new("R134a"),
Pressure::from_bar(8.0),
Enthalpy::from_joules_per_kg(420000.0),
),
)
.expect("connect comp2");
// Test to_json_string and from_json_string
let node_comp2 = system.add_component(Box::new(comp2));
system.register_component_name("condenser", node_comp2);
// Add edge between them
system.add_edge(node_comp, node_comp2).expect("add edge");
// Add thermal coupling
let coupling = ThermalCoupling::new(
CircuitId(0),
CircuitId(0),
ThermalConductance::from_watts_per_kelvin(500.0),
);
let _ = system.add_thermal_coupling(coupling);
system
}
// ────────────────────────────────────────────────────────────────────────
// Test 1: Topology round-trip
// ────────────────────────────────────────────────────────────────────────
#[test]
fn test_topology_round_trip() {
let original = build_two_component_system();
let json_str = original.to_json_string().expect("Serialization failed");
let restored = System::from_json_string(&json_str).expect("Deserialization failed");
// Verify topology is identical
assert_eq!(
original.node_count(),
restored.node_count(),
"Node count mismatch"
);
assert_eq!(
original.edge_count(),
restored.edge_count(),
"Edge count mismatch"
);
assert_eq!(
original.thermal_coupling_count(),
restored.thermal_coupling_count(),
"Thermal coupling count mismatch"
);
// Verify component names are preserved (order-independent since deserialization sorts keys)
let mut original_names: Vec<&str> = original.registered_component_names().collect();
let mut restored_names: Vec<&str> = restored.registered_component_names().collect();
original_names.sort();
restored_names.sort();
assert_eq!(original_names, restored_names, "Component names mismatch");
// Verify component types via the JSON snapshot
let parsed: Value = serde_json::from_str(&json_str).expect("JSON parse");
let params = parsed.get("parameters").expect("parameters field");
assert!(params.get("compressor").is_some(), "compressor in params");
assert!(params.get("condenser").is_some(), "condenser in params");
}
// ────────────────────────────────────────────────────────────────────────
// Test 3: Constraints preservation
// ────────────────────────────────────────────────────────────────────────
#[test]
fn test_constraints_preserved_in_round_trip() {
use entropyk_solver::inverse::{ComponentOutput, Constraint, ConstraintId};
let mut system = build_single_compressor_system();
// Add a constraint referencing the compressor
let constraint = Constraint::new(
ConstraintId::new("superheat_ctrl"),
ComponentOutput::Superheat {
component_id: "compressor".to_string(),
},
5.0,
);
system.add_constraint(constraint).expect("add constraint");
assert_eq!(system.constraint_count(), 1);
// Serialize
let json_str = system.to_json_string().expect("Serialization failed");
// Verify JSON is valid and human-readable
let parsed: Value = serde_json::from_str(&json_str).expect("JSON parsing failed");
assert!(parsed.is_object());
assert!(parsed.get("version").is_some());
assert_eq!(parsed["version"], "1.0");
// Verify constraints are in the JSON
let parsed: Value = serde_json::from_str(&json_str).expect("JSON parse");
let constraints = parsed.get("constraints").expect("constraints field");
assert!(constraints.is_array());
assert_eq!(constraints.as_array().unwrap().len(), 1);
// Deserialize
let restored_system = System::from_json_string(&json_str).expect("Deserialization failed");
let c = &constraints.as_array().unwrap()[0];
assert_eq!(c["id"], "superheat_ctrl");
assert_eq!(c["component"], "compressor");
assert_eq!(c["target"], 5.0);
// Verify the system is reconstructed
// (Full component reconstruction will be implemented in future tasks)
assert!(true);
// Verify the constraint snapshot round-trips through serde
let snapshot: entropyk_solver::SystemSnapshot =
serde_json::from_str(&json_str).expect("snapshot parse");
assert_eq!(snapshot.constraints.len(), 1);
assert_eq!(snapshot.constraints[0].id, "superheat_ctrl");
assert_eq!(snapshot.constraints[0].component, "compressor");
assert!((snapshot.constraints[0].target - 5.0).abs() < 1e-12);
}
// ────────────────────────────────────────────────────────────────────────
// Test 4: Thermal couplings preservation
// ────────────────────────────────────────────────────────────────────────
#[test]
fn test_thermal_couplings_preserved_in_round_trip() {
let original = build_two_component_system();
let json_str = original.to_json_string().expect("Serialization failed");
// Verify thermal couplings in JSON
let parsed: Value = serde_json::from_str(&json_str).expect("JSON parse");
let couplings = parsed
.get("topology")
.and_then(|t| t.get("thermalCouplings"))
.expect("thermal couplings in topology");
assert!(couplings.is_array());
assert_eq!(couplings.as_array().unwrap().len(), 1);
let c = &couplings.as_array().unwrap()[0];
assert_eq!(c["hotCircuit"], 0);
assert_eq!(c["coldCircuit"], 0);
// Verify the snapshot round-trips
let snapshot: entropyk_solver::SystemSnapshot =
serde_json::from_str(&json_str).expect("snapshot parse");
assert_eq!(snapshot.topology.thermal_couplings.len(), 1);
assert_eq!(snapshot.topology.thermal_couplings[0].hot_circuit, CircuitId(0));
assert_eq!(snapshot.topology.thermal_couplings[0].cold_circuit, CircuitId(0));
// Verify ua value round-trip
let ua_val = snapshot.topology.thermal_couplings[0].ua.to_watts_per_kelvin();
assert!((ua_val - 500.0).abs() < 1e-6, "UA value mismatch: {}", ua_val);
}
// ────────────────────────────────────────────────────────────────────────
// Test 5: File save/load round-trip
// ────────────────────────────────────────────────────────────────────────
#[test]
fn test_file_save_and_load() {
let system = build_two_component_system();
let temp_dir = std::env::temp_dir();
let file_path = temp_dir.join("entropyk_test_round_trip.json");
// Save
system.save_json(&file_path).expect("Save failed");
assert!(file_path.exists());
// Load
let loaded = System::load_json(&file_path).expect("Load failed");
// Verify topology matches
assert_eq!(system.node_count(), loaded.node_count());
assert_eq!(system.edge_count(), loaded.edge_count());
assert_eq!(
system.thermal_coupling_count(),
loaded.thermal_coupling_count()
);
// Clean up
std::fs::remove_file(&file_path).ok();
}
// ────────────────────────────────────────────────────────────────────────
// Test 6: Missing backend error
// ────────────────────────────────────────────────────────────────────────
#[test]
fn test_missing_backend_returns_error() {
// AC5: missing backend must produce an explicit error
let json_with_unknown_backend = json!({
"version": "1.0",
"topology": {
"edges": [],
"thermalCouplings": []
},
"parameters": {},
"fluidBackend": {
"name": "NonExistentBackend",
"version": "99.0.0"
}
})
.to_string();
let result = System::from_json_string(&json_with_unknown_backend);
assert!(result.is_err(), "Should fail with BackendUnavailable for unknown backend");
}
// ────────────────────────────────────────────────────────────────────────
// Test 7: Version mismatch error
// ────────────────────────────────────────────────────────────────────────
#[test]
fn test_version_mismatch() {
let json_with_wrong_version = json!({
"version": "999.0", // Incompatible version
"version": "99.0",
"topology": {
"edges": [],
"thermal_couplings": []
"thermalCouplings": []
},
"parameters": {},
"fluid_backend": {
"fluidBackend": {
"name": "TestBackend",
"version": "1.0.0",
"hash": "abc123"
}
}).to_string();
})
.to_string();
let result = System::from_json_string(&json_with_wrong_version);
assert!(result.is_err());
// Just verify it's an error - don't try to unwrap
assert!(true);
assert!(result.is_err(), "Should fail with version mismatch");
}
// ────────────────────────────────────────────────────────────────────────
// Additional: JSON human-readable and deterministic
// ────────────────────────────────────────────────────────────────────────
#[test]
fn test_simple_system_round_trip() {
let system = build_single_compressor_system();
let json_str = system.to_json_string().expect("Serialization failed");
let parsed: Value = serde_json::from_str(&json_str).expect("JSON parsing failed");
assert!(parsed.is_object());
assert_eq!(parsed["version"], "1.0");
// Single isolated component should fail finalize during deserialization
let result = System::from_json_string(&json_str);
assert!(result.is_err(), "Isolated node should fail deserialization");
}
#[test]
@@ -117,43 +375,60 @@ fn test_json_is_human_readable() {
let system = System::new();
let json_str = system.to_json_string().expect("Serialization failed");
// Check that JSON is pretty-printed (contains newlines and indentation)
assert!(json_str.contains('\n'));
assert!(json_str.contains(" ")); // Indentation
assert!(json_str.contains(" "));
// Verify it's valid JSON
let _: Value = serde_json::from_str(&json_str).expect("Should be valid JSON");
}
#[test]
fn test_deterministic_serialization() {
let system = System::new();
// Note: HashMap-based fields (parameters, ComponentParams) may produce
// different key ordering across serializations, so we compare parsed
// JSON values rather than raw strings.
let system = build_single_compressor_system();
let json1 = system.to_json_string().expect("Serialization failed");
let json2 = system.to_json_string().expect("Serialization failed");
// Same system should produce same JSON
assert_eq!(json1, json2);
let val1: Value = serde_json::from_str(&json1).expect("parse json1");
let val2: Value = serde_json::from_str(&json2).expect("parse json2");
assert_eq!(val1, val2, "Same system should produce identical JSON (structurally)");
}
// ────────────────────────────────────────────────────────────────────────
// Test: Bounded variables in snapshot
// ────────────────────────────────────────────────────────────────────────
#[test]
fn test_file_save_and_load() {
let system = System::new();
let temp_dir = std::env::temp_dir();
let file_path = temp_dir.join("test_system.json");
fn test_bounded_variables_in_snapshot() {
use entropyk_solver::inverse::{BoundedVariable, BoundedVariableId};
// Save to file
system.save_json(&file_path).expect("Save failed");
let mut system = build_single_compressor_system();
// Verify file exists
assert!(file_path.exists());
let valve =
BoundedVariable::with_component(BoundedVariableId::new("valve"), "compressor", 0.5, 0.0, 1.0)
.expect("create bounded var");
system.add_bounded_variable(valve).expect("add bounded var");
// Load from file
let _loaded_system = System::load_json(&file_path).expect("Load failed");
let json_str = system.to_json_string().expect("Serialization failed");
let parsed: Value = serde_json::from_str(&json_str).expect("JSON parse");
// Clean up
std::fs::remove_file(&file_path).ok();
let bounded = parsed.get("boundedVariables").expect("boundedVariables field");
assert!(bounded.is_array());
assert_eq!(bounded.as_array().unwrap().len(), 1);
// Verify system is reconstructed
assert!(true);
let bv = &bounded.as_array().unwrap()[0];
assert_eq!(bv["id"], "valve");
assert_eq!(bv["component"], "compressor");
assert!((bv["initialValue"].as_f64().unwrap() - 0.5).abs() < 1e-12);
// Verify snapshot round-trip
let snapshot: entropyk_solver::SystemSnapshot =
serde_json::from_str(&json_str).expect("snapshot parse");
assert_eq!(snapshot.bounded_variables.len(), 1);
assert_eq!(snapshot.bounded_variables[0].id, "valve");
assert_eq!(snapshot.bounded_variables[0].component, "compressor");
assert!((snapshot.bounded_variables[0].initial_value - 0.5).abs() < 1e-12);
assert!((snapshot.bounded_variables[0].lower_bound - 0.0).abs() < 1e-12);
assert!((snapshot.bounded_variables[0].upper_bound - 1.0).abs() < 1e-12);
}