Files
Entropyk/crates/solver/src/macro_component.rs
sepehr 3358b74342 Add diagram workbench UI with Modelica DoF coaching and ISO glyphs.
Ship the Next.js cycle editor with CAD chrome, technical HX symbols, Fixed/Free boundary guidance, and secondary water/air pressure drop support in the solver stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 22:46:46 +02:00

826 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<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, 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<dyn Component>` 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<String>,
/// 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<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 + 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<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 `(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<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 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<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 `(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<f64> = 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<f64> = 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<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).
///
/// 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<f64> {
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<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();
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);
}
}