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:
349
crates/solver/src/dof.rs
Normal file
349
crates/solver/src/dof.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user