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

@@ -10,14 +10,14 @@
//! 1. Estimate evaporator pressure: `P_evap = P_sat(T_source - ΔT_approach)`
//! 2. Estimate condenser pressure: `P_cond = P_sat(T_sink + ΔT_approach)`
//! 3. Clamp `P_evap` to `0.5 * P_critical` if it exceeds the critical pressure
//! 4. Fill the state vector with `[P, h_default]` per edge, using circuit topology
//! 4. Fill the state vector with `[ṁ, P, h_default]` per edge, using circuit topology
//!
//! # Supported Fluids
//!
//! Built-in Antoine coefficients are provided for:
//! - R134a, R410A, R32, R744 (CO2), R290 (Propane)
//!
//! Unknown fluids fall back to sensible defaults (5 bar / 20 bar) with a warning.
//! Unknown fluids return an explicit error; no pressure guesses are invented.
//!
//! # No-Allocation Guarantee
//!
@@ -29,6 +29,7 @@ use entropyk_core::{Enthalpy, Pressure, Temperature};
use thiserror::Error;
use crate::system::System;
use serde::{Deserialize, Serialize};
// ─────────────────────────────────────────────────────────────────────────────
// Error types
@@ -57,6 +58,13 @@ pub enum InitializerError {
/// Actual length of the provided slice.
actual: usize,
},
/// No Antoine coefficients are available for the configured fluid.
#[error("No Antoine saturation-pressure coefficients are available for {fluid}")]
UnsupportedFluid {
/// Fluid identifier string.
fluid: String,
},
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -197,6 +205,80 @@ pub struct InitializerConfig {
pub dt_approach: f64,
}
/// Optional start values for one solver edge or auxiliary unknown group.
///
/// These values are numerical guesses only. They must never be interpreted as
/// imposed boundary conditions or component equations.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct StartValues {
/// Pressure start value [Pa].
pub pressure_pa: Option<f64>,
/// Enthalpy start value [J/kg].
pub enthalpy_j_kg: Option<f64>,
/// Mass-flow start value [kg/s].
pub mass_flow_kg_s: Option<f64>,
/// Temperature start value [K], useful for diagnostics and backend conversion.
pub temperature_k: Option<f64>,
/// Vapour quality start value [-], when the intended regime is two-phase.
pub vapor_quality: Option<f64>,
}
/// Regime label used to explain why a start value was assigned.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InitializationRegime {
/// High-pressure superheated vapour, typically compressor discharge.
HighPressureVapor,
/// High-pressure liquid, typically condenser outlet.
HighPressureLiquid,
/// Low-pressure two-phase mixture, typically EXV outlet.
LowPressureTwoPhase,
/// Low-pressure superheated vapour, typically compressor suction.
LowPressureVapor,
/// Secondary water/brine/air branch.
Secondary,
/// Generic fallback seed.
Generic,
/// Boundary condition seed from a source/sink component.
Boundary,
/// Control or actuator unknown seed.
Control,
}
/// One initialization diagnostic entry.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InitializationSeed {
/// Human-readable edge/control label.
pub label: String,
/// Assigned regime.
pub regime: InitializationRegime,
/// Values written to the state vector.
pub values: StartValues,
}
/// Diagnostics emitted by an initialization pass.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct InitializationDiagnostics {
/// Ordered seed records for edges and control variables.
pub seeds: Vec<InitializationSeed>,
}
impl InitializationDiagnostics {
/// Appends a diagnostic seed record.
pub fn push(
&mut self,
label: impl Into<String>,
regime: InitializationRegime,
values: StartValues,
) {
self.seeds.push(InitializationSeed {
label: label.into(),
regime,
values,
});
}
}
impl Default for InitializerConfig {
fn default() -> Self {
Self {
@@ -248,13 +330,12 @@ impl SmartInitializer {
/// - `P_evap = P_sat(T_source - ΔT_approach)`, clamped to `0.5 * P_critical`
/// - `P_cond = P_sat(T_sink + ΔT_approach)`
///
/// For unknown fluids, returns sensible defaults (5 bar / 20 bar) with a
/// `tracing::warn!` log entry.
///
/// # Errors
///
/// Returns [`InitializerError::TemperatureAboveCritical`] if the adjusted
/// source temperature exceeds the critical temperature for a known fluid.
/// Returns [`InitializerError::UnsupportedFluid`] if no Antoine coefficients
/// are available for the configured fluid.
pub fn estimate_pressures(
&self,
t_source: Temperature,
@@ -263,15 +344,7 @@ impl SmartInitializer {
let fluid_str = self.config.fluid.to_string();
match AntoineCoefficients::for_fluid(&fluid_str) {
None => {
// Unknown fluid: emit warning and return sensible defaults
tracing::warn!(
fluid = %fluid_str,
"Unknown fluid for Antoine estimation — using fallback pressures \
(P_evap = 5 bar, P_cond = 20 bar)"
);
Ok((Pressure::from_bar(5.0), Pressure::from_bar(20.0)))
}
None => Err(InitializerError::UnsupportedFluid { fluid: fluid_str }),
Some(coeffs) => {
let t_source_c = t_source.to_celsius();
let t_sink_c = t_sink.to_celsius();
@@ -332,9 +405,10 @@ impl SmartInitializer {
/// Fill a pre-allocated state vector with smart initial guesses.
///
/// No heap allocation is performed. The `state` slice must have length equal
/// to `system.state_vector_len()` (i.e., `2 * edge_count`).
/// to `system.state_vector_len()` (i.e., `3 * edge_count` for a system of
/// refrigerant/hydraulic edges).
///
/// State layout per edge: `[P_edge_i, h_edge_i]`
/// State layout per edge: `[ṁ_edge_i, P_edge_i, h_edge_i]`
///
/// Pressure assignment follows circuit topology:
/// - Edges in circuit 0 → `p_evap`
@@ -344,7 +418,8 @@ impl SmartInitializer {
/// # Errors
///
/// Returns [`InitializerError::StateLengthMismatch`] if `state.len()` does
/// not match `system.state_vector_len()`.
/// not match `system.full_state_vector_len()` (edges plus any inverse-control
/// and coupling auxiliary unknowns).
pub fn populate_state(
&self,
system: &System,
@@ -353,7 +428,9 @@ impl SmartInitializer {
h_default: Enthalpy,
state: &mut [f64],
) -> Result<(), InitializerError> {
let expected = system.state_vector_len();
// Size against the FULL state vector (the length Newton/Picard expect):
// base edge unknowns + inverse-control mappings + coupling residual slots.
let expected = system.full_state_vector_len();
if state.len() != expected {
return Err(InitializerError::StateLengthMismatch {
expected,
@@ -365,11 +442,28 @@ impl SmartInitializer {
let p_cond_pa = p_cond.to_pascals();
let h_jkg = h_default.to_joules_per_kg();
for (i, edge_idx) in system.edge_indices().enumerate() {
for edge_idx in system.edge_indices() {
let circuit = system.edge_circuit(edge_idx);
let p = if circuit.0 == 0 { p_evap_pa } else { p_cond_pa };
state[2 * i] = p;
state[2 * i + 1] = h_jkg;
let (m_idx, p_idx, h_idx) = system.edge_state_indices_full(edge_idx);
// CM1.4: m_idx is BRANCH-shared — multiple edges in the same series
// branch point to the same slot. Writing the same seed value multiple
// times is idempotent and stays within state bounds (m_idx < state_len).
state[m_idx] = crate::system::DEFAULT_MASS_FLOW_SEED_KG_S;
state[p_idx] = p;
state[h_idx] = h_jkg;
}
// Seed inverse-control unknowns (fan speed, opening, frequency, …) to the
// midpoint of their bounds so the cold start sits inside the feasible box
// instead of at zero (often an out-of-bounds, non-physical control value).
// Coupling auxiliary slots keep their 0.0 default.
for (_, idx) in system.control_variable_indices() {
if let Some((min, max)) = system.get_bounds_for_state_index(idx) {
if min.is_finite() && max.is_finite() && min <= max {
state[idx] = 0.5 * (min + max);
}
}
}
Ok(())
@@ -385,6 +479,30 @@ mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn test_initialization_diagnostics_records_start_values() {
let mut diagnostics = InitializationDiagnostics::default();
diagnostics.push(
"comp->cond",
InitializationRegime::HighPressureVapor,
StartValues {
pressure_pa: Some(1.2e6),
enthalpy_j_kg: Some(430_000.0),
mass_flow_kg_s: Some(0.05),
temperature_k: None,
vapor_quality: None,
},
);
assert_eq!(diagnostics.seeds.len(), 1);
assert_eq!(diagnostics.seeds[0].label, "comp->cond");
assert_eq!(
diagnostics.seeds[0].regime,
InitializationRegime::HighPressureVapor
);
assert_eq!(diagnostics.seeds[0].values.pressure_pa, Some(1.2e6));
}
// ── Antoine equation unit tests ──────────────────────────────────────────
/// AC: #1, #5 — R134a at 0°C: P_sat ≈ 2.93 bar (293,000 Pa), within 5%
@@ -465,9 +583,9 @@ mod tests {
assert_relative_eq!(p_cond.to_pascals(), expected_pa, max_relative = 1e-9);
}
/// AC: #6 — Unknown fluid returns fallback (5 bar / 20 bar) without panic
/// AC: #6 — Unknown fluid returns an explicit error instead of invented pressures.
#[test]
fn test_unknown_fluid_fallback() {
fn test_unknown_fluid_returns_error() {
let init = SmartInitializer::new(InitializerConfig {
fluid: FluidId::new("R999-Unknown"),
dt_approach: 5.0,
@@ -476,10 +594,12 @@ mod tests {
Temperature::from_celsius(5.0),
Temperature::from_celsius(40.0),
);
assert!(result.is_ok(), "Unknown fluid should not return Err");
let (p_evap, p_cond) = result.unwrap();
assert_relative_eq!(p_evap.to_bar(), 5.0, max_relative = 1e-9);
assert_relative_eq!(p_cond.to_bar(), 20.0, max_relative = 1e-9);
assert_eq!(
result,
Err(InitializerError::UnsupportedFluid {
fluid: "R999-Unknown".to_string()
})
);
}
/// AC: #1 — Verify evaporator pressure uses T_source - ΔT_approach
@@ -559,12 +679,21 @@ mod tests {
init.populate_state(&sys, p_evap, p_cond, h_default, &mut state)
.unwrap();
// All edges in circuit 0 (single-circuit) → p_evap
assert_eq!(state.len(), 4); // 2 edges × 2 entries
assert_relative_eq!(state[0], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[1], h_default.to_joules_per_kg(), max_relative = 1e-9);
assert_relative_eq!(state[2], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[3], h_default.to_joules_per_kg(), max_relative = 1e-9);
// CM1.4: 2-edge linear chain → 1 branch → state_len = 1 + 2×2 = 5
// Layout: [0:ṁ_branch, 1:P_e0, 2:h_e0, 3:P_e1, 4:h_e1]
assert_eq!(state.len(), 5);
// Branch ṁ seeded at DEFAULT_MASS_FLOW_SEED_KG_S
assert_relative_eq!(
state[0],
crate::system::DEFAULT_MASS_FLOW_SEED_KG_S,
max_relative = 1e-9
);
// P and h for edge 0 (circuit 0 → p_evap)
assert_relative_eq!(state[1], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[2], h_default.to_joules_per_kg(), max_relative = 1e-9);
// P and h for edge 1 (circuit 0 → p_evap)
assert_relative_eq!(state[3], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[4], h_default.to_joules_per_kg(), max_relative = 1e-9);
}
/// AC: #4 — populate_state uses P_cond for circuit 1 edges in multi-circuit system.
@@ -633,13 +762,33 @@ mod tests {
init.populate_state(&sys, p_evap, p_cond, h_default, &mut state)
.unwrap();
assert_eq!(state.len(), 4); // 2 edges × 2 entries
// Edge 0 (circuit 0) → p_evap
assert_relative_eq!(state[0], p_evap.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[1], h_default.to_joules_per_kg(), max_relative = 1e-9);
// Edge 1 (circuit 1) → p_cond
assert_relative_eq!(state[2], p_cond.to_pascals(), max_relative = 1e-9);
assert_relative_eq!(state[3], h_default.to_joules_per_kg(), max_relative = 1e-9);
// CM1.4: 2 isolated 1-edge chains → 2 branches → state_len = 2 + 2×2 = 6
// Layout: [0:ṁ_B0, 1:ṁ_B1, 2:P_e0, 3:h_e0, 4:P_e1, 5:h_e1]
assert_eq!(state.len(), 6);
// Branch ṁ slots seeded at DEFAULT_MASS_FLOW_SEED_KG_S
assert_relative_eq!(
state[0],
crate::system::DEFAULT_MASS_FLOW_SEED_KG_S,
max_relative = 1e-9
);
assert_relative_eq!(
state[1],
crate::system::DEFAULT_MASS_FLOW_SEED_KG_S,
max_relative = 1e-9
);
// Verify P and h values are seeded correctly for each edge using actual state indices.
// Edge 0 is circuit 0 (p_evap), edge 1 is circuit 1 (p_cond).
for edge_idx in sys.edge_indices() {
let circuit = sys.edge_circuit(edge_idx);
let (_m, p, h) = sys.edge_state_indices_full(edge_idx);
let expected_p = if circuit.0 == 0 {
p_evap.to_pascals()
} else {
p_cond.to_pascals()
};
assert_relative_eq!(state[p], expected_p, max_relative = 1e-9);
assert_relative_eq!(state[h], h_default.to_joules_per_kg(), max_relative = 1e-9);
}
}
/// AC: #7 — populate_state returns error on length mismatch (no panic).
@@ -689,13 +838,13 @@ mod tests {
let p_cond = Pressure::from_bar(15.0);
let h_default = Enthalpy::from_joules_per_kg(400_000.0);
// Wrong length: system has 2 state entries (1 edge × 2), we provide 5
// Wrong length: system has 3 state entries (1 edge × 3), we provide 5
let mut state = vec![0.0f64; 5];
let result = init.populate_state(&sys, p_evap, p_cond, h_default, &mut state);
assert!(matches!(
result,
Err(InitializerError::StateLengthMismatch {
expected: 2,
expected: 3,
actual: 5
})
));