Fix bugs from 5-2 code review

This commit is contained in:
Sepehr
2026-02-21 10:43:55 +01:00
parent 400f1c420e
commit 0d9a0e4231
27 changed files with 9838 additions and 114 deletions

View 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);
}
}