//! 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. With the stride-3 `(ṁ, P, h)` layout //! introduced in Story CM1.2, P is at `offset + 3 * pos + 1` and h at //! `offset + 3 * pos + 2` (resolved symbolically via `edge_state_indices()`): //! //! ```text //! r_P = state[p_ext] − state[offset + p_local] = 0 //! r_h = state[h_ext] − state[offset + h_local] = 0 //! ``` //! //! Note: ṁ continuity at ports is not yet enforced (deferred to Story CM1.3). //! //! ## Serialization (AC #4) //! //! The full component graph inside a `MacroComponent` cannot be trivially //! serialized because `Box` 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, StateSlice, }; 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` nodes) is **not** included /// — reconstruction of the topology is the caller's responsibility. /// /// # Example (JSON) /// /// ```json /// { /// "label": "chiller_1", /// "internal_edge_states": [0.05, 1.5e5, 4.2e5, 0.05, 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, /// Flat state vector for the internal edges: `[ṁ_e0, P_e0, h_e0, ṁ_e1, P_e1, h_e1, ...]`. /// /// Per-edge layout is `(ṁ, P, h)` (stride 3, introduced in Story CM1.2). pub internal_edge_states: Vec, /// Names of exposed ports, in the same order as `port_mappings`. pub port_names: Vec, } // ───────────────────────────────────────────────────────────────────────────── // 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 + p_local_i] = 0 /// r_h[i] = state[h_ext_i] − state[offset + h_local_i] = 0 /// ``` /// (where `p_local_i`, `h_local_i` are the internal-local state indices of the /// mapped internal edge — stride 3 in the `(ṁ, P, h)` layout) /// /// TEMP (CM1.2): ṁ continuity at ports is not yet coupled (`r_m = ṁ_ext − ṁ_int` /// is missing). Each side has its own independent mass-flow closure (`ṁ = seed`). /// Story CM1.3 must add the ṁ coupling residual + Jacobian entries here, and /// update `n_equations()` from `2 * n_ports` to `3 * n_ports`. /// /// # 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, /// Cached external ports (mirrors port_mappings order). external_ports: Vec, /// Maps external-port-index → internal-edge-position for fast lookup. ext_to_internal_edge: HashMap, /// 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 `(m_idx, 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, 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, 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 3 state variables: ṁ, P, h). pub fn internal_edge_count(&self) -> usize { self.internal.edge_count() } /// Total number of internal state variables (3 per edge: ṁ, P, h). 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() } /// Number of internal residuals produced by `internal.compute_residuals`: /// component equations plus constraint and coupling rows. fn n_internal_residuals(&self) -> usize { self.n_internal_equations() + self.internal.constraint_residual_count() + self.internal.coupling_residual_count() } /// Internal-local `(p_idx, h_idx)` of the internal edge at graph-order /// position `pos`, accounting for the per-edge stride (CM1.2: `(ṁ, P, h)`). /// Returns `None` if no such internal edge exists. fn internal_edge_ph_local(&self, pos: usize) -> Option<(usize, usize)> { self.internal .edge_indices() .nth(pos) .map(|e| self.internal.edge_state_indices(e)) } // ─── 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: &StateSlice, label: Option, ) -> Option { 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 `(m_idx, p_idx, h_idx)` triple 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, 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: &StateSlice, 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_res = self.n_internal_residuals(); let n_coupling = 2 * self.port_mappings.len(); let n_total = n_int_res + n_coupling; if residuals.len() < n_total { return Err(ComponentError::InvalidResidualDimensions { expected: n_total, actual: residuals.len(), }); } // --- 1. Delegate internal residuals ---------------------------------- let internal_state: Vec = state[start..end].to_vec(); let mut internal_residuals = vec![0.0; n_int_res]; self.internal .compute_residuals(&internal_state, &mut internal_residuals)?; residuals[..n_int_res].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 + p_local] = 0 // r_h = state[h_ext] − state[offset + h_local] = 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 (p_local, h_local) = self .internal_edge_ph_local(mapping.internal_edge_pos) .ok_or(ComponentError::InvalidStateDimensions { expected: mapping.internal_edge_pos, actual: self.internal.edge_count(), })?; let int_p = self.global_state_offset + p_local; let int_h = self.global_state_offset + h_local; 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_res + 2 * i] = state[p_ext] - state[int_p]; residuals[n_int_res + 2 * i + 1] = state[h_ext] - state[int_h]; } } Ok(()) } fn jacobian_entries( &self, state: &StateSlice, 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_res = self.n_internal_residuals(); // --- 1. Internal Jacobian entries ------------------------------------ let internal_state: Vec = 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 (p_local, h_local) = match self.internal_edge_ph_local(mapping.internal_edge_pos) { Some(v) => v, None => continue, }; let int_p = self.global_state_offset + p_local; let int_h = self.global_state_offset + h_local; let row_p = n_int_res + 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 residuals (component eqs + mass-flow closures) + 2 coupling // equations per exposed port (P and h only). // TEMP (CM1.2): becomes `3 * n_ports` in CM1.3 when ṁ coupling is added. self.n_internal_residuals() + 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: &StateSlice, 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: &StateSlice, 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 { 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). /// /// Intentionally not square (6 eqs / 5 unknowns) — used only to exercise /// macro residual plumbing, not physical DoF. DoF gate disabled for that reason. fn build_simple_internal_system() -> System { let mut sys = System::new(); sys.set_enforce_dof_gate(false); 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 internal residuals (no ports exposed yet). assert_eq!(mc.n_equations(), 6); // CM1.4: 2-edge linear chain → 1 branch → state_len = 1 + 2×2 = 5 assert_eq!(mc.internal_state_len(), 5); // 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 residuals + 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 residuals + 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); // 6 state variables for 2 internal edges (ṁ,P,h each; no external coupling) let state = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]; let mut residuals = vec![0.0; mc.n_equations()]; mc.compute_residuals(&state, &mut residuals).unwrap(); // 6 internal residuals (3 components × 2 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 + 6 internal = 10 total let state = vec![0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.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 10 (offset 4 + 6 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 = (ṁ0, P0, h0), edge1 = (ṁ1, 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); // Concrete global layout with internal block at [4..10] (stride 3): // index 4 = ṁ_int_e0, 5 = P_int_e0, 6 = h_int_e0 // index 7 = ṁ_int_e1, 8 = P_int_e1, 9 = h_int_e1 // index 10 = ṁ_ext, 11 = P_ext, 12 = h_ext (external edge) mc.set_global_state_offset(4); mc.set_system_context(4, &[(10, 11, 12)]); let mut state = vec![0.0; 13]; state[5] = 1.5e5; // P_int_e0 state[6] = 3.9e5; // h_int_e0 state[11] = 2.0e5; // P_ext state[12] = 4.1e5; // h_ext let n_eqs = mc.n_equations(); // 6 internal residuals + 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 occupy the last 2 rows (indices 6, 7): // r[6] = state[11] - state[5] = 2e5 - 1.5e5 = 0.5e5 // r[7] = state[12] - state[6] = 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(); let find = |row: usize, col: usize| -> Option { entries .iter() .find(|&&(r, c, _)| r == row && c == col) .map(|&(_, _, v)| v) }; assert_eq!(find(6, 11), Some(1.0), "expect ∂r_P/∂p_ext = +1"); assert_eq!(find(6, 5), Some(-1.0), "expect ∂r_P/∂int_p = -1"); assert_eq!(find(7, 12), Some(1.0), "expect ∂r_h/∂h_ext = +1"); assert_eq!(find(7, 6), Some(-1.0), "expect ∂r_h/∂int_h = -1"); } #[test] fn test_n_equations_empty_system() { let mut sys = System::new(); sys.set_enforce_dof_gate(false); 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); // 2 components × 0 equations = 0 (no mass-flow closures since CM1.3). 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 let component: Box = 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(); parent.set_enforce_dof_gate(false); 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); // CM1.4: internal block has 5 slots (1 ṁ_branch + 2×2 P,h for 2 edges) // Layout: [0:ṁ_branch, 1:P_e0, 2:h_e0, 3:P_e1, 4:h_e1] let global_state = vec![0.05, 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(), 5); 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); } }