easyfea 3.2.2__tar.gz → 3.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/Operators/Linear.py +17 -0
  2. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/Operators/NonLinear.py +5 -3
  3. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/_field.py +3 -0
  4. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/_group_elem.py +58 -53
  5. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/_linalg.py +215 -105
  6. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/_mesh.py +15 -7
  7. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/_mesher.py +502 -234
  8. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Geoms/__init__.py +3 -2
  9. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Geoms/_circle.py +89 -19
  10. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Geoms/_contour.py +4 -0
  11. easyfea-3.4.0/EasyFEA/Geoms/_domain.py +124 -0
  12. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Geoms/_geom.py +91 -19
  13. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Geoms/_line.py +30 -5
  14. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Geoms/_points.py +15 -5
  15. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Geoms/_utils.py +42 -0
  16. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Models/Beam/_beam.py +13 -9
  17. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Models/Elastic/_laws.py +15 -45
  18. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Models/HyperElastic/_laws.py +18 -3
  19. easyfea-3.4.0/EasyFEA/Models/InElastic/IsotropicHardening.py +98 -0
  20. easyfea-3.4.0/EasyFEA/Models/InElastic/KinematicHardening.py +95 -0
  21. easyfea-3.4.0/EasyFEA/Models/InElastic/ViscoElastic.py +40 -0
  22. easyfea-3.4.0/EasyFEA/Models/InElastic/ViscoPlastic.py +90 -0
  23. easyfea-3.4.0/EasyFEA/Models/InElastic/Yield.py +188 -0
  24. easyfea-3.4.0/EasyFEA/Models/InElastic/__init__.py +15 -0
  25. easyfea-3.4.0/EasyFEA/Models/InElastic/_behavior.py +832 -0
  26. easyfea-3.4.0/EasyFEA/Models/InElastic/_materialpoint.py +139 -0
  27. easyfea-3.4.0/EasyFEA/Models/InElastic/_spectral.py +184 -0
  28. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Models/__init__.py +1 -0
  29. easyfea-3.4.0/EasyFEA/Models/_kelvin.py +36 -0
  30. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Models/_phasefield.py +20 -19
  31. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Models/_utils.py +5 -38
  32. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Simulations/Solvers.py +0 -1
  33. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Simulations/__init__.py +1 -0
  34. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Simulations/_beam.py +5 -5
  35. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Simulations/_elastic.py +1 -1
  36. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Simulations/_hyperelastic.py +45 -25
  37. easyfea-3.4.0/EasyFEA/Simulations/_inelastic.py +340 -0
  38. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Simulations/_phasefield.py +14 -16
  39. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Simulations/_simu.py +190 -14
  40. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Simulations/_thermal.py +1 -1
  41. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Utilities/MeshIO.py +262 -69
  42. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Utilities/Paraview.py +2 -3
  43. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Utilities/_mpi.py +56 -0
  44. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Utilities/_params.py +17 -0
  45. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Utilities/_types.py +3 -0
  46. {easyfea-3.2.2 → easyfea-3.4.0}/PKG-INFO +11 -10
  47. {easyfea-3.2.2 → easyfea-3.4.0}/README.md +10 -9
  48. {easyfea-3.2.2 → easyfea-3.4.0}/easyfea.egg-info/PKG-INFO +11 -10
  49. {easyfea-3.2.2 → easyfea-3.4.0}/easyfea.egg-info/SOURCES.txt +11 -0
  50. {easyfea-3.2.2 → easyfea-3.4.0}/pyproject.toml +4 -2
  51. easyfea-3.2.2/EasyFEA/Geoms/_domain.py +0 -73
  52. {easyfea-3.2.2 → easyfea-3.4.0}/AUTHORS.md +0 -0
  53. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/Elems/__init__.py +0 -0
  54. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/Elems/_beam.py +0 -0
  55. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/Elems/_hexa.py +0 -0
  56. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/Elems/_point.py +0 -0
  57. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/Elems/_prism.py +0 -0
  58. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/Elems/_quad.py +0 -0
  59. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/Elems/_seg.py +0 -0
  60. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/Elems/_tetra.py +0 -0
  61. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/Elems/_tri.py +0 -0
  62. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/Operators/Bilinear.py +0 -0
  63. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/Operators/__init__.py +0 -0
  64. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/__init__.py +0 -0
  65. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/_boundary_conditions.py +0 -0
  66. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/_forms.py +0 -0
  67. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/_gauss.py +0 -0
  68. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/FEM/_utils.py +0 -0
  69. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Models/Beam/__init__.py +0 -0
  70. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Models/Elastic/__init__.py +0 -0
  71. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Models/HyperElastic/__init__.py +0 -0
  72. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Models/HyperElastic/_state.py +0 -0
  73. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Models/_thermal.py +0 -0
  74. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Models/_weakforms.py +0 -0
  75. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Simulations/_dic.py +0 -0
  76. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Simulations/_utils.py +0 -0
  77. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Simulations/_weakforms.py +0 -0
  78. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Utilities/Folder.py +0 -0
  79. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Utilities/GLTF.py +0 -0
  80. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Utilities/Matplotlib.py +0 -0
  81. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Utilities/PyVista.py +0 -0
  82. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Utilities/Terminal.py +0 -0
  83. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Utilities/USD.py +0 -0
  84. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Utilities/Vizir.py +0 -0
  85. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Utilities/__init__.py +0 -0
  86. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Utilities/_cache.py +0 -0
  87. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Utilities/_observers.py +0 -0
  88. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Utilities/_requires.py +0 -0
  89. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/Utilities/_tic.py +0 -0
  90. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/__about__.py +0 -0
  91. {easyfea-3.2.2 → easyfea-3.4.0}/EasyFEA/__init__.py +0 -0
  92. {easyfea-3.2.2 → easyfea-3.4.0}/LICENSE.txt +0 -0
  93. {easyfea-3.2.2 → easyfea-3.4.0}/easyfea.egg-info/dependency_links.txt +0 -0
  94. {easyfea-3.2.2 → easyfea-3.4.0}/easyfea.egg-info/requires.txt +0 -0
  95. {easyfea-3.2.2 → easyfea-3.4.0}/easyfea.egg-info/top_level.txt +0 -0
  96. {easyfea-3.2.2 → easyfea-3.4.0}/setup.cfg +0 -0
