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>
350 lines
12 KiB
Rust
350 lines
12 KiB
Rust
//! 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"));
|
||
}
|
||
}
|