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>
This commit is contained in:
2026-07-17 22:46:46 +02:00
parent 62efea0646
commit 3358b74342
275 changed files with 70187 additions and 5230 deletions

View File

@@ -26,13 +26,17 @@
//!
//! 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:
//! 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 + 2 * internal_edge_pos] = 0
//! r_h = state[h_ext] state[offset + 2 * internal_edge_pos + 1] = 0
//! 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
@@ -82,7 +86,7 @@ pub struct PortMapping {
/// ```json
/// {
/// "label": "chiller_1",
/// "internal_edge_states": [1.5e5, 4.2e5, 8.0e4, 3.8e5],
/// "internal_edge_states": [0.05, 1.5e5, 4.2e5, 0.05, 8.0e4, 3.8e5],
/// "port_names": ["evap_in", "evap_out"]
/// }
/// ```
@@ -90,7 +94,9 @@ pub struct PortMapping {
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, ...]`.
/// 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>,
@@ -113,9 +119,16 @@ pub struct MacroComponentSnapshot {
/// `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
/// 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
///
@@ -151,10 +164,10 @@ pub struct MacroComponent {
/// 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
/// 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)>,
external_edge_state_indices: Vec<(usize, usize, usize)>,
}
impl MacroComponent {
@@ -239,12 +252,12 @@ impl MacroComponent {
&self.port_mappings
}
/// Number of internal edges (each contributes 2 state variables: P, h).
/// 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 (2 per edge).
/// Total number of internal state variables (3 per edge: ṁ, P, h).
pub fn internal_state_len(&self) -> usize {
self.internal.state_vector_len()
}
@@ -259,6 +272,24 @@ impl MacroComponent {
.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.
@@ -292,14 +323,14 @@ 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
/// `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)],
external_edge_state_indices: &[(usize, usize, usize)],
) {
self.global_state_offset = state_offset;
self.external_edge_state_indices = external_edge_state_indices.to_vec();
@@ -326,9 +357,9 @@ impl Component for MacroComponent {
});
}
let n_int_eqs = self.n_internal_equations();
let n_int_res = self.n_internal_residuals();
let n_coupling = 2 * self.port_mappings.len();
let n_total = n_int_eqs + n_coupling;
let n_total = n_int_res + n_coupling;
if residuals.len() < n_total {
return Err(ComponentError::InvalidResidualDimensions {
@@ -339,22 +370,28 @@ impl Component for MacroComponent {
// --- 1. Delegate internal residuals ----------------------------------
let internal_state: Vec<f64> = state[start..end].to_vec();
let mut internal_residuals = vec![0.0; n_int_eqs];
let mut internal_residuals = vec![0.0; n_int_res];
self.internal
.compute_residuals(&internal_state, &mut internal_residuals)?;
residuals[..n_int_eqs].copy_from_slice(&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 + 2·internal_edge_pos] = 0
// r_h = state[h_ext] state[offset + 2·internal_edge_pos + 1] = 0
// 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 int_p = self.global_state_offset + 2 * mapping.internal_edge_pos;
let int_h = int_p + 1;
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 {
@@ -363,8 +400,8 @@ impl Component for MacroComponent {
});
}
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];
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];
}
}
@@ -387,7 +424,7 @@ impl Component for MacroComponent {
});
}
let n_int_eqs = self.n_internal_equations();
let n_int_res = self.n_internal_residuals();
// --- 1. Internal Jacobian entries ------------------------------------
let internal_state: Vec<f64> = state[start..end].to_vec();
@@ -410,10 +447,15 @@ impl Component for MacroComponent {
// ∂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;
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);
@@ -427,8 +469,10 @@ impl Component for MacroComponent {
}
fn n_equations(&self) -> usize {
// Internal equations + 2 coupling equations per exposed port.
self.n_internal_equations() + 2 * self.port_mappings.len()
// 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] {
@@ -505,8 +549,12 @@ mod tests {
}
/// 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));
@@ -521,11 +569,10 @@ mod tests {
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).
// 3 components × 2 equations each = 6 internal residuals (no ports exposed yet).
assert_eq!(mc.n_equations(), 6);
// 2 edges → 4 state variables
assert_eq!(mc.internal_state_len(), 4);
// 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());
}
@@ -538,7 +585,7 @@ mod tests {
let port = make_connected_port("R134a", 100_000.0, 400_000.0);
mc.expose_port(0, "inlet", port.clone());
// 6 internal + 2 coupling = 8
// 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");
@@ -556,7 +603,7 @@ mod tests {
mc.expose_port(0, "inlet", port_in);
mc.expose_port(1, "outlet", port_out);
// 6 internal + 4 coupling = 10
// 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");
@@ -577,13 +624,13 @@ mod tests {
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];
// 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 equations (no coupling ports)
// 6 internal residuals (3 components × 2 equations, no coupling ports)
assert_eq!(residuals.len(), 6);
}
@@ -593,8 +640,8 @@ mod tests {
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];
// 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();
@@ -607,7 +654,7 @@ mod tests {
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 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);
@@ -647,7 +694,7 @@ mod tests {
#[test]
fn test_coupling_residuals_and_jacobian() {
// 2-edge internal system: edge0 = (P0, h0), edge1 = (P1, h1)
// 2-edge internal system: edge0 = (ṁ0, P0, h0), edge1 = (ṁ1, P1, h1)
let sys = build_simple_internal_system();
let mut mc = MacroComponent::new(sys);
@@ -655,36 +702,28 @@ mod tests {
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).
// 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, &[(6, 7)]);
mc.set_system_context(4, &[(10, 11, 12)]);
// 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; 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 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
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:
// r[6] = state[8] - state[4] = 2e5 - 1.5e5 = 0.5e5
// r[7] = state[9] - state[5] = 4.1e5 - 3.9e5 = 0.2e5
// 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: {}",
@@ -701,28 +740,29 @@ mod tests {
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");
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);
}
@@ -744,6 +784,7 @@ mod tests {
// 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();
@@ -765,14 +806,15 @@ mod tests {
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];
// 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(), 4);
assert_eq!(snap.internal_edge_states.len(), 5);
assert_eq!(snap.port_names, vec!["inlet"]);
// Round-trip through JSON