Files
Entropyk/crates/solver/tests/capacity_control_integration.rs
sepehr 3358b74342 Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
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>
2026-07-17 22:46:46 +02:00

224 lines
8.1 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.
//! 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
);
}