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,271 @@
//! DoF balance for a water-cooled chiller with FloodedEvaporator (4-port live secondary).
//!
//! This test is intentionally **topology + ledger only** (no Newton solve):
//! - builds the honest machine graph with named multi-port edges;
//! - finalizes and asserts `validate_system_dof()` (square system);
//! - does **not** require CoolProp (uses `TestBackend` for boundary enthalpy only).
//!
//! Budget target (CM1.4):
//! unknowns = 3 ṁ-branches + 2×8 edges = 19
//! equations = Comp2 + Cond4 + EXV1 + Flooded4 + 2×(Src3+Sink1) = 19
//!
//! Run:
//! cargo test -p entropyk-solver --test flooded_4port_dof
use std::sync::Arc;
use entropyk_components::brine_boundary::{BrineSink, BrineSource};
use entropyk_components::isentropic_compressor::VolumetricEfficiency;
use entropyk_components::port::{FluidId as PortFluidId, Port};
use entropyk_components::{
Component, Condenser, FloodedEvaporator, IsenthalpicExpansionValve, IsentropicCompressor,
};
use entropyk_core::{Concentration, Pressure, Temperature};
use entropyk_fluids::{FluidBackend, TestBackend};
use entropyk_solver::system::System;
use entropyk_solver::{EquationRole, SystemDofBalance, SystemDofError};
fn dummy_port(fluid: &str) -> entropyk_components::ConnectedPort {
let a = Port::new(
PortFluidId::new(fluid),
Pressure::from_bar(2.0),
entropyk_core::Enthalpy::from_joules_per_kg(50_000.0),
);
let b = Port::new(
PortFluidId::new(fluid),
Pressure::from_bar(2.0),
entropyk_core::Enthalpy::from_joules_per_kg(50_000.0),
);
a.connect(b).expect("dummy port pair").0
}
/// Assemble the flooded water-cooled topology and return a finalized system.
fn build_flooded_watercooled() -> System {
let backend: Arc<dyn FluidBackend> = Arc::new(TestBackend::new());
let ref_fluid = "R134a";
let water = "Water";
let comp = Box::new(
IsentropicCompressor::new(0.70, 313.15, 278.15, 5.0)
.with_refrigerant(ref_fluid)
.with_fluid_backend(backend.clone())
.with_displacement(5.0e-5, 50.0, VolumetricEfficiency::Constant(0.92)),
);
let cond = Box::new(
Condenser::new(2200.0)
.with_refrigerant(ref_fluid)
.with_secondary_fluid(water)
.with_fluid_backend(backend.clone())
.with_emergent_pressure(5.0),
);
let exv = Box::new(
IsenthalpicExpansionValve::new(278.15)
.with_refrigerant(ref_fluid)
.with_fluid_backend(backend.clone())
.with_emergent_pressure(),
);
// quality_control=false → saturated-vapor suction closure (default).
let evap = Box::new(
FloodedEvaporator::new(9000.0)
.with_refrigerant(ref_fluid)
.with_secondary_fluid(water)
.with_fluid_backend(backend.clone())
.with_quality_control(false),
);
// TestBackend Water P-T is only valid near 1 atm liquid — keep p ≤ 1.05 bar.
let p_water = Pressure::from_bar(1.0);
let cond_src = Box::new(
BrineSource::new(
water,
p_water,
Temperature::from_celsius(30.0),
Concentration::from_percent(0.0),
backend.clone(),
dummy_port(water),
)
.expect("cond BrineSource")
.with_imposed_mass_flow(0.45)
.expect("cond m_flow"),
);
let cond_sink = Box::new(
BrineSink::new(
water,
p_water,
None,
None,
backend.clone(),
dummy_port(water),
)
.expect("cond BrineSink"),
);
let evap_src = Box::new(
BrineSource::new(
water,
p_water,
Temperature::from_celsius(12.0),
Concentration::from_percent(0.0),
backend.clone(),
dummy_port(water),
)
.expect("evap BrineSource")
.with_imposed_mass_flow(0.55)
.expect("evap m_flow"),
);
let evap_sink = Box::new(
BrineSink::new(water, p_water, None, None, backend, dummy_port(water))
.expect("evap BrineSink"),
);
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);
let n_cwi = system.add_component(cond_src);
let n_cwo = system.add_component(cond_sink);
let n_ewi = system.add_component(evap_src);
let n_ewo = system.add_component(evap_sink);
system.register_component_name("comp", n_comp);
system.register_component_name("cond", n_cond);
system.register_component_name("exv", n_exv);
system.register_component_name("evap", n_evap);
system.register_component_name("cond_water_in", n_cwi);
system.register_component_name("cond_water_out", n_cwo);
system.register_component_name("evap_water_in", n_ewi);
system.register_component_name("evap_water_out", n_ewo);
// Refrigerant loop: outlet(1) → inlet(0). get_ports() empty → indices kept as-is.
system
.add_edge_with_ports(n_comp, 1, n_cond, 0)
.expect("comp→cond");
system
.add_edge_with_ports(n_cond, 1, n_exv, 0)
.expect("cond→exv");
system
.add_edge_with_ports(n_exv, 1, n_evap, 0)
.expect("exv→evap");
system
.add_edge_with_ports(n_evap, 1, n_comp, 0)
.expect("evap→comp");
// Condenser water: source outlet(0) → cond secondary_in(2); cond secondary_out(3) → sink(0)
system
.add_edge_with_ports(n_cwi, 0, n_cond, 2)
.expect("cw in");
system
.add_edge_with_ports(n_cond, 3, n_cwo, 0)
.expect("cw out");
// Evaporator water
system
.add_edge_with_ports(n_ewi, 0, n_evap, 2)
.expect("chw in");
system
.add_edge_with_ports(n_evap, 3, n_ewo, 0)
.expect("chw out");
system.finalize().expect("finalize flooded water-cooled graph");
system
}
#[test]
fn flooded_watercooled_4port_is_dof_balanced() {
let system = build_flooded_watercooled();
let report = system.dof_report();
assert_eq!(
report.n_unknowns, 19,
"unknowns: 3 branches + 2×8 edges = 19\n{}",
report.summary()
);
assert_eq!(
report.n_equations, 19,
"equations must match unknowns\n{}",
report.summary()
);
assert_eq!(
report.balance,
SystemDofBalance::Balanced,
"square system required\n{}",
report.summary()
);
assert!(
system.validate_system_dof().is_ok(),
"hard DoF gate must pass\n{}",
report.summary()
);
// Flooded block must declare saturated-vapor closure (not quality) by default.
let evap = report
.components
.iter()
.find(|c| c.component_name == "evap")
.expect("evap in ledger");
assert_eq!(evap.n_equations, 4, "ΔP + energy + sat-vapor + secondary energy");
assert!(
evap.roles.iter().any(|r| matches!(
r,
EquationRole::OutletClosure {
kind: "saturated_vapor"
}
)),
"expected saturated_vapor outlet closure, got {:?}",
evap.roles
);
}
#[test]
fn quality_control_without_extra_free_still_same_equation_count() {
// quality_control replaces sat-vapor residual — n_equations must stay constant
// (no silent DoF jump). This guards against re-introducing +1 without free.
let backend: Arc<dyn FluidBackend> = Arc::new(TestBackend::new());
let mut with_q = FloodedEvaporator::new(9000.0)
.with_refrigerant("R134a")
.with_secondary_fluid("Water")
.with_fluid_backend(backend.clone())
.with_quality_control(true);
let mut without_q = FloodedEvaporator::new(9000.0)
.with_refrigerant("R134a")
.with_secondary_fluid("Water")
.with_fluid_backend(backend)
.with_quality_control(false);
// Wire same 4-port context so n_secondary matches.
let ports = [
Some((0, 1, 2)),
Some((0, 3, 4)),
Some((5, 6, 7)),
Some((5, 8, 9)),
];
with_q.set_port_context(&ports);
without_q.set_port_context(&ports);
assert_eq!(
with_q.n_equations(),
without_q.n_equations(),
"quality_control must replace sat-vapor closure, not add a residual"
);
assert_eq!(with_q.n_equations(), 4);
}
#[test]
fn overconstrained_extra_quality_anchor_is_rejected_by_finalize_gate() {
// If someone stacked an extra outlet closure without freeing an unknown,
// finalize with enforce_dof_gate must refuse (over-constrained).
// Here we only assert the public gate API rejects imbalance when equations > unknowns
// using the already-balanced system as baseline — flip by adding a free residual mock
// is covered in dof_balance.rs. This test documents the expected production policy.
let system = build_flooded_watercooled();
match system.validate_system_dof() {
Ok(()) => {}
Err(SystemDofError::Imbalance { .. }) => {
panic!("balanced flooded machine must not report Imbalance")
}
Err(e) => panic!("unexpected DoF error: {e}"),
}
}