EasyFEA 3.2.0__tar.gz → 3.2.1__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 (84) hide show
  1. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/Operators/NonLinear.py +41 -36
  2. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Simulations/_hyperelastic.py +51 -29
  3. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Simulations/_simu.py +158 -92
  4. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Utilities/PyVista.py +20 -5
  5. {easyfea-3.2.0 → easyfea-3.2.1}/PKG-INFO +1 -1
  6. {easyfea-3.2.0 → easyfea-3.2.1}/easyfea.egg-info/PKG-INFO +1 -1
  7. {easyfea-3.2.0 → easyfea-3.2.1}/pyproject.toml +1 -1
  8. {easyfea-3.2.0 → easyfea-3.2.1}/AUTHORS.md +0 -0
  9. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/Elems/__init__.py +0 -0
  10. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/Elems/_beam.py +0 -0
  11. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/Elems/_hexa.py +0 -0
  12. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/Elems/_point.py +0 -0
  13. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/Elems/_prism.py +0 -0
  14. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/Elems/_quad.py +0 -0
  15. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/Elems/_seg.py +0 -0
  16. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/Elems/_tetra.py +0 -0
  17. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/Elems/_tri.py +0 -0
  18. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/Operators/Bilinear.py +0 -0
  19. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/Operators/Linear.py +0 -0
  20. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/Operators/__init__.py +0 -0
  21. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/__init__.py +0 -0
  22. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/_boundary_conditions.py +0 -0
  23. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/_field.py +0 -0
  24. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/_forms.py +0 -0
  25. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/_gauss.py +0 -0
  26. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/_group_elem.py +0 -0
  27. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/_linalg.py +0 -0
  28. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/_mesh.py +0 -0
  29. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/_mesher.py +0 -0
  30. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/FEM/_utils.py +0 -0
  31. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Geoms/__init__.py +0 -0
  32. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Geoms/_circle.py +0 -0
  33. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Geoms/_contour.py +0 -0
  34. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Geoms/_domain.py +0 -0
  35. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Geoms/_geom.py +0 -0
  36. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Geoms/_line.py +0 -0
  37. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Geoms/_points.py +0 -0
  38. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Geoms/_utils.py +0 -0
  39. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Models/Beam/__init__.py +0 -0
  40. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Models/Beam/_beam.py +0 -0
  41. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Models/Elastic/__init__.py +0 -0
  42. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Models/Elastic/_laws.py +0 -0
  43. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Models/HyperElastic/__init__.py +0 -0
  44. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Models/HyperElastic/_laws.py +0 -0
  45. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Models/HyperElastic/_state.py +0 -0
  46. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Models/__init__.py +0 -0
  47. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Models/_phasefield.py +0 -0
  48. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Models/_thermal.py +0 -0
  49. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Models/_utils.py +0 -0
  50. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Models/_weakforms.py +0 -0
  51. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Simulations/Solvers.py +0 -0
  52. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Simulations/__init__.py +0 -0
  53. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Simulations/_beam.py +0 -0
  54. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Simulations/_dic.py +0 -0
  55. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Simulations/_elastic.py +0 -0
  56. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Simulations/_phasefield.py +0 -0
  57. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Simulations/_thermal.py +0 -0
  58. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Simulations/_utils.py +0 -0
  59. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Simulations/_weakforms.py +0 -0
  60. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Utilities/Folder.py +0 -0
  61. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Utilities/GLTF.py +0 -0
  62. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Utilities/Matplotlib.py +0 -0
  63. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Utilities/MeshIO.py +0 -0
  64. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Utilities/Paraview.py +0 -0
  65. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Utilities/Terminal.py +0 -0
  66. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Utilities/USD.py +0 -0
  67. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Utilities/Vizir.py +0 -0
  68. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Utilities/__init__.py +0 -0
  69. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Utilities/_cache.py +0 -0
  70. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Utilities/_mpi.py +0 -0
  71. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Utilities/_observers.py +0 -0
  72. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Utilities/_params.py +0 -0
  73. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Utilities/_requires.py +0 -0
  74. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Utilities/_tic.py +0 -0
  75. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/Utilities/_types.py +0 -0
  76. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/__about__.py +0 -0
  77. {easyfea-3.2.0 → easyfea-3.2.1}/EasyFEA/__init__.py +0 -0
  78. {easyfea-3.2.0 → easyfea-3.2.1}/LICENSE.txt +0 -0
  79. {easyfea-3.2.0 → easyfea-3.2.1}/README.md +0 -0
  80. {easyfea-3.2.0 → easyfea-3.2.1}/easyfea.egg-info/SOURCES.txt +0 -0
  81. {easyfea-3.2.0 → easyfea-3.2.1}/easyfea.egg-info/dependency_links.txt +0 -0
  82. {easyfea-3.2.0 → easyfea-3.2.1}/easyfea.egg-info/requires.txt +0 -0
  83. {easyfea-3.2.0 → easyfea-3.2.1}/easyfea.egg-info/top_level.txt +0 -0
  84. {easyfea-3.2.0 → easyfea-3.2.1}/setup.cfg +0 -0
@@ -402,9 +402,9 @@ def __AdaptiveTimeQuadratureStressTensor(
402
402
  ) -> tuple["FeArray", "FeArray", int]:
403
403
  r"""Per-element adaptive strain-path quadrature — the ``tol``-driven path of :func:`TimeQuadratureStressTensor`.
404
404
 
405
- Each element refines along the nested chain ``1, 3, 5, 9, …`` until *its own* energy defect is within ``tol``, then freezes — so a low-strain element stops at one point while a high-strain one keeps refining. The test is the **integrated** relative error over the element, ``∫_Ωe (S:Δe − ΔW)² dΩ ≤ tol² ∫_Ωe ΔW² dΩ``. It is evaluated as the Gauss-point sums ``Σ_p V_(ep)(S:Δe − ΔW)² ≤ tol² Σ_p V_(ep) ΔW²`` with ``V_(ep)`` the Gauss point's volume (weight × Jacobian) — so the ``V_(ep)`` factor is what makes each side a genuine *integral over the element*, **not** a pointwise energy-density comparison. Energy-safe because ``S:Δe = ΔW`` holds per Gauss point; the squared, volume-weighted form is safe against sign cancellation between Gauss points, and per element the well-resolved regions cannot mask a coarse one.
405
+ Each element refines along the nested chain ``1, 3, 5, 9, …`` until *its own* energy defect is within ``tol``, then freezes — so a low-strain element stops at one point while a high-strain one keeps refining. The test is the **integrated** relative error over the element, ``∫_Ωe |S:Δe − ΔW| dΩ ≤ tol · ∫_Ωe |ΔW| dΩ``, evaluated as the Gauss-point sums ``Σ_p V_(ep) |S:Δe − ΔW| ≤ tol · Σ_p V_(ep) |ΔW|`` with ``V_(ep)`` the Gauss point's volume (weight × Jacobian) — so the ``V_(ep)`` factor makes each side a genuine *integral over the element*, **not** a pointwise energy-density comparison. This L1 (absolute) form is the tightest simple bound on the element's actual per-step energy drift ``|∫_Ωe (S:Δe − ΔW) dΩ|`` (triangle inequality, no volume factor), and taking ``|·|`` before summing makes it safe against sign cancellation between Gauss points — a well-resolved region cannot mask a coarse one. Energy-safe because ``S:Δe = ΔW`` holds per Gauss point.
406
406
 
407
- Only the still-active elements are evaluated at each level — the rule is applied to that subset via :meth:`_StrainPathState._sliced` — so the constitutive cost tracks the hard elements, not the mesh. (Nodes shared with a coarser level are re-evaluated rather than cached; the active set shrinks fast, so that stays cheap and keeps the loop plain.) Returns ``(dWde_quad, d2Wde_quad)`` — each row carrying its element's accepted rule — and the largest point count any element reached.
407
+ Only the still-active elements are evaluated at each level — the rule is applied to that subset via :meth:`_StrainPathState._sliced` — so the constitutive cost tracks the hard elements, not the mesh. (Nodes shared with a coarser level are re-evaluated rather than cached; the active set shrinks fast, so that stays cheap and keeps the loop plain.) Returns ``(dWde_quad, d2Wde_quad)`` — each row carrying its element's accepted rule — and ``nPts_e``, the point count each element accepted.
408
408
  """