@@ -33,3 +33,20 @@ def V(
33
33
  Ne, nPg = vec_e_pg.shape[:2]
34
34
  f = FeArray.broadcast(f, Ne, nPg)
35
35
  return (f * vec_e_pg).integrate()
36
+
37
+
38
+ def InternalForce(
39
+ groupElem: "_GroupElem",
40
+ sigma_e_pg: FeArray.FeArrayALike,
41
+ matrixType: MatrixType = MatrixType.rigi,
42
+ ) -> np.ndarray:
43
+ """``∫_Ω σ : ε(v) dΩ`` — internal force of a known stress field.
44
+
45
+ Returns ``(Ne, nPe·dim)``.
46
+
47
+ ``sigma_e_pg`` is Kelvin-Mandel with shape ``(Ne, nPg, nstrain)``. This is the residual side
48
+ of a nonlinear problem: unlike :func:`Bilinear.LinearizedElasticity`, the stress is given
49
+ rather than derived from ``C : ε(u)``.
50
+ """
51
+ leftDispPart_e_pg = groupElem.Get_leftDispPart_e_pg(matrixType)
52
+ return (leftDispPart_e_pg @ FeArray.asfearray(sigma_e_pg)).integrate()
@@ -438,7 +438,7 @@ def __AdaptiveTimeQuadratureStressTensor(
438
438
  ),
439
439
  0.0,
440
440
  )
441
- defect = einsum("epi,epi->ep", S, dE_vec[activeElements]) - dW[activeElements]
441
+ defect = S @ dE_vec[activeElements] - dW[activeElements]
442
442
  next_nPts = 3 if nPts == 1 else 2 * nPts - 1 # next level in the chain
443
443
  # accept an element once its own energy defect is within tol (all of them at the last level)
