From 7cbf202c5c43931486639b77eeebb97bc4af9a05 Mon Sep 17 00:00:00 2001 From: Diego Talledo <38036285+talledodiego@users.noreply.github.com> Date: Tue, 17 Mar 2026 08:16:20 +0100 Subject: [PATCH 01/12] minor fix to next previous and set step for MomentCurvatureResults --- structuralcodes/core/_section_results.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/structuralcodes/core/_section_results.py b/structuralcodes/core/_section_results.py index 3dbde253..b40d0fd5 100644 --- a/structuralcodes/core/_section_results.py +++ b/structuralcodes/core/_section_results.py @@ -318,17 +318,13 @@ def create_detailed_result(self, num_points=1000): def next_step(self): """Advance to the next step in the detailed result.""" - if self.detailed_result is None: - return - if self.current_step < len(self.m_y) - 1: + if self.detailed_result and self.current_step < len(self.m_y) - 1: self.current_step += 1 self._create_detailed_result() def previous_step(self): """Go back to the previous step in the detailed result.""" - if self.detailed_result is None: - return - if self.current_step > 0: + if self.detailed_result and self.current_step > 0: self.current_step -= 1 self._create_detailed_result() @@ -338,9 +334,7 @@ def set_step(self, step: int): Arguments: step (int): the step to set for the datailed_result object. """ - if self.detailed_result is None: - return - if 0 <= step < len(self.m_y): + if self.detailed_result and 0 <= step < len(self.m_y): self.current_step = step self._create_detailed_result() From 8724c33fa80defb44dad8d5b793b743610c58a19 Mon Sep 17 00:00:00 2001 From: Diego Talledo <38036285+talledodiego@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:16:48 +0100 Subject: [PATCH 02/12] refactor: rename eps_axial to eps_a for consistency in MomentCurvatureResults --- structuralcodes/core/_section_results.py | 8 ++++---- structuralcodes/sections/_generic.py | 2 +- tests/test_sections/test_rc_sections/test_rc_results.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/structuralcodes/core/_section_results.py b/structuralcodes/core/_section_results.py index b40d0fd5..4eef75a0 100644 --- a/structuralcodes/core/_section_results.py +++ b/structuralcodes/core/_section_results.py @@ -278,7 +278,7 @@ class MomentCurvatureResults: n: float = 0 # axial load - mantained constant during analysis chi_y: ArrayLike = None # the curvatures chi_z: ArrayLike = None # the curvatures - eps_axial: ArrayLike = 0 # the axial strain (at section 0,0) + eps_a: ArrayLike = None # the axial strain (at section 0,0) m_y: ArrayLike = None # the moment m_z: ArrayLike = None # the moment @@ -292,7 +292,7 @@ class MomentCurvatureResults: def _create_detailed_result(self): self.detailed_result = SectionDetailedResultState( section=self.section, - eps_a=self.eps_axial[self.current_step], + eps_a=self.eps_a[self.current_step], chi_y=self.chi_y[self.current_step], chi_z=self.chi_z[self.current_step], n=self.n, @@ -368,7 +368,7 @@ def get_point_strain( """ return _get_point_response( section=self.section, - eps_a=np.asarray(self.eps_axial), + eps_a=np.asarray(self.eps_a), chi_y=np.asarray(self.chi_y), chi_z=np.asarray(self.chi_z), y=y, @@ -411,7 +411,7 @@ def get_point_stress( """ return _get_point_response( section=self.section, - eps_a=np.asarray(self.eps_axial), + eps_a=np.asarray(self.eps_a), chi_y=np.asarray(self.chi_y), chi_z=np.asarray(self.chi_z), y=y, diff --git a/structuralcodes/sections/_generic.py b/structuralcodes/sections/_generic.py index ccfe7ff9..7b840a10 100644 --- a/structuralcodes/sections/_generic.py +++ b/structuralcodes/sections/_generic.py @@ -1204,7 +1204,7 @@ def calculate_moment_curvature( self._rotate_integration_data(theta) res.chi_y = chi_y res.chi_z = chi_z - res.eps_axial = eps_a + res.eps_a = eps_a res.m_y = my res.m_z = mz diff --git a/tests/test_sections/test_rc_sections/test_rc_results.py b/tests/test_sections/test_rc_sections/test_rc_results.py index 1d1fb8e9..6cf25fab 100644 --- a/tests/test_sections/test_rc_sections/test_rc_results.py +++ b/tests/test_sections/test_rc_sections/test_rc_results.py @@ -266,7 +266,7 @@ def test_moment_curvature_result_point(simple_rc_section): stress = res.get_point_stress(y=y1, z=z1, group_label='bottom') assert isinstance(stress, np.ndarray) - assert len(stress) == len(res.eps_axial) + assert len(stress) == len(res.eps_a) # Check that at the end the stress on the bar is the yield strength of # reinforcement. @@ -283,7 +283,7 @@ def test_moment_curvature_result_point(simple_rc_section): stress = res.get_point_stress(y=y2, z=z2, group_label='top') assert isinstance(stress, np.ndarray) - assert len(stress) == len(res.eps_axial) + assert len(stress) == len(res.eps_a) def test_moment_curvature_result(simple_rc_section): From a9f666fbed04af9e4ed4dd720934a840358042f7 Mon Sep 17 00:00:00 2001 From: Diego Talledo <38036285+talledodiego@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:19:18 +0100 Subject: [PATCH 03/12] refactor: encapsulate detailed_result with property in Results classes. Auto-construct the object when first accessed. --- structuralcodes/core/_section_results.py | 50 +++++++++++++++---- .../test_rc_sections/test_rc_results.py | 7 --- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/structuralcodes/core/_section_results.py b/structuralcodes/core/_section_results.py index 4eef75a0..8080c6bb 100644 --- a/structuralcodes/core/_section_results.py +++ b/structuralcodes/core/_section_results.py @@ -284,13 +284,13 @@ class MomentCurvatureResults: section = None - detailed_result: SectionDetailedResultState = None + _detailed_result: SectionDetailedResultState = None seed: int = None current_step: int = None num_points: int = None def _create_detailed_result(self): - self.detailed_result = SectionDetailedResultState( + self._detailed_result = SectionDetailedResultState( section=self.section, eps_a=self.eps_a[self.current_step], chi_y=self.chi_y[self.current_step], @@ -302,6 +302,13 @@ def _create_detailed_result(self): seed=self.seed, ) + @property + def detailed_result(self) -> SectionDetailedResultState: + """Returns the detailed result.""" + if self._detailed_result is None: + self.create_detailed_result() + return self._detailed_result + def create_detailed_result(self, num_points=1000): """Create the detailed result object. @@ -318,13 +325,13 @@ def create_detailed_result(self, num_points=1000): def next_step(self): """Advance to the next step in the detailed result.""" - if self.detailed_result and self.current_step < len(self.m_y) - 1: + if self._detailed_result and self.current_step < len(self.m_y) - 1: self.current_step += 1 self._create_detailed_result() def previous_step(self): """Go back to the previous step in the detailed result.""" - if self.detailed_result and self.current_step > 0: + if self._detailed_result and self.current_step > 0: self.current_step -= 1 self._create_detailed_result() @@ -334,7 +341,7 @@ def set_step(self, step: int): Arguments: step (int): the step to set for the datailed_result object. """ - if self.detailed_result and 0 <= step < len(self.m_y): + if self._detailed_result and 0 <= step < len(self.m_y): self.current_step = step self._create_detailed_result() @@ -690,7 +697,7 @@ class UltimateBendingMomentResults: section = None - detailed_result: SectionDetailedResultState = None + _detailed_result: SectionDetailedResultState = None def create_detailed_result(self, num_points=1000): """Create the detailed result object. @@ -699,7 +706,7 @@ def create_detailed_result(self, num_points=1000): num_points (int): Number of random points to sample for each surface geometry (default = 1000). """ - self.detailed_result = SectionDetailedResultState( + self._detailed_result = SectionDetailedResultState( section=self.section, eps_a=self.eps_a, chi_y=self.chi_y, @@ -710,6 +717,13 @@ def create_detailed_result(self, num_points=1000): num_points=num_points, ) + @property + def detailed_result(self) -> SectionDetailedResultState: + """Returns the detailed result.""" + if self._detailed_result is None: + self.create_detailed_result() + return self._detailed_result + def get_point_strain( self, y: float, @@ -830,7 +844,7 @@ class StrainProfileResult: # For context store the section section: t.Any = None # Note for future: if I want to type this I also have problem of circular import? #noqa E501 # The detailed result data structure - detailed_result: SectionDetailedResultState = None + _detailed_result: SectionDetailedResultState = None @property def residual_norm_history(self) -> t.List: @@ -866,6 +880,13 @@ def residual_norm(self) -> float: """Returns the norm of the residual at last iteration.""" return float(np.linalg.norm(self.residual)) + @property + def detailed_result(self) -> SectionDetailedResultState: + """Returns the detailed result.""" + if self._detailed_result is None: + self.create_detailed_result() + return self._detailed_result + def to_list(self) -> t.List: """Returns the strain profile coefficients in a list.""" return [self.eps_a, self.chi_y, self.chi_z] @@ -877,7 +898,7 @@ def create_detailed_result(self, num_points=1000): num_points (int): Number of random points to sample for each surface geometry (default = 1000). """ - self.detailed_result = SectionDetailedResultState( + self._detailed_result = SectionDetailedResultState( section=self.section, eps_a=self.eps_a, chi_y=self.chi_y, @@ -1008,7 +1029,7 @@ class IntegrateStrainForceResult: # For context store the section section: t.Any = None # Note for future: if I want to type this I also have problem of circular import? #noqa E501 # The detailed result data structure - detailed_result: SectionDetailedResultState = None + _detailed_result: SectionDetailedResultState = None def asarray(self, dtype=np.float64) -> NDArray: """Return an array representation of the forces.""" @@ -1018,6 +1039,13 @@ def astuple(self) -> tuple[float, float, float]: """Return a tuple representation of the forces.""" return (self.n, self.m_y, self.m_z) + @property + def detailed_result(self) -> SectionDetailedResultState: + """Returns the detailed result.""" + if self._detailed_result is None: + self.create_detailed_result() + return self._detailed_result + def create_detailed_result(self, num_points=1000): """Create the detailed result object. @@ -1025,7 +1053,7 @@ def create_detailed_result(self, num_points=1000): num_points (int): Number of random points to sample for each surface geometry (default = 1000). """ - self.detailed_result = SectionDetailedResultState( + self._detailed_result = SectionDetailedResultState( section=self.section, eps_a=self.eps_a, chi_y=self.chi_y, diff --git a/tests/test_sections/test_rc_sections/test_rc_results.py b/tests/test_sections/test_rc_sections/test_rc_results.py index 6cf25fab..2c94c2dd 100644 --- a/tests/test_sections/test_rc_sections/test_rc_results.py +++ b/tests/test_sections/test_rc_sections/test_rc_results.py @@ -292,8 +292,6 @@ def test_moment_curvature_result(simple_rc_section): res = section.section_calculator.calculate_moment_curvature() - res.create_detailed_result() - stresses_bottom = res.get_point_stress( y=-160.0, z=-160.0, group_label='bottom' ) @@ -326,8 +324,6 @@ def test_bending_strength_result(simple_rc_section): res = section.section_calculator.calculate_bending_strength() - res.create_detailed_result() - stress_bottom = res.get_point_stress( y=-160.0, z=-160.0, group_label='bottom' ) @@ -403,9 +399,6 @@ def test_calc_strain_profile_results( y=-160.0, z=-160.0, group_label='bottom' ) - # Get detailed result - strain_res.create_detailed_result() - assert np.isclose( stress_bottom, strain_res.detailed_result.point_data['stress'][0] ) From 74838cbb2d9a7df29a4035d111d542d29acb71c0 Mon Sep 17 00:00:00 2001 From: Diego Talledo <38036285+talledodiego@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:41:10 +0100 Subject: [PATCH 04/12] bugfix: pass section to IntegrateStrainStiffnessResult --- structuralcodes/sections/_generic.py | 1 + 1 file changed, 1 insertion(+) diff --git a/structuralcodes/sections/_generic.py b/structuralcodes/sections/_generic.py index 7b840a10..4650e693 100644 --- a/structuralcodes/sections/_generic.py +++ b/structuralcodes/sections/_generic.py @@ -912,6 +912,7 @@ def integrate_strain_profile( n=result[0], m_y=result[1], m_z=result[2], + section=self.section, ) if integrate == 'modulus': return s_res.IntegrateStrainStiffnessResult(tangent=result[0]) From 9f3dba4f74c62b815dccf7755aa8299a750deb75 Mon Sep 17 00:00:00 2001 From: Diego Talledo <38036285+talledodiego@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:17:32 +0100 Subject: [PATCH 05/12] refactor: rename interaction domain classes and update type hints for clarity --- structuralcodes/core/_section_results.py | 80 ++++++++++++++++-------- structuralcodes/sections/_generic.py | 29 ++++++--- 2 files changed, 73 insertions(+), 36 deletions(-) diff --git a/structuralcodes/core/_section_results.py b/structuralcodes/core/_section_results.py index 8080c6bb..3458fd21 100644 --- a/structuralcodes/core/_section_results.py +++ b/structuralcodes/core/_section_results.py @@ -1149,7 +1149,7 @@ def get_point_stress( @dataclass -class InteractionDomain: +class InteractionDomainResult: """Class for storing common data on all interaction domain results. Attributes: @@ -1157,16 +1157,12 @@ class InteractionDomain: ky and kz. forces (numpy.Array): A numpy array with shape (n, 3) containing n, my and mz. - field_num (numpy.Array): a numpy array with shape (n,) containing a - number between 1 and 6 indicating the failure field. """ # array with shape (n,3) containing ea, ky, kz: - strains: ArrayLike = None + strains: NDArray[np.float64] = None # array with shape(n,3) containing N, My, Mz - forces: ArrayLike = None - # array with shape(n,) containing the field number from 1 to 6 - field_num: ArrayLike = None + forces: NDArray[np.float64] = None @property def n(self): @@ -1190,46 +1186,78 @@ def m_z(self): return self.forces[:, 2] @property - def e_a(self): - """Return ea.""" + def eps_a(self): + """Return eps_a (axial strain at 0, 0).""" if self.strains is None: return None return self.strains[:, 0] @property - def k_y(self): - """Return ky.""" + def chi_y(self): + """Return chi_y (curvature about y-axis.""" if self.strains is None: return None return self.strains[:, 1] @property - def k_z(self): - """Return kz.""" + def chi_z(self): + """Return chi_z (curvature about z-axis).""" if self.strains is None: return None return self.strains[:, 2] -@dataclass -class NMMInteractionDomain(InteractionDomain): - """Class for storing the NMM interaction domain results.""" +class NMMInteractionDomainResult(InteractionDomainResult): + """Class for storing the NMM interaction domain results. - num_theta: int = 0 # number of discretizations along the angle - num_axial: int = 0 # number of discretizations along axial load axis + Attributes: + num_theta (int): the number of discretizations from 0 to 2pi. + field_num (numpy.Array): a numpy array with shape (n,) containing a + number between 1 and 6 indicating the failure field. + """ + + # number of discretizations along the angle + num_theta: int = 0 + + # number of points from discretization of ultimate strain profiles + num_points: int = 0 + + # array with shape(n,) containing the field number from 1 to 6 + field_num: NDArray[np.float64] = None @dataclass -class NMInteractionDomain(InteractionDomain): - """Class for storing the NM interaction domain results.""" +class NMInteractionDomainResult(InteractionDomainResult): + """Class for storing the NM interaction domain results. - theta: float = 0 # the inclination of n.a. - num_axial: float = 0 # number of discretizations along axial load axis + Attributes: + theta (float): the inclination of n.a. + field_num (numpy.Array): a numpy array with shape (n,) containing a + number between 1 and 6 indicating the failure field. + """ + + # the inclination of n.a. + theta: float = 0 + + # number of points from discretization of ultimate strain profiles + num_points: int = 0 + + # array with shape(n,) containing the field number from 1 to 6 + field_num: NDArray[np.float64] = None @dataclass -class MMInteractionDomain(InteractionDomain): - """Class for storing the MM interaction domain results.""" +class MMInteractionDomainResult(InteractionDomainResult): + """Class for storing the MM interaction domain results. + + + Attributes: + num_theta (int): the number of discretizations from 0 to 2pi. + theta (numpy.Array): a numpy array with shape (n,) the angle theta + """ + + # number of discretizations along the angle + num_theta: float = 0 - num_theta: float = 0 # number of discretizations along the angle - theta: ArrayLike = None # Array with shape (n,) containing the angle of NA + # Array with shape (n,) containing the angle of NA + theta: NDArray[np.float64] = None diff --git a/structuralcodes/sections/_generic.py b/structuralcodes/sections/_generic.py index 4650e693..65bb7a33 100644 --- a/structuralcodes/sections/_generic.py +++ b/structuralcodes/sections/_generic.py @@ -1284,7 +1284,7 @@ def calculate_nm_interaction_domain( type_5: t.Literal['linear', 'geometric', 'quadratic'] = 'linear', type_6: t.Literal['linear', 'geometric', 'quadratic'] = 'linear', complete_domain: bool = False, - ) -> s_res.NMInteractionDomain: + ) -> s_res.NMInteractionDomainResult: """Calculate the NM interaction domain. Arguments: @@ -1326,7 +1326,7 @@ def calculate_nm_interaction_domain( NMInteractionDomain: The calculation results. """ # Prepare the results - res = s_res.NMInteractionDomain() + res = s_res.NMInteractionDomainResult() res.theta = theta # Process num if given. @@ -1396,6 +1396,7 @@ def calculate_nm_interaction_domain( forces[i, 2] = Mz # Save to results + res.num_points = len(strains) res.strains = strains res.forces = forces res.field_num = field_num @@ -1563,7 +1564,7 @@ def _np_space(a, b, n, type, endpoint): def calculate_nmm_interaction_domain( self, - num_theta: int = 32, + num_theta: int = 33, num_1: int = 1, num_2: int = 2, num_3: int = 15, @@ -1577,12 +1578,12 @@ def calculate_nmm_interaction_domain( type_4: t.Literal['linear', 'geometric', 'quadratic'] = 'linear', type_5: t.Literal['linear', 'geometric', 'quadratic'] = 'linear', type_6: t.Literal['linear', 'geometric', 'quadratic'] = 'linear', - ) -> s_res.NMMInteractionDomain: + ) -> s_res.NMMInteractionDomainResult: """Calculates the NMM interaction domain. Arguments: num_theta (int): Number of discretization of angle of neutral axis - (Optional, Default = 32). + (Optional, Default = 33). num_1 (int): Number of strain profiles in field 1 (Optional, default = 1). num_2 (int): Number of strain profiles in field 2 @@ -1615,7 +1616,7 @@ def calculate_nmm_interaction_domain( Returns: NMInteractionDomain: The calculation results. """ - res = s_res.NMMInteractionDomain() + res = s_res.NMMInteractionDomainResult() res.num_theta = num_theta # Process num if given. @@ -1630,6 +1631,7 @@ def calculate_nmm_interaction_domain( thetas = np.linspace(0, np.pi * 2, num_theta) # Initialize an empty array with the correct shape strains = np.empty((0, 3)) + num_points = 0 for theta in thetas: # Get ultimate strain profiles for theta angle strain, field_num = self._compute_ultimate_strain_profiles( @@ -1647,6 +1649,12 @@ def calculate_nmm_interaction_domain( type_5=type_5, type_6=type_6, ) + if num_points == 0: + num_points = len(strain) + if num_points != len(strain): + raise AssertionError( + 'All strain arrays should have the same number of points!' + ) strains = np.vstack((strains, strain)) # integrate all strain profiles @@ -1666,6 +1674,7 @@ def calculate_nmm_interaction_domain( forces[i, 2] = Mz # Save to results + res.num_points = num_points res.strains = strains res.forces = forces res.field_num = field_num @@ -1675,15 +1684,15 @@ def calculate_nmm_interaction_domain( def calculate_mm_interaction_domain( self, n: float = 0, - num_theta: int = 32, + num_theta: int = 33, max_iter: int = 100, tol: float = 1e-2, - ) -> s_res.MMInteractionDomain: + ) -> s_res.MMInteractionDomainResult: r"""Calculate the My-Mz interaction domain. Arguments: n (float): Axial force, default = 0. - n_theta (int): Number of discretization for theta, default = 32. + n_theta (int): Number of discretization for theta, default = 33. max_iter (int): the maximum number of iterations in the iterative process (default = 10). tol (float): the tolerance for convergence test in terms of @@ -1693,7 +1702,7 @@ def calculate_mm_interaction_domain( MMInteractionDomain: The calculation results. """ # Prepare the results - res = s_res.MMInteractionDomain() + res = s_res.MMInteractionDomainResult() res.num_theta = num_theta # Create array of thetas res.theta = np.linspace(0, np.pi * 2, num_theta) From b412bade5a740f76d85aaed7f3c6bc8e84b55a29 Mon Sep 17 00:00:00 2001 From: Diego Talledo <38036285+talledodiego@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:19:04 +0100 Subject: [PATCH 06/12] refactor: add slots=True to result dataclasses --- structuralcodes/core/_section_results.py | 27 +++++++++++++++--------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/structuralcodes/core/_section_results.py b/structuralcodes/core/_section_results.py index 3458fd21..1c8461e4 100644 --- a/structuralcodes/core/_section_results.py +++ b/structuralcodes/core/_section_results.py @@ -11,7 +11,7 @@ from shapely import Point -@dataclass +@dataclass(slots=True) class SectionProperties: """Simple dataclass for storing section properties.""" @@ -143,8 +143,10 @@ def isclose(self, other, rtol=1e-5, atol=1e-8): if not isinstance(other, self.__class__): return NotImplemented - a = np.array(list(vars(self).values())) - b = np.array(list(vars(other).values())) + field_names = [f.name for f in fields(self)] + a = np.array([getattr(self, name) for name in field_names]) + b = np.array([getattr(other, name) for name in field_names]) + return np.allclose(a, b, rtol=rtol, atol=atol) @@ -267,7 +269,9 @@ def _get_point_response( return None -@dataclass +# TODO: to be able using slots = True we mush type annotate section, but this +# would trigger a circular import @mortenengen +@dataclass(slots=True) class MomentCurvatureResults: """Class for storing moment curvature results. @@ -282,7 +286,7 @@ class MomentCurvatureResults: m_y: ArrayLike = None # the moment m_z: ArrayLike = None # the moment - section = None + section: t.Any = None _detailed_result: SectionDetailedResultState = None seed: int = None @@ -681,7 +685,9 @@ def point_data(self): return self._point_data -@dataclass +# TODO: to be able using slots = True we mush type annotate section, but this +# would trigger a circular import @mortenengen +@dataclass(slots=True) class UltimateBendingMomentResults: """Class for storing the ultimate bending moment computation for a given inclination of n.a. and axial load. @@ -695,7 +701,7 @@ class UltimateBendingMomentResults: chi_z: float = 0 # the curvature corresponding to the ultimate moment eps_a: float = 0 # the axial strain at 0,0 corresponding to Mult - section = None + section: t.Any = None _detailed_result: SectionDetailedResultState = None @@ -1148,7 +1154,7 @@ def get_point_stress( ) -@dataclass +@dataclass(slots=True) class InteractionDomainResult: """Class for storing common data on all interaction domain results. @@ -1207,6 +1213,7 @@ def chi_z(self): return self.strains[:, 2] +@dataclass(slots=True) class NMMInteractionDomainResult(InteractionDomainResult): """Class for storing the NMM interaction domain results. @@ -1226,7 +1233,7 @@ class NMMInteractionDomainResult(InteractionDomainResult): field_num: NDArray[np.float64] = None -@dataclass +@dataclass(slots=True) class NMInteractionDomainResult(InteractionDomainResult): """Class for storing the NM interaction domain results. @@ -1246,7 +1253,7 @@ class NMInteractionDomainResult(InteractionDomainResult): field_num: NDArray[np.float64] = None -@dataclass +@dataclass(slots=True) class MMInteractionDomainResult(InteractionDomainResult): """Class for storing the MM interaction domain results. From b1eb25e3d13ace076ca6b1e7de21bf43e5f71054 Mon Sep 17 00:00:00 2001 From: Diego Talledo <38036285+talledodiego@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:28:22 +0100 Subject: [PATCH 07/12] refactor: improve error handling for forces and strains in InteractionDomainResult properties --- structuralcodes/core/_section_results.py | 44 ++++++++++++++---------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/structuralcodes/core/_section_results.py b/structuralcodes/core/_section_results.py index 1c8461e4..3badb404 100644 --- a/structuralcodes/core/_section_results.py +++ b/structuralcodes/core/_section_results.py @@ -1173,44 +1173,44 @@ class InteractionDomainResult: @property def n(self): """Return axial force.""" - if self.forces is None: - return None - return self.forces[:, 0] + if self.forces is not None: + return self.forces[:, 0] + raise ValueError('Forces are not available in this result object.') @property def m_y(self): """Return my.""" - if self.forces is None: - return None - return self.forces[:, 1] + if self.forces is not None: + return self.forces[:, 1] + raise ValueError('Forces are not available in this result object.') @property def m_z(self): """Return mz.""" - if self.forces is None: - return None - return self.forces[:, 2] + if self.forces is not None: + return self.forces[:, 2] + raise ValueError('Forces are not available in this result object.') @property def eps_a(self): """Return eps_a (axial strain at 0, 0).""" - if self.strains is None: - return None - return self.strains[:, 0] + if self.strains is not None: + return self.strains[:, 0] + raise ValueError('Strains are not available in this result object.') @property def chi_y(self): """Return chi_y (curvature about y-axis.""" - if self.strains is None: - return None - return self.strains[:, 1] + if self.strains is not None: + self.strains[:, 1] + raise ValueError('Strains are not available in this result object.') @property def chi_z(self): """Return chi_z (curvature about z-axis).""" - if self.strains is None: - return None - return self.strains[:, 2] + if self.strains is not None: + return self.strains[:, 2] + raise ValueError('Strains are not available in this result object.') @dataclass(slots=True) @@ -1232,6 +1232,14 @@ class NMMInteractionDomainResult(InteractionDomainResult): # array with shape(n,) containing the field number from 1 to 6 field_num: NDArray[np.float64] = None + # TODO: in the future we could also add methods for returning the results + # as a structured mesh for easy plotting. Maybe for increasing values of + # N and for increasing values of theta (from 0 to 2pi), so having a nicely + # structured grid of points, and maybe also adding the possibility to + # the results as a dataframe, with columns for N, theta, field_num, etc. + # This would make it easier to analyze the results and to create custom + # plots. + @dataclass(slots=True) class NMInteractionDomainResult(InteractionDomainResult): From cc0d67c48c9c96bf0519e64efd25d0ff14359a8a Mon Sep 17 00:00:00 2001 From: Diego Talledo <38036285+talledodiego@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:42:57 +0100 Subject: [PATCH 08/12] test: add more tests for results --- .../test_rc_sections/test_rc_results.py | 3 + tests/test_sections/test_results.py | 235 ++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 tests/test_sections/test_results.py diff --git a/tests/test_sections/test_rc_sections/test_rc_results.py b/tests/test_sections/test_rc_sections/test_rc_results.py index 2c94c2dd..ea27924e 100644 --- a/tests/test_sections/test_rc_sections/test_rc_results.py +++ b/tests/test_sections/test_rc_sections/test_rc_results.py @@ -372,6 +372,9 @@ def test_calc_strain_profile_results( assert np.allclose( strain_res.residual, f_ext - f_int, rtol=1e-7, atol=1e-7 ) + assert math.isclose( + strain_res.residual_norm, np.linalg.norm(f_ext - f_int), abs_tol=1e-7 + ) # Check the the correct data are stored from convergence assert math.isclose(strain_res.tolerance, tol) diff --git a/tests/test_sections/test_results.py b/tests/test_sections/test_results.py new file mode 100644 index 00000000..d99df92d --- /dev/null +++ b/tests/test_sections/test_results.py @@ -0,0 +1,235 @@ +"""Test for results objects.""" + +import math + +import numpy as np +import pytest + +from structuralcodes.geometry import RectangularGeometry +from structuralcodes.materials.basic import ElasticMaterial +from structuralcodes.sections import GenericSection + +width = 200 +height = 400 +E = 10000 +eps_u = 0.01 + + +@pytest.fixture +def elastic_rec_section() -> GenericSection: + """Create a simple rectangular section with known properties.""" + # Square section centered at (0, 0) + mat = ElasticMaterial(E=E, density=700, ultimate_strain=eps_u) + + geo = RectangularGeometry(width, height, mat) + + # It should not be concrete + assert not geo.concrete + + return GenericSection(geo, integrator='marin') + + +# Test the string representation of gross properties +def test_gross_properties_text(elastic_rec_section): + """A test for gross properties result string representation.""" + sec = elastic_rec_section + + str_res = sec.gross_properties.__str__() + + # Check the string starts with the expected title + assert str_res.startswith('Section Properties:') + + # Check that there are 30+1 rows of properties + assert len(str_res.split('\n')) == 31 + + # Check that last item is principal axis angle + last_entry = str_res.split('\n')[-2] + assert last_entry.startswith('Principal axis angle (theta):') + + str_fmt = sec.gross_properties.__format__('.2f') + # Check the string starts with the expected title + assert str_fmt.startswith('Section Properties:') + + # Check that there are 30+1 rows of properties + assert len(str_fmt.split('\n')) == 31 + + # Check that last item is principal axis angle + last_entry = str_fmt.split('\n')[-2] + assert last_entry.startswith('Principal axis angle (theta):') + + last_val = last_entry.split(':')[-1].strip() + assert last_val == '0.00' + + # Check the value of Iyy as an example meeting the format required + Iyy = sec.gross_properties.iyy + Iyy_str = format(Iyy, '.2f') + for entry in str_fmt.split('\n'): + if entry.startswith('Second moment (Iyy):'): + val = entry.split(':')[-1].strip() + assert val == Iyy_str + break + + +def test_detailed_result_none_point(elastic_rec_section): + """Test that point data in detailed result is empty.""" + sec = elastic_rec_section + + mult = sec.section_calculator.calculate_bending_strength() + + mult.create_detailed_result() + + # Now detailed result should not be None anymore + assert mult.detailed_result + + # But point data should be None + assert not mult.detailed_result.point_data + + +def test_detailed_result_bending_strength(elastic_rec_section): + """Test detailed result for beinding strength.""" + sec = elastic_rec_section + + Iyy = sec.gross_properties.iyy + + mult = sec.section_calculator.calculate_bending_strength() + + # Check detailed_result data correspond with results + assert mult.n == mult.detailed_result.n + assert mult.m_y == mult.detailed_result.m_y + assert mult.m_z == mult.detailed_result.m_z + assert np.allclose( + np.array([mult.eps_a, mult.chi_y, mult.chi_z]), + mult.detailed_result.strain, + ) + + # Check the ultimate moment result + fy = eps_u * E + # - due to sign convention + m_y_expected = -width * height**2 / 6 * fy + + assert math.isclose(m_y_expected, mult.m_y) + + # Check stress and strain in top and bottom fiber + stress_top_expected = m_y_expected / Iyy * height / 2 + strain_top_expected = stress_top_expected / E + + stress_bottom_expected = m_y_expected / Iyy * (-height / 2) + strain_bottom_expected = stress_bottom_expected / E + + stress_top = mult.get_point_stress(0, height / 2) + stress_bottom = mult.get_point_stress(0, -height / 2) + + strain_top = mult.get_point_strain(0, height / 2) + strain_bottom = mult.get_point_strain(0, -height / 2) + + assert math.isclose(stress_top_expected, stress_top) + assert math.isclose(stress_bottom_expected, stress_bottom) + assert math.isclose(strain_top_expected, strain_top) + assert math.isclose(strain_bottom_expected, strain_bottom) + + +def test_detailed_result_moment_curvature(elastic_rec_section): + """Test detailed result for moment curvature.""" + sec = elastic_rec_section + + mcurv = sec.section_calculator.calculate_moment_curvature() + + # Check detailed_result data correspond with results + assert mcurv.n == mcurv.detailed_result.n + + for i in range(len(mcurv.chi_y)): + mcurv.set_step(i) + assert mcurv.chi_y[i] == mcurv.detailed_result.chi_y + assert mcurv.chi_z[i] == mcurv.detailed_result.chi_z + + assert np.allclose( + np.array([mcurv.eps_a[i], mcurv.chi_y[i], mcurv.chi_z[i]]), + mcurv.detailed_result.strain, + ) + + +@pytest.mark.parametrize( + 'n, my', + [ + (0, -30e6), # pure bending + (-10e3, -30e6), # bending with compression + (10e3, -30e6), # bending with tension + ], +) +def test_detailed_result_plane_strain(elastic_rec_section, n, my): + """Test detailed result for plane strain calculation.""" + sec = elastic_rec_section + + Iyy = sec.gross_properties.iyy + A = sec.gross_properties.area + + strain_res = sec.section_calculator.calculate_strain_profile( + n=n, my=my, mz=0 + ) + + assert math.isclose(strain_res.n_ext, n) + assert math.isclose(strain_res.m_y_ext, my) + assert math.isclose(strain_res.m_z_ext, 0) + + # Check detailed_result data correspond with results + assert strain_res.n == strain_res.detailed_result.n + assert strain_res.m_y == strain_res.detailed_result.m_y + assert strain_res.m_z == strain_res.detailed_result.m_z + + stress_top_expected = n / A + my / Iyy * height / 2 + strain_top_expected = stress_top_expected / E + + stress_bottom_expected = n / A - my / Iyy * height / 2 + strain_bottom_expected = stress_bottom_expected / E + + stress_top = strain_res.get_point_stress(0, height / 2) + stress_bottom = strain_res.get_point_stress(0, -height / 2) + + strain_top = strain_res.get_point_strain(0, height / 2) + strain_bottom = strain_res.get_point_strain(0, -height / 2) + + assert math.isclose(stress_top_expected, stress_top) + assert math.isclose(stress_bottom_expected, stress_bottom) + assert math.isclose(strain_top_expected, strain_top) + assert math.isclose(strain_bottom_expected, strain_bottom) + + +@pytest.mark.parametrize( + 'eps_a, chi_y', + [ + (0, -1e-7), # pure bending + (-1e-4, -1e-7), # bending with compression + (1e-4, -2e-7), # bending with tension + ], +) +def test_detailed_result_integrate_plane_strain( + elastic_rec_section, eps_a, chi_y +): + """Test detailed result for integration of stresses.""" + sec = elastic_rec_section + + int_res = sec.section_calculator.integrate_strain_profile( + strain=np.array([eps_a, chi_y, 0]) + ) + + # Check detailed_result data correspond with results + assert int_res.n == int_res.detailed_result.n + assert int_res.m_y == int_res.detailed_result.m_y + assert int_res.m_z == int_res.detailed_result.m_z + + strain_top_expected = eps_a + height / 2 * chi_y + stress_top_expected = strain_top_expected * E + + strain_bottom_expected = eps_a - height / 2 * chi_y + stress_bottom_expected = strain_bottom_expected * E + + stress_top = int_res.get_point_stress(0, height / 2) + stress_bottom = int_res.get_point_stress(0, -height / 2) + + strain_top = int_res.get_point_strain(0, height / 2) + strain_bottom = int_res.get_point_strain(0, -height / 2) + + assert math.isclose(stress_top_expected, stress_top) + assert math.isclose(stress_bottom_expected, stress_bottom) + assert math.isclose(strain_top_expected, strain_top) + assert math.isclose(strain_bottom_expected, strain_bottom) From 238370621e451c53f287d74df9bbc68f0795a2e8 Mon Sep 17 00:00:00 2001 From: Diego Talledo <38036285+talledodiego@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:43:19 +0100 Subject: [PATCH 09/12] docs: update section results documentation --- docs/api/sections/section_calculator.md | 57 +++++++++++++++++++----- structuralcodes/core/_section_results.py | 16 +++++++ 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/docs/api/sections/section_calculator.md b/docs/api/sections/section_calculator.md index 8df78ece..5f46bef6 100644 --- a/docs/api/sections/section_calculator.md +++ b/docs/api/sections/section_calculator.md @@ -51,6 +51,7 @@ ```{eval-rst} .. autoclass:: structuralcodes.core._section_results.MomentCurvatureResults + .. autoproperty:: detailed_result .. automethod:: create_detailed_result .. automethod:: next_step .. automethod:: previous_step @@ -62,38 +63,72 @@ ```{eval-rst} .. autoclass:: structuralcodes.core._section_results.UltimateBendingMomentResults + .. autoproperty:: detailed_result .. automethod:: create_detailed_result .. automethod:: get_point_strain .. automethod:: get_point_stress ``` ```{eval-rst} -.. autoclass:: structuralcodes.core._section_results.NMInteractionDomain +.. autoclass:: structuralcodes.core._section_results.StrainProfileResult + + .. autoproperty:: strain_plane + .. autoproperty:: residual_norm + .. autoproperty:: residual_norm_history + .. autoproperty:: delta_strain_history + .. autoproperty:: delta_strain_norm_history + .. autoproperty:: response_history + .. autoproperty:: detailed_result + .. automethod:: to_list + .. automethod:: create_detailed_result + .. automethod:: get_point_strain + .. automethod:: get_point_stress +``` + +```{eval-rst} +.. autoclass:: structuralcodes.core._section_results.IntegrateStrainStiffnessResult + + .. automethod:: asarray +``` + +```{eval-rst} +.. autoclass:: structuralcodes.core._section_results.IntegrateStrainForceResult + + .. autoproperty:: detailed_result + .. automethod:: asarray + .. automethod:: astuple + .. automethod:: create_detailed_result + .. automethod:: get_point_strain + .. automethod:: get_point_stress +``` + +```{eval-rst} +.. autoclass:: structuralcodes.core._section_results.NMInteractionDomainResult .. autoproperty:: n .. autoproperty:: m_y - .. autoproperty:: e_a - .. autoproperty:: k_y + .. autoproperty:: eps_a + .. autoproperty:: chi_y ``` ```{eval-rst} -.. autoclass:: structuralcodes.core._section_results.NMMInteractionDomain +.. autoclass:: structuralcodes.core._section_results.NMMInteractionDomainResult .. autoproperty:: n .. autoproperty:: m_y .. autoproperty:: m_z - .. autoproperty:: e_a - .. autoproperty:: k_y - .. autoproperty:: k_z + .. autoproperty:: eps_a + .. autoproperty:: chi_y + .. autoproperty:: chi_z ``` ```{eval-rst} -.. autoclass:: structuralcodes.core._section_results.MMInteractionDomain +.. autoclass:: structuralcodes.core._section_results.MMInteractionDomainResult .. autoproperty:: n .. autoproperty:: m_y .. autoproperty:: m_z - .. autoproperty:: e_a - .. autoproperty:: k_y - .. autoproperty:: k_z + .. autoproperty:: eps_a + .. autoproperty:: chi_y + .. autoproperty:: chi_z ``` diff --git a/structuralcodes/core/_section_results.py b/structuralcodes/core/_section_results.py index 3badb404..a5028bd3 100644 --- a/structuralcodes/core/_section_results.py +++ b/structuralcodes/core/_section_results.py @@ -319,6 +319,10 @@ def create_detailed_result(self, num_points=1000): Arguments: num_points (int): Number of random points to sample for each surface geometry (default = 1000). + + Notes: + It is called with default values when first accessing the property + `detailed_result`. """ if self.seed is None: self.seed = np.random.randint(1, 100, 1)[0].item() @@ -711,6 +715,10 @@ def create_detailed_result(self, num_points=1000): Arguments: num_points (int): Number of random points to sample for each surface geometry (default = 1000). + + Notes: + It is called with default values when first accessing the property + `detailed_result`. """ self._detailed_result = SectionDetailedResultState( section=self.section, @@ -903,6 +911,10 @@ def create_detailed_result(self, num_points=1000): Arguments: num_points (int): Number of random points to sample for each surface geometry (default = 1000). + + Notes: + It is called with default values when first accessing the property + `detailed_result`. """ self._detailed_result = SectionDetailedResultState( section=self.section, @@ -1058,6 +1070,10 @@ def create_detailed_result(self, num_points=1000): Arguments: num_points (int): Number of random points to sample for each surface geometry (default = 1000). + + Notes: + It is called with default values when first accessing the property + `detailed_result`. """ self._detailed_result = SectionDetailedResultState( section=self.section, From b9cd3307f0b5bf16f9990819cee123628fd2bfc6 Mon Sep 17 00:00:00 2001 From: Diego Talledo <38036285+talledodiego@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:35:07 +0100 Subject: [PATCH 10/12] refactor: update section type annotations --- structuralcodes/core/_section_results.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/structuralcodes/core/_section_results.py b/structuralcodes/core/_section_results.py index a1e89690..00654bb0 100644 --- a/structuralcodes/core/_section_results.py +++ b/structuralcodes/core/_section_results.py @@ -10,6 +10,7 @@ from numpy.typing import ArrayLike, NDArray from shapely import Point +from ..geometry import CompoundGeometry from .base import Geometry, Section @@ -158,16 +159,16 @@ def isclose(self, other, rtol=1e-5, atol=1e-8): def _matching_geometries( - section: Section, + geometry: CompoundGeometry, name: t.Optional[str] = None, group_label: t.Optional[str] = None, case_sensitive: bool = True, ): """Return (surfaces, points) that match name/group filters.""" - geometries_name = section.geometry.name_filter( + geometries_name = geometry.name_filter( name, return_mode='split', case_sensitive=case_sensitive ) - geometries_group = section.geometry.group_filter( + geometries_group = geometry.group_filter( group_label, return_mode='split', case_sensitive=case_sensitive ) @@ -707,7 +708,7 @@ class UltimateBendingMomentResults: chi_z: float = 0 # the curvature corresponding to the ultimate moment eps_a: float = 0 # the axial strain at 0,0 corresponding to Mult - section: t.Any = None + section: Section = None _detailed_result: SectionDetailedResultState = None @@ -858,7 +859,7 @@ class StrainProfileResult: strain_history: list[NDArray[np.float64]] = field(default_factory=list) # For context store the section - section: t.Any = None # Note for future: if I want to type this I also have problem of circular import? #noqa E501 + section: Section = None # The detailed result data structure _detailed_result: SectionDetailedResultState = None @@ -1047,7 +1048,7 @@ class IntegrateStrainForceResult: m_z: float = 0.0 # Bending moment Mz # For context store the section - section: t.Any = None # Note for future: if I want to type this I also have problem of circular import? #noqa E501 + section: Section = None # The detailed result data structure _detailed_result: SectionDetailedResultState = None From 1eb6fdaa2caaffe246810b07fe2b1972f1be873d Mon Sep 17 00:00:00 2001 From: Diego Talledo <38036285+talledodiego@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:47:30 +0100 Subject: [PATCH 11/12] fix tests --- structuralcodes/core/_section_results.py | 2 +- tests/test_sections/test_rc_sections/test_rc_results.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/structuralcodes/core/_section_results.py b/structuralcodes/core/_section_results.py index 00654bb0..fd58853b 100644 --- a/structuralcodes/core/_section_results.py +++ b/structuralcodes/core/_section_results.py @@ -230,7 +230,7 @@ def _get_point_response( return None surfaces, points = _matching_geometries( - section, name, group_label, case_sensitive + section.geometry, name, group_label, case_sensitive ) if all_results: diff --git a/tests/test_sections/test_rc_sections/test_rc_results.py b/tests/test_sections/test_rc_sections/test_rc_results.py index ea27924e..ec695e05 100644 --- a/tests/test_sections/test_rc_sections/test_rc_results.py +++ b/tests/test_sections/test_rc_sections/test_rc_results.py @@ -62,17 +62,19 @@ def test_matching_geometries_intersection_case_sensitive(simple_rc_section): section = simple_rc_section # Case sensitive: "con*" matches "concrete" but "CON*" does not - surfaces, points = s_res._matching_geometries(section, name='con*') + surfaces, points = s_res._matching_geometries( + section.geometry, name='con*' + ) assert len(surfaces) == 1 assert surfaces[0].name == 'concrete' assert len(points) == 0 - surfaces, _ = s_res._matching_geometries(section, name='CON*') + surfaces, _ = s_res._matching_geometries(section.geometry, name='CON*') assert len(surfaces) == 0 # Case insensitive: "CON*" matches "concrete" surfaces, _ = s_res._matching_geometries( - section, name='CON*', case_sensitive=False + section.geometry, name='CON*', case_sensitive=False ) assert len(surfaces) == 1 assert surfaces[0].name == 'concrete' From 710df345d140f46d2c4d8c52e357c53863172d19 Mon Sep 17 00:00:00 2001 From: Morten Engen Date: Wed, 18 Mar 2026 20:10:28 +0100 Subject: [PATCH 12/12] Remove todos and add optional annotations --- structuralcodes/core/_section_results.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/structuralcodes/core/_section_results.py b/structuralcodes/core/_section_results.py index fd58853b..c5c111ea 100644 --- a/structuralcodes/core/_section_results.py +++ b/structuralcodes/core/_section_results.py @@ -272,8 +272,6 @@ def _get_point_response( return None -# TODO: to be able using slots = True we mush type annotate section, but this -# would trigger a circular import @mortenengen @dataclass(slots=True) class MomentCurvatureResults: """Class for storing moment curvature results. @@ -692,8 +690,6 @@ def point_data(self): return self._point_data -# TODO: to be able using slots = True we mush type annotate section, but this -# would trigger a circular import @mortenengen @dataclass(slots=True) class UltimateBendingMomentResults: """Class for storing the ultimate bending moment computation for a given @@ -708,7 +704,7 @@ class UltimateBendingMomentResults: chi_z: float = 0 # the curvature corresponding to the ultimate moment eps_a: float = 0 # the axial strain at 0,0 corresponding to Mult - section: Section = None + section: t.Optional[Section] = None _detailed_result: SectionDetailedResultState = None @@ -859,7 +855,7 @@ class StrainProfileResult: strain_history: list[NDArray[np.float64]] = field(default_factory=list) # For context store the section - section: Section = None + section: t.Optional[Section] = None # The detailed result data structure _detailed_result: SectionDetailedResultState = None @@ -1048,7 +1044,7 @@ class IntegrateStrainForceResult: m_z: float = 0.0 # Bending moment Mz # For context store the section - section: Section = None + section: t.Optional[Section] = None # The detailed result data structure _detailed_result: SectionDetailedResultState = None