409
409
  groupElem = state_n.groupElem # state_n and state_np1 share the group
410
410
  dim = groupElem.dim
@@ -417,7 +417,7 @@ def __AdaptiveTimeQuadratureStressTensor(
417
417
  dW = material.Compute_W(state_np1) - material.Compute_W(state_n) # ΔW per point
418
418
  dE = state_np1.Compute_GreenLagrange() - state_n.Compute_GreenLagrange()
419
419
  dE_vec = Project_matrix_to_vector(dE[..., :dim, :dim]) # Δe (Kelvin-Mandel)
420
- refW = einsum("ep,ep,ep->e", wJ_e_pg, dW, dW) # Σ_p V ΔW² (Ne,)
420
+ refW = einsum("ep,ep->e", wJ_e_pg, np.abs(dW)) # Σ_p V |ΔW| (Ne,)
421
421
  Ne, nPg, ncomp = *wJ_e_pg.shape[:2], dE_vec.shape[-1]
422
422
 
423
423
  def at(
@@ -428,7 +428,8 @@ def __AdaptiveTimeQuadratureStressTensor(
428
428
  dWde_quad = FeArray.zeros(Ne, nPg, ncomp)
429
429
  d2Wde_quad = FeArray.zeros(Ne, nPg, ncomp, ncomp)
430
430
  activeElements = np.arange(Ne) # elements still refining
431
- nPts, nPts_used = 1, 1
431
+ nPts_e = np.zeros(Ne, dtype=int) # accepted point count per element (diagnostic)
432
+ nPts = 1
432
433
  while activeElements.size:
433
434
  nodes, weights = __clenshaw_curtis(nPts)
434
435
  S = sum(
@@ -441,27 +442,27 @@ def __AdaptiveTimeQuadratureStressTensor(
441
442
  defect = einsum("epi,epi->ep", S, dE_vec[activeElements]) - dW[activeElements]
442
443
  next_nPts = 3 if nPts == 1 else 2 * nPts - 1 # next level in the chain
443
444
  # accept an element once its own energy defect is within tol (all of them at the last level)
444
- done = (next_nPts > max(maxPoints, 1)) | (
445
- einsum("ep,ep,ep->e", wJ_e_pg[activeElements], defect, defect)
446
- <= tol * tol * (refW[activeElements] + 1e-300)
445
+ isAccepted = (next_nPts > max(maxPoints, 1)) | (
446
+ einsum("ep,ep->e", wJ_e_pg[activeElements], np.abs(defect))
447
+ <= tol * refW[activeElements]
447
448
  )
448
- if done.any():
449
- e = activeElements[done] # elements accepting this rule
450
- dWde_quad[e] = S[done]
449
+ if isAccepted.any():
450
+ acceptedElems = activeElements[isAccepted] # elements accepting this rule
451
+ dWde_quad[acceptedElems] = S[isAccepted]
451
452
  # their tangent only: Σ_k 2 w_k s_k d2Wde, s=0 drops out (∂e/∂u = s B)
452
- d2Wde_quad[e] = sum(
453
+ d2Wde_quad[acceptedElems] = sum(
453
454
  (
454
- (2.0 * w * s) * material.Compute_d2Wde(at(s, e))
455
+ (2.0 * w * s) * material.Compute_d2Wde(at(s, acceptedElems))
455
456
  for s, w in zip(nodes, weights)
456
457
  if s
457
458
  ),
458
459
  0.0,
459
460
  )
460
- nPts_used = max(nPts_used, nPts)
461
- activeElements = activeElements[~done]
461
+ nPts_e[acceptedElems] = nPts # record each frozen element's accepted rule
462
+ activeElements = activeElements[~isAccepted]
462
463
  nPts = next_nPts
463
464
 
464
- return dWde_quad, d2Wde_quad, nPts_used
465
+ return dWde_quad, d2Wde_quad, nPts_e
465
466
 
466
467
 
467
468
  def TimeQuadratureStressTensor(
@@ -484,7 +485,7 @@ def TimeQuadratureStressTensor(
484
485
 
485
486
  ``(s_k, w_k)`` is the Clenshaw-Curtis rule on ``nPoints`` points (:func:`__clenshaw_curtis`); ``1, 2, 3`` are the midpoint, trapezoid and Simpson rules. Intermediate nodes are :class:`_StrainPathState`; ``s = 0, 1`` reuse the end states.
486
487
 
487
- **Adaptive (per-element) mode.** With ``tol`` set, the rule is chosen *element by element* by :func:`__AdaptiveTimeQuadratureStressTensor`: each element walks the nested chain ``1, 3, 5, 9, 17, 33`` (up to ``maxPoints``) and freezes once *its own* energy defect is within ``tol``, so points are spent only where the step is nonlinear — a low-strain element may stop at a single midpoint while a stiff one keeps refining. The defect ``S_quad:Δe − ΔW`` is the quadrature error of the discrete-gradient identity ``S_quad:Δe = ΔW`` (with ``ΔW`` *known* from the endpoints), so the test is absolute — no consecutive-difference guess. It is the **integrated** relative error over the element, ``∫_Ωe (S_quad:Δe − ΔW)² dΩ ≤ tol² ∫_Ωe ΔW² dΩ`` — the integrals are the volume-weighted Gauss-point sums ``Σ_p V_(ep)···``, *not* a pointwise energy-density comparison — so ``tol`` reads as "conserve energy to this relative tolerance". Squaring guards against sign cancellation between Gauss points, and being absolute the test accepts the coarsest rule directly: the ``1``-point midpoint is exact for a linear energy integrand, so a quadratic ``W`` converges at a single point. The tangent uses each element's accepted rule, so residual and tangent stay consistent.
488
+ **Adaptive (per-element) mode.** With ``tol`` set, the rule is chosen *element by element* by :func:`__AdaptiveTimeQuadratureStressTensor`: each element walks the nested chain ``1, 3, 5, 9, 17, 33`` (up to ``maxPoints``) and freezes once *its own* energy defect is within ``tol``, so points are spent only where the step is nonlinear — a low-strain element may stop at a single midpoint while a stiff one keeps refining. The defect ``S_quad:Δe − ΔW`` is the quadrature error of the discrete-gradient identity ``S_quad:Δe = ΔW`` (with ``ΔW`` *known* from the endpoints), so the test is absolute — no consecutive-difference guess. It is the **integrated** relative error over the element, ``∫_Ωe |S_quad:Δe − ΔW| dΩ ≤ tol · ∫_Ωe |ΔW| dΩ`` — the integrals are the volume-weighted Gauss-point sums ``Σ_p V_(ep)···``, *not* a pointwise energy-density comparison — so ``tol`` reads as "conserve energy to this relative tolerance". Taking the absolute value before summing bounds the element's actual energy drift directly and guards against sign cancellation between Gauss points. Since each level is scored on its own (no comparison to a coarser one) the test accepts the coarsest rule directly: the ``1``-point midpoint is exact for a linear energy integrand, so a quadratic ``W`` converges at a single point. The tangent uses each element's accepted rule, so residual and tangent stay consistent.
488
489
 
489
490
  Since ``Δe = B(ū)·Δu`` exactly and ``de/ds = Δe`` is constant along the segment, the fundamental theorem of calculus gives ``S_quad:Δe = ΔW`` once the ``s``-integral is exact — a **discrete gradient**. The energy defect is therefore just the quadrature error, which Clenshaw-Curtis drives down spectrally; a quadratic ``W`` is exact at every rule. Note ``nPoints = 1`` is the average-strain stress ``S(½(e_n + e_{n+1}))``, *not* the midpoint-displacement stress of :func:`SecondPiolaKirchhoffStressTensor`.
490
491
 
@@ -518,9 +519,9 @@ def TimeQuadratureStressTensor(
518
519
  Consistent tangent, built for ``coefK = 0.5``.
519
520
  R_e : ndarray of shape ``(Ne, nPe·dim)``
520
521
  Internal residual force.
521
- nPts : int
522
- Clenshaw-Curtis points used — ``nPoints`` when fixed; when adaptive, the *largest* level
523
- any element reached (individual elements may use fewer). Lets the caller log the cost.
522
+ nPts_e : ndarray of shape ``(Ne,)``
523
+ Clenshaw-Curtis points each element used — constant ``nPoints`` when fixed, per-element
524
+ when adaptive.
524
525
  """
525
526
 
526
527
  groupElem = state_mid.groupElem
@@ -547,7 +548,7 @@ def TimeQuadratureStressTensor(
547
548
  dWde_quad += w * material.Compute_dWde(state)
548
549
  if s != 0.0:
549
550
  d2Wde_quad += (2.0 * w * s) * material.Compute_d2Wde(state)
550
- nPts_used = int(nPoints)
551
+ nPts_e = np.full(groupElem.Ne, int(nPoints)) # every element uses the same rule
551
552
  else:
552
553
  # Adaptive: refine per element on the *energy defect*. This stress exists so that
553
554
  # S_quad : Δe = ΔW exactly (a discrete gradient, for energy conservation); the only error
@@ -555,7 +556,7 @@ def TimeQuadratureStressTensor(
555
556
  # is *known* from the endpoints — so the test is absolute (each level scored on its own).
556
557
  # Refining element-by-element rather than the whole block spends points only where the
557
558
  # material is nonlinear over the step (see __AdaptiveTimeQuadratureStressTensor).
558
- dWde_quad, d2Wde_quad, nPts_used = __AdaptiveTimeQuadratureStressTensor(
559
+ dWde_quad, d2Wde_quad, nPts_e = __AdaptiveTimeQuadratureStressTensor(
559
560
  material, state_n, state_np1, tol, int(maxPoints)
560
561
  )
561
562
 
@@ -572,7 +573,7 @@ def TimeQuadratureStressTensor(
572
573
  residual_e *= thickness
573
574
 
574
575
  K_e, R_e = __reorder_dofs(dim, nPe, tangent_e, residual_e)
575
- return K_e, R_e, nPts_used
576
+ return K_e, R_e, nPts_e
576
577
 
577
578
 
578
579
  def ActiveStressTensor(
@@ -628,17 +629,20 @@ def KelvinVoigtDamping(
628
629
  material: "_HyperElastic",
629
630
  state: "HyperElasticState",
630
631
  velocity: np.ndarray,
631
- ) -> tuple[np.ndarray, np.ndarray]:
632
- r"""Kelvin–Voigt viscous element contributions (C_e, Kgeo_e) for the
632
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
633
+ r"""Kelvin–Voigt viscous element contributions (Kgeo_e, R_e, C_e) for the
633
634
  large-strain viscous force F_visco(u) = C(u)·v, with Σ_visco = η·Ė
634
635
  (Green-Lagrange strain rate of velocity) and B = De(u)·grad.
635
636
 
636
- - C_e = thickness · η · ∫ Bᵀ B dΩ — the damping matrix; the simulation puts
637
- it in slot 2 of (K, C, M, F) (residual b -= C @ v_t, time-scheme history,
638
- and the coefC·C tangent).
637
+ Ordered ``(tangent, residual, extra)`` like the rest of this module.
638
+
639
639
  - Kgeo_e — the configuration tangent ∂(C·v)/∂u at fixed velocity (geometric
640
640
  stiffening from Σ_visco plus the ∂Ė/∂u term); the simulation adds it to
641
641
  K_e so it rides coefK.
642
+ - R_e = thickness · η · ∫ Bᵀ Ė dΩ — the viscous residual, which the simulation
643
+ subtracts from F_e.
644
+ - C_e = thickness · η · ∫ Bᵀ B dΩ — the damping matrix; the simulation puts it
645
+ in slot 2 of (K, C, M, F), where it rides the coefC·C tangent.
642
646
 
643
647
  Parameters
644
648
  ----------
@@ -653,11 +657,12 @@ def KelvinVoigtDamping(
653
657
  Returns
654
658
  -------
655
659
  tuple
656
- (None, None) when material.eta == 0 or velocity is None. Both matrices
657
- are (Ne, nPe·dim, nPe·dim) reordered to (xi, yi, zi, ..., xn, yn, zn).
660
+ (None, None, None) when material.eta == 0 or velocity is None. Kgeo_e and C_e
661
+ are (Ne, nPe·dim, nPe·dim) and R_e is (Ne, nPe·dim), all reordered to
662
+ (xi, yi, zi, ..., xn, yn, zn).
658
663
  """
659
664
  if material.eta == 0.0 or velocity is None:
660
- return None, None # type: ignore [return-value]
665
+ return None, None, None # type: ignore [return-value]
661
666
 
662
667
  groupElem = state.groupElem
663
668
  matrixType = state.matrixType
@@ -668,22 +673,22 @@ def KelvinVoigtDamping(
668
673
 
669
674
  grad_e_pg, B_e_pg = __block_grad_B(state)
670
675
  Beta_e_pg = state.Compute_Deta(velocity) @ grad_e_pg
676
+ sig_e_pg = material.eta * state.Compute_Edot_vec(velocity) # Σ_visco = η·Ė
671
677
 
672
678
  # damping matrix C = thickness · η · ∫ Bᵀ B (fused einsum, see SPK above)
673
679
  subscripts = "ep,epji,epjl->eil"
674
680
  C_e = thickness * material.eta * einsum(subscripts, wJ_e_pg, B_e_pg, B_e_pg)
675
681
 
682
+ # viscous residual ∫ Bᵀ Σ_visco — same contraction as the active stress
683
+ residual_e = thickness * einsum("ep,epji,epj->ei", wJ_e_pg, B_e_pg, sig_e_pg)
684
+
676
685
  # configuration tangent ∂(C·v)/∂u = geometric (∫ gradᵀ Sig grad) + material-like
677
686
  # (η ∫ Bᵀ (∂Ė/∂u)) pieces
678
687
  A_mat = material.eta * einsum(subscripts, wJ_e_pg, B_e_pg, Beta_e_pg)
679
- A_geo = __geometric_tangent(
680
- wJ_e_pg,
681
- state,
682
- material.eta * state.Compute_Edot_vec(velocity),
683
- )
688
+ A_geo = __geometric_tangent(wJ_e_pg, state, sig_e_pg)
684
689
  Kgeo_e = thickness * (A_mat + A_geo)
685
690
 
686
- return __reorder_dofs(dim, nPe, C_e, Kgeo_e)
691
+ return __reorder_dofs(dim, nPe, Kgeo_e, residual_e, C_e)
687
692
 
688
693
 
689
694
  def __skew(v: np.ndarray) -> np.ndarray:
@@ -15,6 +15,7 @@ from ..Utilities import Terminal, _types
15
15
  if TYPE_CHECKING:
16
16
  from ..FEM import Mesh
17
17
  from ..FEM import MatrixType, Operators
18
+ from ..Utilities._cache import cache_computed_values
18
19
 
19
20
  # models
20
21
  from ..Models import ModelType, Project_Kelvin, Result_strain_or_stress_field_e
@@ -169,7 +170,7 @@ class HyperElastic(_Simu):
169
170
  stressType: "HyperElastic.StressType" = StressType.pointwise,
170
171
  nPoints: int = 3,
171
172
  useConsistentTangent: bool = True,
172
- quadTol: Optional[float] = None,
173
+ energyTol: Optional[float] = None,
173
174
  ) -> None:
174
175
  r"""Selects the stress used by the internal force. Call **after** :py:meth:`~EasyFEA.Simulations._Simu.Solver_Set_Hyperbolic_Algorithm`.
175
176
 
@@ -192,18 +193,18 @@ class HyperElastic(_Simu):
192
193
  nPoints : int, optional
193
194
  ``quadrature`` only: number of Clenshaw-Curtis points, by default 3 (Simpson).
194
195
  ``1`` and ``2`` are the midpoint and trapezoid rules; more converges spectrally.
195
- When ``quadTol`` is set this is the starting (minimum) level instead of a fixed count.
196
+ When ``energyTol`` is set this is the starting (minimum) level instead of a fixed count.
196
197
  useConsistentTangent : bool, optional
197
198
  ``gonzalez`` only: if False, drop the discrete-gradient corrections from the
198
199
  tangent. Same residual and same exact conservation, but Newton converges linearly
199
200
  — a diagnostic, to measure what the consistent tangent is worth.
200
- quadTol : float, optional
201
+ energyTol : float, optional
201
202
  ``quadrature`` only: if set, the rule is refined adaptively *element by element*
202
203
  along the nested Clenshaw-Curtis chain ``1, 3, 5, 9, ...`` (capped at 33) — each
203
204
  element stops once its own **integrated** relative energy defect
204
- ``∫(S_quad:Δe − ΔW)² dΩ ≤ quadTol² ∫ΔW² dΩ`` over that element is met (a volume
205
+ ``∫|S_quad:Δe − ΔW| dΩ ≤ energyTol · ∫|ΔW| dΩ`` over that element is met (a volume
205
206
  integral over its Gauss points, not a pointwise density). So energy is conserved to
206
- ``quadTol`` while points are spent only where the step is nonlinear. ``None``
207
+ ``energyTol`` while points are spent only where the step is nonlinear. ``None``
207
208
  (default) keeps the fixed ``nPoints`` rule.
208
209
  """
209
210
  stressType = HyperElastic.StressType(stressType)
@@ -216,13 +217,14 @@ class HyperElastic(_Simu):
216
217
  )
217
218
  assert nPoints >= 1, f"nPoints must be >= 1 (got {nPoints})."
218
219
 
219
- self.__stressParams = (stressType, nPoints, useConsistentTangent, quadTol)
220
- self.__quadNPoints = 0 # diagnostic: points the last assembly used
220
+ self.__stressParams = (stressType, nPoints, useConsistentTangent, energyTol)
221
+ # diagnostic: per-element quadrature-point counts, last assembly
222
+ self.__nPts_e = None
221
223
 
222
224
  def __Solver_Get_Stress_Params(
223
225
  self,
224
226
  ) -> tuple["HyperElastic.StressType", int, bool, Optional[float]]:
225
- """Returns (stressType, nPoints, useConsistentTangent, quadTol) internal-force props."""
227
+ """Returns (stressType, nPoints, useConsistentTangent, energyTol) internal-force props."""
226
228
  return self.__stressParams
227
229
 
228
230
  @property
@@ -230,6 +232,11 @@ class HyperElastic(_Simu):
230
232
  """Stress used by the internal force — see :py:meth:`Solver_Set_Stress`."""
231
233
  return self.__Solver_Get_Stress_Params()[0]
232
234
 
235
+ @cache_computed_values
236
+ def __Mass_e(self, groupElem, rho, thickness, dim):
237
+ """Constant element mass matrix ``thickness · ∫ρ N·N``."""
238
+ return thickness * Operators.Bilinear.UV(groupElem, rho, dof_n=dim)
239
+
233
240
  def Construct_local_matrix_system(
234
241
  self,
235
242
  problemType,
@@ -237,22 +244,22 @@ class HyperElastic(_Simu):
237
244
  ):
238
245
  """Returns ``{groupElem: (K_e, C_e, M_e, F_e)}`` for the current Newton iterate.
239
246
 
240
- Newton solves ``A(u)·Δu = -R(u)``; the simulation assembles ``A = coefK·K + coefC·C + coefM·M`` and ``b -= C @ v_t``. Per group of elements: ``K_e`` is the elastic tangent (plus the viscous configuration tangent ``Kgeo_e``), ``F_e = -R_e`` the residual, ``C_e`` the Kelvin–Voigt damping matrix and ``M_e`` the mass matrix (dynamic schemes only).
247
+ Newton solves ``A(u)·Δu = -R(u)`` with ``A = coefK·K + coefC·C + coefM·M``. Per group of elements: ``K_e`` is the elastic tangent (plus the viscous configuration tangent ``Kgeo_e``), ``C_e`` the Kelvin–Voigt damping matrix, ``M_e`` the mass matrix (dynamic schemes only) and ``F_e = -R_e`` the complete residual — internal force together with the ``C_e·v_t + M_e·a_t`` inertia and damping terms.
241
248
  """
242
249
  dim = self.dim
243
250
  thickness = self.material.thickness if dim == 2 else 1
244
251
  isDynamic = self.algo in AlgoType.Get_Hyperbolic_Types()
245
252
 
246
- # current Newton-Raphson iterate; for dynamic schemes also capture the
247
- # velocity, for Kelvin–Voigt viscosity.
253
+ # current Newton-Raphson iterate; for dynamic schemes also capture the velocity
254
+ # (Kelvin–Voigt viscosity) and the acceleration (inertia), both needed by F_e.
248
255
  displacement = self._Solver_Get_Newton_Raphson_current_solution()
249
- velocity = None
256
+ velocity = accel = None
250
257
  # Both non-default stresses are built from the step endpoints (u_n, u_{n+1}) on top
251
258
  # of the midpoint base point, so both need u_{n+1} kept before the midpoint
252
259
  # evaluation overwrites `displacement` with ū. Re-checked here (not only in the
253
260
  # setter) so that re-calling Solver_Set_Hyperbolic_Algorithm with another algo
254
261
  # can't leave a stale selection.
255
- stressType, nPoints, useConsistentTangent, quadTol = (
262
+ stressType, nPoints, useConsistentTangent, energyTol = (
256
263
  self.__Solver_Get_Stress_Params()
257
264
  )
258
265
  isPointwise = stressType == HyperElastic.StressType.pointwise
@@ -261,7 +268,7 @@ class HyperElastic(_Simu):
261
268
  ), f"the '{stressType}' stress requires AlgoType.midpoint (got {self.algo})."
262
269
  u_np1 = displacement
263
270
  if isDynamic:
264
- displacement, velocity, _ = self._Solver_Evaluate_u_v_a_for_time_scheme(
271
+ displacement, velocity, accel = self._Solver_Evaluate_u_v_a_for_time_scheme(
265
272
  problemType, displacement
266
273
  )
267
274
  if not isPointwise:
@@ -270,7 +277,7 @@ class HyperElastic(_Simu):
270
277
  errDetF = "det(F) < 0 - reduce load steps"
271
278
 
272
279
  out = {}
273
- quadNPoints = 0 # max Clenshaw-Curtis points over groups this Newton iteration
280
+ list_nPts_e = [] # per-element point counts across groups (quadrature only)
274
281
  for groupElem in self.mesh.Get_list_groupElem():
275
282
  state = HyperElasticState(groupElem, displacement, matrixType)
276
283
 
@@ -298,15 +305,15 @@ class HyperElastic(_Simu):
298
305
  useConsistentTangent,
299
306
  )
300
307
  elif stressType == HyperElastic.StressType.quadrature:
301
- K_e, residual_e, nPts = (
308
+ K_e, residual_e, nPts_e = (
302
309
  Operators.NonLinear.TimeQuadratureStressTensor(
303
310
  self.material,
304
311
  *hyperElasticStates,
305
312
  nPoints,
306
- quadTol,
313
+ energyTol,
307
314
  )
308
315
  )
309
- quadNPoints = max(quadNPoints, nPts)
316
+ list_nPts_e.append(nPts_e)
310
317
  else:
311
318
  raise NotImplementedError
312
319
 
@@ -324,24 +331,41 @@ class HyperElastic(_Simu):
324
331
 
325
332
  F_e = -residual_e
326
333
 
327
- # Kelvin–Voigt viscosity: C_e is the damping matrix (slot 2, rides
328
- # coefC, carries the viscous residual b -= C @ v_t); Kgeo_e is the
329
- # configuration tangent ∂(C·v)/∂u, added to K_e so it rides coefK.
334
+ # Kelvin–Voigt viscosity: C_e is the damping matrix (slot 2, so it rides coefC
335
+ # in A), R_visco_e its residual, and Kgeo_e the configuration tangent
336
+ # ∂(C·v)/∂u, added to K_e so it rides coefK.
330
337
  C_e = None
331
338
  if self.material.eta != 0 and velocity is not None:
332
- C_e, Kgeo_e = Operators.NonLinear.KelvinVoigtDamping(
339
+ Kgeo_e, R_visco_e, C_e = Operators.NonLinear.KelvinVoigtDamping(
333
340
  self.material, state, velocity
334
341
  )
335
342
  K_e += Kgeo_e
343
+ F_e -= R_visco_e
336
344
 
337
- # mass matrix — only assembled for dynamic schemes
345
+ # mass matrix and the inertia residual — dynamic schemes only
338
346
  M_e = None
339
347
  if isDynamic:
340
- M_e = thickness * Operators.Bilinear.UV(groupElem, self.rho, dof_n=dim)
348
+ # M_e = thickness · ∫ρ N·N is constant across the solve, so it is cached (see __Mass_e)
349
+ # instead of rebuilt every Newton iteration. An array ρ is unhashable, so it cannot key the
350
+ # cache and is recomputed directly.
351
+ if isinstance(self.rho, np.ndarray):
352
+ M_e = thickness * Operators.Bilinear.UV(
353
+ groupElem, self.rho, dof_n=dim
354
+ )
355
+ else:
356
+ M_e = self.__Mass_e(groupElem, self.rho, thickness, dim)
357
+ F_e -= np.einsum(
358
+ "eij,ej->ei",
359
+ M_e,
360
+ groupElem.Locates_sol_e(accel, dim),
361
+ optimize=True,
362
+ )
341
363
 
342
364
  out[groupElem] = (K_e, C_e, M_e, F_e)
343
365
 
344
- self.__quadNPoints = quadNPoints # 0 unless quadrature stress ran this assembly
366
+ # per-element Clenshaw-Curtis point counts this assembly, or None if the quadrature
367
+ # stress did not run (pointwise / gonzalez)
368
+ self.__nPts_e = np.concatenate(list_nPts_e) if list_nPts_e else None
345
369
 
346
370
  return out
347
371
 
@@ -358,10 +382,8 @@ class HyperElastic(_Simu):
358
382
  if self.algo in AlgoType.Get_Hyperbolic_Types():
359
383
  iter["speed"] = self._Get_v_n(self.problemType)
360
384
  iter["accel"] = self._Get_a_n(self.problemType)
361
- if (
362
- self.__quadNPoints
363
- ): # points the converged step used (quadrature stress only)
364
- iter["quadNPoints"] = self.__quadNPoints
385
+ if self.__nPts_e is not None: # per-element point counts, quadrature only
386
+ iter["nPts_e"] = self.__nPts_e
365
387
 
366
388
  return super().Save_Iter(iter)
367
389
 
@@ -16,6 +16,7 @@ from ..__about__ import __version__
16
16
 
17
17
  # utilities
18
18
  from ..Utilities import Folder, Terminal, Tic, _params, _types
19
+ from ..Utilities._cache import cache_computed_values, clear_cached_computed_values
19
20
  from ..Utilities._observers import Observable, _IObserver
20
21
  from ..Utilities._mpi import CAN_USE_MPI, MPI_SIZE, MPI_RANK, MPI_COMM
21
22
 
@@ -220,7 +221,12 @@ class _Simu(_IObserver, _params.Updatable, ABC):
220
221
  Optional[np.ndarray],
221
222
  ],
222
223
  ]:
223
- r"""Construct the local matrix system :math:`\Krm \, \mathrm{u} + \Crm \, \vrm + \Mrm \, \arm = \Frm` for the given problem, returned per contributing group of elements `{groupElem: (K_e, C_e, M_e, F_e)}`."""
224
+ r"""Construct the local matrix system :math:`\Krm \, \mathrm{u} + \Crm \, \vrm + \Mrm \, \arm = \Frm` for the given problem, returned per contributing group of elements `{groupElem: (K_e, C_e, M_e, F_e)}`.
225
+
226
+ For a **linear** problem :math:`\Frm` is the load alone: :py:meth:`_Solver_Apply_Neumann` moves :math:`\mathrm{u}^n, \vrm^n, \arm^n` to the right-hand side with the history terms of the active time scheme.
227
+
228
+ For a **nonlinear** problem the unknown is :math:`\Delta \mathrm{u}`, so :math:`\Frm_e` is the complete residual :math:`-\Rrm_e`, inertia and damping included (:math:`-\Crm_e \, \vrm_t - \Mrm_e \, \arm_t`, with :math:`\vrm_t, \arm_t` from :py:meth:`_Solver_Evaluate_u_v_a_for_time_scheme`).
229
+ """
224
230
  raise NotImplementedError
225
231
 
226
232
  # Iterations
@@ -711,6 +717,11 @@ class _Simu(_IObserver, _params.Updatable, ABC):
711
717
  self.__listMesh.append(mesh)
712
718
  self.__mesh = mesh
713
719
 
720
+ # New connectivity invalidates the geometry-derived caches.
721
+ # This cannot live in `Need_Update`, which fires on every Newton
722
+ # iteration — exactly the case those caches exist to serve.
723
+ clear_cached_computed_values(self)
724
+
714
725
  # The mesh changes, so the matrices must be reconstructed
715
726
  self.Need_Update()
716
727
  # Initialize boundary conditions
@@ -738,7 +749,7 @@ class _Simu(_IObserver, _params.Updatable, ABC):
738
749
  if MPI_SIZE == 1:
739
750
  return
740
751
 
741
- list_mesh = []
752
+ list_mesh: list[Mesh] = []
742
753
  for mesh in self.__listMesh:
743
754
  if isinstance(mesh, str):
744
755
  mesh = Load_Mesh(Folder.Join(self.folder, mesh))
@@ -750,6 +761,9 @@ class _Simu(_IObserver, _params.Updatable, ABC):
750
761
  self.__listMesh[i] = mesh
751
762
  self.__mesh = self.__listMesh[self.__indexMesh]
752
763
  self.Bc_Init()
764
+ # rank 0 swaps its partition for the full global mesh — the geometry-derived caches describe the
765
+ # partition and no longer apply.
766
+ clear_cached_computed_values(self)
753
767
  self.Need_Update()
754
768
  self.__isGathered = True
755
769
 
@@ -774,6 +788,9 @@ class _Simu(_IObserver, _params.Updatable, ABC):
774
788
 
775
789
  self.__mesh = mesh
776
790
 
791
+ # switching to another mesh in the history changes the connectivity
792
+ clear_cached_computed_values(self)
793
+
777
794
  self.Need_Update() # need to reconstruct matrices
778
795
 
779
796
  def _Update(self, observable: Observable, event: str) -> None:
@@ -793,8 +810,8 @@ class _Simu(_IObserver, _params.Updatable, ABC):
793
810
  # Solver
794
811
  # ----------------------------------------------
795
812
 
796
- @staticmethod
797
813
  def __Assemble_csr(
814
+ self,
798
815
  dict_group_data: dict["_GroupElem", np.ndarray],
799
816
  dof_n: int,
800
817
  Ndof: int,
@@ -802,6 +819,23 @@ class _Simu(_IObserver, _params.Updatable, ABC):
802
819
  ) -> sparse.csr_matrix:
803
820
  """Scatter per-group local element matrices/vectors into a global CSR.
804
821
 
822
+ A finite-element matrix has far more element-level entries than stored coefficients — each shared
823
+ node makes several element contributions land on the same ``(row, col)`` — so assembly is
824
+ fundamentally "sum every contribution into its slot". The element→DOF coordinates depend only on
825
+ connectivity, so across a Newton loop or time stepping they never change; only the values do. This
826
+ method exploits that: scipy builds and owns the sparsity pattern on the first assembly, and a small
827
+ precomputed map lets every later assembly do the summation with a single ``np.bincount``, skipping
828
+ scipy's repeated sort-and-sum-duplicates.
829
+
830
+ The map is cached per ``(dof_n, isMatrix, Ndof, contributing groups)``. The contributing groups are
831
+ part of the key because a simulation may feed different group sets to different slots — in the
832
+ cardiac benchmark the surface group contributes to K, C and F but leaves M ``None``, so K and M have
833
+ genuinely different patterns.
834
+
835
+ Reuse is exact to floating-point round-off (~1e-16 relative), not bit-identical to a fresh scipy
836
+ build, because ``bincount`` sums each slot's contributions in a different order than scipy's
837
+ coo->csr does.
838
+
805
839
  Parameters
806
840
  ----------
807
841
  dict_group_data : dict[_GroupElem, FeArray]
@@ -819,28 +853,77 @@ class _Simu(_IObserver, _params.Updatable, ABC):
819
853
  if not dict_group_data:
820
854
  return sparse.csr_matrix(shape)
821
855
 
822
- list_rows, list_cols, list_data = [], [], []
823
- for groupElem, X_e in dict_group_data.items():
824
- if X_e is None:
825
- continue
826
- if isMatrix:
827
- rows = groupElem.Get_rows_e(dof_n).ravel()
828
- cols = groupElem.Get_columns_e(dof_n).ravel()
829
- else:
830
- rows = groupElem.Get_assembly_e(dof_n).ravel()
831
- cols = np.zeros_like(rows)
832
- list_rows.append(rows)
833
- list_cols.append(cols)
834
- list_data.append(X_e.ravel())
856
+ # Fixing the group order here keeps `data` aligned with the map built from these same groups.
857
+ groups = tuple(g for g, X_e in dict_group_data.items() if X_e is not None)
858
+ if not groups:
859
+ return sparse.csr_matrix(shape)
835
860
 
836
- if not list_data:
861
+ data = np.concatenate([dict_group_data[g].ravel() for g in groups])
862
+
863
+ # An MPI rank may own no element of any contributing group.
864
+ if data.size == 0:
837
865
  return sparse.csr_matrix(shape)
838
866
 
839
- data = np.concatenate(list_data)
867
+ # The reduction map (which CSR slot each element entry feeds) is cached per key; it depends only on
868
+ # connectivity, so a Newton loop / time stepping reuses it and only the values below change.
869
+ inv, indices, indptr, nnz = self.__Get_csr_map(dof_n, isMatrix, Ndof, groups)
870
+ assert data.size == inv.size, f"Not enough data to fill a {shape} CSR."
871
+
872
+ # Sum every element entry into its slot with one bincount. `bincount` is real-only, so a complex
873
+ # system is summed as its real and imaginary parts — still one pass each, still the rare path.
874
+ if np.iscomplexobj(data):
875
+ csr_data = np.bincount(
876
+ inv, weights=data.real, minlength=nnz
877
+ ) + 1j * np.bincount(inv, weights=data.imag, minlength=nnz)
878
+ else:
879
+ csr_data = np.bincount(inv, weights=data, minlength=nnz)
880
+
881
+ matrix = sparse.csr_matrix((csr_data, indices, indptr), shape=shape)
882
+ # Canonical by construction (scipy sorted the pattern): lets Solvers skip its canonical fixup.
883
+ matrix.has_canonical_format = True
884
+ return matrix
885
+
886
+ @cache_computed_values
887
+ def __Get_csr_map(self, dof_n, isMatrix, Ndof, groups):
888
+ """Reduction map from element entries to global CSR slots for one assembly key.
889
+
890
+ Returns ``(inv, indices, indptr, nnz)``: ``inv[k]`` is the CSR-data slot that element entry ``k``
891
+ feeds, and ``(indices, indptr, nnz)`` is scipy's canonical pattern. Depends only on connectivity, so
892
+ ``@cache_computed_values`` (keyed on ``dof_n, isMatrix, Ndof, groups``) reuses it across every
893
+ assembly of a key and rebuilds it when the groups / Ndof / dof_n change or the mesh is reset. ``inv``
894
+ is the single largest cached array; int32 halves it and is safe since slots are bounded by nnz < 2^31.
895
+ """
896
+ shape = (Ndof, Ndof) if isMatrix else (Ndof, 1)
897
+
898
+ list_rows, list_cols = [], []
899
+ for groupElem in groups:
900
+ if isMatrix:
901
+ list_rows.append(groupElem.Get_rows_e(dof_n).ravel())
902
+ list_cols.append(groupElem.Get_columns_e(dof_n).ravel())
903
+ else:
904
+ r = groupElem.Get_assembly_e(dof_n).ravel()
905
+ list_rows.append(r)
906
+ list_cols.append(np.zeros_like(r))
840
907
  rows = np.concatenate(list_rows)
841
908
  cols = np.concatenate(list_cols)
842
- assert data.size == rows.size, f"Not enough data to fill a {shape} CSR."
843
- return sparse.csr_matrix((data, (rows, cols)), shape=shape)
909
+
910
+ # scipy owns the canonical sparsity pattern; build it once from a structure-only matrix (the values
911
+ # are irrelevant — only the nonzero positions matter).
912
+ matrix = sparse.csr_matrix((np.ones(rows.size), (rows, cols)), shape=shape)
913
+ matrix.sort_indices()
914
+
915
+ # `canon` is the sorted linear (row-major) index of each stored coefficient; `searchsorted` maps each
916
+ # element entry to the slot whose `(row, col)` it matches. Building the map from scipy's own pattern
917
+ # (rather than a parallel hand-built one) is correct by construction, independent of any sort order.
918
+ ncol = Ndof if isMatrix else 1
919
+ canon = (
920
+ np.repeat(np.arange(shape[0]), np.diff(matrix.indptr)) * ncol
921
+ + matrix.indices
922
+ )
923
+ inv = np.searchsorted(canon, rows.astype(np.int64) * ncol + cols).astype(
924
+ np.int32
925
+ )
926
+ return inv, matrix.indices, matrix.indptr, matrix.nnz
844
927
 
845
928
  def Assembly(
846
929
  self, problemType: ModelType
@@ -1464,96 +1547,79 @@ class _Simu(_IObserver, _params.Updatable, ABC):
1464
1547
 
1465
1548
  b += F
1466
1549
 
1467
- if (
1468
- self.isNonLinear
1469
- and self.algo in AlgoType.Get_Hyperbolic_and_Parabolic_Types()
1470
- ):
1471
-
1472
- # get the current newton raphson solution (updated via u += delta_u)
1473
- u = self._Solver_Get_Newton_Raphson_current_solution()
1474
-
1475
- # here evaluate the vectors according to the time scheme
1476
- _, v_t, a_t = self._Solver_Evaluate_u_v_a_for_time_scheme(problemType, u)
1477
-
1478
- # add residual contributions in b
1479
- if v_t is not None:
1480
- b -= C @ sparse.csr_matrix(v_t.reshape(-1, 1))
1481
- if a_t is not None:
1482
- b -= M @ sparse.csr_matrix(a_t.reshape(-1, 1))
1483
- # K(u) Δu = - R(u)
1484
- # = - (F(u) - b)
1485
- # = - F(u) + b
1486
-
1487
- else:
1550
+ if self.isNonLinear:
1551
+ # The unknown is Δu and `F` is the complete residual -R(u), so b is already
1552
+ # built. The history terms below belong to a solve for u^{n+1}.
1553
+ pass
1488
1554
 
1489
- if algo == AlgoType.elliptic:
1490
- pass
1555
+ elif algo == AlgoType.elliptic:
1556
+ pass
1491
1557
 
1492
- elif algo == AlgoType.parabolic:
1493
- dt, alpha = self.__Solver_Get_Parabolic_Params()
1558
+ elif algo == AlgoType.parabolic:
1559
+ dt, alpha = self.__Solver_Get_Parabolic_Params()
1494
1560
 
1495
- ut_np1 = u_n + (1 - alpha) * dt * v_n
1561
+ ut_np1 = u_n + (1 - alpha) * dt * v_n
1496
1562
 
1497
- b += 1 / (alpha * dt) * C @ ut_np1
1563
+ b += 1 / (alpha * dt) * C @ ut_np1
1498
1564
 
1499
- elif algo == AlgoType.newmark:
1500
- # same as hht in accel with alpha = 0
1501
- dt, beta, gamma, _ = self.__Solver_Get_Hyperbolic_Params()
1565
+ elif algo == AlgoType.newmark:
1566
+ # same as hht in accel with alpha = 0
1567
+ dt, beta, gamma, _ = self.__Solver_Get_Hyperbolic_Params()
1502
1568
 
1503
- ut_np1 = u_n + dt * v_n + dt**2 / 2 * (1 - 2 * beta) * a_n
1504
- vt_np1 = v_n + dt * (1 - gamma) * a_n
1569
+ ut_np1 = u_n + dt * v_n + dt**2 / 2 * (1 - 2 * beta) * a_n
1570
+ vt_np1 = v_n + dt * (1 - gamma) * a_n
1505
1571
 
1506
- # ut_np1
1507
- coefC = gamma / (beta * dt)
1508
- coefM = 1 / (beta * dt**2)
1509
- b += (coefC * C + coefM * M) @ ut_np1
1572
+ # ut_np1
1573
+ coefC = gamma / (beta * dt)
1574
+ coefM = 1 / (beta * dt**2)
1575
+ b += (coefC * C + coefM * M) @ ut_np1
1510
1576
 
1511
- # vt_np1
1512
- b -= C @ vt_np1
1577
+ # vt_np1
1578
+ b -= C @ vt_np1
1513
1579
 
1514
- elif algo == AlgoType.midpoint:
1515
- # hht with alpha = 1/2, gamma = 1/2 and beta = 1/4
1516
- dt = self.__Solver_Get_Hyperbolic_Params()[0]
1580
+ elif algo == AlgoType.midpoint:
1581
+ # hht with alpha = 1/2, gamma = 1/2 and beta = 1/4
1582
+ dt = self.__Solver_Get_Hyperbolic_Params()[0]
1517
1583
 
1518
- # u_n
1519
- coefM = 2 / dt**2
1520
- coefC = 1 / dt
1521
- b += (coefM * M + coefC * C - 1 / 2 * K) @ u_n
1584
+ # u_n
1585
+ coefM = 2 / dt**2
1586
+ coefC = 1 / dt
1587
+ b += (coefM * M + coefC * C - 1 / 2 * K) @ u_n
1522
1588
 
1523
- # v_n
1524
- coefM = 2 / dt
1525
- b += coefM * M @ v_n
1589
+ # v_n
1590
+ coefM = 2 / dt
1591
+ b += coefM * M @ v_n
1526
1592
 
1527
- elif algo == AlgoType.hht:
1528
- dt, beta, gamma, alpha = self.__Solver_Get_Hyperbolic_Params()
1593
+ elif algo == AlgoType.hht:
1594
+ dt, beta, gamma, alpha = self.__Solver_Get_Hyperbolic_Params()
1529
1595
 
1530
- # u_n
1531
- coefM = 1 / (beta * dt**2)
1532
- coefC = gamma / (beta * dt)
1533
- b -= ((alpha - 1) * (coefM * M + coefC * C) + alpha * K) @ u_n
1596
+ # u_n
1597
+ coefM = 1 / (beta * dt**2)
1598
+ coefC = gamma / (beta * dt)
1599
+ b -= ((alpha - 1) * (coefM * M + coefC * C) + alpha * K) @ u_n
1534
1600
 
1535
- # v_n
1536
- coefM = (alpha - 1) / (beta * dt)
1537
- coefC = (alpha - 1) * (gamma / beta) + 1
1538
- b -= (coefM * M + coefC * C) @ v_n
1601
+ # v_n
1602
+ coefM = (alpha - 1) / (beta * dt)
1603
+ coefC = (alpha - 1) * (gamma / beta) + 1
1604
+ b -= (coefM * M + coefC * C) @ v_n
1539
1605
 
1540
- # a_n
1541
- coefM = (alpha - 1) / (2 * beta) + 1
1542
- coefC = dt * (alpha - 1) * (gamma / (2 * beta) - 1)
1543
- b -= (coefM * M + coefC * C) @ a_n
1606
+ # a_n
1607
+ coefM = (alpha - 1) / (2 * beta) + 1
1608
+ coefC = dt * (alpha - 1) * (gamma / (2 * beta) - 1)
1609
+ b -= (coefM * M + coefC * C) @ a_n
1544
1610
 
1545
- elif algo == AlgoType.euler_implicit:
1546
- dt = self.__Solver_Get_Hyperbolic_Params()[0]
1547
- b += (1 / dt**2 * M + 1 / dt * C) @ u_n
1548
- b += (1 / dt * M) @ v_n
1611
+ elif algo == AlgoType.euler_implicit:
1612
+ dt = self.__Solver_Get_Hyperbolic_Params()[0]
1613
+ b += (1 / dt**2 * M + 1 / dt * C) @ u_n
1614
+ b += (1 / dt * M) @ v_n
1549
1615
 
1550
- elif algo == AlgoType.euler_explicit:
1551
- # b = F^n - K u^n - C v^n (A = M, solving for a^n)
1552
- b -= K @ u_n
1553
- b -= C @ v_n
1616
+ elif algo == AlgoType.euler_explicit:
1617
+ # b = F^n - K u^n - C v^n (A = M, solving for a^n)
1618
+ b -= K @ u_n
1619
+ b -= C @ v_n
1554
1620
 
1555
- else:
1556
- raise NotImplementedError(f"Algo {algo} is not implemented here.")
1621
+ else:
1622
+ raise NotImplementedError(f"Algo {algo} is not implemented here.")
1557
1623
 
1558
1624
  tic.Tac("Solver", f"Neumann ({problemType}, {algo})", self._verbosity)
1559
1625
 
@@ -63,6 +63,7 @@ def Plot(
63
63
  show_grid=False,
64
64
  colorbarTitle=None,
65
65
  verticalColobar=True,
66
+ scalar_bar_kwargs: Optional[dict] = None,
66
67
  **kwargs,
67
68
  ):
68
69
  """Plots the object obj that can be either a simu, mesh, MultiBlock, PolyData.\n
@@ -111,6 +112,15 @@ def Plot(
111
112
  colorbar title, by default None
112
113
  verticalColobar : bool, optionnal
113
114
  color bar is vertical, by default True
115
+ scalar_bar_kwargs : dict, optionnal
116
+ Extra scalar-bar options, merged over the ones built from ``colorbarTitle`` and ``verticalColobar`` and taking precedence over them, by default None.
117
+ Useful when the defaults are unreadable — a horizontal bar in a narrow subplot needs ``n_labels`` and ``fmt`` to stop the ticks overlapping::
118
+
119
+ PyVista.Plot(simu, "uz", verticalColobar=False,
120
+ scalar_bar_kwargs={"n_labels": 3, "fmt": "%.1e"})
121
+
122
+ Note that pyvista keys scalar bars by title, so several bars in one figure need distinct ``title`` entries.
123
+ Everything accepted by https://docs.pyvista.org/api/plotting/_autosummary/pyvista.plotter.add_scalar_bar
114
124
  **kwargs:
115
125
  Everything that can goes in add_mesh function https://docs.pyvista.org/version/stable/api/plotting/_autosummary/pyvista.Plotter.add_mesh.html#pyvista.Plotter.add_mesh
116
126
 
@@ -172,6 +182,15 @@ def Plot(
172
182
  pos = "position_y"
173
183
  val = 0.025
174
184
 
185
+ # caller options win, so `scalar_bar_kwargs` can override the title, the orientation or the position as well as add to them
186
+ scalar_bar_args = {
187
+ "title": colorbarTitle,
188
+ "vertical": verticalColobar,
189
+ pos: val,
190
+ }
191
+ if scalar_bar_kwargs is not None:
192
+ scalar_bar_args.update(scalar_bar_kwargs)
193
+
175
194
  # plot the mesh
176
195
  if not isinstance(pvMesh, list):
177
196
  pvMeshs = [pvMesh]
@@ -191,11 +210,7 @@ def Plot(
191
210
  cmap=cmap,
192
211
  n_colors=nColors,
193
212
  clim=clim,
194
- scalar_bar_args={
195
- "title": colorbarTitle,
196
- "vertical": verticalColobar,
197
- pos: val,
198
- },
213
+ scalar_bar_args=scalar_bar_args,
199
214
  **kwargs,
200
215
  )
201
216
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: easyfea
3
- Version: 3.2.0
3
+ Version: 3.2.1
4
4
  Summary: User-friendly Python library that simplifies finite element analysis.
5
5
  Author-email: Matthieu Noel <matthieu.noel7@gmail.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: easyfea
3
- Version: 3.2.0
3
+ Version: 3.2.1
4
4
  Summary: User-friendly Python library that simplifies finite element analysis.
5
5
  Author-email: Matthieu Noel <matthieu.noel7@gmail.com>
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "easyfea"
7
- version = "3.2.0"
7
+ version = "3.2.1"
8
8
  description = "User-friendly Python library that simplifies finite element analysis."
9
9
  authors = [{ name = "Matthieu Noel", email = "matthieu.noel7@gmail.com" }]
10
10
  readme = "README.md"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes