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>
This commit is contained in:
223
crates/solver/tests/capacity_control_integration.rs
Normal file
223
crates/solver/tests/capacity_control_integration.rs
Normal file
@@ -0,0 +1,223 @@
|
||||
//! 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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user