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,192 @@
//! End-to-end saturated PI control integration test.
//!
//! The loop is co-solved with the emergent-pressure refrigeration cycle: the
//! saturated controller contributes `(u, x)` unknowns, wires compressor `f_m`
//! through `CalibIndices`, and measures real evaporator capacity from component
//! thermodynamics.
#![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, ConstraintId, SaturatedController,
Saturation,
};
use entropyk_solver::solver::Solver;
use entropyk_solver::system::System;
use entropyk_solver::{FallbackSolver, NewtonConfig};
const N_BASE: usize = 9;
fn build_system(controller: Option<SaturatedController>) -> System {
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();
system.add_edge(n_cond, n_exv).unwrap();
system.add_edge(n_exv, n_evap).unwrap();
system.add_edge(n_evap, n_comp).unwrap();
if let Some(ctrl) = controller {
let bv = BoundedVariable::with_component(
BoundedVariableId::new("compressor_f_m"),
"compressor",
1.0,
ctrl.u_min(),
ctrl.u_max(),
)
.unwrap();
system.add_bounded_variable(bv).unwrap();
system.add_saturated_controller(ctrl);
}
system.finalize().unwrap();
system
}
fn seed_state(system: &System) -> Vec<f64> {
let mut initial_state = vec![
0.05, 11.6e5, 445e3, 11.6e5, 262e3, 3.50e5, 262e3, 3.50e5, 405e3,
];
debug_assert_eq!(initial_state.len(), N_BASE);
while initial_state.len() < system.full_state_vector_len() {
initial_state.push(if initial_state.len() == N_BASE {
1.0
} else {
0.0
});
}
initial_state
}
fn solve_capacity(controller: Option<SaturatedController>) -> (f64, f64, f64, f64) {
let mut system = build_system(controller);
let initial_state = seed_state(&system);
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!("saturated capacity solve must converge: {e:?}"));
let state = &converged.state;
let q_evap = state[0] * (state[8] - state[6]);
let u = if system.saturated_controller_count() > 0 {
state[N_BASE]
} else {
1.0
};
let x = if system.saturated_controller_count() > 0 {
state[N_BASE + 1]
} else {
0.0
};
(state[0], q_evap, u, x)
}
fn capacity_controller(setpoint: f64, u_min: f64, u_max: f64) -> SaturatedController {
SaturatedController::new(
ConstraintId::new("capacity_sat_loop"),
ComponentOutput::Capacity {
component_id: "evaporator".to_string(),
},
BoundedVariableId::new("compressor_f_m"),
setpoint,
u_min,
u_max,
)
.unwrap()
.with_gain(1.0e-2)
.unwrap()
.with_band(1.0)
.unwrap()
.with_saturation(Saturation::Hard)
}
#[test]
fn saturated_lwt_control_tracks_when_unsaturated() {
let (_m_nom, q_nom, _, _) = solve_capacity(None);
assert!(q_nom > 0.0);
let (_m, q, u, x) = solve_capacity(Some(capacity_controller(q_nom, 0.5, 1.5)));
assert!(
(q - q_nom).abs() < 0.03 * q_nom,
"wide saturated loop should track nominal capacity: got {q:.1} W, target {q_nom:.1} W"
);
assert!(
(0.5..=1.5).contains(&u) && x.abs() < 0.25,
"controller should remain unsaturated: u={u:.4}, x={x:.4}"
);
}
#[test]
fn saturated_lwt_control_pins_actuator_when_saturated() {
let (_m_nom, q_nom, _, _) = solve_capacity(None);
let target = 1.30 * q_nom;
let (_m, q, u, x) = solve_capacity(Some(capacity_controller(target, 0.75, 1.0)));
assert!(
(u - 1.0).abs() < 2.0e-3,
"tight loop should pin compressor f_m at upper bound: u={u:.6}"
);
assert!(
x > 1.0,
"anti-windup state should move beyond the saturation band: x={x:.4}"
);
assert!(
(q - target).abs() > 0.10 * q_nom,
"tracking error should be released at saturation: q={q:.1} W, target={target:.1} W"
);
}