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

349
crates/solver/src/dof.rs Normal file
View File

@@ -0,0 +1,349 @@
//! System-wide degrees-of-freedom (DoF) bookkeeping.
//!
//! A thermodynamic cycle is a square nonlinear system:
//!
//! ```text
//! n_equations == n_unknowns
//! ```
//!
//! Every time a quantity is **fixed** (Dirichlet residual, outlet closure,
//! inverse constraint), either another quantity must be **freed** (actuator,
//! emergent pressure, free boundary) or an existing residual must be dropped.
//!
//! This module provides:
//! - re-export of component-level [`EquationRole`];
//! - unknown labels and a full system ledger;
//! - hard validation (`SystemDofBalance`) used as a pre-solve gate.
//!
//! # Design rules
//!
//! 1. Parameters outside the state vector are **not** free unknowns.
//! 2. Scalar secondary streams (`T_sec`, `C_sec`) are **rating-mode inputs**, not
//! system-mode substitutes for a live secondary loop.
//! 3. Outlet closures (superheat / subcooling / quality / level) consume one DoF
//! and must be paired with a free actuator or an intentional residual drop.
//! 4. Imbalance is an error, not a warning.
use std::fmt;
use thiserror::Error;
pub use entropyk_components::{unspecified_roles, EquationRole};
// ─────────────────────────────────────────────────────────────────────────────
// Unknown labels
// ─────────────────────────────────────────────────────────────────────────────
/// Kind of Newton unknown in the full state vector.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UnknownKind {
/// Shared branch mass-flow slot.
BranchMassFlow {
/// Branch id from topology presolve.
branch_id: usize,
},
/// Edge pressure.
EdgePressure {
/// Edge ordinal in finalize order.
edge_ordinal: usize,
},
/// Edge enthalpy.
EdgeEnthalpy {
/// Edge ordinal in finalize order.
edge_ordinal: usize,
},
/// Hard inverse-control variable.
InverseControl {
/// Bounded-variable id string.
id: String,
},
/// Thermal-coupling heat unknown Q [W].
CouplingHeat {
/// Coupling index.
index: usize,
},
/// Saturated-PI actuator `u`.
SaturatedActuator {
/// Controller index.
index: usize,
},
/// Saturated-PI internal state `x`.
SaturatedIntegrator {
/// Controller index.
index: usize,
},
/// Physical free actuator (EXV opening, fan speed, …).
FreeActuator {
/// Bounded-variable id string.
id: String,
},
/// Internal component state slot (macro-components / reserved).
Internal {
/// Slot index within the physical block.
index: usize,
},
}
impl fmt::Display for UnknownKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BranchMassFlow { branch_id } => write!(f, "m_branch[{branch_id}]"),
Self::EdgePressure { edge_ordinal } => write!(f, "P_edge[{edge_ordinal}]"),
Self::EdgeEnthalpy { edge_ordinal } => write!(f, "h_edge[{edge_ordinal}]"),
Self::InverseControl { id } => write!(f, "ctrl[{id}]"),
Self::CouplingHeat { index } => write!(f, "Q_coupling[{index}]"),
Self::SaturatedActuator { index } => write!(f, "u_sat[{index}]"),
Self::SaturatedIntegrator { index } => write!(f, "x_sat[{index}]"),
Self::FreeActuator { id } => write!(f, "actuator[{id}]"),
Self::Internal { index } => write!(f, "internal[{index}]"),
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Ledger entries
// ─────────────────────────────────────────────────────────────────────────────
/// One residual block contributed by a graph node.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComponentEquationBlock {
/// Human-readable component name (or node index fallback).
pub component_name: String,
/// Graph node index.
pub node_index: usize,
/// Declared `n_equations()`.
pub n_equations: usize,
/// Semantic roles (length should equal `n_equations` when fully migrated).
pub roles: Vec<EquationRole>,
}
/// Outcome of comparing equation count to unknown count.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemDofBalance {
/// Square system — well posed at the dimension level.
Balanced,
/// More equations than unknowns.
OverConstrained {
/// `n_equations n_unknowns`.
excess_equations: usize,
},
/// Fewer equations than unknowns.
UnderConstrained {
/// `n_unknowns n_equations`.
free_dofs: usize,
},
}
impl SystemDofBalance {
/// Builds the balance enum from raw counts.
pub fn from_counts(n_equations: usize, n_unknowns: usize) -> Self {
match n_equations.cmp(&n_unknowns) {
std::cmp::Ordering::Equal => Self::Balanced,
std::cmp::Ordering::Greater => Self::OverConstrained {
excess_equations: n_equations - n_unknowns,
},
std::cmp::Ordering::Less => Self::UnderConstrained {
free_dofs: n_unknowns - n_equations,
},
}
}
/// Returns `true` when the system is square.
pub fn is_balanced(self) -> bool {
matches!(self, Self::Balanced)
}
}
impl fmt::Display for SystemDofBalance {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Balanced => write!(f, "balanced"),
Self::OverConstrained { excess_equations } => {
write!(f, "over-constrained by {excess_equations}")
}
Self::UnderConstrained { free_dofs } => {
write!(f, "under-constrained by {free_dofs}")
}
}
}
}
/// Full DoF report for a finalized [`crate::System`].
#[derive(Debug, Clone, PartialEq)]
pub struct DofReport {
/// Total residual equations assembled by the solver.
pub n_equations: usize,
/// Total Newton unknowns (`full_state_vector_len`).
pub n_unknowns: usize,
/// Balance classification.
pub balance: SystemDofBalance,
/// Per-component residual blocks.
pub components: Vec<ComponentEquationBlock>,
/// System-level residual roles (constraints, couplings, saturated).
pub system_equations: Vec<EquationRole>,
/// Catalog of unknowns (may be compact when edge map is large).
pub unknowns: Vec<UnknownKind>,
/// Human diagnostics (pairing warnings, role-length mismatches, …).
pub diagnostics: Vec<String>,
}
impl DofReport {
/// Renders a concise multi-line summary suitable for logs and CLI.
pub fn summary(&self) -> String {
let mut lines = Vec::new();
lines.push(format!(
"DoF: equations={} unknowns={}{}",
self.n_equations, self.n_unknowns, self.balance
));
for block in &self.components {
let roles = if block.roles.is_empty() {
"(no roles declared)".to_string()
} else {
block
.roles
.iter()
.map(|r| r.to_string())
.collect::<Vec<_>>()
.join(", ")
};
lines.push(format!(
" node {} `{}`: {} eqs — {}",
block.node_index, block.component_name, block.n_equations, roles
));
}
if !self.system_equations.is_empty() {
lines.push(format!(
" system-level: {}",
self.system_equations
.iter()
.map(|r| r.to_string())
.collect::<Vec<_>>()
.join(", ")
));
}
for d in &self.diagnostics {
lines.push(format!(" ! {d}"));
}
lines.join("\n")
}
}
/// Errors raised by the hard DoF gate.
#[derive(Error, Debug, Clone, PartialEq)]
pub enum SystemDofError {
/// Square-system check failed.
#[error(
"System DoF imbalance: {n_equations} equations vs {n_unknowns} unknowns ({balance}).\n{summary}"
)]
Imbalance {
/// Equation count.
n_equations: usize,
/// Unknown count.
n_unknowns: usize,
/// Balance tag.
balance: SystemDofBalance,
/// Full summary text.
summary: String,
},
/// Component declared roles that disagree with `n_equations()`.
#[error(
"Component `{component}` equation_roles length {roles_len} != n_equations {n_equations}"
)]
RoleCountMismatch {
/// Component name.
component: String,
/// Roles length.
roles_len: usize,
/// Declared equation count.
n_equations: usize,
},
}
/// Pads or trims roles to `n`, recording a diagnostic on mismatch.
pub fn align_roles(
component: &str,
n_equations: usize,
mut roles: Vec<EquationRole>,
diagnostics: &mut Vec<String>,
) -> Vec<EquationRole> {
if roles.len() == n_equations {
return roles;
}
if roles.is_empty() {
return unspecified_roles(n_equations);
}
diagnostics.push(format!(
"component `{component}`: equation_roles len {} != n_equations {n_equations} (aligned)",
roles.len()
));
if roles.len() < n_equations {
let start = roles.len();
roles.extend(unspecified_roles(n_equations - start));
} else {
roles.truncate(n_equations);
}
roles
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn balance_from_counts() {
assert_eq!(
SystemDofBalance::from_counts(9, 9),
SystemDofBalance::Balanced
);
assert_eq!(
SystemDofBalance::from_counts(10, 9),
SystemDofBalance::OverConstrained {
excess_equations: 1
}
);
assert_eq!(
SystemDofBalance::from_counts(8, 9),
SystemDofBalance::UnderConstrained { free_dofs: 1 }
);
}
#[test]
fn align_roles_empty_becomes_unspecified() {
let mut diag = Vec::new();
let roles = align_roles("x", 3, Vec::new(), &mut diag);
assert_eq!(roles.len(), 3);
assert!(diag.is_empty());
assert!(matches!(
roles[2],
EquationRole::Unspecified { local_index: 2 }
));
}
#[test]
fn summary_mentions_balance() {
let report = DofReport {
n_equations: 2,
n_unknowns: 1,
balance: SystemDofBalance::OverConstrained {
excess_equations: 1,
},
components: vec![ComponentEquationBlock {
component_name: "c".into(),
node_index: 0,
n_equations: 2,
roles: vec![
EquationRole::EnergyBalance {
stream: "refrigerant",
},
EquationRole::OutletClosure { kind: "quality" },
],
}],
system_equations: vec![],
unknowns: vec![],
diagnostics: vec!["quality residual without free actuator".into()],
};
let s = report.summary();
assert!(s.contains("over-constrained"));
assert!(s.contains("quality"));
}
}