Files
Entropyk/crates/solver/src/dof.rs
sepehr 3358b74342 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>
2026-07-17 22:46:46 +02:00

350 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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"));
}
}