# Story 3.3: Multi-Circuit Machine Definition Status: done ## Story As a R&D engineer (Marie), I want machines with N independent circuits, so that I simulate complex heat pumps. ## Acceptance Criteria 1. **Circuit Tracking** (AC: #1) - [x] Given a machine with 2+ circuits - [x] When defining topology - [x] Then each circuit is tracked independently - [x] And each node (component) and edge belongs to exactly one circuit 2. **Circuit Isolation** (AC: #2) - [x] Given two circuits (e.g., refrigerant and water) - [x] When adding edges - [x] Then flow edges connect only nodes within the same circuit - [x] And cross-circuit connections are rejected at build time (thermal coupling is Story 3.4) 3. **Solver-Ready Structure** (AC: #3) - [x] Given a machine with N circuits - [x] When the solver queries circuit structure - [x] Then circuits can be solved simultaneously or sequentially (strategy deferred to Epic 4) - [x] And the solver receives circuit membership for each node/edge 4. **Circuit Limit** (AC: #4) - [x] Given a machine definition - [x] When adding circuits - [x] Then supports up to N=5 circuits - [x] And returns clear error if limit exceeded ## Tasks / Subtasks - [x] Add CircuitId and circuit tracking (AC: #1, #4) - [x] Define `CircuitId` (newtype or enum 0..=4, max 5 circuits) - [x] Add `node_to_circuit: HashMap` to System - [x] Add `add_component_to_circuit(component, circuit_id)` or extend `add_component` - [x] Validate circuit count ≤ 5 when adding - [x] Enforce circuit isolation on edges (AC: #2) - [x] When adding edge (add_edge or add_edge_with_ports): validate source and target have same circuit_id - [x] Return `TopologyError::CrossCircuitConnection` or `ConnectionError` variant if mismatch - [x] Document that thermal coupling (cross-circuit) is Story 3.4 - [x] Expose circuit structure for solver (AC: #3) - [x] Add `circuit_count() -> usize` - [x] Add `circuit_nodes(circuit_id: CircuitId) -> impl Iterator` - [x] Add `circuit_edges(circuit_id: CircuitId) -> impl Iterator` - [x] Ensure `traverse_for_jacobian` or new `traverse_circuit_for_jacobian(circuit_id)` supports per-circuit iteration - [x] Backward compatibility - [x] Single-circuit case: default circuit_id = 0 for existing `add_component` calls - [x] Existing tests (3.1, 3.2) must pass without modification - [x] Tests - [x] Test: 2-circuit machine, each circuit has own components and edges - [x] Test: cross-circuit edge rejected - [x] Test: circuit_count, circuit_nodes, circuit_edges return correct values - [x] Test: N=5 circuits accepted, N=6 rejected - [x] Test: single-circuit backward compat (add_component without circuit uses circuit 0) ## Dev Notes ### Epic Context **Epic 3: System Topology (Graph)** — Enable component assembly via Ports and manage multi-circuits with thermal coupling. FR9 (multi-circuit machine definition), FR10 (ports), FR12 (simultaneous/sequential solving) map to `crates/solver`. **Story Dependencies:** - Story 3.1 (System graph structure) — done - Story 3.2 (Port compatibility validation) — done - Story 3.4 (Thermal coupling) — adds cross-circuit heat transfer; 3.3 provides circuit structure - Story 3.5 (Zero-flow) — independent ### Architecture Context (Step 3.2 — CRITICAL EXTRACTION) **Technical Stack:** - Rust, petgraph 0.6.x, thiserror, entropyk-core, entropyk-components - No new external dependencies **Code Structure:** - `crates/solver/src/system.rs` — primary modification site (add circuit tracking) - `crates/solver/src/error.rs` — add `TopologyError::CrossCircuitConnection`, `TooManyCircuits` - Architecture line 797: System Topology FR9–FR13 in `system.rs` - Architecture line 702: `tests/integration/multi_circuit.rs` for FR9 **API Patterns:** - Extend `add_component` to accept optional `CircuitId` (default 0 for backward compat) - Or add `add_component_to_circuit(component, circuit_id)` — explicit - Edge validation: in `add_edge` and `add_edge_with_ports`, check `node_to_circuit[source] == node_to_circuit[target]` **Relevant Architecture Sections:** - **System Topology** (architecture line 797): FR9–FR13 in solver/system.rs - **Project Structure** (architecture line 702): `tests/integration/multi_circuit.rs` for FR9 - **Pre-allocation** (architecture line 239): No dynamic allocation in solver loop — circuit metadata built at finalize/build time **Performance Requirements:** - Circuit metadata built at topology build time (not in solver hot path) - `circuit_nodes` / `circuit_edges` can be iterators over filtered collections **Testing Standards:** - `approx::assert_relative_eq!` for float comparisons - Use existing mock components from 3.1/3.2 tests ### Developer Context **Existing Implementation:** - `System` has `graph`, `edge_to_state`, `finalized` - `add_component(component)` adds node, returns NodeIndex - `add_edge` and `add_edge_with_ports` add edges with optional port validation - `finalize()` builds edge→state mapping, validates topology (isolated nodes) - No circuit concept yet — single implicit circuit **Design Decision:** - **Single graph with circuit metadata** (not Vec): Simpler for Story 3.4 thermal coupling — cross-circuit heat exchangers will connect nodes in different circuits via coupling equations, not flow edges. Flow edges remain same-circuit only. - **CircuitId**: `pub struct CircuitId(pub u8)` with valid range 0..=4, or `NonZeroU8` 1..=5. Use `u8` with validation. - **Default circuit**: When `add_component` is called without circuit_id, assign CircuitId(0). Ensures backward compatibility. **Port Mapping Convention (from 3.2):** - For 2-port components: `get_ports()[0]` = inlet, `get_ports()[1]` = outlet - Port validation unchanged — still validate fluid, P, h when using `add_edge_with_ports` ### Technical Requirements **CircuitId:** ```rust /// Circuit identifier. Valid range 0..=4 (max 5 circuits). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct CircuitId(pub u8); impl CircuitId { pub const MAX: u8 = 4; pub fn new(id: u8) -> Result { if id <= Self::MAX { Ok(CircuitId(id)) } else { Err(TopologyError::TooManyCircuits { requested: id }) } } } ``` **Edge Validation:** - Before adding edge: `node_to_circuit.get(&source) == node_to_circuit.get(&target)` (both Some and equal) - If source or target has no circuit (legacy?) — treat as circuit 0 or reject **Error Types:** - `TopologyError::CrossCircuitConnection { source_circuit, target_circuit }` - `TopologyError::TooManyCircuits { requested: u8 }` ### Architecture Compliance - **NewType pattern**: Use `CircuitId` (not bare u8) for circuit identification - **tracing** for validation failures (e.g., cross-circuit attempt) - **Result** — no unwrap/expect in production - **#![deny(warnings)]** — all crates ### Library/Framework Requirements - **entropyk_components**: Component, get_ports, ConnectionError — unchanged - **petgraph**: Graph, NodeIndex, EdgeIndex — unchanged - **thiserror**: TopologyError extension ### File Structure Requirements **Modified files:** - `crates/solver/src/system.rs` — add CircuitId, node_to_circuit, circuit validation, circuit accessors - `crates/solver/src/error.rs` — add TopologyError::CrossCircuitConnection, TooManyCircuits **New files (optional):** - `crates/solver/src/circuit.rs` — CircuitId definition if preferred over system.rs **Tests:** - Add to `crates/solver/src/system.rs` (inline `#[cfg(test)]` module) or `tests/integration/multi_circuit.rs` ### Testing Requirements **Unit tests:** - `test_two_circuit_machine` — add 2 circuits, add components to each, add edges within each, verify circuit_nodes/circuit_edges - `test_cross_circuit_edge_rejected` — add edge from circuit 0 node to circuit 1 node → TopologyError::CrossCircuitConnection - `test_circuit_count_and_accessors` — 3 circuits, verify circuit_count()=3, circuit_nodes(0).count() correct - `test_max_five_circuits` — add 5 circuits OK, 6th fails with TooManyCircuits - `test_single_circuit_backward_compat` — add_component without circuit_id, add_edge — works as before (implicit circuit 0) **Integration:** - `tests/integration/multi_circuit.rs` — 2-circuit heat pump topology (refrigerant + water), no thermal coupling yet ### Project Structure Notes - Architecture specifies `crates/solver/src/system.rs` for FR9 — matches - Story 3.2 added `add_edge_with_ports` — extend validation to check circuit match - Story 3.4 will add thermal coupling (cross-circuit heat transfer) — 3.3 provides foundation - Story 3.5 (zero-flow) — independent ### Previous Story Intelligence (3.2) - `add_edge_with_ports(source, source_port_idx, target, target_port_idx)` validates fluid, P, h via `validate_port_continuity` - `add_edge` (no ports) — no validation; use for mock components - **Extend both**: before adding edge, check `node_to_circuit[source] == node_to_circuit[target]` - `ConnectionError` from components; `TopologyError` from solver — use TopologyError for circuit errors (topology-level) - MockComponent in tests has `get_ports() -> &[]` — use for circuit tests; add_component_to_circuit with CircuitId ### Previous Story Intelligence (3.1) - System uses `Graph, FlowEdge, Directed>` - State vector layout: `[P_edge0, h_edge0, P_edge1, h_edge1, ...]` - `traverse_for_jacobian` yields (node, component, edge_indices) - For multi-circuit: solver may iterate per circuit; ensure `circuit_edges(cid)` returns edges in consistent order for state indexing ### References - **Epic 3 Story 3.3:** [Source: planning-artifacts/epics.md#Story 3.3] - **FR9:** [Source: planning-artifacts/epics.md — Multi-circuit machine definition] - **Architecture FR9-FR13:** [Source: planning-artifacts/architecture.md — line 797] - **tests/integration/multi_circuit.rs:** [Source: planning-artifacts/architecture.md — line 702] - **Story 3.1:** [Source: implementation-artifacts/3-1-system-graph-structure.md] - **Story 3.2:** [Source: implementation-artifacts/3-2-port-compatibility-validation.md] ## Dev Agent Record ### Agent Model Used {{agent_model_name_version}} ### Debug Log References ### Completion Notes List - Ultimate context engine analysis completed — comprehensive developer guide created - Added CircuitId newtype (0..=4), node_to_circuit map, add_component_to_circuit - add_edge now returns Result; add_edge_with_ports returns Result - circuit_count(), circuit_nodes(), circuit_edges() for solver-ready structure - 6 new unit tests + 2 integration tests in tests/multi_circuit.rs - All 21 solver unit tests pass; backward compat verified ### File List - crates/solver/src/system.rs (modified: CircuitId, node_to_circuit, add_component_to_circuit, circuit accessors, add_edge/add_edge_with_ports circuit validation) - crates/solver/src/error.rs (modified: TopologyError::CrossCircuitConnection, TooManyCircuits, AddEdgeError) - crates/solver/src/lib.rs (modified: re-export CircuitId, AddEdgeError) - crates/solver/tests/multi_circuit.rs (new: integration tests for 2-circuit heat pump topology) ## Change Log - 2026-02-15: Story 3.3 implementation complete. CircuitId, node_to_circuit, add_component_to_circuit, circuit validation in add_edge/add_edge_with_ports, circuit_count/circuit_nodes/circuit_edges. All ACs satisfied, 6 unit tests + 2 integration tests. - 2026-02-17: Code review complete. Fixed 8 issues: - Made `node_circuit()` and added `edge_circuit()` public API for circuit membership queries - Added edge circuit validation in `finalize()` to ensure edge-circuit consistency - Improved `circuit_count()` documentation for edge case behavior - Enhanced `test_max_five_circuits()` documentation clarity - Enhanced `test_single_circuit_backward_compat()` to verify `edge_circuit()` and `circuit_edges()` - Added `test_maximum_five_circuits_integration()` for N=5 circuit coverage - Updated module documentation to clarify valid circuit ID range (0-4) - All 43 unit tests + 3 integration tests pass