Skip to content

Commit afba8a8

Browse files
authored
feat: detailed results for calculate strain profile (#325)
* Results for calculate strain profile * fix linting
1 parent 732bc0f commit afba8a8

4 files changed

Lines changed: 308 additions & 18 deletions

File tree

structuralcodes/core/_section_results.py

Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from dataclasses import dataclass, field, fields
88

99
import numpy as np
10-
from numpy.typing import ArrayLike
10+
from numpy.typing import ArrayLike, NDArray
1111
from shapely import Point
1212

1313

@@ -800,6 +800,184 @@ def get_point_stress(
800800
)
801801

802802

803+
@dataclass(slots=True)
804+
class StrainProfileResult:
805+
"""Class for storing the results from calculate_strain_profile method."""
806+
807+
# Solved generalized strains
808+
eps_a: float = 0.0 # the axial strain at 0, 0
809+
chi_y: float = 0.0 # the curvature respect y axes
810+
chi_z: float = 0.0 # the curvature respect z axes
811+
812+
# target external loads assigned by user
813+
n_ext: float = 0.0 # Target axial load acting at 0, 0
814+
m_y_ext: float = 0.0 # Target moment My
815+
m_z_ext: float = 0.0 # Target moment Mz
816+
817+
# Actual integrated loads at the converged strain state
818+
n: float = 0.0 # Axial load acting at 0, 0
819+
m_y: float = 0.0 # Bending moment My
820+
m_z: float = 0.0 # Bending moment Mz
821+
822+
# Solver settings and diagnostics
823+
tolerance: float = 0.0
824+
max_iter: int = 0
825+
used_initial_tangent: bool = False
826+
iterations: int = 0
827+
converged: bool = False
828+
residual: NDArray[np.float64] = field(
829+
default_factory=lambda: np.zeros(3, dtype=float)
830+
)
831+
832+
# Iteration history
833+
residual_history: list[NDArray[np.float64]] = field(default_factory=list)
834+
strain_history: list[NDArray[np.float64]] = field(default_factory=list)
835+
836+
# For context store the section
837+
section: t.Any = None # Note for future: if I want to type this I also have problem of circular import? #noqa E501
838+
# The detailed result data structure
839+
detailed_result: SectionDetailedResultState = None
840+
841+
@property
842+
def residual_norm_history(self) -> t.List:
843+
"""Returns the history of residual norm."""
844+
return [float(np.linalg.norm(x)) for x in self.residual_history]
845+
846+
@property
847+
def delta_strain_history(self) -> t.List:
848+
"""Returns as a list the history of delta_strain."""
849+
return [
850+
self.strain_history[i] - self.strain_history[i - 1]
851+
for i in range(1, len(self.strain_history))
852+
]
853+
854+
@property
855+
def delta_strain_norm_history(self) -> t.List:
856+
"""Returns as a list the history of norm of delta_strain."""
857+
return [float(np.linalg.norm(x)) for x in self.delta_strain_history]
858+
859+
@property
860+
def response_history(self) -> t.List:
861+
"""Returns as a list the response (i.e. internal forces) history."""
862+
loads = np.array([self.n_ext, self.m_y_ext, self.m_z_ext])
863+
return [(loads - x) for x in self.residual_history]
864+
865+
@property
866+
def strain_plane(self) -> NDArray[np.float64]:
867+
"""Returns the strain profile as a numpy array."""
868+
return np.array([self.eps_a, self.chi_y, self.chi_z])
869+
870+
@property
871+
def residual_norm(self) -> float:
872+
"""Returns the norm of the residual at last iteration."""
873+
return float(np.linalg.norm(self.residual))
874+
875+
def to_list(self) -> t.List:
876+
"""Returns the strain profile coefficients in a list."""
877+
return [self.eps_a, self.chi_y, self.chi_z]
878+
879+
def create_detailed_result(self, num_points=1000):
880+
"""Create the detailed result object.
881+
882+
Arguments:
883+
num_points (int): Number of random points to sample for each
884+
surface geometry (default = 1000).
885+
"""
886+
self.detailed_result = SectionDetailedResultState(
887+
section=self.section,
888+
eps_a=self.eps_a,
889+
chi_y=self.chi_y,
890+
chi_z=self.chi_z,
891+
n=self.n,
892+
m_y=self.m_y,
893+
m_z=self.m_z,
894+
num_points=num_points,
895+
)
896+
897+
def get_point_strain(
898+
self,
899+
y: float,
900+
z: float,
901+
name: t.Optional[str] = None,
902+
group_label: t.Optional[str] = None,
903+
case_sensitive: bool = True,
904+
all_results: bool = False,
905+
) -> float:
906+
"""Return the strain at a given point (y,z).
907+
908+
Arguments:
909+
y (float): The y-coordinate of the point.
910+
z (float): The z-coordinate of the point.
911+
name (str, optional): The name of the surface geometry to check.
912+
group_label (str, optional): The group label of the surface
913+
geometry to check.
914+
case_sensitive (bool, optional): If True (default) the matching is
915+
case sensitive.
916+
all_results (bool): If True, return the strain for all geometries
917+
that matches the filters, otherwise return the strain for the
918+
first geometry that matches the filters (default False).
919+
920+
Returns:
921+
float: The strain at the given point, or None if the point is not
922+
within any of the geometries that match the filters.
923+
"""
924+
return _get_point_response(
925+
section=self.section,
926+
eps_a=self.eps_a,
927+
chi_y=self.chi_y,
928+
chi_z=self.chi_z,
929+
y=y,
930+
z=z,
931+
response_type='strain',
932+
name=name,
933+
group_label=group_label,
934+
case_sensitive=case_sensitive,
935+
all_results=all_results,
936+
)
937+
938+
def get_point_stress(
939+
self,
940+
y: float,
941+
z: float,
942+
name: t.Optional[str] = None,
943+
group_label: t.Optional[str] = None,
944+
case_sensitive: bool = True,
945+
all_results: bool = False,
946+
) -> float:
947+
"""Return the stress at a given point (y,z).
948+
949+
Arguments:
950+
y (float): The y-coordinate of the point.
951+
z (float): The z-coordinate of the point.
952+
name (str, optional): The pattern for filtering the geometries by
953+
their name.
954+
group_label (str, optional): The pattern for filtering the
955+
geometries by their group_label.
956+
case_sensitive (bool, optional): If True (default) the matching is
957+
case sensitive.
958+
all_results (bool): If True, return the stress for all geometries
959+
that matches the filters, otherwise return the stress for the
960+
first geometry that matches the filters (default False).
961+
962+
Returns:
963+
float: The strain at the given point, or None if the point is not
964+
within any of the geometries that match the filters.
965+
"""
966+
return _get_point_response(
967+
section=self.section,
968+
eps_a=self.eps_a,
969+
chi_y=self.chi_y,
970+
chi_z=self.chi_z,
971+
y=y,
972+
z=z,
973+
response_type='stress',
974+
name=name,
975+
group_label=group_label,
976+
case_sensitive=case_sensitive,
977+
all_results=all_results,
978+
)
979+
980+
803981
@dataclass
804982
class InteractionDomain:
805983
"""Class for storing common data on all interaction domain results.

structuralcodes/sections/_generic.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1712,7 +1712,7 @@ def calculate_strain_profile(
17121712
initial: bool = False,
17131713
max_iter: int = 10,
17141714
tol: float = 1e-6,
1715-
) -> t.List[float]:
1715+
) -> s_res.StrainProfileResult:
17161716
"""Get the strain plane for a given axial force and biaxial bending.
17171717
17181718
Args:
@@ -1727,8 +1727,10 @@ def calculate_strain_profile(
17271727
increment.
17281728
17291729
Returns:
1730-
List(float): 3 floats: Axial strain at (0,0), and curvatures of the
1731-
section around y and z axes.
1730+
StrainProfileResult: A custom object of class StrainProfileResult
1731+
that contains the results. Note that the to_list() method
1732+
returns as a list only the strain profile coefficients. These
1733+
can be for instanced passed to `integrate_strain_profile`.
17321734
"""
17331735
# Get the gometry
17341736
geom = self.section.geometry
@@ -1751,7 +1753,12 @@ def calculate_strain_profile(
17511753

17521754
# Calculate strain plane with Newton Rhapson Iterative method
17531755
num_iter = 0
1754-
strain = np.zeros(3)
1756+
strain = np.zeros(3, dtype=float)
1757+
residual = np.zeros(3, dtype=float)
1758+
converged = False
1759+
1760+
residual_history = []
1761+
strain_history = []
17551762

17561763
# Factorize once the stiffness matrix if using initial
17571764
if initial:
@@ -1769,6 +1776,10 @@ def calculate_strain_profile(
17691776
response = np.array(self.integrate_strain_profile(strain=strain))
17701777
residual = loads - response
17711778

1779+
# Append to history variables
1780+
residual_history.append(residual.copy())
1781+
strain_history.append(strain.copy())
1782+
17721783
if initial:
17731784
# Solve using the decomposed matrix
17741785
delta_strain = lu_solve((lu, piv), residual)
@@ -1786,14 +1797,37 @@ def calculate_strain_profile(
17861797
# Solve using the current tangent stiffness
17871798
delta_strain = np.linalg.solve(stiffness_tangent, residual)
17881799

1800+
# Check for convergence:
1801+
if np.linalg.norm(delta_strain) < tol:
1802+
converged = True
1803+
break
1804+
17891805
# Update the strain
17901806
strain += delta_strain
17911807

17921808
num_iter += 1
17931809

1794-
# Check for convergence:
1795-
if np.linalg.norm(delta_strain) < tol:
1796-
break
1810+
# Create the results object
1811+
res = s_res.StrainProfileResult(
1812+
eps_a=float(strain[0]),
1813+
chi_y=float(strain[1]),
1814+
chi_z=float(strain[2]),
1815+
n_ext=float(n),
1816+
m_y_ext=float(my),
1817+
m_z_ext=float(mz),
1818+
n=float(response[0]),
1819+
m_y=float(response[1]),
1820+
m_z=float(response[2]),
1821+
max_iter=max_iter,
1822+
tolerance=tol,
1823+
used_initial_tangent=initial,
1824+
iterations=num_iter + 1,
1825+
converged=converged,
1826+
residual=residual.copy(),
1827+
residual_history=residual_history.copy(),
1828+
strain_history=strain_history.copy(),
1829+
section=self.section,
1830+
)
17971831

17981832
if num_iter >= max_iter:
17991833
warnings.warn(
@@ -1804,4 +1838,4 @@ def calculate_strain_profile(
18041838
category=NoConvergenceWarning,
18051839
)
18061840

1807-
return strain.tolist()
1841+
return res

tests/test_sections/test_generic_section.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -920,10 +920,10 @@ def test_strain_plane_calculation_elastic_Nmm(n, my, mz, Ec, b, h):
920920

921921
# Compare
922922
assert np.allclose(
923-
np.array(strain_fiber), expected_strain, rtol=1e-2, atol=1e-8
923+
strain_fiber.strain_plane, expected_strain, rtol=1e-2, atol=1e-8
924924
)
925925
assert np.allclose(
926-
np.array(strain_marin), expected_strain, rtol=1e-2, atol=1e-8
926+
strain_marin.strain_plane, expected_strain, rtol=1e-2, atol=1e-8
927927
)
928928

929929

@@ -994,10 +994,10 @@ def test_strain_plane_calculation_elastic_kNm(n, my, mz, Ec, b, h):
994994

995995
# Compare
996996
assert np.allclose(
997-
np.array(strain_fiber), expected_strain, rtol=5e-2, atol=1e-5
997+
strain_fiber.strain_plane, expected_strain, rtol=5e-2, atol=1e-5
998998
)
999999
assert np.allclose(
1000-
np.array(strain_marin), expected_strain, rtol=5e-2, atol=1e-5
1000+
strain_marin.strain_plane, expected_strain, rtol=5e-2, atol=1e-5
10011001
)
10021002

10031003

@@ -1049,24 +1049,26 @@ def test_strain_plane_calculation_rectangular_rc(n, my, mz, fck, b, h):
10491049
strain_marin = section.section_calculator.calculate_strain_profile(
10501050
n, my, mz, tol=1e-7
10511051
)
1052-
strain_marin = np.array(strain_marin)
1052+
strain_marin_array = np.array(strain_marin.to_list())
10531053

10541054
section = GenericSection(geo, integrator='fiber', mesh_size=0.0001)
10551055

10561056
strain_fiber = section.section_calculator.calculate_strain_profile(
10571057
n, my, mz, tol=1e-7
10581058
)
1059-
strain_fiber = np.array(strain_fiber)
1059+
strain_fiber_array = np.array(strain_fiber.to_list())
10601060

10611061
# check that initial tangent gives the same solution at the end
10621062
strain_fiber_initial = section.section_calculator.calculate_strain_profile(
10631063
n, my, mz, tol=1e-7, initial=True, max_iter=80
10641064
)
1065-
strain_fiber_initial = np.array(strain_fiber_initial)
1065+
strain_fiber_initial_array = np.array(strain_fiber_initial.to_list())
10661066

1067-
assert np.allclose(strain_marin, strain_fiber, rtol=2e-2, atol=1e-6)
10681067
assert np.allclose(
1069-
strain_fiber, strain_fiber_initial, rtol=2e-2, atol=1e-6
1068+
strain_marin_array, strain_fiber_array, rtol=2e-2, atol=1e-6
1069+
)
1070+
assert np.allclose(
1071+
strain_fiber_array, strain_fiber_initial_array, rtol=2e-2, atol=1e-6
10701072
)
10711073

10721074

0 commit comments

Comments
 (0)