chore: sync project state and current artifacts

This commit is contained in:
Sepehr
2026-02-22 23:27:31 +01:00
parent 1b6415776e
commit dd77089b22
232 changed files with 37056 additions and 4296 deletions

View File

@@ -10,7 +10,7 @@
use entropyk_components::{
validate_port_continuity, Component, ComponentError, ConnectionError, JacobianBuilder,
ResidualVector, SystemState as StateSlice,
ResidualVector, StateSlice,
};
use petgraph::algo;
use petgraph::graph::{EdgeIndex, Graph, NodeIndex};
@@ -24,32 +24,10 @@ use crate::inverse::{
BoundedVariable, BoundedVariableError, BoundedVariableId, Constraint, ConstraintError,
ConstraintId, DoFError, InverseControlConfig,
};
use entropyk_core::Temperature;
use entropyk_core::{CircuitId, Temperature};
/// Circuit identifier. Valid range 0..=4 (max 5 circuits per machine).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CircuitId(pub u8);
impl CircuitId {
/// Maximum circuit ID (inclusive). Machine supports up to 5 circuits.
pub const MAX: u8 = 4;
/// Creates a new CircuitId if within valid range.
///
/// # Errors
///
/// Returns `TopologyError::TooManyCircuits` if `id > 4`.
pub fn new(id: u8) -> Result<Self, TopologyError> {
if id <= Self::MAX {
Ok(CircuitId(id))
} else {
Err(TopologyError::TooManyCircuits { requested: id })
}
}
/// Circuit 0 (default for single-circuit systems).
pub const ZERO: CircuitId = CircuitId(0);
}
/// Maximum circuit ID (inclusive). Machine supports up to 5 circuits.
pub const MAX_CIRCUIT_ID: u16 = 4;
/// Weight for flow edges in the system graph.
///
@@ -130,7 +108,11 @@ impl System {
component: Box<dyn Component>,
circuit_id: CircuitId,
) -> Result<NodeIndex, TopologyError> {
CircuitId::new(circuit_id.0)?;
if circuit_id.0 > MAX_CIRCUIT_ID {
return Err(TopologyError::TooManyCircuits {
requested: circuit_id.0,
});
}
self.finalized = false;
let node_idx = self.graph.add_node(component);
self.node_to_circuit.insert(node_idx, circuit_id);
@@ -577,7 +559,7 @@ impl System {
if self.graph.node_count() == 0 {
return 0;
}
let mut ids: Vec<u8> = self.node_to_circuit.values().map(|c| c.0).collect();
let mut ids: Vec<u16> = self.node_to_circuit.values().map(|c| c.0).collect();
if ids.is_empty() {
// This shouldn't happen since add_component adds to node_to_circuit,
// but handle defensively
@@ -1761,25 +1743,69 @@ impl System {
Ok(())
}
/// Tolerance for mass balance validation [kg/s].
///
/// This value (1e-9 kg/s) is tight enough to catch numerical issues
/// while allowing for floating-point rounding errors.
pub const MASS_BALANCE_TOLERANCE_KG_S: f64 = 1e-9;
/// Tolerance for energy balance validation in Watts (1e-6 kW)
pub const ENERGY_BALANCE_TOLERANCE_W: f64 = 1e-3;
/// Verifies that global mass balance is conserved.
///
/// Sums the mass flow rates at the ports of each component and ensures they
/// sum to zero within a tight tolerance (1e-9 kg/s).
///
/// # Returns
///
/// * `Ok(())` if all components pass mass balance validation
/// * `Err(SolverError::Validation)` if any component violates mass conservation
///
/// # Note
///
/// Components without `port_mass_flows` implementation are logged as warnings
/// and skipped. This ensures visibility of incomplete implementations without
/// failing the validation.
pub fn check_mass_balance(&self, state: &StateSlice) -> Result<(), crate::SolverError> {
let tolerance = 1e-9;
let mut total_mass_error = 0.0;
let mut has_violation = false;
let mut components_checked = 0usize;
let mut components_skipped = 0usize;
for (_node_idx, component, _edge_indices) in self.traverse_for_jacobian() {
if let Ok(mass_flows) = component.port_mass_flows(state) {
let sum: f64 = mass_flows.iter().map(|m| m.to_kg_per_s()).sum();
if sum.abs() > tolerance {
has_violation = true;
total_mass_error += sum.abs();
for (node_idx, component, _edge_indices) in self.traverse_for_jacobian() {
match component.port_mass_flows(state) {
Ok(mass_flows) => {
let sum: f64 = mass_flows.iter().map(|m| m.to_kg_per_s()).sum();
if sum.abs() > Self::MASS_BALANCE_TOLERANCE_KG_S {
has_violation = true;
total_mass_error += sum.abs();
tracing::warn!(
node_index = node_idx.index(),
mass_imbalance_kg_s = sum,
"Mass balance violation detected at component"
);
}
components_checked += 1;
}
Err(e) => {
components_skipped += 1;
tracing::warn!(
node_index = node_idx.index(),
error = %e,
"Component does not implement port_mass_flows - skipping mass balance check"
);
}
}
}
tracing::debug!(
components_checked,
components_skipped,
total_mass_error_kg_s = total_mass_error,
"Mass balance validation complete"
);
if has_violation {
return Err(crate::SolverError::Validation {
mass_error: total_mass_error,
@@ -1788,6 +1814,164 @@ impl System {
}
Ok(())
}
/// Verifies the First Law of Thermodynamics for all components in the system.
///
/// Validates that ΣQ - ΣW + Σ(ṁ·h) = 0 for each component.
/// Returns `SolverError::Validation` if any component violates the balance.
pub fn check_energy_balance(&self, state: &StateSlice) -> Result<(), crate::SolverError> {
let mut total_energy_error = 0.0;
let mut has_violation = false;
let mut components_checked = 0usize;
let mut components_skipped = 0usize;
let mut skipped_components: Vec<String> = Vec::new();
for (node_idx, component, _edge_indices) in self.traverse_for_jacobian() {
let energy_transfers = component.energy_transfers(state);
let mass_flows = component.port_mass_flows(state);
let enthalpies = component.port_enthalpies(state);
match (energy_transfers, mass_flows, enthalpies) {
(Some((heat, work)), Ok(m_flows), Ok(h_flows))
if m_flows.len() == h_flows.len() =>
{
let mut net_energy_flow = 0.0;
for (m, h) in m_flows.iter().zip(h_flows.iter()) {
net_energy_flow += m.to_kg_per_s() * h.to_joules_per_kg();
}
let balance = heat.to_watts() - work.to_watts() + net_energy_flow;
if balance.abs() > Self::ENERGY_BALANCE_TOLERANCE_W {
has_violation = true;
total_energy_error += balance.abs();
tracing::warn!(
node_index = node_idx.index(),
energy_imbalance_w = balance,
"Energy balance violation detected at component"
);
}
components_checked += 1;
}
_ => {
components_skipped += 1;
let component_type = std::any::type_name_of_val(component)
.split("::")
.last()
.unwrap_or("unknown");
let component_info =
format!("{} (type: {})", component.signature(), component_type);
skipped_components.push(component_info.clone());
tracing::warn!(
component = %component_info,
node_index = node_idx.index(),
"Component lacks energy_transfers() or port_enthalpies() - SKIPPED in energy balance validation"
);
}
}
}
// Summary warning if components were skipped
if components_skipped > 0 {
tracing::warn!(
components_checked = components_checked,
components_skipped = components_skipped,
skipped = ?skipped_components,
"Energy balance validation incomplete: {} component(s) skipped. \
Implement energy_transfers() and port_enthalpies() for full validation.",
components_skipped
);
} else {
tracing::debug!(
components_checked,
components_skipped,
total_energy_error_w = total_energy_error,
"Energy balance validation complete"
);
}
if has_violation {
return Err(crate::SolverError::Validation {
mass_error: 0.0,
energy_error: total_energy_error,
});
}
Ok(())
}
/// Generates a deterministic byte representation of the system configuration.
/// Used for simulation traceability logic.
pub fn generate_canonical_bytes(&self) -> Vec<u8> {
let mut repr = String::new();
repr.push_str("Nodes:\n");
// To be deterministic, we just iterate in graph order which is stable
// as long as we don't delete nodes.
for node in self.graph.node_indices() {
let circuit_id = self.node_to_circuit.get(&node).map(|c| c.0).unwrap_or(0);
repr.push_str(&format!(
" Node({}): Circuit({})\n",
node.index(),
circuit_id
));
if let Some(comp) = self.graph.node_weight(node) {
repr.push_str(&format!(" Signature: {}\n", comp.signature()));
}
}
repr.push_str("Edges:\n");
for edge_idx in self.graph.edge_indices() {
if let Some((src, tgt)) = self.graph.edge_endpoints(edge_idx) {
repr.push_str(&format!(" Edge: {} -> {}\n", src.index(), tgt.index()));
}
}
repr.push_str("Thermal Couplings:\n");
for coupling in &self.thermal_couplings {
repr.push_str(&format!(
" Hot: {}, Cold: {}, UA: {}\n",
coupling.hot_circuit.0, coupling.cold_circuit.0, coupling.ua
));
}
repr.push_str("Constraints:\n");
let mut constraint_keys: Vec<_> = self.constraints.keys().collect();
constraint_keys.sort_by_key(|k| k.as_str());
for key in constraint_keys {
let c = &self.constraints[key];
repr.push_str(&format!(" {}: {}\n", c.id().as_str(), c.target_value()));
}
repr.push_str("Bounded Variables:\n");
let mut bounded_keys: Vec<_> = self.bounded_variables.keys().collect();
bounded_keys.sort_by_key(|k| k.as_str());
for key in bounded_keys {
let var = &self.bounded_variables[key];
repr.push_str(&format!(
" {}: [{}, {}]\n",
var.id().as_str(),
var.min(),
var.max()
));
}
repr.push_str("Inverse Control Mappings:\n");
// For inverse control mappings, they are ordered internally. We'll just iterate linked controls.
for (i, (constraint, bounded_var)) in self.inverse_control.mappings().enumerate() {
repr.push_str(&format!(
" Mapping {}: {} -> {}\n",
i,
constraint.as_str(),
bounded_var.as_str()
));
}
repr.into_bytes()
}
/// Computes the SHA-256 hash uniquely identifying the input configuration.
pub fn input_hash(&self) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(self.generate_canonical_bytes());
format!("{:064x}", hasher.finalize())
}
}
impl Default for System {
@@ -1801,7 +1985,7 @@ mod tests {
use super::*;
use approx::assert_relative_eq;
use entropyk_components::port::{FluidId, Port};
use entropyk_components::{ConnectedPort, SystemState};
use entropyk_components::{ConnectedPort, StateSlice};
use entropyk_core::{Enthalpy, Pressure};
/// Minimal mock component for testing.
@@ -1812,7 +1996,7 @@ mod tests {
impl Component for MockComponent {
fn compute_residuals(
&self,
_state: &SystemState,
_state: &StateSlice,
residuals: &mut entropyk_components::ResidualVector,
) -> Result<(), ComponentError> {
for r in residuals.iter_mut().take(self.n_equations) {
@@ -1823,7 +2007,7 @@ mod tests {
fn jacobian_entries(
&self,
_state: &SystemState,
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
for i in 0..self.n_equations {
@@ -1869,7 +2053,7 @@ mod tests {
impl Component for PortedMockComponent {
fn compute_residuals(
&self,
_state: &SystemState,
_state: &StateSlice,
residuals: &mut entropyk_components::ResidualVector,
) -> Result<(), ComponentError> {
for r in residuals.iter_mut() {
@@ -1880,7 +2064,7 @@ mod tests {
fn jacobian_entries(
&self,
_state: &SystemState,
_state: &StateSlice,
_jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
Ok(())
@@ -2579,7 +2763,7 @@ mod tests {
impl Component for ZeroFlowMock {
fn compute_residuals(
&self,
state: &SystemState,
state: &StateSlice,
residuals: &mut entropyk_components::ResidualVector,
) -> Result<(), ComponentError> {
if !state.is_empty() {
@@ -2590,7 +2774,7 @@ mod tests {
fn jacobian_entries(
&self,
_state: &SystemState,
_state: &StateSlice,
jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
jacobian.add_entry(0, 0, 1.0);
@@ -3565,7 +3749,7 @@ mod tests {
impl Component for BadMassFlowComponent {
fn compute_residuals(
&self,
_state: &SystemState,
_state: &StateSlice,
_residuals: &mut entropyk_components::ResidualVector,
) -> Result<(), ComponentError> {
Ok(())
@@ -3573,7 +3757,7 @@ mod tests {
fn jacobian_entries(
&self,
_state: &SystemState,
_state: &StateSlice,
_jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
Ok(())
@@ -3587,7 +3771,10 @@ mod tests {
&self.ports
}
fn port_mass_flows(&self, _state: &SystemState) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
fn port_mass_flows(
&self,
_state: &StateSlice,
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
Ok(vec![
entropyk_core::MassFlow::from_kg_per_s(1.0),
entropyk_core::MassFlow::from_kg_per_s(-0.5), // Intentionally unbalanced
@@ -3595,10 +3782,52 @@ mod tests {
}
}
/// Component with balanced mass flow (inlet = outlet)
struct BalancedMassFlowComponent {
ports: Vec<ConnectedPort>,
}
impl Component for BalancedMassFlowComponent {
fn compute_residuals(
&self,
_state: &StateSlice,
_residuals: &mut entropyk_components::ResidualVector,
) -> Result<(), ComponentError> {
Ok(())
}
fn jacobian_entries(
&self,
_state: &StateSlice,
_jacobian: &mut JacobianBuilder,
) -> Result<(), ComponentError> {
Ok(())
}
fn n_equations(&self) -> usize {
0
}
fn get_ports(&self) -> &[ConnectedPort] {
&self.ports
}
fn port_mass_flows(
&self,
_state: &StateSlice,
) -> Result<Vec<entropyk_core::MassFlow>, ComponentError> {
// Balanced: inlet = 1.0 kg/s, outlet = -1.0 kg/s (sum = 0)
Ok(vec![
entropyk_core::MassFlow::from_kg_per_s(1.0),
entropyk_core::MassFlow::from_kg_per_s(-1.0),
])
}
}
#[test]
fn test_mass_balance_violation() {
fn test_mass_balance_passes_for_balanced_component() {
let mut system = System::new();
let inlet = Port::new(
FluidId::new("R134a"),
Pressure::from_bar(1.0),
@@ -3610,20 +3839,295 @@ mod tests {
Enthalpy::from_joules_per_kg(400000.0),
);
let (c1, c2) = inlet.connect(outlet).unwrap();
let comp = Box::new(BalancedMassFlowComponent {
ports: vec![c1, c2],
});
let n0 = system.add_component(comp);
system.add_edge(n0, n0).unwrap(); // Self-edge to avoid isolated node
system.finalize().unwrap();
let state = vec![0.0; system.full_state_vector_len()];
let result = system.check_mass_balance(&state);
assert!(
result.is_ok(),
"Expected mass balance to pass for balanced component"
);
}
#[test]
fn test_mass_balance_violation() {
let mut system = System::new();
let inlet = Port::new(
FluidId::new("R134a"),
Pressure::from_bar(1.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let outlet = Port::new(
FluidId::new("R134a"),
Pressure::from_bar(1.0),
Enthalpy::from_joules_per_kg(400000.0),
);
let (c1, c2) = inlet.connect(outlet).unwrap();
let comp = Box::new(BadMassFlowComponent {
ports: vec![c1, c2], // Just to have ports
});
let n0 = system.add_component(comp);
system.add_edge(n0, n0).unwrap(); // Self-edge to avoid isolated node
system.finalize().unwrap();
// Ensure state is appropriately sized for finalize
let state = vec![0.0; system.full_state_vector_len()];
let result = system.check_mass_balance(&state);
assert!(result.is_err());
// Verify error contains mass error information
if let Err(crate::SolverError::Validation {
mass_error,
energy_error,
}) = result
{
assert!(mass_error > 0.0, "Mass error should be positive");
assert_eq!(
energy_error, 0.0,
"Energy error should be zero for mass-only validation"
);
} else {
panic!("Expected Validation error, got {:?}", result);
}
}
#[test]
fn test_mass_balance_tolerance_constant() {
// Verify the tolerance constant is accessible and has expected value
assert_eq!(System::MASS_BALANCE_TOLERANCE_KG_S, 1e-9);
}
#[test]
fn test_generate_canonical_bytes() {
let mut sys = System::new();
let n0 = sys.add_component(make_mock(0));
let n1 = sys.add_component(make_mock(0));
sys.add_edge(n0, n1).unwrap();
let bytes1 = sys.generate_canonical_bytes();
let bytes2 = sys.generate_canonical_bytes();
// Exact same graph state should produce same bytes
assert_eq!(bytes1, bytes2);
}
#[test]
fn test_input_hash_deterministic() {
let mut sys1 = System::new();
let n0_1 = sys1.add_component(make_mock(0));
let n1_1 = sys1.add_component(make_mock(0));
sys1.add_edge(n0_1, n1_1).unwrap();
let mut sys2 = System::new();
let n0_2 = sys2.add_component(make_mock(0));
let n1_2 = sys2.add_component(make_mock(0));
sys2.add_edge(n0_2, n1_2).unwrap();
// Two identically constructed systems should have same hash
assert_eq!(sys1.input_hash(), sys2.input_hash());
// Now mutate one system by adding an edge
sys1.add_edge(n1_1, n0_1).unwrap();
// Hash should be different now
assert_ne!(sys1.input_hash(), sys2.input_hash());
}
// ────────────────────────────────────────────────────────────────────────
// Story 9.6: Energy Validation Logging Improvement Tests
// ────────────────────────────────────────────────────────────────────────
// Story 9.6: Energy Validation Logging Improvement Tests
// ────────────────────────────────────────────────────────────────────────
/// Test that check_energy_balance emits warnings for components without energy methods.
/// This test verifies the logging improvement from Story 9.6.
#[test]
fn test_energy_balance_warns_for_skipped_components() {
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
// Create a system with mock components that don't implement energy_transfers()
let mut sys = System::new();
let n0 = sys.add_component(make_mock(0));
let n1 = sys.add_component(make_mock(0));
sys.add_edge(n0, n1).unwrap();
sys.add_edge(n1, n0).unwrap();
sys.finalize().unwrap();
let state = vec![0.0; sys.state_vector_len()];
// Capture log output using tracing_subscriber
let log_buffer = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
let buffer_clone = log_buffer.clone();
let layer = tracing_subscriber::fmt::layer()
.with_writer(move || {
use std::io::Write;
struct BufWriter {
buf: std::sync::Arc<std::sync::Mutex<String>>,
}
impl Write for BufWriter {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
let mut buf = self.buf.lock().unwrap();
buf.push_str(&String::from_utf8_lossy(data));
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
BufWriter {
buf: buffer_clone.clone(),
}
})
.without_time();
let _guard = tracing_subscriber::registry().with(layer).set_default();
// check_energy_balance should succeed (no violations) but will emit warnings
// for components that lack energy_transfers() and port_enthalpies()
let result = sys.check_energy_balance(&state);
assert!(
result.is_ok(),
"check_energy_balance should succeed even with skipped components"
);
// Verify warning was emitted
let log_output = log_buffer.lock().unwrap();
assert!(
log_output.contains("SKIPPED in energy balance validation"),
"Expected warning message not found in logs. Actual output: {}",
*log_output
);
}
/// Test that check_energy_balance includes component type in warning message.
#[test]
fn test_energy_balance_includes_component_type_in_warning() {
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
// Create a system with mock components (need at least 2 nodes with edges to avoid isolated node error)
let mut sys = System::new();
let n0 = sys.add_component(make_mock(0));
let n1 = sys.add_component(make_mock(0));
sys.add_edge(n0, n1).unwrap();
sys.add_edge(n1, n0).unwrap();
sys.finalize().unwrap();
let state = vec![0.0; sys.state_vector_len()];
// Capture log output using tracing_subscriber
let log_buffer = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
let buffer_clone = log_buffer.clone();
let layer = tracing_subscriber::fmt::layer()
.with_writer(move || {
use std::io::Write;
struct BufWriter {
buf: std::sync::Arc<std::sync::Mutex<String>>,
}
impl Write for BufWriter {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
let mut buf = self.buf.lock().unwrap();
buf.push_str(&String::from_utf8_lossy(data));
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
BufWriter {
buf: buffer_clone.clone(),
}
})
.without_time();
let _guard = tracing_subscriber::registry().with(layer).set_default();
let result = sys.check_energy_balance(&state);
assert!(result.is_ok());
// Verify warning message includes component type information
// Note: type_name_of_val on a trait object returns the trait name ("Component"),
// not the concrete type. This is a known Rust limitation.
let log_output = log_buffer.lock().unwrap();
assert!(
log_output.contains("type: Component"),
"Expected component type information not found in logs. Actual output: {}",
*log_output
);
}
/// Test that check_energy_balance emits a summary warning with skipped component count.
#[test]
fn test_energy_balance_summary_warning() {
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
// Create a system with mock components
let mut sys = System::new();
let n0 = sys.add_component(make_mock(0));
let n1 = sys.add_component(make_mock(0));
sys.add_edge(n0, n1).unwrap();
sys.add_edge(n1, n0).unwrap();
sys.finalize().unwrap();
let state = vec![0.0; sys.state_vector_len()];
// Capture log output
let log_buffer = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
let buffer_clone = log_buffer.clone();
let layer = tracing_subscriber::fmt::layer()
.with_writer(move || {
use std::io::Write;
struct BufWriter {
buf: std::sync::Arc<std::sync::Mutex<String>>,
}
impl Write for BufWriter {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
let mut buf = self.buf.lock().unwrap();
buf.push_str(&String::from_utf8_lossy(data));
Ok(data.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
BufWriter {
buf: buffer_clone.clone(),
}
})
.without_time();
let _guard = tracing_subscriber::registry().with(layer).set_default();
let result = sys.check_energy_balance(&state);
assert!(result.is_ok());
// Verify summary warning was emitted
let log_output = log_buffer.lock().unwrap();
assert!(
log_output.contains("Energy balance validation incomplete"),
"Expected summary warning not found in logs. Actual output: {}",
*log_output
);
assert!(
log_output.contains("component(s) skipped"),
"Expected 'component(s) skipped' not found in logs. Actual output: {}",
*log_output
);
}
}