444
444
  isAccepted = (next_nPts > max(maxPoints, 1)) | (
@@ -600,9 +600,11 @@ def ActiveStressTensor(
600
600
  Returns
601
601
  -------
602
602
  tuple
603
- ``(None, None)`` when ``material.active_stress == 0``. Otherwise ``Kgeo_e`` of shape ``(Ne, nPe·dim, nPe·dim)`` and ``R_e`` of shape ``(Ne, nPe·dim)``, reordered to ``(xi, yi, zi, ..., xn, yn, zn)``.
603
+ ``(None, None)`` when ``material.active_stress`` is nowhere non-zero. Otherwise ``Kgeo_e`` of shape ``(Ne, nPe·dim, nPe·dim)`` and ``R_e`` of shape ``(Ne, nPe·dim)``, reordered to ``(xi, yi, zi, ..., xn, yn, zn)``.
604
604
  """
605
- if material.active_stress == 0.0:
605
+ # `np.all` rather than `== 0.0`: the magnitude may be an (Ne, nPg) field, and a partly-active
606
+ # body still has to be assembled
607
+ if np.all(material.active_stress == 0.0):
606
608
  return None, None # type: ignore [return-value]
607
609
 
608
610
  groupElem = state.groupElem
@@ -17,6 +17,9 @@ from ..Utilities import _types
17
17
  class Field:
18
18
  """Field class."""
19
19
 
20
+ _isFeField = True
21
+ """marks the class for :mod:`._linalg`, which cannot import it without a cycle"""
22
+
20
23
  def __init__(
21
24
  self,
22
25
  groupElem: _GroupElem,
@@ -211,17 +211,22 @@ class _GroupElem(ABC):
211
211
  Remark
212
212
  ------
213
213
  Ghost nodes will be computed using the given (non-ghost) nodes array.
214
+
215
+ The four index arrays are stored **sorted**, whatever order they arrive in: `Mesher` builds them
216
+ from python sets, so they come in hash order, while `connect` is in global-index order. Every
217
+ consumer pairs the two through `np.searchsorted`, which requires sorted input and returns
218
+ out-of-range indices rather than complaining when it does not get it.
214
219
  """
215
220
 
216
221
  Ne = self.__connect.shape[0]
217
222
  # set elements (may be smaller than Ne when ghost elements are present)
218
- elements = np.asarray(elements, dtype=int)
223
+ elements = np.sort(np.asarray(elements, dtype=int))
219
224
  assert elements.size <= Ne
220
- ghostElements = np.asarray(ghostElements, dtype=int)
225
+ ghostElements = np.sort(np.asarray(ghostElements, dtype=int))
221
226
  assert ghostElements.size <= Ne
222
227
  # get nodes and ghost nodes
223
- nodes = np.asarray(nodes, dtype=int)
224
- ghostNodes = np.asarray(list(set(self.nodes) - set(nodes)), dtype=int)
228
+ nodes = np.sort(np.asarray(nodes, dtype=int))
229
+ ghostNodes = np.sort(np.asarray(list(set(self.nodes) - set(nodes)), dtype=int))
225
230
 
226
231
  self.__partitionned_data = (rank, elements, ghostElements, nodes, ghostNodes)
227
232
 
@@ -232,6 +237,30 @@ class _GroupElem(ABC):
232
237
  (rank, elements, ghostElements, nodes, ghostNodes)"""
233
238
  return self.__partitionned_data
234
239
 
240
+ @property
241
+ def _globalElements(self) -> _types.IntArray:
242
+ """Global element index of each row of `connect`.
243
+
244
+ `arange(Ne)` in serial. Under MPI the rows are this rank's owned and ghost elements, sorted by
245
+ global index. Use it to index an array stored for the whole mesh (a fibre field, a per-element
246
+ contractility) with this rank's elements — the element counterpart of `_global_to_local_nodes`.
247
+
248
+ Not to be confused with `elements`, which is `arange(Ne)` in both cases because tags, `vol_e`
249
+ and every other per-element array are indexed by local row.
250
+
251
+ Always `Ne` long, so it indexes a whole-mesh array into exactly this group's rows. That holds
252
+ at the edges too: an empty group gives an empty `int` array rather than a float one, and a rank
253
+ holding only the boundary layer of a type (no owned element, ghosts only) gives the ghosts —
254
+ `connect` is built from the same `unique(owned ∪ ghosts)`.
255
+ """
256
+ _, elements, ghostElements, _, _ = self.__partitionned_data
257
+
258
+ # both arrays are stored sorted, so the merge is the only work left
259
+ if ghostElements.size == 0:
260
+ return elements
261
+
262
+ return np.unique(np.concatenate([elements, ghostElements]))
263
+
235
264
  @property
236
265
  def _rankPartition(self) -> Optional[_types.IntArray]:
237
266
  """Array of shape (Ne,) where rankPartition[e] is the MPI rank that owned
@@ -606,7 +635,8 @@ class _GroupElem(ABC):
606
635
  coord_e_pg[:, :, 0], coord_e_pg[:, :, 1], coord_e_pg[:, :, 2]
607
636
  )
608
637
 
609
- eval_e_pg = FeArray.asfearray(eval_e_pg)
638
+ # func may return a constant, so go through the field constructor rather than a view
639
+ eval_e_pg = FeArray.broadcast(eval_e_pg, *wJ_e_pg.shape[:2])
610
640
 
611
641
  values_e = (wJ_e_pg * eval_e_pg).integrate()
612
642
 
@@ -1577,59 +1607,29 @@ class _GroupElem(ABC):
1577
1607
 
1578
1608
  assert isinstance(line, Line)
1579
1609
 
1580
- unitVector = line.unitVector
1581
-
1582
- vect = self.coord - line.coord[0]
1583
-
1584
- scalarProd = np.einsum("i,ni-> n", unitVector, vect, optimize="optimal")
1585
- crossProd = np.cross(vect, unitVector)
1586
- norm = np.linalg.norm(crossProd, axis=1)
1587
-
1588
- eps = 1e-12
1589
-
1590
- idx = np.where(
1591
- (norm < eps) & (scalarProd >= -eps) & (scalarProd <= line.length + eps)
1592
- )[0]
1593
-
1610
+ idx = np.where(line.Contains(self.coord, 1e-12))[0]
1594
1611
  return self.__nodes[idx].copy()
1595
1612
 
1596
1613
  def Get_Nodes_Domain(self, domain: "Domain") -> _types.IntArray:
1597
1614
  """Returns nodes in the domain."""
1598
1615
 
1599
- assert isinstance(domain, Line)
1600
-
1601
- xn, yn, zn = self.coord.T
1602
-
1603
- eps = 1e-12
1604
-
1605
- idx = np.where(
1606
- (xn >= domain.pt1.x - eps)
1607
- & (xn <= domain.pt2.x + eps)
1608
- & (yn >= domain.pt1.y - eps)
1609
- & (yn <= domain.pt2.y + eps)
1610
- & (zn >= domain.pt1.z - eps)
1611
- & (zn <= domain.pt2.z + eps)
1612
- )[0]
1616
+ assert isinstance(domain, Domain)
1613
1617
 
1618
+ idx = np.where(domain.Encloses(self.coord, 1e-12))[0]
1614
1619
  return self.__nodes[idx].copy()
1615
1620
 
1616
1621
  def Get_Nodes_Circle(self, circle: "Circle", onlyOnEdge=False) -> _types.IntArray:
1617
- """Returns nodes in the circle."""
1622
+ """Returns nodes in the circle.
1618
1623
 
1619
- assert isinstance(circle, Circle)
1620
-
1621
- eps = 1e-12
1624
+ Both selections are taken in the circle's own plane; use `Get_Nodes_Cylinder` to select through a thickness.
1625
+ """
1622
1626
 
1623
- vals = np.linalg.norm(self.coord - circle.center.coord, axis=1)
1627
+ assert isinstance(circle, Circle)
1624
1628
 
1625
- if onlyOnEdge:
1626
- idx = np.where(
1627
- (vals <= circle.diam / 2 + eps) & (vals >= circle.diam / 2 - eps)
1628
- )
1629
- else:
1630
- idx = np.where(vals <= circle.diam / 2 + eps)
1629
+ test = circle.Contains if onlyOnEdge else circle.Encloses
1631
1630
 
1632
- return self.__nodes[idx]
1631
+ idx = np.where(test(self.coord, 1e-12))[0]
1632
+ return self.__nodes[idx].copy()
1633
1633
 
1634
1634
  def Get_Nodes_Cylinder(
1635
1635
  self, circle: "Circle", direction=[0, 0, 1], onlyOnEdge=False
@@ -1756,7 +1756,7 @@ class _GroupElem(ABC):
1756
1756
  return self.__dict_elements_tags[tag]
1757
1757
  else:
1758
1758
  print(f"The {tag} tag is unknown")
1759
- return np.array([])
1759
+ return np.array([], dtype=int)
1760
1760
 
1761
1761
  def Get_Nodes_Tag(self, tag: str) -> _types.IntArray:
1762
1762
  """Returns node associated with the tag."""
@@ -1764,7 +1764,7 @@ class _GroupElem(ABC):
1764
1764
  return self.__dict_nodes_tags[tag]
1765
1765
  else:
1766
1766
  print(f"The {tag} tag is unknown")
1767
- return np.array([])
1767
+ return np.array([], dtype=int)
1768
1768
 
1769
1769
  def Locates_sol_e(
1770
1770
  self, sol: _types.FloatArray, dof_n: Optional[int] = None, asFeArray=False
@@ -2138,11 +2138,14 @@ class _GroupElem(ABC):
2138
2138
  # xiP are the n coordinates of the n points in (ξ, η, ζ).
2139
2139
  coordInElem_n[nodesInElement, :] = np.asarray(xiP) # type: ignore
2140
2140
 
2141
- detectedNodes = detectedNodes[detectedNodes != None].astype(int)
2141
+ # both arrays are object arrays pre-filled with None, so the comparison is
2142
+ # elementwise; np.not_equal says so without tripping E711
2143
+ mask_n = np.not_equal(detectedNodes, None)
2144
+ detectedNodes = detectedNodes[mask_n].astype(int)
2142
2145
 
2143
- mask = detectedElements_e != None
2144
- detectedElements_e = detectedElements_e[mask].astype(int)
2145
- connect_e_n = connect_e_n[mask]
2146
+ mask_e = np.not_equal(detectedElements_e, None)
2147
+ detectedElements_e = detectedElements_e[mask_e].astype(int)
2148
+ connect_e_n = connect_e_n[mask_e]
2146
2149
 
2147
2150
  return detectedNodes, detectedElements_e, connect_e_n, coordInElem_n
2148
2151
 
@@ -2216,7 +2219,7 @@ class _GroupElem(ABC):
2216
2219
  # --------------------------------------------------------------------------------------------
2217
2220
 
2218
2221
  # import must be done here to avoid circular imports
2219
- from . import Elems
2222
+ from . import Elems # noqa: E402
2220
2223
 
2221
2224
 
2222
2225
  class GroupElemFactory:
@@ -2264,7 +2267,9 @@ class GroupElemFactory:
2264
2267
  """
2265
2268
 
2266
2269
  if gmshId not in GroupElemFactory.DICT_GMSH_DATA:
2267
- raise KeyError("gmshId is unknown.")
2270
+ raise KeyError(
2271
+ f"gmshId {gmshId} is not supported yet, but it can be added to DICT_GMSH_DATA."
2272
+ )
2268
2273
 
2269
2274
  return GroupElemFactory.DICT_GMSH_DATA[gmshId]
2270
2275
 
@@ -11,10 +11,81 @@ from typing import Union, Optional, Iterable
11
11
  from ..Utilities import _types
12
12
 
13
13
 
14
+ def _Evaluate(operand):
15
+ """A Field evaluates to its finite element array; anything else is passed through."""
16
+ return operand() if getattr(operand, "_isFeField", False) else operand
17
+
18
+
19
+ def _Base(operand):
20
+ """Drops the FeArray view, so numpy's own machinery handles the operand."""
21
+ if isinstance(operand, FeArray):
22
+ return operand.view(np.ndarray)
23
+ elif isinstance(operand, (list, tuple)):
24
+ return type(operand)(_Base(item) for item in operand)
25
+ else:
26
+ return operand
27
+
28
+
29
+ def _KeepsFeAxes(axis, ndim: int) -> bool:
30
+ """True when a reduction consumes tensor axes only, so the (Ne, nPg) axes survive."""
31
+ if axis is None:
32
+ return False
33
+ axes = axis if isinstance(axis, tuple) else (axis,)
34
+ return all(a >= 2 if a >= 0 else a >= 2 - ndim for a in axes)
35
+
36
+
37
+ _REDUCERS = frozenset(
38
+ {
39
+ np.sum,
40
+ np.prod,
41
+ np.mean,
42
+ np.std,
43
+ np.var,
44
+ np.median,
45
+ np.average,
46
+ np.max,
47
+ np.min,
48
+ np.amax,
49
+ np.amin,
50
+ np.all,
51
+ np.any,
52
+ np.argmax,
53
+ np.argmin,
54
+ }
55
+ )
56
+
57
+
58
+ def _FeShape(operands) -> tuple:
59
+ """The (Ne, nPg) an operation runs at: the broadcast of its FeArray operands'."""
60
+ shapes = set()
61
+ stack = list(operands)
62
+ while stack:
63
+ operand = stack.pop()
64
+ if isinstance(operand, FeArray):
65
+ shapes.add(operand.shape[:2])
66
+ elif isinstance(operand, (list, tuple)):
67
+ stack.extend(operand)
68
+ if len(shapes) == 1:
69
+ return shapes.pop()
70
+ return np.broadcast_shapes(*shapes) if shapes else ()
71
+
72
+
14
73
  class FeArray(np.ndarray):
15
74
  """Finite Element array.\n
16
75
 
17
76
  FeArray is a Python class designed to optimize finite element simulations by leveraging NumPy arrays with a shape of `(Ne, nPg, ...)`. This structure enables vectorized operations, eliminating the need for slow loops over elements and integration points. By using np.einsum, it efficiently handles tensor computations, significantly improving performance and code clarity for finite element analyses.
77
+
78
+ Two rules govern how it mixes with other arrays:
79
+
80
+ - **Rank.** A FeArray's tensor rank is ``ndim - 2`` and a plain array's is its own ``ndim``
81
+ -- always, with no exception and nothing inferred from a shape coincidence. So a
82
+ ``(Ne, nPg)`` FeArray is a scalar field even where ``Ne`` and ``nPg`` match a tensor's
83
+ dimensions, and a plain array is a constant tensor held at every Gauss point. Fields are
84
+ padded to the widest rank, then broadcast once. A plain array that is really a field must
85
+ say so with :meth:`asfearray`, or it multiplies out as a constant tensor.
86
+ - **Type.** An operation stays a FeArray exactly when the ``(Ne, nPg)`` axes come out
87
+ unchanged. ``np.einsum``, ``np.where`` and ``np.linalg.solve`` keep them; ``reshape``,
88
+ ``ravel`` and a sum over elements do not.
18
89
  """
19
90
 
20
91
  FeArrayALike = Union["FeArray", _types.AnyArray]
@@ -33,110 +104,134 @@ class FeArray(np.ndarray):
33
104
  if obj is None:
34
105
  return
35
106
 
107
+ def __check_fe_dims(self) -> None:
108
+ """A FeArray must keep its leading (Ne, nPg) axes.
109
+
110
+ ``__new__`` enforces that, but indexing and reshaping do not go through it: ``fe[0]``
111
+ returns a FeArray of one dimension, whose finite element rank would be -1. Nothing
112
+ downstream expects that, so it is caught here rather than surfacing later as a
113
+ confusing shape error.
114
+ """
115
+ if self.ndim < 2:
116
+ raise ValueError(
117
+ f"this FeArray has shape {self.shape}, which has lost the leading "
118
+ "(Ne, nPg) axes -- indexing or reshaping dropped them. Use np.asarray(...) "
119
+ "if plain array semantics are what is wanted here."
120
+ )
121
+
36
122
  @property
37
123
  def _shape(self) -> tuple:
38
124
  """finite element shape"""
125
+ self.__check_fe_dims()
39
126
  return self.shape[2:]
40
127
 
41
128
  @property
42
129
  def _ndim(self) -> int:
43
130
  """finite element ndim"""
131
+ self.__check_fe_dims()
44
132
  return self.ndim - 2
45
133
 
46
- @property
47
- def _idx(self) -> str:
48
- """einsum indicator (e.g "", "i", "ij") used in `np.einsum()` function.\n
49
- see https://numpy.org/doc/stable/reference/generated/numpy.einsum.html
50
- """
51
- if self._ndim == 0:
52
- return ""
53
- elif self._ndim == 1:
54
- return "i"
55
- elif self._ndim == 2:
56
- return "ij"
57
- elif self._ndim == 4:
58
- return "ijkl"
59
- else:
60
- raise ValueError("wrong dimension")
61
-
62
- @property
63
- def _type(self) -> str:
64
- if self._ndim == 0:
65
- return "scalar"
66
- elif self._ndim == 1:
67
- return "vector"
68
- elif self._ndim == 2:
69
- return "matrix"
70
- elif self._ndim == 4:
71
- return "tensor"
72
- else:
73
- raise ValueError("wrong dimension")
74
-
75
- def __get_array1_array2(self, other) -> tuple[_types.AnyArray, _types.AnyArray]:
76
- array1 = np.asarray(self)
77
- ndim1 = self._ndim
78
- shape1 = self._shape
134
+ @staticmethod
135
+ def _align(operands: tuple) -> tuple:
136
+ """Pads each field's tensor rank up to the widest, so one numpy broadcast is correct.
79
137
 
80
- array2 = np.asarray(other)
81
- if isinstance(other, FeArray):
82
- ndim2 = other._ndim
83
- shape2 = other._shape
84
- elif isinstance(other, (np.ndarray, float, int)):
85
- ndim2 = array2.ndim
86
- shape2 = array2.shape
87
- elif type(other).__name__ == "Field":
88
- other: FeArray = other() # type: ignore [no-redef]
89
- array2 = np.asarray(other)
90
- ndim2 = other._ndim
91
- shape2 = other._shape
138
+ The finite element axes then line up on the left and the tensor axes on the right,
139
+ which is numpy's own rule.
140
+ """
141
+ # by far the commonest case: all fields of the same shape, nothing to line up
142
+ shape = operands[0].shape if isinstance(operands[0], FeArray) else None
143
+ for op in operands:
144
+ if not isinstance(op, FeArray) or op.shape != shape:
145
+ break
92
146
  else:
93
- raise TypeError("other must be a FeArray, ndarray, float, int or a Field.")
147
+ return operands
94
148
 
95
- if ndim1 == 0:
96
- # array1(Ne, nPg) array2(...) => (Ne, nPg, ...)
97
- # or
98
- # array1(Ne, nPg) array2(Ne, nPg, ...) => (Ne, nPg, ...)
99
- array1 = array1[(Ellipsis,) + (np.newaxis,) * ndim2]
100
- elif ndim2 == 0:
101
- if array2.size == 1:
102
- # array1(Ne, nPg, ...) array2() => (Ne, nPg, ...)
103
- pass
104
- else:
105
- # array1(Ne, nPg, ...) array2(Ne, nPg) => (Ne, nPg, ...)
106
- array2 = array2[(Ellipsis,) + (np.newaxis,) * ndim1]
107
- elif shape1 == shape2:
108
- pass
109
- else:
110
- type_str = "FeArray" if isinstance(other, FeArray) else "np.array"
111
- raise ValueError(
112
- f"The {type_str} `other` with shape {shape2} must be either a {self.shape} np.array or a {shape1} FeArray."
149
+ operands = tuple(_Evaluate(op) for op in operands)
150
+ ranks = [
151
+ op.ndim - 2 if isinstance(op, FeArray) else np.ndim(op) for op in operands
152
+ ]
153
+ nt = max(ranks)
154
+ return tuple(
155
+ (
156
+ op[(slice(None), slice(None)) + (None,) * (nt - rank)]
157
+ if isinstance(op, FeArray) and rank < nt
158
+ else op
113
159
  )
160
+ for op, rank in zip(operands, ranks)
161
+ )
114
162
 
115
- return array1, array2
116
-
117
- def __add__(self, other) -> FeArrayALike:
118
- if isinstance(other, FeArray) and self.shape == other.shape:
119
- return super().__add__(other)
120
- array1, array2 = self.__get_array1_array2(other)
121
- return FeArray.asfearray(array1 + array2)
122
-
123
- def __sub__(self, other) -> FeArrayALike: # type: ignore [override]
124
- if isinstance(other, FeArray) and self.shape == other.shape:
125
- return super().__sub__(other)
126
- array1, array2 = self.__get_array1_array2(other)
127
- return FeArray.asfearray(array1 - array2)
163
+ @staticmethod
164
+ def __wrap(res, feShape: tuple):
165
+ """A result is a field exactly when it came out on the operation's (Ne, nPg) axes."""
166
+ if not isinstance(res, np.ndarray):
167
+ return res
168
+ elif res.ndim >= 2 and res.shape[:2] == feShape:
169
+ return res.view(FeArray)
170
+ else:
171
+ return np.asarray(res)
172
+
173
+ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
174
+ # numpy asks that a subclass put all its override logic here rather than also defining
175
+ # __add__ and friends, so that the type hierarchy is decided in one place
176
+ elementwise = method == "__call__" and ufunc.signature is None
177
+
178
+ # two fields of the same shape need no alignment and no rewrapping decision; this is
179
+ # the overwhelming majority of calls, and it is what keeps small arrays cheap
180
+ if elementwise and not kwargs and len(inputs) == 2:
181
+ left, right = inputs
182
+ if (
183
+ type(left) is FeArray
184
+ and type(right) is FeArray
185
+ and left.shape == right.shape
186
+ ):
187
+ return ufunc(left.view(np.ndarray), right.view(np.ndarray)).view(
188
+ FeArray
189
+ )
128
190
 
129
- def __mul__(self, other) -> FeArrayALike:
130
- if isinstance(other, FeArray) and self.shape == other.shape:
131
- return super().__mul__(other)
132
- array1, array2 = self.__get_array1_array2(other)
133
- return FeArray.asfearray(array1 * array2)
191
+ if elementwise:
192
+ inputs = FeArray._align(inputs)
193
+
194
+ # ndarray refuses to run a ufunc on a subclass that overrides __array_ufunc__, so hand
195
+ # it plain views -- of the `out` and `where` operands too, or the call comes straight
196
+ # back here -- and put the type back afterwards
197
+ out = kwargs.pop("out", None) if kwargs else None
198
+ if kwargs:
199
+ kwargs = {key: _Base(value) for key, value in kwargs.items()}
200
+ if out is not None:
201
+ kwargs["out"] = _Base(out)
202
+
203
+ args = [_Base(array) for array in inputs]
204
+ res = (
205
+ ufunc(*args, **kwargs)
206
+ if elementwise
207
+ else getattr(ufunc, method)(*args, **kwargs)
208
+ )
134
209
 
135
- def __truediv__(self, other) -> FeArrayALike: # type: ignore [override]
136
- if isinstance(other, FeArray) and self.shape == other.shape:
137
- return super().__truediv__(other)
138
- array1, array2 = self.__get_array1_array2(other)
139
- return FeArray.asfearray(array1 / array2)
210
+ if out is not None:
211
+ return out[0] if len(out) == 1 else out
212
+ elif elementwise and type(res) is np.ndarray:
213
+ # broadcasting against a FeArray always keeps the (Ne, nPg) axes
214
+ return res.view(FeArray)
215
+ feShape = _FeShape(inputs)
216
+ if isinstance(res, tuple):
217
+ return tuple(FeArray.__wrap(array, feShape) for array in res)
218
+ return FeArray.__wrap(res, feShape)
219
+
220
+ def __array_function__(self, func, types, args, kwargs):
221
+ # numpy's own implementations broadcast the plain way and must keep doing so: einsum
222
+ # with optimize= reaches for np.multiply internally, which would otherwise come back
223
+ # through __array_ufunc__ and be aligned a second time
224
+ feShape = _FeShape(args) or _FeShape(kwargs.values())
225
+ # numpy calls a dispatched reduction on the stripped array, so the method wrapper never
226
+ # sees it and the axis has to be read here instead
227
+ if func in _REDUCERS:
228
+ axis = kwargs.get("axis", args[1] if len(args) > 1 else None)
229
+ if not _KeepsFeAxes(axis, np.ndim(args[0])):
230
+ feShape = ()
231
+ args = tuple(_Base(arg) for arg in args)
232
+ kwargs = {key: _Base(value) for key, value in kwargs.items()}
233
+ res = super().__array_function__(func, types, args, kwargs)
234
+ return FeArray.__wrap(res, feShape)
140
235
 
141
236
  @property
142
237
  def T(self) -> FeArrayALike: # type: ignore [override]
@@ -158,7 +253,7 @@ class FeArray(np.ndarray):
158
253
  ndim2 = other._ndim
159
254
  elif isinstance(other, np.ndarray):
160
255
  ndim2 = other.ndim
161
- elif type(other).__name__ == "Field":
256
+ elif getattr(other, "_isFeField", False):
162
257
  other: FeArray = other() # type: ignore [no-redef]
163
258
  ndim2 = other._ndim
164
259
  else:
@@ -204,7 +299,7 @@ class FeArray(np.ndarray):
204
299
  ndim2 = other._ndim
205
300
  elif isinstance(other, np.ndarray):
206
301
  ndim2 = other.ndim
207
- elif type(other).__name__ == "Field":
302
+ elif getattr(other, "_isFeField", False):
208
303
  other: FeArray = other() # type: ignore [no-redef]
209
304
  ndim2 = other._ndim
210
305
  else:
@@ -230,7 +325,7 @@ class FeArray(np.ndarray):
230
325
  ndim2 = other._ndim
231
326
  elif isinstance(other, np.ndarray):
232
327
  ndim2 = other.ndim
233
- elif type(other).__name__ == "Field":
328
+ elif getattr(other, "_isFeField", False):
234
329
  other: FeArray = other() # type: ignore [no-redef]
235
330
  ndim2 = other._ndim
236
331
  else:
@@ -245,17 +340,23 @@ class FeArray(np.ndarray):
245
340
 
246
341
  return result.view(FeArray)
247
342
 
248
- # Reduction methods that consume an axis — always return a plain ndarray,
249
- # since the (Ne, nPg) framing is no longer meaningful after the reduction.
343
+ # A reduction over a tensor axis is still a field; one over elements or Gauss points is
344
+ # not. Which axes were consumed is read from `axis`, never guessed from the result shape.
250
345
  def _make_reducer(_name: str):
251
346
  _parent = getattr(np.ndarray, _name)
252
347
 
253
348
  def _reducer(self, *args, **kwargs):
254
- return np.asarray(_parent(self, *args, **kwargs))
349
+ res = _parent(self, *args, **kwargs)
350
+ axis = kwargs.get("axis", args[0] if args else None)
351
+ if _KeepsFeAxes(axis, self.ndim) and getattr(res, "ndim", 0) >= 2:
352
+ return res.view(FeArray)
353
+ return np.asarray(res)
255
354
 
256
355
  _reducer.__name__ = _name
257
356
  _reducer.__qualname__ = f"FeArray.{_name}"
258
- _reducer.__doc__ = f"``np.{_name}()`` wrapper — returns ``ndarray``."
357
+ _reducer.__doc__ = (
358
+ f"``np.{_name}()`` wrapper — ``ndarray`` unless (Ne, nPg) survives."
359
+ )
259
360
  return _reducer
260
361
 
261
362
  for _name in (
@@ -276,12 +377,10 @@ class FeArray(np.ndarray):
276
377
  del _name, _make_reducer
277
378
 
278
379
  def reshape(self, *args, **kwargs):
279
- Ne, nPg = self.shape[:2]
280
380
  new = super().reshape(*args, **kwargs)
281
- if new.shape[:2] == (Ne, nPg):
381
+ if self.ndim >= 2 and new.shape[:2] == self.shape[:2]:
282
382
  return new
283
- else:
284
- return np.asarray(new)
383
+ return np.asarray(new)
285
384
 
286
385
  def integrate(self) -> np.ndarray:
287
386
  """Integrate over the Gauss-point axis (axis 1). Returns ``(Ne, ...)`` ndarray."""
@@ -310,15 +409,19 @@ class FeArray(np.ndarray):
310
409
  self[tuple(idx)] = value
311
410
 
312
411
  @staticmethod
313
- def asfearray(array, broadcastFeArrays=False) -> FeArrayALike:
412
+ def asfearray(array, broadcastFeArrays=False) -> "FeArray":
413
+ """Views ``array`` as a FeArray. Refuses anything without the (Ne, nPg) axes."""
314
414
  if not isinstance(array, np.ndarray):
315
415
  array = np.asarray(array)
316
416
  if broadcastFeArrays:
317
417
  return FeArray(array, broadcastFeArrays=broadcastFeArrays)
318
- elif array.ndim >= 2:
319
- return array.view(FeArray)
320
- else:
321
- return array
418
+ elif array.ndim < 2:
419
+ raise ValueError(
420
+ f"cannot view a {array.shape} array as a FeArray: it has no (Ne, nPg) axes. "
421
+ "Pass broadcastFeArrays=True to hold it at every Gauss point, or keep it a "
422
+ "plain array."
423
+ )
424
+ return array.view(FeArray)
322
425
 
323
426
  @staticmethod
324
427
  def broadcast(
@@ -380,13 +483,20 @@ class FeArray(np.ndarray):
380
483
  for array in arrays
381
484
  ]
382
485
 
486
+ @staticmethod
487
+ def __shape(shape: tuple) -> tuple:
488
+ """Accepts both ``zeros(Ne, nPg, 6)`` and ``zeros((Ne, nPg, 6))``."""
489
+ if len(shape) == 1 and isinstance(shape[0], (tuple, list)):
490
+ return tuple(shape[0])
491
+ return shape
492
+
383
493
  @staticmethod
384
494
  def zeros(*shape, dtype=None) -> FeArrayALike:
385
- return FeArray.asfearray(np.zeros(shape=shape, dtype=dtype))
495
+ return FeArray.asfearray(np.zeros(FeArray.__shape(shape), dtype=dtype))
386
496
 
387
497
  @staticmethod
388
498
  def ones(*shape, dtype=None) -> FeArrayALike:
389
- return FeArray.asfearray(np.ones(shape=shape, dtype=dtype))
499
+ return FeArray.asfearray(np.ones(FeArray.__shape(shape), dtype=dtype))
390
500
 
391
501
 
392
502
  def __CheckMat(mat: FeArray.FeArrayALike) -> None: