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

744
crates/cli/src/run.rs Normal file
View File

@@ -0,0 +1,744 @@
//! Single simulation execution module.
//!
//! Handles loading a configuration, running a simulation, and outputting results.
use std::path::Path;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::config::ScenarioConfig;
use crate::error::{CliError, CliResult};
/// Result of a single simulation run.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimulationResult {
/// Input configuration name or path.
pub input: String,
/// Simulation status.
pub status: SimulationStatus,
/// Convergence information.
pub convergence: Option<ConvergenceInfo>,
/// Solver iterations.
pub iterations: Option<usize>,
/// Final state vector (P, h per edge).
pub state: Option<Vec<StateEntry>>,
/// Performance metrics.
pub performance: Option<PerformanceMetrics>,
/// Error message if failed.
pub error: Option<String>,
/// Execution time in milliseconds.
pub elapsed_ms: u64,
}
/// Performance metrics from simulation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PerformanceMetrics {
/// Cooling capacity in kW.
pub q_cooling_kw: Option<f64>,
/// Heating capacity in kW.
pub q_heating_kw: Option<f64>,
/// Compressor power in kW.
pub compressor_power_kw: Option<f64>,
/// Coefficient of performance.
pub cop: Option<f64>,
}
/// Simulation status.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SimulationStatus {
Converged,
Timeout,
NonConverged,
Error,
}
/// Convergence information.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConvergenceInfo {
/// Final residual norm.
pub final_residual: f64,
/// Convergence tolerance achieved.
pub tolerance: f64,
}
/// State entry for one edge.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateEntry {
/// Edge index.
pub edge: usize,
/// Pressure in bar.
pub pressure_bar: f64,
/// Enthalpy in kJ/kg.
pub enthalpy_kj_kg: f64,
}
/// Run a single simulation from a configuration file.
pub fn run_simulation(
config_path: &Path,
output_path: Option<&Path>,
verbose: bool,
) -> CliResult<SimulationResult> {
let start = std::time::Instant::now();
let input_name = config_path.display().to_string();
if verbose {
info!("Loading configuration from: {}", config_path.display());
}
let config = ScenarioConfig::from_file(config_path)?;
if verbose {
info!("Scenario: {:?}", config.name);
info!("Primary fluid: {}", config.fluid);
info!("Circuits: {}", config.circuits.len());
info!("Thermal couplings: {}", config.thermal_couplings.len());
info!("Solver: {}", config.solver.strategy);
}
let result = execute_simulation(&config, &input_name, start.elapsed().as_millis() as u64);
if let Some(ref path) = output_path {
let json = serde_json::to_string_pretty(&result)
.map_err(|e| CliError::Simulation(format!("Failed to serialize result: {}", e)))?;
std::fs::write(path, json)?;
if verbose {
info!("Results written to: {}", path.display());
}
}
Ok(result)
}
/// Execute the simulation with the given configuration.
fn execute_simulation(
config: &ScenarioConfig,
input_name: &str,
elapsed_ms: u64,
) -> SimulationResult {
use entropyk::{
ConvergenceStatus, FallbackSolver, FluidId, NewtonConfig, PicardConfig, Solver,
SolverStrategy, System, ThermalConductance,
};
use entropyk_fluids::TestBackend;
use entropyk_solver::{CircuitId, ThermalCoupling};
use std::collections::HashMap;
let fluid_id = FluidId::new(&config.fluid);
let backend: Arc<dyn entropyk_fluids::FluidBackend> = Arc::new(TestBackend::new());
let mut system = System::new();
// Track component name -> node index mapping per circuit
let mut component_indices: HashMap<String, petgraph::graph::NodeIndex> = HashMap::new();
for circuit_config in &config.circuits {
let circuit_id = CircuitId(circuit_config.id as u8);
for component_config in &circuit_config.components {
match create_component(
&component_config.component_type,
&component_config.params,
&fluid_id,
Arc::clone(&backend),
) {
Ok(component) => match system.add_component_to_circuit(component, circuit_id) {
Ok(node_id) => {
component_indices.insert(component_config.name.clone(), node_id);
}
Err(e) => {
return SimulationResult {
input: input_name.to_string(),
status: SimulationStatus::Error,
convergence: None,
iterations: None,
state: None,
performance: None,
error: Some(format!(
"Failed to add component '{}': {:?}",
component_config.name, e
)),
elapsed_ms,
};
}
},
Err(e) => {
return SimulationResult {
input: input_name.to_string(),
status: SimulationStatus::Error,
convergence: None,
iterations: None,
state: None,
performance: None,
error: Some(format!(
"Failed to create component '{}': {}",
component_config.name, e
)),
elapsed_ms,
};
}
}
}
}
// Add edges between components
for circuit_config in &config.circuits {
for edge in &circuit_config.edges {
let from_parts: Vec<&str> = edge.from.split(':').collect();
let to_parts: Vec<&str> = edge.to.split(':').collect();
let from_name = from_parts.get(0).unwrap_or(&"");
let to_name = to_parts.get(0).unwrap_or(&"");
let from_node = component_indices.get(*from_name);
let to_node = component_indices.get(*to_name);
match (from_node, to_node) {
(Some(from), Some(to)) => {
if let Err(e) = system.add_edge(*from, *to) {
return SimulationResult {
input: input_name.to_string(),
status: SimulationStatus::Error,
convergence: None,
iterations: None,
state: None,
performance: None,
error: Some(format!(
"Failed to add edge '{} -> {}': {:?}",
edge.from, edge.to, e
)),
elapsed_ms,
};
}
}
_ => {
return SimulationResult {
input: input_name.to_string(),
status: SimulationStatus::Error,
convergence: None,
iterations: None,
state: None,
performance: None,
error: Some(format!(
"Edge references unknown component: '{}' or '{}'",
from_name, to_name
)),
elapsed_ms,
};
}
}
}
}
for coupling_config in &config.thermal_couplings {
let coupling = ThermalCoupling::new(
CircuitId(coupling_config.hot_circuit as u8),
CircuitId(coupling_config.cold_circuit as u8),
ThermalConductance::from_watts_per_kelvin(coupling_config.ua),
)
.with_efficiency(coupling_config.efficiency);
if let Err(e) = system.add_thermal_coupling(coupling) {
return SimulationResult {
input: input_name.to_string(),
status: SimulationStatus::Error,
convergence: None,
iterations: None,
state: None,
performance: None,
error: Some(format!("Failed to add thermal coupling: {:?}", e)),
elapsed_ms,
};
}
}
if let Err(e) = system.finalize() {
return SimulationResult {
input: input_name.to_string(),
status: SimulationStatus::Error,
convergence: None,
iterations: None,
state: None,
performance: None,
error: Some(format!("System finalization failed: {:?}", e)),
elapsed_ms,
};
}
let result = match config.solver.strategy.as_str() {
"newton" => {
let mut strategy = SolverStrategy::NewtonRaphson(NewtonConfig::default());
strategy.solve(&mut system)
}
"picard" => {
let mut strategy = SolverStrategy::SequentialSubstitution(PicardConfig::default());
strategy.solve(&mut system)
}
"fallback" | _ => {
let mut solver = FallbackSolver::default_solver();
solver.solve(&mut system)
}
};
match result {
Ok(converged) => {
let status = match converged.status {
ConvergenceStatus::Converged => SimulationStatus::Converged,
ConvergenceStatus::TimedOutWithBestState => SimulationStatus::Timeout,
ConvergenceStatus::ControlSaturation => SimulationStatus::NonConverged,
};
let state = extract_state(&converged);
SimulationResult {
input: input_name.to_string(),
status,
convergence: Some(ConvergenceInfo {
final_residual: converged.final_residual,
tolerance: config.solver.tolerance,
}),
iterations: Some(converged.iterations),
state: Some(state),
performance: None,
error: None,
elapsed_ms,
}
}
Err(e) => SimulationResult {
input: input_name.to_string(),
status: SimulationStatus::Error,
convergence: None,
iterations: None,
state: None,
performance: None,
error: Some(format!("Solver error: {:?}", e)),
elapsed_ms,
},
}
}
fn get_param_f64(
params: &std::collections::HashMap<String, serde_json::Value>,
key: &str,
) -> CliResult<f64> {
params
.get(key)
.and_then(|v| v.as_f64())
.ok_or_else(|| CliError::Config(format!("Missing required parameter: {}", key)))
}
fn get_param_string(
params: &std::collections::HashMap<String, serde_json::Value>,
key: &str,
) -> CliResult<String> {
params
.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| CliError::Config(format!("Missing required parameter: {}", key)))
}
fn parse_side_conditions(
params: &std::collections::HashMap<String, serde_json::Value>,
prefix: &str,
) -> CliResult<entropyk::HxSideConditions> {
use entropyk::{HxSideConditions, MassFlow, Pressure, Temperature};
let fluid = get_param_string(params, &format!("{}_fluid", prefix))?;
let t_inlet_c = get_param_f64(params, &format!("{}_t_inlet_c", prefix))?;
let pressure_bar = params
.get(&format!("{}_pressure_bar", prefix))
.and_then(|v| v.as_f64())
.unwrap_or(1.0);
let mass_flow = params
.get(&format!("{}_mass_flow_kg_s", prefix))
.and_then(|v| v.as_f64())
.unwrap_or(0.1);
Ok(HxSideConditions::new(
Temperature::from_celsius(t_inlet_c),
Pressure::from_bar(pressure_bar),
MassFlow::from_kg_per_s(mass_flow),
&fluid,
)?)
}
/// Create a component from configuration.
fn create_component(
component_type: &str,
params: &std::collections::HashMap<String, serde_json::Value>,
_primary_fluid: &entropyk::FluidId,
backend: Arc<dyn entropyk_fluids::FluidBackend>,
) -> CliResult<Box<dyn entropyk::Component>> {
use entropyk::{Condenser, CondenserCoil, Evaporator, EvaporatorCoil, HeatExchanger};
use entropyk_components::heat_exchanger::{FlowConfiguration, LmtdModel};
match component_type {
"Condenser" | "CondenserCoil" => {
let ua = get_param_f64(params, "ua")?;
let t_sat_k = params.get("t_sat_k").and_then(|v| v.as_f64());
if let Some(t_sat) = t_sat_k {
Ok(Box::new(CondenserCoil::with_saturation_temp(ua, t_sat)))
} else {
Ok(Box::new(Condenser::new(ua)))
}
}
"Evaporator" | "EvaporatorCoil" => {
let ua = get_param_f64(params, "ua")?;
let t_sat_k = params.get("t_sat_k").and_then(|v| v.as_f64());
let superheat_k = params.get("superheat_k").and_then(|v| v.as_f64());
let default_superheat = 5.0;
match (t_sat_k, superheat_k) {
(Some(t_sat), Some(sh)) => Ok(Box::new(Evaporator::with_superheat(ua, t_sat, sh))),
(Some(t_sat), None) => Ok(Box::new(EvaporatorCoil::with_superheat(ua, t_sat, default_superheat))),
(None, _) => Ok(Box::new(Evaporator::new(ua))),
}
}
"HeatExchanger" => {
let ua = get_param_f64(params, "ua")?;
let name = params
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("HeatExchanger");
let model = LmtdModel::new(ua, FlowConfiguration::CounterFlow);
let mut hx = HeatExchanger::new(model, name).with_fluid_backend(backend);
if params.contains_key("hot_fluid") {
let hot = parse_side_conditions(params, "hot")?;
hx = hx.with_hot_conditions(hot);
}
if params.contains_key("cold_fluid") {
let cold = parse_side_conditions(params, "cold")?;
hx = hx.with_cold_conditions(cold);
}
Ok(Box::new(hx))
}
"Compressor" => {
let speed_rpm = get_param_f64(params, "speed_rpm")?;
let displacement_m3 = get_param_f64(params, "displacement_m3")?;
let efficiency = params
.get("efficiency")
.and_then(|v| v.as_f64())
.unwrap_or(0.85);
let fluid = get_param_string(params, "fluid")?;
let m1 = params.get("m1").and_then(|v| v.as_f64()).unwrap_or(0.85);
let m2 = params.get("m2").and_then(|v| v.as_f64()).unwrap_or(2.5);
let m3 = params.get("m3").and_then(|v| v.as_f64()).unwrap_or(500.0);
let m4 = params.get("m4").and_then(|v| v.as_f64()).unwrap_or(1500.0);
let m5 = params.get("m5").and_then(|v| v.as_f64()).unwrap_or(-2.5);
let m6 = params.get("m6").and_then(|v| v.as_f64()).unwrap_or(1.8);
let m7 = params.get("m7").and_then(|v| v.as_f64()).unwrap_or(600.0);
let m8 = params.get("m8").and_then(|v| v.as_f64()).unwrap_or(1600.0);
let m9 = params.get("m9").and_then(|v| v.as_f64()).unwrap_or(-3.0);
let m10 = params.get("m10").and_then(|v| v.as_f64()).unwrap_or(2.0);
let comp = PyCompressor::new(&fluid, speed_rpm, displacement_m3, efficiency)
.with_coefficients(m1, m2, m3, m4, m5, m6, m7, m8, m9, m10);
Ok(Box::new(comp))
}
"ExpansionValve" => {
let fluid = get_param_string(params, "fluid")?;
let opening = params.get("opening").and_then(|v| v.as_f64()).unwrap_or(1.0);
let valve = PyExpansionValve::new(&fluid, opening);
Ok(Box::new(valve))
}
"Pump" => {
let name = params
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Pump");
Ok(Box::new(SimpleComponent::new(name, 0)))
}
"Placeholder" => {
let n_eqs = params.get("n_equations").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
Ok(Box::new(SimpleComponent::new("", n_eqs)))
}
_ => Err(CliError::Config(format!(
"Unknown component type: '{}'. Supported: Condenser, CondenserCoil, Evaporator, EvaporatorCoil, HeatExchanger, Compressor, ExpansionValve, Pump, Placeholder",
component_type
))),
}
}
/// Extract state entries from converged state.
fn extract_state(converged: &entropyk::ConvergedState) -> Vec<StateEntry> {
let state = &converged.state;
let edge_count = state.len() / 2;
(0..edge_count)
.map(|i| {
let p_pa = state[i * 2];
let h_j_kg = state[i * 2 + 1];
StateEntry {
edge: i,
pressure_bar: p_pa / 1e5,
enthalpy_kj_kg: h_j_kg / 1000.0,
}
})
.collect()
}
// =============================================================================
// Python-style components for CLI (no type-state pattern)
// =============================================================================
use entropyk_fluids::FluidId as FluidsFluidId;
use std::fmt;
struct SimpleComponent {
name: String,
n_eqs: usize,
}
impl SimpleComponent {
fn new(name: &str, n_eqs: usize) -> Self {
Self {
name: name.to_string(),
n_eqs,
}
}
}
impl entropyk::Component for SimpleComponent {
fn compute_residuals(
&self,
state: &entropyk::SystemState,
residuals: &mut entropyk::ResidualVector,
) -> Result<(), entropyk::ComponentError> {
for i in 0..self.n_eqs.min(residuals.len()) {
residuals[i] = if state.is_empty() {
0.0
} else {
state[i % state.len()] * 1e-3
};
}
Ok(())
}
fn jacobian_entries(
&self,
_state: &entropyk::SystemState,
jacobian: &mut entropyk::JacobianBuilder,
) -> Result<(), entropyk::ComponentError> {
for i in 0..self.n_eqs {
jacobian.add_entry(i, i, 1.0);
}
Ok(())
}
fn n_equations(&self) -> usize {
self.n_eqs
}
fn get_ports(&self) -> &[entropyk::ConnectedPort] {
&[]
}
}
impl fmt::Debug for SimpleComponent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SimpleComponent")
.field("name", &self.name)
.finish()
}
}
#[derive(Debug, Clone)]
struct PyCompressor {
fluid: FluidsFluidId,
speed_rpm: f64,
displacement_m3: f64,
efficiency: f64,
m1: f64,
m2: f64,
m3: f64,
m4: f64,
m5: f64,
m6: f64,
m7: f64,
m8: f64,
m9: f64,
m10: f64,
}
impl PyCompressor {
fn new(fluid: &str, speed_rpm: f64, displacement_m3: f64, efficiency: f64) -> Self {
Self {
fluid: FluidsFluidId::new(fluid),
speed_rpm,
displacement_m3,
efficiency,
m1: 0.85,
m2: 2.5,
m3: 500.0,
m4: 1500.0,
m5: -2.5,
m6: 1.8,
m7: 600.0,
m8: 1600.0,
m9: -3.0,
m10: 2.0,
}
}
fn with_coefficients(
mut self,
m1: f64,
m2: f64,
m3: f64,
m4: f64,
m5: f64,
m6: f64,
m7: f64,
m8: f64,
m9: f64,
m10: f64,
) -> Self {
self.m1 = m1;
self.m2 = m2;
self.m3 = m3;
self.m4 = m4;
self.m5 = m5;
self.m6 = m6;
self.m7 = m7;
self.m8 = m8;
self.m9 = m9;
self.m10 = m10;
self
}
}
impl entropyk::Component for PyCompressor {
fn compute_residuals(
&self,
state: &entropyk::SystemState,
residuals: &mut entropyk::ResidualVector,
) -> Result<(), entropyk::ComponentError> {
for r in residuals.iter_mut() {
*r = 0.0;
}
if state.len() >= 2 {
residuals[0] = state[0] * 1e-3;
residuals[1] = state[1] * 1e-3;
}
Ok(())
}
fn jacobian_entries(
&self,
_state: &entropyk::SystemState,
jacobian: &mut entropyk::JacobianBuilder,
) -> Result<(), entropyk::ComponentError> {
jacobian.add_entry(0, 0, 1.0);
jacobian.add_entry(1, 1, 1.0);
Ok(())
}
fn n_equations(&self) -> usize {
2
}
fn get_ports(&self) -> &[entropyk::ConnectedPort] {
&[]
}
}
#[derive(Debug, Clone)]
struct PyExpansionValve {
fluid: FluidsFluidId,
opening: f64,
}
impl PyExpansionValve {
fn new(fluid: &str, opening: f64) -> Self {
Self {
fluid: FluidsFluidId::new(fluid),
opening,
}
}
}
impl entropyk::Component for PyExpansionValve {
fn compute_residuals(
&self,
state: &entropyk::SystemState,
residuals: &mut entropyk::ResidualVector,
) -> Result<(), entropyk::ComponentError> {
for r in residuals.iter_mut() {
*r = 0.0;
}
if !state.is_empty() {
residuals[0] = state[0] * 1e-3;
}
Ok(())
}
fn jacobian_entries(
&self,
_state: &entropyk::SystemState,
jacobian: &mut entropyk::JacobianBuilder,
) -> Result<(), entropyk::ComponentError> {
jacobian.add_entry(0, 0, 1.0);
Ok(())
}
fn n_equations(&self) -> usize {
1
}
fn get_ports(&self) -> &[entropyk::ConnectedPort] {
&[]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_simulation_status_serialization() {
let status = SimulationStatus::Converged;
let json = serde_json::to_string(&status).unwrap();
assert_eq!(json, "\"converged\"");
let status = SimulationStatus::NonConverged;
let json = serde_json::to_string(&status).unwrap();
assert_eq!(json, "\"non_converged\"");
}
#[test]
fn test_simulation_result_serialization() {
let result = SimulationResult {
input: "test.json".to_string(),
status: SimulationStatus::Converged,
convergence: Some(ConvergenceInfo {
final_residual: 1e-8,
tolerance: 1e-6,
}),
iterations: Some(25),
state: Some(vec![StateEntry {
edge: 0,
pressure_bar: 10.0,
enthalpy_kj_kg: 400.0,
}]),
performance: None,
error: None,
elapsed_ms: 50,
};
let json = serde_json::to_string_pretty(&result).unwrap();
assert!(json.contains("\"status\": \"converged\""));
assert!(json.contains("\"iterations\": 25"));
}
}