Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack. Co-authored-by: Cursor <cursoragent@cursor.com>
224 lines
8.1 KiB
Rust
224 lines
8.1 KiB
Rust
//! End-to-end **closed-loop capacity control** integration test.
|
||
//!
|
||
//! This exercises the design/control vertical slice built on top of the
|
||
//! emergent-pressure cycle:
|
||
//!
|
||
//! * The evaporator cooling capacity is measured with REAL thermodynamics
|
||
//! (`Component::measure_output(Capacity, …)` → `energy_transfers`), NOT the
|
||
//! legacy placeholder formula.
|
||
//! * The compressor exposes a genuine actuator: the inverse-control variable
|
||
//! `f_m` scales the swept mass flow in its residual `r0 = ṁ − f_m·ṁ_calc`
|
||
//! and emits the matching Jacobian column `∂r0/∂f_m = −ṁ_calc`.
|
||
//!
|
||
//! A `Capacity` constraint on the evaporator is linked to an `f_m`
|
||
//! `BoundedVariable` on the compressor. The solver must therefore find the
|
||
//! compressor loading that makes the emergent cooling capacity meet the target —
|
||
//! this is the core "design a machine to a duty" loop, with no bricolage.
|
||
//!
|
||
//! Requires the `coolprop` feature (entropy + saturation properties):
|
||
//! cargo test -p entropyk-solver --features coolprop --test capacity_control_integration
|
||
#![cfg(feature = "coolprop")]
|
||
|
||
use std::sync::Arc;
|
||
|
||
use entropyk_components::isentropic_compressor::VolumetricEfficiency;
|
||
use entropyk_components::{Condenser, Evaporator, IsenthalpicExpansionValve, IsentropicCompressor};
|
||
use entropyk_fluids::{CoolPropBackend, FluidBackend};
|
||
use entropyk_solver::inverse::{
|
||
BoundedVariable, BoundedVariableId, ComponentOutput, Constraint, ConstraintId,
|
||
};
|
||
use entropyk_solver::solver::Solver;
|
||
use entropyk_solver::system::System;
|
||
use entropyk_solver::{FallbackSolver, NewtonConfig};
|
||
|
||
/// Base emergent-cycle state layout (9 unknowns, same-branch series loop):
|
||
/// `[ṁ, P0,h0, P1,h1, P2,h2, P3,h3]`.
|
||
const N_BASE: usize = 9;
|
||
|
||
/// Assembles the emergent cycle. When `capacity_target` is `Some(w)`, a
|
||
/// `Capacity` constraint on the evaporator is linked to an `f_m` actuator on the
|
||
/// compressor (closed-loop capacity control). Returns `(ṁ, q_evap, f_m)`.
|
||
fn solve(capacity_target: Option<f64>) -> (f64, f64, f64) {
|
||
let backend: Arc<dyn FluidBackend> = Arc::new(CoolPropBackend::new());
|
||
let fluid = "R134a";
|
||
|
||
let comp = Box::new(
|
||
IsentropicCompressor::new(0.70, 318.15, 278.15, 5.0)
|
||
.with_refrigerant(fluid)
|
||
.with_fluid_backend(backend.clone())
|
||
.with_displacement(6.5e-5, 50.0, VolumetricEfficiency::Constant(0.92)),
|
||
);
|
||
let cond = Box::new(
|
||
Condenser::new(766.0)
|
||
.with_refrigerant(fluid)
|
||
.with_fluid_backend(backend.clone())
|
||
.with_secondary_stream(303.15, 1500.0)
|
||
.with_emergent_pressure(5.0),
|
||
);
|
||
let exv = Box::new(
|
||
IsenthalpicExpansionValve::new(278.15)
|
||
.with_refrigerant(fluid)
|
||
.with_fluid_backend(backend.clone())
|
||
.with_emergent_pressure(),
|
||
);
|
||
let evap = Box::new(
|
||
Evaporator::new(1468.0)
|
||
.with_refrigerant(fluid)
|
||
.with_fluid_backend(backend.clone())
|
||
.with_secondary_stream(285.15, 2000.0)
|
||
.with_emergent_pressure(),
|
||
);
|
||
|
||
let mut system = System::new();
|
||
let n_comp = system.add_component(comp);
|
||
let n_cond = system.add_component(cond);
|
||
let n_exv = system.add_component(exv);
|
||
let n_evap = system.add_component(evap);
|
||
|
||
system.register_component_name("compressor", n_comp);
|
||
system.register_component_name("evaporator", n_evap);
|
||
|
||
system.add_edge(n_comp, n_cond).unwrap(); // E0 comp→cond
|
||
system.add_edge(n_cond, n_exv).unwrap(); // E1 cond→exv
|
||
system.add_edge(n_exv, n_evap).unwrap(); // E2 exv→evap
|
||
system.add_edge(n_evap, n_comp).unwrap(); // E3 evap→comp
|
||
|
||
if let Some(target_w) = capacity_target {
|
||
// Constraint: evaporator cooling capacity = target (real ε-NTU duty).
|
||
system
|
||
.add_constraint(Constraint::new(
|
||
ConstraintId::new("capacity_control"),
|
||
ComponentOutput::Capacity {
|
||
component_id: "evaporator".to_string(),
|
||
},
|
||
target_w,
|
||
))
|
||
.unwrap();
|
||
|
||
// Actuator: compressor mass-flow multiplier f_m ∈ [0.5, 2.0]. The `f_m`
|
||
// suffix wires it into the compressor's CalibIndices during finalize().
|
||
let bv = BoundedVariable::with_component(
|
||
BoundedVariableId::new("compressor_f_m"),
|
||
"compressor",
|
||
1.0,
|
||
0.5,
|
||
2.0,
|
||
)
|
||
.unwrap();
|
||
system.add_bounded_variable(bv).unwrap();
|
||
|
||
system
|
||
.link_constraint_to_control(
|
||
&ConstraintId::new("capacity_control"),
|
||
&BoundedVariableId::new("compressor_f_m"),
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
system.finalize().unwrap();
|
||
|
||
// Physically-consistent seed near the expected operating point.
|
||
let mut initial_state = vec![
|
||
0.05, // ṁ [kg/s]
|
||
11.6e5, 445e3, // E0 comp→cond : P_cond, h_dis
|
||
11.6e5, 262e3, // E1 cond→exv : P_cond, h_liq
|
||
3.50e5, 262e3, // E2 exv→evap : P_evap, h (isenthalpic)
|
||
3.50e5, 405e3, // E3 evap→comp : P_evap, h_suction (superheated)
|
||
];
|
||
debug_assert_eq!(initial_state.len(), N_BASE);
|
||
// Append control / coupling slots (f_m seeded at its nominal 1.0, rest 0).
|
||
let n_full = system.full_state_vector_len();
|
||
while initial_state.len() < n_full {
|
||
initial_state.push(if initial_state.len() == N_BASE {
|
||
1.0
|
||
} else {
|
||
0.0
|
||
});
|
||
}
|
||
|
||
let config = NewtonConfig {
|
||
max_iterations: 300,
|
||
tolerance: 1e-6,
|
||
line_search: true,
|
||
use_numerical_jacobian: false,
|
||
initial_state: Some(initial_state.clone()),
|
||
..NewtonConfig::default()
|
||
};
|
||
|
||
let mut solver = FallbackSolver::default_solver()
|
||
.with_newton_config(config)
|
||
.with_initial_state(initial_state);
|
||
|
||
let converged = solver
|
||
.solve(&mut system)
|
||
.unwrap_or_else(|e| panic!("solve(target={:?}) must converge: {:?}", capacity_target, e));
|
||
|
||
let sv = &converged.state;
|
||
let m_dot = sv[0];
|
||
let h_evap_in = sv[6];
|
||
let h_evap_out = sv[8];
|
||
let q_evap = m_dot * (h_evap_out - h_evap_in);
|
||
// f_m lives at total_state_len + 0 (first/only linked control) when present.
|
||
let f_m = if capacity_target.is_some() {
|
||
sv[N_BASE]
|
||
} else {
|
||
1.0
|
||
};
|
||
|
||
(m_dot, q_evap, f_m)
|
||
}
|
||
|
||
/// The compressor `f_m` actuator must genuinely drive the emergent cooling
|
||
/// capacity to a commanded target: a higher capacity target must be met by a
|
||
/// higher solved mass flow AND a higher compressor loading `f_m`.
|
||
#[test]
|
||
fn test_capacity_target_drives_compressor_loading() {
|
||
// 1. Nominal (uncontrolled, f_m = 1) capacity of this machine.
|
||
let (m_nom, q_nom, _) = solve(None);
|
||
assert!(q_nom > 0.0, "nominal capacity must be positive: {}", q_nom);
|
||
assert!(m_nom > 0.0, "nominal mass flow must be positive: {}", m_nom);
|
||
|
||
// 2. Two achievable targets bracketing the nominal point (within f_m range).
|
||
let target_low = 0.85 * q_nom;
|
||
let target_high = 1.15 * q_nom;
|
||
|
||
let (m_low, q_low, fm_low) = solve(Some(target_low));
|
||
let (m_high, q_high, fm_high) = solve(Some(target_high));
|
||
|
||
// The closed loop meets each commanded capacity (5 % tolerance).
|
||
assert!(
|
||
(q_low - target_low).abs() < 0.05 * target_low,
|
||
"low target not met: got {:.0} W, wanted {:.0} W",
|
||
q_low,
|
||
target_low
|
||
);
|
||
assert!(
|
||
(q_high - target_high).abs() < 0.05 * target_high,
|
||
"high target not met: got {:.0} W, wanted {:.0} W",
|
||
q_high,
|
||
target_high
|
||
);
|
||
|
||
// Higher duty ⇒ more mass flow AND more compressor loading — the actuator
|
||
// is doing real physical work, not being ignored.
|
||
assert!(
|
||
m_high > m_low,
|
||
"higher capacity must raise solved ṁ: {:.4} → {:.4} kg/s",
|
||
m_low,
|
||
m_high
|
||
);
|
||
assert!(
|
||
fm_high > fm_low,
|
||
"higher capacity must raise compressor loading z_flow: {:.3} → {:.3}",
|
||
fm_low,
|
||
fm_high
|
||
);
|
||
// f_m must stay within its declared bounds.
|
||
assert!(
|
||
(0.5..=2.0).contains(&fm_low) && (0.5..=2.0).contains(&fm_high),
|
||
"f_m out of bounds: {:.3}, {:.3}",
|
||
fm_low,
|
||
fm_high
|
||
);
|
||
}
|