"""Degree-of-freedom maps for assembling panel-family systems."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
import numpy as np
from buffalo_core import as_int_array
from buffalo_panel.type_aliases import FloatArray, IntArray
[docs]
class DofMap(ABC):
"""
Base class for mapping family-local quantities into global systems.
Concrete DOF maps define both how local influence columns scatter into a
global matrix and how solved global coefficients expand back into the
family-local coefficient space used by kernels.
"""
[docs]
@abstractmethod
def n_local_dofs(self, n_elements: int) -> int:
"""
Return the number of family-local coefficients represented by this map.
Parameters
----------
n_elements : int
Number of elements owned by the family using this DOF map.
Returns
-------
int
Number of local coefficients expected by the kernel block builder.
"""
[docs]
@abstractmethod
def scatter_columns(
self,
local_block: FloatArray,
global_matrix: FloatArray,
) -> None:
"""
Accumulate a local family block into a global matrix.
Parameters
----------
local_block : FloatArray
Family-local influence block with one column per family element.
global_matrix : FloatArray
Global matrix that receives the mapped family contribution.
"""
[docs]
@abstractmethod
def local_coefficients(
self,
x: FloatArray,
n_elements: int,
) -> FloatArray:
"""
Expand solved coefficients into the family-local coefficient space.
Parameters
----------
x : FloatArray
Global solved coefficient vector.
n_elements : int
Number of elements owned by the family using this DOF map.
Returns
-------
FloatArray
One solved coefficient value per family-local degree of freedom.
"""
[docs]
@abstractmethod
def lift_local_row(
self,
local_weights: FloatArray,
n_unknowns: int,
) -> FloatArray:
"""
Lift one family-local linear row into the global unknown space.
Parameters
----------
local_weights : FloatArray
One-dimensional row of local coefficient weights.
n_unknowns : int
Length of the global solve vector.
Returns
-------
FloatArray
Global row equivalent to applying ``local_weights`` in the family's
local coefficient space.
"""
[docs]
@dataclass(slots=True)
class PerElementDofMap(DofMap):
"""
Assign one global degree of freedom to each element.
Each family element contributes to and reads from its own distinct entry in
the global solved coefficient vector.
"""
global_indices: IntArray
"""Global coefficient index associated with each family element."""
def __post_init__(self) -> None:
"""Normalize and validate the stored global indices."""
global_indices = as_int_array(self.global_indices)
if global_indices.ndim != 1:
raise ValueError("global_indices must be a one-dimensional array.")
self.global_indices = global_indices
[docs]
def n_local_dofs(self, n_elements: int) -> int:
"""
Return the number of family-local coefficients for this map.
Parameters
----------
n_elements : int
Number of elements owned by the family using this map.
Returns
-------
int
Number of family-local coefficients.
Raises
------
ValueError
If the stored index count does not match ``n_elements``.
"""
if self.global_indices.size != n_elements:
raise ValueError(
"Expected one global index per element in PerElementDofMap."
)
return int(self.global_indices.size)
[docs]
def scatter_columns(
self,
local_block: FloatArray,
global_matrix: FloatArray,
) -> None:
"""
Accumulate one local column per element into the global matrix.
Parameters
----------
local_block : FloatArray
Family-local influence block with one column per owned element.
global_matrix : FloatArray
Global matrix that receives the columns at ``global_indices``.
Raises
------
ValueError
If ``local_block`` does not have one column per stored global
index.
"""
if local_block.shape[1] != self.global_indices.size:
raise ValueError(
"Expected one local column per global index in "
"PerElementDofMap."
)
global_matrix[:, self.global_indices] += local_block
[docs]
def local_coefficients(
self,
x: FloatArray,
n_elements: int,
) -> FloatArray:
"""
Return the solved coefficient for each family-local degree of freedom.
Parameters
----------
x : FloatArray
Global solved coefficient vector.
n_elements : int
Number of elements owned by the family using this map.
Returns
-------
FloatArray
One solved coefficient per family-local degree of freedom, gathered
from ``global_indices``.
Raises
------
ValueError
If the number of stored global indices does not match
``n_elements``.
"""
if self.global_indices.size != n_elements:
raise ValueError(
"Expected one global index per element in PerElementDofMap."
)
return x[self.global_indices]
[docs]
def lift_local_row(
self,
local_weights: FloatArray,
n_unknowns: int,
) -> FloatArray:
"""
Lift one per-element local row into the global unknown space.
Parameters
----------
local_weights : FloatArray
One-dimensional local weight row with one entry per stored global
index.
n_unknowns : int
Length of the global solve vector.
Returns
-------
FloatArray
Global row with the local weights placed at ``global_indices``.
Raises
------
ValueError
If ``local_weights`` is not one-dimensional, does not match the
number of stored global indices, or ``n_unknowns`` is too small.
"""
if local_weights.ndim != 1:
raise ValueError("local_weights must be a one-dimensional array.")
if local_weights.size != self.global_indices.size:
raise ValueError(
"Expected one local weight per global index in "
"PerElementDofMap."
)
if np.any(self.global_indices >= n_unknowns):
raise ValueError("n_unknowns is too small for PerElementDofMap.")
row = np.zeros(n_unknowns, dtype=np.float64)
row[self.global_indices] = local_weights
return row
[docs]
@dataclass(slots=True)
class SharedGlobalDofMap(DofMap):
"""
Collapse many element-local columns into one shared global degree.
Every owned element contributes to a common global coefficient and reads the
same solved strength during post-processing.
"""
global_index: int
"""Shared global coefficient index used by all owned elements."""
n_elements: int
"""Number of family elements collapsed into the shared coefficient."""
def __post_init__(self) -> None:
"""Validate the stored shared-DOF metadata."""
if self.global_index < 0:
raise ValueError("global_index must be non-negative.")
if self.n_elements < 0:
raise ValueError("n_elements must be non-negative.")
[docs]
def n_local_dofs(self, n_elements: int) -> int:
"""
Return the expanded local coefficient count for this shared map.
Parameters
----------
n_elements : int
Number of elements owned by the family using this map.
Returns
-------
int
Number of expanded family-local coefficients.
Raises
------
ValueError
If ``self.n_elements`` does not match ``n_elements``.
"""
if self.n_elements != n_elements:
raise ValueError(
"SharedGlobalDofMap n_elements must match family n_elements."
)
return self.n_elements
[docs]
def scatter_columns(
self,
local_block: FloatArray,
global_matrix: FloatArray,
) -> None:
"""
Accumulate a shared element family into one global column.
Parameters
----------
local_block : FloatArray
Family-local influence block with one column per owned element.
global_matrix : FloatArray
Global matrix that receives the summed contribution in the shared
column.
Raises
------
ValueError
If ``local_block`` does not have one column per owned element.
"""
if local_block.shape[1] != self.n_elements:
raise ValueError(
"Expected one local column per element for shared DOF collapse."
)
global_matrix[:, self.global_index] += np.sum(local_block, axis=1)
[docs]
def local_coefficients(
self,
x: FloatArray,
n_elements: int,
) -> FloatArray:
"""
Return the expanded shared coefficient for each local kernel column.
Parameters
----------
x : FloatArray
Global solved coefficient vector.
n_elements : int
Number of elements owned by the family using this map.
Returns
-------
FloatArray
Length-``n_elements`` vector filled with the shared solved
coefficient.
Raises
------
ValueError
If ``self.n_elements`` does not match ``n_elements``.
"""
if self.n_elements != n_elements:
raise ValueError(
"SharedGlobalDofMap n_elements must match family n_elements."
)
return np.full(
n_elements,
float(x[self.global_index]),
dtype=np.float64,
)
[docs]
def lift_local_row(
self,
local_weights: FloatArray,
n_unknowns: int,
) -> FloatArray:
"""
Lift one shared local row into the global unknown space.
Parameters
----------
local_weights : FloatArray
One-dimensional local weight row with one entry per owned element.
n_unknowns : int
Length of the global solve vector.
Returns
-------
FloatArray
Global row whose shared coefficient equals the sum of the local
weights.
Raises
------
ValueError
If ``local_weights`` is not one-dimensional, does not match
``n_elements``, or ``n_unknowns`` is too small.
"""
if local_weights.ndim != 1:
raise ValueError("local_weights must be a one-dimensional array.")
if local_weights.size != self.n_elements:
raise ValueError(
"Expected one local weight per element for shared DOF lift."
)
if self.global_index >= n_unknowns:
raise ValueError("n_unknowns is too small for SharedGlobalDofMap.")
row = np.zeros(n_unknowns, dtype=np.float64)
row[self.global_index] = float(np.sum(local_weights))
return row