//! 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, } /// 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, /// System-level residual roles (constraints, couplings, saturated). pub system_equations: Vec, /// Catalog of unknowns (may be compact when edge map is large). pub unknowns: Vec, /// Human diagnostics (pairing warnings, role-length mismatches, …). pub diagnostics: Vec, } 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::>() .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::>() .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, diagnostics: &mut Vec, ) -> Vec { 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")); } }