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:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View 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
);
}