diff --git a/docs/book/adr-005-element-integration-point-indices.md b/docs/book/adr-005-element-integration-point-indices.md new file mode 100644 index 0000000..123afd4 --- /dev/null +++ b/docs/book/adr-005-element-integration-point-indices.md @@ -0,0 +1,230 @@ +--- +title: "ADR-005: Integration Point Indices Instead of Data" +date: 2025-11-11 +author: "Jukka Aho" +status: "Accepted" +tags: ["architecture", "elements", "integration-points", "material-state"] +--- + +## Status + +**Accepted** (November 11, 2025) + +## Context + +Current `Element` struct stores integration points as data: + +```julia +struct Element{N,NIP,F,B} <: AbstractElement{F,B} + id::UInt + connectivity::NTuple{N,UInt} # Node IDs + integration_points::NTuple{NIP,IP} # ← Integration point DATA + fields::F + basis::B +end +``` + +**Problems with this approach:** + +1. **Redundancy**: All elements of the same type have identical integration points + - Seg2 elements all use same 2-point Gauss quadrature + - Tri3 elements all use same 3-point triangle quadrature + - Storing this in every element wastes memory + +2. **Inconsistent with connectivity pattern**: Connectivity stores **indices** (node IDs), not node data + - We don't store node coordinates in element + - We store node **IDs** and look up coordinates elsewhere + +3. **Inconsistent with material state design**: Material state is stored separately + - Material state per integration point stored in global vector + - Element needs **indices** to find its material state + - Current design has no way to link element to material state + +4. **Assembly workflow needs indices**: + + ```julia + for element in elements + node_ids = element.connectivity # Get node indices + node_coords = X[node_ids] # Look up coordinates + + # What about material state? + # Need: ip_ids = element.ip_indices # Get IP indices + # mat_state = material_states[ip_ids] # Look up state + ``` + +## Decision + +**Store integration point INDICES instead of DATA in elements.** + +### New Element Structure + +```julia +struct Element{N,NIP,F,B} <: AbstractElement{F,B} + id::UInt + connectivity::NTuple{N,UInt} # Node IDs (indices) + ip_indices::NTuple{NIP,UInt} # ← Integration point IDs (indices) + fields::F + basis::B +end +``` + +### Global Integration Point Registry + +Integration points are computed once per topology+order combination: + +```julia +# Precompute integration points for each topology type +const INTEGRATION_POINTS = Dict{Tuple{Type,Int}, Vector{Tuple{Float64,Vec}}}( + (Triangle, 1) => get_gauss_points!(Triangle, Gauss{1}), + (Triangle, 2) => get_gauss_points!(Triangle, Gauss{2}), + (Quadrilateral, 2) => get_gauss_points!(Quadrilateral, Gauss{2}), + # ... etc +) + +# Material state stored separately (one entry per integration point) +struct MaterialState + stress::SymmetricTensor{2,3} + strain::SymmetricTensor{2,3} + plastic_strain::SymmetricTensor{2,3} + # ... etc +end + +material_states::Vector{MaterialState} = [MaterialState(...) for _ in 1:n_total_ips] +``` + +### Assembly Workflow + +```julia +# Element creation (during mesh setup) +element_id = 1 +node_ids = (1, 2, 3) # Triangle nodes +ip_start = 1000 # First IP for this element +ip_indices = (ip_start, ip_start+1, ip_start+2) # 3 IPs for Tri3 +element = Element(Triangle, node_ids, ip_indices=ip_indices) + +# Assembly loop +for element in elements + # Get node data + node_ids = element.connectivity + X = node_coords[node_ids] # Coordinates + u = displacements[node_ids] # Displacements + + # Get integration points (from global registry) + topology = typeof(element.basis).parameters[1] # Triangle + order = typeof(element.basis).parameters[2] # 1 + ips = INTEGRATION_POINTS[(topology, order)] + + # Get material state (from global vector) + ip_ids = element.ip_indices + mat_states = material_states[ip_ids] + + # Assembly loop over integration points + for (i, (w, ξ)) in enumerate(ips) + mat_state = mat_states[i] + + # Compute strain from displacements + ε = compute_strain(element, u, ξ) + + # Compute stress from material state + σ = compute_stress(mat_state, ε) + + # Assemble... + end +end +``` + +## Consequences + +### Positive + +1. **Memory efficiency**: + - Old: 1M Tet10 elements × 4 IPs × 32 bytes = 128 MB for IP data + - New: 1 global table × 4 IPs × 32 bytes = 128 bytes (1000× reduction!) + +2. **Consistency**: Same pattern for all element data + - Nodes: Store indices, look up data + - Integration points: Store indices, look up data + - Material state: Store indices, look up data + +3. **Clear data ownership**: + - Node coordinates: Owned by mesh/node array + - Integration points: Owned by global registry + - Material state: Owned by material state vector + - Element: Only owns **indices** to these + +4. **GPU-friendly**: + - All material states in one contiguous array (easy to transfer) + - All integration point data in global tables (transfer once) + - Elements are small (just indices) + +5. **Natural partitioning**: + - Element ownership → node ownership (already clear) + - Integration point ownership → element ownership (via indices) + - Clear for domain decomposition / MPI + +### Negative + +1. **API change**: Existing code expects `element.integration_points` + - **Mitigation**: Provide compatibility function `get_integration_points(element)` + +2. **Index management**: Need to assign IP indices during mesh setup + - **Mitigation**: Automatic during element creation + +3. **Lookup overhead**: Extra indirection to get IP data + - **Mitigation**: Negligible (one array lookup), data still cache-friendly + +## Implementation Plan + +### Phase 1: Add ip_indices field (keep integration_points for compatibility) + +```julia +struct Element{N,NIP,F,B} + id::UInt + connectivity::NTuple{N,UInt} + integration_points::NTuple{NIP,IP} # DEPRECATED, keep temporarily + ip_indices::NTuple{NIP,UInt} # NEW + fields::F + basis::B +end +``` + +### Phase 2: Update assembly code to use indices + +- Update `assemble!` functions to use `element.ip_indices` +- Look up IP data from global registry +- Look up material state from global vector + +### Phase 3: Remove integration_points field + +```julia +struct Element{N,NIP,F,B} + id::UInt + connectivity::NTuple{N,UInt} + ip_indices::NTuple{NIP,UInt} # Only indices remain + fields::F + basis::B +end +``` + +## Rationale + +**Core principle**: Elements should store **relationships** (indices), not **data**. + +Just as we don't store node coordinates in elements, we shouldn't store integration point data. The element's job is to define **which** nodes and **which** integration points participate in its stiffness matrix, not to own their data. + +This aligns perfectly with the nodal assembly approach documented in `docs/src/book/multigpu_nodal_assembly.md`: +- Nodes own displacement DOFs +- Integration points own material state +- Elements define relationships between them + +## References + +- **Golden Standard**: `docs/src/book/multigpu_nodal_assembly.md` - Nodal assembly architecture +- **ADR-004**: Integration Points API Design +- **Material State Design**: `llm/FIELDS_DESIGN.md` - Field system redesign + +## Notes + +This decision was made during test migration (November 11, 2025) when updating Dirichlet tests to use immutable Element API. The question arose: "Why does each element store integration points when they're identical for all elements of the same type?" + +The answer: **They shouldn't.** This ADR documents the correct architecture.