Entropyk/docs/tutorial/04-building-systems.md

8.2 KiB

Building Systems

This guide explains how to create thermodynamic systems by connecting components into a directed graph.

System Architecture

A System is a directed graph where:

  • Nodes are components (Box<dyn Component>)
  • Edges are flow connections carrying state (P, h)
  • Each edge has two state vector indices: state_index_p and state_index_h
┌─────────────────────────────────────────────────────────────────┐
│                        System (Graph)                           │
│                                                                 │
│  NodeIndex(0) ──EdgeIndex(0)──► NodeIndex(1) ──EdgeIndex(1)──► │
│  [Compressor]                   [Condenser]                     │
│                                                                 │
│  State Vector: [P_edge0, h_edge0, P_edge1, h_edge1, ...]       │
└─────────────────────────────────────────────────────────────────┘

Creating a System

Basic Example (Rust)

use entropyk_solver::System;
use entropyk_components::{Component, ComponentError, ConnectedPort, 
    JacobianBuilder, ResidualVector, SystemState};

// Create custom component (or use library components)
struct MySource {
    outlet_pressure: f64,
    outlet_enthalpy: f64,
}

impl Component for MySource {
    fn compute_residuals(&self, state: &SystemState, residuals: &mut ResidualVector) 
        -> Result<(), ComponentError> {
        // Set outlet pressure and enthalpy
        residuals[0] = state.edge_pressure(0) - self.outlet_pressure;
        residuals[1] = state.edge_enthalpy(0) - self.outlet_enthalpy;
        Ok(())
    }
    
    fn jacobian_entries(&self, state: &SystemState, jacobian: &mut JacobianBuilder) 
        -> Result<(), ComponentError> {
        jacobian.add_entry(0, state.pressure_col(0), 1.0);
        jacobian.add_entry(1, state.enthalpy_col(0), 1.0);
        Ok(())
    }
    
    fn n_equations(&self) -> usize { 2 }
    fn get_ports(&self) -> &[ConnectedPort] { &[] }
}

fn main() {
    let mut system = System::new();
    
    // Add components
    let source = system.add_component(Box::new(MySource {
        outlet_pressure: 1e6,  // 10 bar
        outlet_enthalpy: 400e3, // 400 kJ/kg
    }));
    
    // Add more components...
    // let sink = system.add_component(Box::new(MySink { ... }));
    
    // Connect components
    // system.add_edge(source, sink).unwrap();
    
    // Finalize topology
    system.finalize().unwrap();
    
    println!("State vector length: {}", system.state_vector_len());
}

Adding Components

Single Circuit (Default)

let mut system = System::new();

// Add to default circuit (circuit 0)
let n1 = system.add_component(Box::new(compressor));
let n2 = system.add_component(Box::new(condenser));

Multi-Circuit Systems

For machines with multiple independent refrigerant circuits:

use entropyk_solver::CircuitId;

let mut system = System::new();

// Circuit 0 (primary)
let c1 = system.add_component_to_circuit(
    Box::new(compressor1), 
    CircuitId::ZERO
).unwrap();

// Circuit 1 (secondary)
let c2 = system.add_component_to_circuit(
    Box::new(compressor2), 
    CircuitId::new(1).unwrap()
).unwrap();

Limits: Maximum 5 circuits per machine (CircuitId 0-4).

Connecting Components

Simple Connection (No Port Validation)

For components without ports (e.g., test mocks):

let edge = system.add_edge(source, target)?;

Connection with Port Validation

For real components with defined ports:

// Connect source outlet (port 1) to target inlet (port 0)
let edge = system.add_edge_with_ports(
    source, 1,  // source node, outlet port
    target, 0,  // target node, inlet port
)?;

Port validation checks:

  • Circuit compatibility: Both nodes must be in the same circuit
  • Fluid compatibility: Same refrigerant on both sides
  • Pressure continuity: Pressure tolerance check
  • Enthalpy continuity: Enthalpy tolerance check

Cross-Circuit Connections

Not allowed for flow edges. Use thermal coupling instead:

// This will FAIL:
system.add_edge(node_in_circuit_0, node_in_circuit_1)?;
// Error: TopologyError::CrossCircuitConnection

State Vector Layout

After finalize(), the state vector is organized as:

Index:  0      1      2      3      4      5     ...
Value: [P_e0,  h_e0,  P_e1,  h_e1,  P_e2,  h_e2, ...]

Each edge contributes 2 state variables:

  • state_index_p: Pressure (Pa)
  • state_index_h: Specific enthalpy (J/kg)

Accessing State in Components

impl Component for MyComponent {
    fn compute_residuals(&self, state: &SystemState, residuals: &mut ResidualVector) 
        -> Result<(), ComponentError> {
        
        // Get inlet state (edge 0)
        let p_in = state.edge_pressure(0);
        let h_in = state.edge_enthalpy(0);
        
        // Get outlet state (edge 1)
        let p_out = state.edge_pressure(1);
        let h_out = state.edge_enthalpy(1);
        
        // Compute residuals...
        residuals[0] = p_out - p_in + pressure_drop;
        
        Ok(())
    }
}

Finalizing the System

Always call finalize() before solving:

match system.finalize() {
    Ok(()) => println!("System ready. State vector: {} elements", system.state_vector_len()),
    Err(e) => eprintln!("Topology error: {:?}", e),
}

finalize() performs:

  1. Assigns state indices to all edges
  2. Validates topology (no disconnected components, proper port connections)
  3. Sets up internal data structures for solving

Complete Example: Simple Cycle

use entropyk_solver::System;

fn build_simple_cycle() -> System {
    let mut system = System::new();
    
    // Add components
    let compressor = system.add_component(Box::new(Compressor::new(
        2900.0,  // RPM
        0.0001,  // displacement m³
        0.85,    // efficiency
        "R134a".to_string(),
    )));
    
    let condenser = system.add_component(Box::new(Condenser::new(
        5000.0,  // UA W/K
    )));
    
    let valve = system.add_component(Box::new(ExpansionValve::new(
        "R134a".to_string(),
        0.8,     // opening
    )));
    
    let evaporator = system.add_component(Box::new(Evaporator::new(
        3000.0,  // UA W/K
    )));
    
    // Connect in cycle order
    system.add_edge(compressor, condenser).unwrap();
    system.add_edge(condenser, valve).unwrap();
    system.add_edge(valve, evaporator).unwrap();
    system.add_edge(evaporator, compressor).unwrap();
    
    // Finalize
    system.finalize().unwrap();
    
    system
}

Python Bindings

import entropyk

system = entropyk.System()

# Add components
c = system.add_component(entropyk.Compressor(
    speed_rpm=2900.0,
    displacement=0.0001,
    efficiency=0.85,
    fluid="R134a"
))

d = system.add_component(entropyk.Condenser(ua=5000.0))
v = system.add_component(entropyk.ExpansionValve(fluid="R134a", opening=0.8))
e = system.add_component(entropyk.Evaporator(ua=3000.0))

# Connect
system.add_edge(c, d)
system.add_edge(d, v)
system.add_edge(v, e)
system.add_edge(e, c)

# Finalize
system.finalize()
print(f"State vector: {system.state_vector_len} elements")

Note: Python bindings use placeholder adapters. The topology works, but the solver won't converge because there are no real physics equations.

Common Errors

Cross-Circuit Connection

TopologyError::CrossCircuitConnection { source_circuit: 0, target_circuit: 1 }

Fix: Use thermal coupling for cross-circuit heat transfer, or move components to the same circuit.

Disconnected Components

TopologyError::DisconnectedComponent { node: NodeIndex(2) }

Fix: Ensure all components are connected to the cycle.

Port Mismatch

ConnectionError::FluidMismatch { source: "R134a", target: "R717" }

Fix: Use the same refrigerant throughout a circuit.

Next Steps