Fix bugs from 5-2 code review
This commit is contained in:
783
crates/solver/src/macro_component.rs
Normal file
783
crates/solver/src/macro_component.rs
Normal file
@@ -0,0 +1,783 @@
|
||||
//! Hierarchical Subsystems — MacroComponent
|
||||
//!
|
||||
//! A `MacroComponent` wraps a finalized [`System`] (topology + components) and
|
||||
//! exposes it as a single [`Component`], enabling hierarchical composition.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! ┌─────────────────── MacroComponent ───────────────────┐
|
||||
//! │ internal System (finalized) │
|
||||
//! │ ┌─────┐ edge_a ┌─────┐ edge_b ┌─────┐ │
|
||||
//! │ │Comp0├──────────►│Comp1├──────────►│Comp2│ │
|
||||
//! │ └─────┘ └─────┘ └─────┘ │
|
||||
//! │ │
|
||||
//! │ external ports ← port_map │
|
||||
//! │ port 0: edge_a.inlet (in) │
|
||||
//! │ port 1: edge_b.outlet (out) │
|
||||
//! └──────────────────────────────────────────────────────┘
|
||||
//! ```
|
||||
//!
|
||||
//! ## Index Mapping & Coupling Equations
|
||||
//!
|
||||
//! The global solver assigns indices to all edges in the parent `System`.
|
||||
//! Edges *inside* a `MacroComponent` are addressed via `global_state_offset`
|
||||
//! (set during `System::finalize()` via `set_system_context`).
|
||||
//!
|
||||
//! When the `MacroComponent` is connected to external edges in the parent graph,
|
||||
//! coupling residuals enforce continuity between those external edges and the
|
||||
//! corresponding exposed internal edges:
|
||||
//!
|
||||
//! ```text
|
||||
//! r_P = state[p_ext] − state[offset + 2 * internal_edge_pos] = 0
|
||||
//! r_h = state[h_ext] − state[offset + 2 * internal_edge_pos + 1] = 0
|
||||
//! ```
|
||||
//!
|
||||
//! ## Serialization (AC #4)
|
||||
//!
|
||||
//! The full component graph inside a `MacroComponent` cannot be trivially
|
||||
//! serialized because `Box<dyn Component>` requires `typetag` or a custom
|
||||
//! registry (deferred). Instead, `MacroComponent` exposes a **state snapshot**
|
||||
//! ([`MacroComponentSnapshot`]) that captures the internal edge states and port
|
||||
//! mappings. This is sufficient for persistence / restore of operating-point data.
|
||||
|
||||
use crate::system::System;
|
||||
use entropyk_components::{
|
||||
Component, ComponentError, ConnectedPort, JacobianBuilder, ResidualVector, SystemState,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Port mapping
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// An exposed port on the MacroComponent surface.
|
||||
///
|
||||
/// Maps an internal edge (by position) to an external port visible to the
|
||||
/// parent `System`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PortMapping {
|
||||
/// Human-readable name for the external port (e.g. "evap_water_in").
|
||||
pub name: String,
|
||||
/// The internal edge index (position in the internal System's edge iteration
|
||||
/// order) whose state this port corresponds to.
|
||||
pub internal_edge_pos: usize,
|
||||
/// A connected port to present externally (fluid, P, h).
|
||||
pub port: ConnectedPort,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Serialization snapshot
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A serializable snapshot of a `MacroComponent`'s operating state.
|
||||
///
|
||||
/// Captures the internal edge state vector and port metadata so that an
|
||||
/// operating point can be saved to disk and restored. The full component
|
||||
/// topology (graph structure, `Box<dyn Component>` nodes) is **not** included
|
||||
/// — reconstruction of the topology is the caller's responsibility.
|
||||
///
|
||||
/// # Example (JSON)
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "label": "chiller_1",
|
||||
/// "internal_edge_states": [1.5e5, 4.2e5, 8.0e4, 3.8e5],
|
||||
/// "port_names": ["evap_in", "evap_out"]
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct MacroComponentSnapshot {
|
||||
/// Optional human-readable label for the subsystem.
|
||||
pub label: Option<String>,
|
||||
/// Flat state vector for the internal edges: `[P_e0, h_e0, P_e1, h_e1, ...]`.
|
||||
pub internal_edge_states: Vec<f64>,
|
||||
/// Names of exposed ports, in the same order as `port_mappings`.
|
||||
pub port_names: Vec<String>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MacroComponent
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A hierarchical subsystem that wraps a `System` and implements `Component`.
|
||||
///
|
||||
/// This enables Modelica-style block composition: a chiller (compressor +
|
||||
/// condenser + valve + evaporator) can be wrapped in a `MacroComponent` and
|
||||
/// plugged into a higher-level `System`.
|
||||
///
|
||||
/// # Coupling equations
|
||||
///
|
||||
/// When `set_system_context` is called by `System::finalize()`, the component
|
||||
/// stores the state indices of every parent-graph edge incident to its node.
|
||||
/// `compute_residuals` then appends 2 coupling residuals per exposed port:
|
||||
///
|
||||
/// ```text
|
||||
/// r_P[i] = state[p_ext_i] − state[offset + 2·internal_edge_pos_i] = 0
|
||||
/// r_h[i] = state[h_ext_i] − state[offset + 2·internal_edge_pos_i + 1] = 0
|
||||
/// ```
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// ```no_run
|
||||
/// use entropyk_solver::{System, MacroComponent};
|
||||
/// use entropyk_components::Component;
|
||||
///
|
||||
/// // 1. Build and finalize internal system
|
||||
/// let mut internal = System::new();
|
||||
/// // ... add components & edges ...
|
||||
/// internal.finalize().unwrap();
|
||||
///
|
||||
/// // 2. Wrap into a MacroComponent
|
||||
/// let macro_comp = MacroComponent::new(internal);
|
||||
///
|
||||
/// // 3. Optionally expose ports
|
||||
/// // macro_comp.expose_port(0, "inlet", port);
|
||||
///
|
||||
/// // 4. Add to a parent System (finalize() automatically wires context)
|
||||
/// let mut parent = System::new();
|
||||
/// parent.add_component(Box::new(macro_comp));
|
||||
/// ```
|
||||
pub struct MacroComponent {
|
||||
/// The enclosed, finalized subsystem.
|
||||
internal: System,
|
||||
/// External port mappings. Ordered; index = external port index.
|
||||
port_mappings: Vec<PortMapping>,
|
||||
/// Cached external ports (mirrors port_mappings order).
|
||||
external_ports: Vec<ConnectedPort>,
|
||||
/// Maps external-port-index → internal-edge-position for fast lookup.
|
||||
ext_to_internal_edge: HashMap<usize, usize>,
|
||||
/// The global state vector offset assigned to this MacroComponent's first
|
||||
/// internal edge. Set automatically via `set_system_context` during parent
|
||||
/// `System::finalize()`. Defaults to 0.
|
||||
global_state_offset: usize,
|
||||
/// State indices `(p_idx, h_idx)` of every parent-graph edge incident to
|
||||
/// this node (incoming and outgoing), in traversal order.
|
||||
/// Populated by `set_system_context`; empty until finalization.
|
||||
external_edge_state_indices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl MacroComponent {
|
||||
/// Creates a new `MacroComponent` wrapping the given *finalized* system.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the internal system has not been finalized.
|
||||
pub fn new(internal: System) -> Self {
|
||||
Self {
|
||||
internal,
|
||||
port_mappings: Vec::new(),
|
||||
external_ports: Vec::new(),
|
||||
ext_to_internal_edge: HashMap::new(),
|
||||
global_state_offset: 0,
|
||||
external_edge_state_indices: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Exposes an internal edge as an external port on the MacroComponent.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `internal_edge_pos` — Position of the edge in the internal system's
|
||||
/// edge iteration order (0-based).
|
||||
/// * `name` — Human-readable label for this external port.
|
||||
/// * `port` — A `ConnectedPort` representing the fluid, pressure and
|
||||
/// enthalpy at this interface.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `internal_edge_pos >= internal.edge_count()`.
|
||||
pub fn expose_port(
|
||||
&mut self,
|
||||
internal_edge_pos: usize,
|
||||
name: impl Into<String>,
|
||||
port: ConnectedPort,
|
||||
) {
|
||||
assert!(
|
||||
internal_edge_pos < self.internal.edge_count(),
|
||||
"internal_edge_pos {} out of range (internal has {} edges)",
|
||||
internal_edge_pos,
|
||||
self.internal.edge_count()
|
||||
);
|
||||
|
||||
let ext_idx = self.port_mappings.len();
|
||||
self.port_mappings.push(PortMapping {
|
||||
name: name.into(),
|
||||
internal_edge_pos,
|
||||
port: port.clone(),
|
||||
});
|
||||
self.external_ports.push(port);
|
||||
self.ext_to_internal_edge.insert(ext_idx, internal_edge_pos);
|
||||
}
|
||||
|
||||
/// Sets the global state-vector offset for this MacroComponent.
|
||||
///
|
||||
/// Prefer letting `System::finalize()` set this automatically via
|
||||
/// `set_system_context`. This setter is kept for backward compatibility
|
||||
/// and for manual test scenarios.
|
||||
pub fn set_global_state_offset(&mut self, offset: usize) {
|
||||
self.global_state_offset = offset;
|
||||
}
|
||||
|
||||
/// Returns the global state offset.
|
||||
pub fn global_state_offset(&self) -> usize {
|
||||
self.global_state_offset
|
||||
}
|
||||
|
||||
/// Returns a reference to the internal system.
|
||||
pub fn internal_system(&self) -> &System {
|
||||
&self.internal
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the internal system.
|
||||
pub fn internal_system_mut(&mut self) -> &mut System {
|
||||
&mut self.internal
|
||||
}
|
||||
|
||||
/// Returns the port mappings.
|
||||
pub fn port_mappings(&self) -> &[PortMapping] {
|
||||
&self.port_mappings
|
||||
}
|
||||
|
||||
/// Number of internal edges (each contributes 2 state variables: P, h).
|
||||
pub fn internal_edge_count(&self) -> usize {
|
||||
self.internal.edge_count()
|
||||
}
|
||||
|
||||
/// Total number of internal state variables (2 per edge).
|
||||
pub fn internal_state_len(&self) -> usize {
|
||||
self.internal.state_vector_len()
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Number of equations from internal components (excluding coupling eqs).
|
||||
fn n_internal_equations(&self) -> usize {
|
||||
self.internal
|
||||
.traverse_for_jacobian()
|
||||
.map(|(_, c, _)| c.n_equations())
|
||||
.sum()
|
||||
}
|
||||
|
||||
// ─── snapshot ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Captures the current internal state as a serializable snapshot.
|
||||
///
|
||||
/// The caller must supply the global state vector so that internal edge
|
||||
/// states can be extracted. Returns `None` if the state vector is shorter
|
||||
/// than expected.
|
||||
pub fn to_snapshot(
|
||||
&self,
|
||||
global_state: &SystemState,
|
||||
label: Option<String>,
|
||||
) -> Option<MacroComponentSnapshot> {
|
||||
let start = self.global_state_offset;
|
||||
let end = start + self.internal_state_len();
|
||||
if global_state.len() < end {
|
||||
return None;
|
||||
}
|
||||
Some(MacroComponentSnapshot {
|
||||
label,
|
||||
internal_edge_states: global_state[start..end].to_vec(),
|
||||
port_names: self.port_mappings.iter().map(|m| m.name.clone()).collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Component trait implementation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
impl Component for MacroComponent {
|
||||
/// Called by `System::finalize()` to inject the parent-level state offset
|
||||
/// and the external edge state indices for this MacroComponent node.
|
||||
///
|
||||
/// `external_edge_state_indices` contains one `(p_idx, h_idx)` pair per
|
||||
/// parent edge incident to this node (in traversal order: incoming, then
|
||||
/// outgoing). The *i*-th entry is matched to `port_mappings[i]` when
|
||||
/// emitting coupling residuals.
|
||||
fn set_system_context(
|
||||
&mut self,
|
||||
state_offset: usize,
|
||||
external_edge_state_indices: &[(usize, usize)],
|
||||
) {
|
||||
self.global_state_offset = state_offset;
|
||||
self.external_edge_state_indices = external_edge_state_indices.to_vec();
|
||||
}
|
||||
|
||||
fn internal_state_len(&self) -> usize {
|
||||
// Delegates to the inherent method or computes directly
|
||||
self.internal.state_vector_len()
|
||||
}
|
||||
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &SystemState,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
let n_internal_vars = self.internal_state_len();
|
||||
let start = self.global_state_offset;
|
||||
let end = start + n_internal_vars;
|
||||
|
||||
if state.len() < end {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: end,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let n_int_eqs = self.n_internal_equations();
|
||||
let n_coupling = 2 * self.port_mappings.len();
|
||||
let n_total = n_int_eqs + n_coupling;
|
||||
|
||||
if residuals.len() < n_total {
|
||||
return Err(ComponentError::InvalidResidualDimensions {
|
||||
expected: n_total,
|
||||
actual: residuals.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// --- 1. Delegate internal residuals ----------------------------------
|
||||
let internal_state: SystemState = state[start..end].to_vec();
|
||||
let mut internal_residuals = vec![0.0; n_int_eqs];
|
||||
self.internal
|
||||
.compute_residuals(&internal_state, &mut internal_residuals)?;
|
||||
residuals[..n_int_eqs].copy_from_slice(&internal_residuals);
|
||||
|
||||
// --- 2. Port-coupling residuals --------------------------------------
|
||||
// For each exposed port mapping we append two residuals that enforce
|
||||
// continuity between the parent-graph external edge and the
|
||||
// corresponding internal edge:
|
||||
//
|
||||
// r_P = state[p_ext] − state[offset + 2·internal_edge_pos] = 0
|
||||
// r_h = state[h_ext] − state[offset + 2·internal_edge_pos + 1] = 0
|
||||
for (i, mapping) in self.port_mappings.iter().enumerate() {
|
||||
if let Some(&(p_ext, h_ext)) = self.external_edge_state_indices.get(i) {
|
||||
let int_p = self.global_state_offset + 2 * mapping.internal_edge_pos;
|
||||
let int_h = int_p + 1;
|
||||
|
||||
if state.len() <= int_h || state.len() <= p_ext || state.len() <= h_ext {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: int_h.max(p_ext).max(h_ext) + 1,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
|
||||
residuals[n_int_eqs + 2 * i] = state[p_ext] - state[int_p];
|
||||
residuals[n_int_eqs + 2 * i + 1] = state[h_ext] - state[int_h];
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
state: &SystemState,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
let n_internal_vars = self.internal_state_len();
|
||||
let start = self.global_state_offset;
|
||||
let end = start + n_internal_vars;
|
||||
|
||||
if state.len() < end {
|
||||
return Err(ComponentError::InvalidStateDimensions {
|
||||
expected: end,
|
||||
actual: state.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let n_int_eqs = self.n_internal_equations();
|
||||
|
||||
// --- 1. Internal Jacobian entries ------------------------------------
|
||||
let internal_state: SystemState = state[start..end].to_vec();
|
||||
|
||||
let mut internal_jac = JacobianBuilder::new();
|
||||
self.internal
|
||||
.assemble_jacobian(&internal_state, &mut internal_jac)?;
|
||||
|
||||
// Offset columns by global_state_offset to translate from internal-local
|
||||
// to global column indices.
|
||||
for &(row, col, val) in internal_jac.entries() {
|
||||
jacobian.add_entry(row, col + self.global_state_offset, val);
|
||||
}
|
||||
|
||||
// --- 2. Coupling Jacobian entries ------------------------------------
|
||||
// For each coupling residual pair (row_p, row_h):
|
||||
//
|
||||
// ∂r_P/∂state[p_ext] = +1
|
||||
// ∂r_P/∂state[int_p] = −1
|
||||
// ∂r_h/∂state[h_ext] = +1
|
||||
// ∂r_h/∂state[int_h] = −1
|
||||
for (i, mapping) in self.port_mappings.iter().enumerate() {
|
||||
if let Some(&(p_ext, h_ext)) = self.external_edge_state_indices.get(i) {
|
||||
let int_p = self.global_state_offset + 2 * mapping.internal_edge_pos;
|
||||
let int_h = int_p + 1;
|
||||
let row_p = n_int_eqs + 2 * i;
|
||||
let row_h = row_p + 1;
|
||||
|
||||
jacobian.add_entry(row_p, p_ext, 1.0);
|
||||
jacobian.add_entry(row_p, int_p, -1.0);
|
||||
jacobian.add_entry(row_h, h_ext, 1.0);
|
||||
jacobian.add_entry(row_h, int_h, -1.0);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
// Internal equations + 2 coupling equations per exposed port.
|
||||
self.n_internal_equations() + 2 * self.port_mappings.len()
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&self.external_ports
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::system::System;
|
||||
use entropyk_components::port::{FluidId, Port};
|
||||
use entropyk_core::{Enthalpy, Pressure};
|
||||
|
||||
/// Minimal mock component for testing.
|
||||
struct MockInternalComponent {
|
||||
n_equations: usize,
|
||||
}
|
||||
|
||||
impl Component for MockInternalComponent {
|
||||
fn compute_residuals(
|
||||
&self,
|
||||
state: &SystemState,
|
||||
residuals: &mut ResidualVector,
|
||||
) -> Result<(), ComponentError> {
|
||||
// Simple identity: residual[i] = state[i] (so zero when state is zero)
|
||||
for i in 0..self.n_equations {
|
||||
residuals[i] = state.get(i).copied().unwrap_or(0.0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn jacobian_entries(
|
||||
&self,
|
||||
_state: &SystemState,
|
||||
jacobian: &mut JacobianBuilder,
|
||||
) -> Result<(), ComponentError> {
|
||||
for i in 0..self.n_equations {
|
||||
jacobian.add_entry(i, i, 1.0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn n_equations(&self) -> usize {
|
||||
self.n_equations
|
||||
}
|
||||
|
||||
fn get_ports(&self) -> &[ConnectedPort] {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
fn make_mock(n: usize) -> Box<dyn Component> {
|
||||
Box::new(MockInternalComponent { n_equations: n })
|
||||
}
|
||||
|
||||
fn make_connected_port(fluid: &str, p_pa: f64, h_jkg: f64) -> ConnectedPort {
|
||||
let p1 = Port::new(
|
||||
FluidId::new(fluid),
|
||||
Pressure::from_pascals(p_pa),
|
||||
Enthalpy::from_joules_per_kg(h_jkg),
|
||||
);
|
||||
let p2 = Port::new(
|
||||
FluidId::new(fluid),
|
||||
Pressure::from_pascals(p_pa),
|
||||
Enthalpy::from_joules_per_kg(h_jkg),
|
||||
);
|
||||
let (c1, _c2) = p1.connect(p2).unwrap();
|
||||
c1
|
||||
}
|
||||
|
||||
/// Build a simple linear subsystem: A → B → C (2 edges, 3 components).
|
||||
fn build_simple_internal_system() -> System {
|
||||
let mut sys = System::new();
|
||||
let a = sys.add_component(make_mock(2));
|
||||
let b = sys.add_component(make_mock(2));
|
||||
let c = sys.add_component(make_mock(2));
|
||||
sys.add_edge(a, b).unwrap();
|
||||
sys.add_edge(b, c).unwrap();
|
||||
sys.finalize().unwrap();
|
||||
sys
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_macro_component_creation() {
|
||||
let sys = build_simple_internal_system();
|
||||
let mc = MacroComponent::new(sys);
|
||||
|
||||
// 3 components × 2 equations each = 6 equations (no ports exposed yet,
|
||||
// so no coupling equations).
|
||||
assert_eq!(mc.n_equations(), 6);
|
||||
// 2 edges → 4 state variables
|
||||
assert_eq!(mc.internal_state_len(), 4);
|
||||
// No ports exposed yet
|
||||
assert!(mc.get_ports().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expose_port_adds_coupling_equations() {
|
||||
let sys = build_simple_internal_system();
|
||||
let mut mc = MacroComponent::new(sys);
|
||||
|
||||
let port = make_connected_port("R134a", 100_000.0, 400_000.0);
|
||||
mc.expose_port(0, "inlet", port.clone());
|
||||
|
||||
// 6 internal + 2 coupling = 8
|
||||
assert_eq!(mc.n_equations(), 8);
|
||||
assert_eq!(mc.get_ports().len(), 1);
|
||||
assert_eq!(mc.port_mappings()[0].name, "inlet");
|
||||
assert_eq!(mc.port_mappings()[0].internal_edge_pos, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expose_multiple_ports() {
|
||||
let sys = build_simple_internal_system();
|
||||
let mut mc = MacroComponent::new(sys);
|
||||
|
||||
let port_in = make_connected_port("R134a", 100_000.0, 400_000.0);
|
||||
let port_out = make_connected_port("R134a", 500_000.0, 450_000.0);
|
||||
|
||||
mc.expose_port(0, "inlet", port_in);
|
||||
mc.expose_port(1, "outlet", port_out);
|
||||
|
||||
// 6 internal + 4 coupling = 10
|
||||
assert_eq!(mc.n_equations(), 10);
|
||||
assert_eq!(mc.get_ports().len(), 2);
|
||||
assert_eq!(mc.port_mappings()[0].name, "inlet");
|
||||
assert_eq!(mc.port_mappings()[1].name, "outlet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "internal_edge_pos 5 out of range")]
|
||||
fn test_expose_port_out_of_range() {
|
||||
let sys = build_simple_internal_system();
|
||||
let mut mc = MacroComponent::new(sys);
|
||||
let port = make_connected_port("R134a", 100_000.0, 400_000.0);
|
||||
mc.expose_port(5, "bad", port);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_residuals_delegation() {
|
||||
let sys = build_simple_internal_system();
|
||||
let mc = MacroComponent::new(sys);
|
||||
|
||||
// 4 state variables for 2 internal edges (no external coupling)
|
||||
let state = vec![1.0, 2.0, 3.0, 4.0];
|
||||
let mut residuals = vec![0.0; mc.n_equations()];
|
||||
|
||||
mc.compute_residuals(&state, &mut residuals).unwrap();
|
||||
|
||||
// 6 equations (no coupling ports)
|
||||
assert_eq!(residuals.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_residuals_with_offset() {
|
||||
let sys = build_simple_internal_system();
|
||||
let mut mc = MacroComponent::new(sys);
|
||||
mc.set_global_state_offset(4);
|
||||
|
||||
// State vector: 4 padding + 4 internal = 8 total
|
||||
let state = vec![0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0];
|
||||
let mut residuals = vec![0.0; mc.n_equations()];
|
||||
|
||||
mc.compute_residuals(&state, &mut residuals).unwrap();
|
||||
assert_eq!(residuals.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_residuals_state_too_short() {
|
||||
let sys = build_simple_internal_system();
|
||||
let mut mc = MacroComponent::new(sys);
|
||||
mc.set_global_state_offset(4);
|
||||
|
||||
let state = vec![0.0; 5]; // Needs at least 8 (offset 4 + 4 internal vars)
|
||||
let mut residuals = vec![0.0; mc.n_equations()];
|
||||
|
||||
let result = mc.compute_residuals(&state, &mut residuals);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jacobian_entries_delegation() {
|
||||
let sys = build_simple_internal_system();
|
||||
let mc = MacroComponent::new(sys);
|
||||
|
||||
let state = vec![0.0; mc.internal_state_len()];
|
||||
let mut jac = JacobianBuilder::new();
|
||||
|
||||
mc.jacobian_entries(&state, &mut jac).unwrap();
|
||||
|
||||
// 6 equations → at least 6 diagonal entries from internal mocks
|
||||
assert!(jac.len() >= 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jacobian_entries_with_offset() {
|
||||
let sys = build_simple_internal_system();
|
||||
let mut mc = MacroComponent::new(sys);
|
||||
mc.set_global_state_offset(10);
|
||||
|
||||
let state = vec![0.0; 10 + mc.internal_state_len()];
|
||||
let mut jac = JacobianBuilder::new();
|
||||
|
||||
mc.jacobian_entries(&state, &mut jac).unwrap();
|
||||
|
||||
// Verify internal-delegated columns are offset by 10
|
||||
for &(_, col, _) in jac.entries() {
|
||||
assert!(col >= 10, "Column {} should be >= 10 (offset)", col);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_coupling_residuals_and_jacobian() {
|
||||
// 2-edge internal system: edge0 = (P0, h0), edge1 = (P1, h1)
|
||||
let sys = build_simple_internal_system();
|
||||
let mut mc = MacroComponent::new(sys);
|
||||
|
||||
// Expose internal edge 0 as port "inlet"
|
||||
let port = make_connected_port("R134a", 100_000.0, 400_000.0);
|
||||
mc.expose_port(0, "inlet", port);
|
||||
|
||||
// Simulate finalization: inject external edge state index (p=6, h=7)
|
||||
// and global offset = 4 (4 parent edges before the macro's internal block).
|
||||
mc.set_global_state_offset(4);
|
||||
mc.set_system_context(4, &[(6, 7)]);
|
||||
|
||||
// Global state: [*0, 1, 2, 3*, 4, 5, 6, 7, P_ext=1e5, h_ext=4e5]
|
||||
// ^--- internal block at [4..8]
|
||||
// ^--- ext edge at (6,7)... wait,
|
||||
// Let's use a concrete layout:
|
||||
// indices 0..3: some other parent edges
|
||||
// indices 4..7: internal block (2 edges * 2 vars)
|
||||
// 4=P_int_e0, 5=h_int_e0, 6=P_int_e1, 7=h_int_e1
|
||||
// indices 8,9: external edge (p_ext=8, h_ext=9)
|
||||
mc.set_system_context(4, &[(8, 9)]);
|
||||
|
||||
let mut state = vec![0.0; 10];
|
||||
state[4] = 1.5e5; // P_int_e0
|
||||
state[5] = 3.9e5; // h_int_e0
|
||||
state[8] = 2.0e5; // P_ext
|
||||
state[9] = 4.1e5; // h_ext
|
||||
|
||||
let n_eqs = mc.n_equations(); // 6 internal + 2 coupling = 8
|
||||
assert_eq!(n_eqs, 8);
|
||||
|
||||
let mut residuals = vec![0.0; n_eqs];
|
||||
mc.compute_residuals(&state, &mut residuals).unwrap();
|
||||
|
||||
// Coupling residuals:
|
||||
// r[6] = state[8] - state[4] = 2e5 - 1.5e5 = 0.5e5
|
||||
// r[7] = state[9] - state[5] = 4.1e5 - 3.9e5 = 0.2e5
|
||||
assert!(
|
||||
(residuals[6] - 0.5e5).abs() < 1.0,
|
||||
"r_P mismatch: {}",
|
||||
residuals[6]
|
||||
);
|
||||
assert!(
|
||||
(residuals[7] - 0.2e5).abs() < 1.0,
|
||||
"r_h mismatch: {}",
|
||||
residuals[7]
|
||||
);
|
||||
|
||||
// Jacobian coupling entries
|
||||
let mut jac = JacobianBuilder::new();
|
||||
mc.jacobian_entries(&state, &mut jac).unwrap();
|
||||
|
||||
let entries = jac.entries();
|
||||
// Check that we have entries for p_ext=8 → +1, int_p=4 → -1
|
||||
let find = |row: usize, col: usize| -> Option<f64> {
|
||||
entries
|
||||
.iter()
|
||||
.find(|&&(r, c, _)| r == row && c == col)
|
||||
.map(|&(_, _, v)| v)
|
||||
};
|
||||
assert_eq!(find(6, 8), Some(1.0), "expect ∂r_P/∂p_ext = +1");
|
||||
assert_eq!(find(6, 4), Some(-1.0), "expect ∂r_P/∂int_p = -1");
|
||||
assert_eq!(find(7, 9), Some(1.0), "expect ∂r_h/∂h_ext = +1");
|
||||
assert_eq!(find(7, 5), Some(-1.0), "expect ∂r_h/∂int_h = -1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_n_equations_empty_system() {
|
||||
let mut sys = System::new();
|
||||
let a = sys.add_component(make_mock(0));
|
||||
let b = sys.add_component(make_mock(0));
|
||||
sys.add_edge(a, b).unwrap();
|
||||
sys.finalize().unwrap();
|
||||
|
||||
let mc = MacroComponent::new(sys);
|
||||
assert_eq!(mc.n_equations(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_macro_component_as_trait_object() {
|
||||
let sys = build_simple_internal_system();
|
||||
let mc = MacroComponent::new(sys);
|
||||
|
||||
// Verify it can be used as Box<dyn Component>
|
||||
let component: Box<dyn Component> = Box::new(mc);
|
||||
assert_eq!(component.n_equations(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_macro_component_in_parent_system() {
|
||||
// Build an internal subsystem
|
||||
let internal = build_simple_internal_system();
|
||||
let mc = MacroComponent::new(internal);
|
||||
|
||||
// Place it in a parent system alongside another component
|
||||
let mut parent = System::new();
|
||||
let mc_node = parent.add_component(Box::new(mc));
|
||||
let other = parent.add_component(make_mock(2));
|
||||
parent.add_edge(mc_node, other).unwrap();
|
||||
parent.finalize().unwrap();
|
||||
|
||||
// Parent should have 2 nodes and 1 edge
|
||||
assert_eq!(parent.node_count(), 2);
|
||||
assert_eq!(parent.edge_count(), 1);
|
||||
}
|
||||
|
||||
// ── Serialization snapshot ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_snapshot_round_trip() {
|
||||
let sys = build_simple_internal_system();
|
||||
let mut mc = MacroComponent::new(sys);
|
||||
mc.set_global_state_offset(0);
|
||||
|
||||
let port = make_connected_port("R134a", 1e5, 4e5);
|
||||
mc.expose_port(0, "inlet", port);
|
||||
|
||||
// Fake global state with known values in the internal block [0..4]
|
||||
let global_state = vec![1.5e5, 3.9e5, 8.0e4, 4.2e5];
|
||||
let snap = mc
|
||||
.to_snapshot(&global_state, Some("chiller_1".into()))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(snap.label.as_deref(), Some("chiller_1"));
|
||||
assert_eq!(snap.internal_edge_states.len(), 4);
|
||||
assert_eq!(snap.port_names, vec!["inlet"]);
|
||||
|
||||
// Round-trip through JSON
|
||||
let json = serde_json::to_string(&snap).unwrap();
|
||||
let restored: MacroComponentSnapshot = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(restored.internal_edge_states, snap.internal_edge_states);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user