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>
826 lines
32 KiB
Rust
826 lines
32 KiB
Rust
//! 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);
|
||
}
|
||
}
|