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:
@@ -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
|
||||
})
|
||||
));
|
||||
|
||||
Reference in New Issue
Block a user