diff --git a/blueprints/geometry/line.py b/blueprints/geometry/line.py index 55f125ff..0162da87 100644 --- a/blueprints/geometry/line.py +++ b/blueprints/geometry/line.py @@ -1,6 +1,5 @@ """Line module.""" -from enum import Enum from typing import Literal import numpy as np @@ -12,13 +11,6 @@ from blueprints.unit_conversion import RAD_TO_DEG -class Reference(Enum): - """Enum of the reference options start/end.""" - - START = 0 - END = 1 - - class Line: """Represents a line in a 3D modelling space. diff --git a/blueprints/structural_sections/concrete/covers.py b/blueprints/structural_sections/concrete/covers.py new file mode 100644 index 00000000..19d8d936 --- /dev/null +++ b/blueprints/structural_sections/concrete/covers.py @@ -0,0 +1,46 @@ +"""Module with the representation of the covers for cross-sections.""" + +from collections import defaultdict +from dataclasses import asdict, dataclass + +from blueprints.type_alias import MM +from blueprints.validations import raise_if_negative + +DEFAULT_COVER = 50 # mm + + +@dataclass(frozen=True) +class CoversRectangular: + """Representation of the covers of a rectangular cross-section.""" + + upper: MM = DEFAULT_COVER + right: MM = DEFAULT_COVER + lower: MM = DEFAULT_COVER + left: MM = DEFAULT_COVER + + def __post_init__(self) -> None: + """Post initialization of the covers.""" + self.validate() + + def get_covers_info(self) -> str: + """Return a string with the covers of the cross-section.""" + caption_text = "Cover:" + + covers = defaultdict(list) + + for key, value in asdict(self).items(): + covers[value].append(key) + + if len(covers) == 1: + return f"{caption_text} {self.upper:.0f} mm" + + cover_texts = [caption_text] + + for cover, names in covers.items(): + cover_texts.append(f"{'|'.join(names)}: {cover:.0f} mm") + + return "\n ".join(cover_texts) + + def validate(self) -> None: + """Validate the covers.""" + raise_if_negative(upper=self.upper, right=self.right, lower=self.lower, left=self.left) diff --git a/blueprints/structural_sections/concrete/rebar.py b/blueprints/structural_sections/concrete/rebar.py index 9bd04bac..000a3cf7 100644 --- a/blueprints/structural_sections/concrete/rebar.py +++ b/blueprints/structural_sections/concrete/rebar.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from blueprints.materials.reinforcement_steel import ReinforcementSteelMaterial -from blueprints.structural_sections.concrete.reinforced_concrete_sections.cross_section_shapes import CircularCrossSection +from blueprints.structural_sections.cross_section_shapes import CircularCrossSection from blueprints.type_alias import KG_M, RATIO from blueprints.unit_conversion import MM2_TO_M2 @@ -17,6 +17,10 @@ class Rebar(CircularCrossSection): ---------- diameter : MM Diameter of the bar (for example: ⌀12, ⌀16, ⌀20, etc.) [mm] + x : MM + x-coordinate in the cross-section [mm] + y : MM + y-coordinate in the cross-section [mm] material : ReinforcementSteelMaterial Representation of the properties of reinforcement steel suitable for use with NEN-EN 1992-1-1. relative_start_position: RATIO diff --git a/blueprints/structural_sections/concrete/reinforced_concrete_sections/base.py b/blueprints/structural_sections/concrete/reinforced_concrete_sections/base.py new file mode 100644 index 00000000..3dcae505 --- /dev/null +++ b/blueprints/structural_sections/concrete/reinforced_concrete_sections/base.py @@ -0,0 +1,203 @@ +"""Base class of all reinforced cross-sections.""" + +from abc import ABC +from functools import partial +from typing import Callable + +from shapely import LineString + +from blueprints.materials.concrete import ConcreteMaterial +from blueprints.materials.reinforcement_steel import ReinforcementSteelMaterial +from blueprints.structural_sections.concrete.rebar import Rebar +from blueprints.structural_sections.concrete.reinforced_concrete_sections.reinforcement_configurations import ( + ReinforcementConfiguration, +) +from blueprints.structural_sections.concrete.stirrups import StirrupConfiguration +from blueprints.structural_sections.cross_section_shapes import CrossSection +from blueprints.type_alias import KG_M, KG_M3, M3_M, MM2_M +from blueprints.unit_conversion import M_TO_MM, MM3_TO_M3 + + +class ReinforcedCrossSection(ABC): + """Base class of all reinforced cross-sections.""" + + def __init__( + self, + cross_section: CrossSection, + concrete_material: ConcreteMaterial, + ) -> None: + """Initialize the reinforced cross-section. + + Parameters + ---------- + cross_section : CrossSection + Cross-section of the reinforced concrete section. + concrete_material : ConcreteMaterial + Material properties of the concrete. + """ + self.cross_section = cross_section + self.concrete_material = concrete_material + self._reinforcement_configurations: list[tuple[LineString | Callable[..., LineString], ReinforcementConfiguration]] = [] + self._single_longitudinal_rebars: list[Rebar] = [] + self._stirrups: list[StirrupConfiguration] = [] + + @property + def longitudinal_rebars(self) -> list[Rebar]: + """Return a list of all longitudinal rebars.""" + rebars: list[Rebar] = [] + + # add the single longitudinal rebars + rebars.extend(self._single_longitudinal_rebars) + + # add the rebars from the reinforcement configurations + for line, configuration in self._reinforcement_configurations: + if callable(line): + # partial function with additional arguments where the line should be called to get the LineString + # the implementation will be made at that level and inserted here to produce the rebars needed. + # this keeps this ABC class clean and allows for a lot of flexibility in the implementation of the line. + # this has been done to be able to add any shape of line to the cross-section (e.g. a circle or any other in the future). + rebars.extend(configuration.to_rebars(line=line())) + else: + rebars.extend(configuration.to_rebars(line=line)) + + # check if all rebars are inside the cross-section. + # needed for the case where custom configurations are added to the RCS + for rebar in rebars: + if not self.cross_section.geometry.contains(other=rebar.geometry): + msg = f"Rebar (diameter={rebar.diameter}, x={rebar.x}, y={rebar.y}) is not (fully) inside the cross-section." + raise ValueError(msg) + + return rebars + + @property + def stirrups(self) -> list[StirrupConfiguration]: + """Return a list of all stirrups.""" + return self._stirrups + + @property + def reinforcement_weight_longitudinal_bars(self) -> KG_M: + """Total mass of the longitudinal reinforcement in the cross-section per meter length [kg/m].""" + return sum(rebar.weight_per_meter for rebar in self.longitudinal_rebars) + + @property + def reinforcement_weight_stirrups(self) -> KG_M: + """Total mass of the stirrups' reinforcement in the cross-section per meter length [kg/m].""" + return sum(stirrup.weight_per_meter for stirrup in self._stirrups) + + @property + def reinforcement_weight(self) -> KG_M: + """Total mass of the reinforcement in the cross-section per meter length [kg/m].""" + return self.reinforcement_weight_longitudinal_bars + self.reinforcement_weight_stirrups + + @property + def reinforcement_area_longitudinal_bars(self) -> MM2_M: + """Total area of the longitudinal reinforcement in the cross-section per meter length [mm²/m].""" + return sum(rebar.area for rebar in self.longitudinal_rebars) + + @property + def concrete_volume(self) -> M3_M: + """Total volume of the reinforced cross-section per meter length [m³/m].""" + length = M_TO_MM + return self.cross_section.area * length * MM3_TO_M3 + + @property + def weight_per_volume(self) -> KG_M3: + """Total mass of the cross-section per meter length (concrete_checks+reinforcement) [kg/m³].""" + return self.reinforcement_weight / self.concrete_volume + + def get_present_steel_materials(self) -> list[ReinforcementSteelMaterial]: + """Return a list of all present steel materials in the cross-section.""" + materials = [rebar.material for rebar in self.longitudinal_rebars] + materials.extend(stirrup.material for stirrup in self._stirrups) + return list(set(materials)) + + def add_longitudinal_rebar( + self, + rebar: Rebar, + ) -> Rebar: + """Adds a single reinforcement bar to the cross-section. + + Parameters + ---------- + rebar : Rebar + Rebar to be added to the cross-section. + + Raises + ------ + ValueError + If the rebar is not fully inside the cross-section. + + Returns + ------- + Rebar + Newly created Rebar + """ + # check if given diameter/coordinates are fully inside the cross-section + if not rebar.geometry.within(self.cross_section.geometry): + msg = f"Rebar (diameter={rebar.diameter}, x={rebar.x}, y={rebar.y}) is not (fully) inside the cross-section." + raise ValueError(msg) + + # add the rebar to the list of longitudinal rebars + self._single_longitudinal_rebars.append(rebar) + + return rebar + + def add_stirrup_configuration(self, stirrup: StirrupConfiguration) -> StirrupConfiguration: + """Add a stirrup configuration to the cross-section. + + Parameters + ---------- + stirrup : StirrupConfiguration + Configuration of stirrup reinforcement in the cross-section. + + Returns + ------- + StirrupConfiguration + Newly created Stirrup + + Raises + ------ + ValueError + If the stirrup is not fully inside the cross-section. + """ + # check if the stirrup is inside the cross-section + stirrup_outside_edge = stirrup.geometry.buffer(distance=stirrup.diameter / 2) + if not self.cross_section.geometry.contains(stirrup_outside_edge): + msg = "Stirrup is not (fully) inside the cross-section." + raise ValueError(msg) + + # add the stirrup to the list + self._stirrups.append(stirrup) + + return stirrup + + def add_reinforcement_configuration( + self, + line: LineString | Callable[..., LineString], + configuration: ReinforcementConfiguration, + *args, + **kwargs, + ) -> None: + """Add a reinforcement configuration to the cross-section. + + Parameters + ---------- + line : LineString | Callable[..., LineString] + Representing the path of the reinforcement in the cross-section. + Start of the line defines the first rebar of the configuration, end of the line defines the last rebar. + If a callable is given, it should return a LineString. The callable can take additional arguments. + Arguments can be passed to the callable using the *args and **kwargs. + configuration : ReinforcementConfiguration + Configuration of the reinforcement. + args : Any + Additional arguments for the callable line. If line is not a callable, these arguments are ignored. + kwargs : Any + Additional keyword arguments for the callable line. If line is not a callable, these arguments are ignored. + + """ + # check if the line is a callable and wrap it with the given arguments + if callable(line): + line = partial(line, *args, **kwargs) # type: ignore[misc] + + # add the reinforcement configuration to the list + self._reinforcement_configurations.append((line, configuration)) diff --git a/blueprints/structural_sections/concrete/reinforced_concrete_sections/plotters/__init__.py b/blueprints/structural_sections/concrete/reinforced_concrete_sections/plotters/__init__.py new file mode 100644 index 00000000..604728c3 --- /dev/null +++ b/blueprints/structural_sections/concrete/reinforced_concrete_sections/plotters/__init__.py @@ -0,0 +1 @@ +"""Default plotters for Blueprint's reinforced concrete sections.""" diff --git a/blueprints/structural_sections/concrete/reinforced_concrete_sections/plotters/rectangular.py b/blueprints/structural_sections/concrete/reinforced_concrete_sections/plotters/rectangular.py new file mode 100644 index 00000000..4d0164f5 --- /dev/null +++ b/blueprints/structural_sections/concrete/reinforced_concrete_sections/plotters/rectangular.py @@ -0,0 +1,415 @@ +"""Plotter for Reinforced Rectangular Cross-Sections.""" + +# ruff: noqa: PLR0913, F821 +from typing import TypeVar + +from matplotlib import patches as mplpatches +from matplotlib import pyplot as plt +from matplotlib.axes import Axes +from shapely import Point + +from blueprints.structural_sections.concrete.rebar import Rebar + +T = TypeVar("T", bound="RectangularReinforcedCrossSection") # type: ignore[name-defined] + +RCS_CROSS_SECTION_COLOR = (0.827, 0.827, 0.827) +STIRRUP_COLOR = (0.412, 0.412, 0.412) +REBAR_COLOR = (0.717, 0.255, 0.055) + + +class RectangularCrossSectionPlotter: + """Plotter for Reinforced Rectangular Cross-Sections (RRCS).""" + + def __init__( + self, + cross_section: T, + ) -> None: + """Initialize the RRCSPlotter. + + Parameters + ---------- + cross_section: RectangularReinforcedCrossSection + Reinforced cross-section to plot. + """ + self.cross_section = cross_section + self.fig: plt.Figure | None = None + self.axes: list[Axes] = [] + + def plot( + self, + figsize: tuple[float, float] = (15.0, 8.0), + title: str | None = None, + font_size_title: float = 18.0, + font_size_legend: float = 10.0, + include_legend: bool = True, + font_size_dimension: float = 12.0, + custom_text_legend: str | None = None, + custom_text_width: str | None = None, + custom_text_height: str | None = None, + offset_line_width: float = 1.25, + offset_line_height: float = 1.2, + center_line_style: dict[str, float | str] | None = None, + show: bool = False, + axes_i: int = 0, + ) -> plt.Figure: + """Plots the cross-section. + + Parameters + ---------- + figsize: tuple[float, float] + Size of the plot window. + title: str + Title of the plot. + font_size_title: float + Font size of the title. + font_size_legend: float + Font size of the legend. + include_legend: bool + Include legend in the plot. + font_size_dimension: float + Font size of the dimensions. + custom_text_legend: str + Custom text for the legend. + custom_text_width: str + Custom text for the width dimension. Replaces the width of the cross-section with the custom text. + custom_text_height: str + Custom text for the height dimension. Replaces the height of the cross-section with the custom text. + offset_line_width: float + Offset of the width line. + offset_line_height: float + Offset of the height line. + center_line_style: dict[str, float | str] | None + Style of the center lines. Check matplotlib documentation for more information (Annotation-arrowprops). + show: bool + Show the plot. + axes_i: int + Index of the axes to plot on. Default is 0. + + Returns + ------- + plt.Figure + Matplotlib figure. + """ + self._start_plot(figsize=figsize) + self._add_rectangle(axes_i=axes_i) + self._add_center_lines(axes_i=axes_i, style=center_line_style) + self._add_dimension_lines( + axes_i=axes_i, + font_size_dimension=font_size_dimension, + custom_text_height=custom_text_height, + custom_text_width=custom_text_width, + offset_line_width=offset_line_width, + offset_line_height=offset_line_height, + ) + self._add_stirrups(axes_i=axes_i) + self._add_longitudinal_rebars(axes_i=axes_i) + + # set limits and title + self.axes[axes_i].axis("off") + self.axes[axes_i].axis("equal") + self.axes[axes_i].set_title( + label=title or "", + fontdict={"fontsize": font_size_title}, + ) + + if include_legend: + self._add_legend( + axes_i=axes_i, + font_size_legend=font_size_legend, + custom_legend_text=custom_text_legend, + ) + if show: + plt.show() # pragma: no cover + assert self.fig is not None + return self.fig + + def _start_plot(self, figsize: tuple[float, float] = (15.0, 8.0)) -> tuple[float, float]: + """Starts the plot by initialising a matplotlib plot window of the given size. + + Parameters + ---------- + figsize: tuple[float, float] + Size of the plot window. + """ + plt.close("all") + self.fig = plt.figure(figsize=figsize) + self.axes = [self.fig.add_subplot()] + return self.fig.get_figwidth(), self.fig.get_figheight() + + def _add_rectangle( + self, + edge_color: str = "black", + axes_i: int = 0, + ) -> mplpatches.Rectangle: + """Adds a rectangle to the plot. + + Parameters + ---------- + edge_color: str + Color of the edge of the rectangle. Use any matplotlib color. + axes_i: int + Index of the axes to plot on. Default is 0. + """ + patch = mplpatches.Rectangle( + xy=(-self.cross_section.width / 2, -self.cross_section.height / 2), + width=self.cross_section.width, + height=self.cross_section.height, + edgecolor=edge_color, + facecolor=RCS_CROSS_SECTION_COLOR, + fill=True, + lw=1, + ) + self.axes[axes_i].add_patch(patch) + return patch + + def _add_center_lines(self, axes_i: int = 0, style: dict[str, float | str] | None = None) -> None: + """Adds center lines to the plot. + + Parameters + ---------- + axes_i: int + Index of the axes to plot on. Default is 0. + style: dict[str, float] + Style of the center lines. Check matplotlib documentation for more information (Annotation-arrowprops). + """ + default_style = {"arrowstyle": "-", "linewidth": 0.8, "color": "gray", "linestyle": "dashdot"} + if style: + default_style.update(style) + offset_center_line = 1.05 + self.axes[axes_i].annotate( + text="z", + xy=(0, (-self.cross_section.height / 2) * offset_center_line), + xytext=(0, (self.cross_section.height / 2) * offset_center_line), + arrowprops=default_style, + verticalalignment="bottom", + horizontalalignment="center", + ) + self.axes[axes_i].annotate( + text="y", + xy=((self.cross_section.width / 2) * offset_center_line, 0), + xytext=(-(self.cross_section.width / 2) * offset_center_line, 0), + arrowprops=default_style, + verticalalignment="center", + horizontalalignment="right", + ) + + def _add_dimension_lines( + self, + axes_i: int = 0, + style: mplpatches.ArrowStyle | None = None, + offset_line_width: float = 1.25, + offset_line_height: float = 1.2, + custom_text_width: str | None = None, + custom_text_height: str | None = None, + font_size_dimension: float = 12.0, + ) -> None: + """Adds dimension lines to the plot. + + Parameters + ---------- + axes_i: int + Index of the axes to plot on. Default is 0. + style: dict[str, float] + Style of the dimension lines. Check matplotlib documentation for more information (Annotation-arrowprops). + offset_line_width: float + Offset of the width line. + offset_line_height: float + Offset of the height line. + custom_text_width: str + Custom text for the width dimension. Replaces the width of the cross-section with the custom text. + custom_text_height: str + Custom text for the height dimension. Replaces the height of the cross-section with the custom text. + font_size_dimension: float + Font size of the dimensions. + """ + # add the width dimension line + diameter_line_style = { + "arrowstyle": style or mplpatches.ArrowStyle(stylename="<->", head_length=0.5, head_width=0.5), + } + offset_width = (-self.cross_section.height / 2) * offset_line_width + self.axes[axes_i].annotate( + text="", + xy=(-self.cross_section.width / 2, offset_width), + xytext=(self.cross_section.width / 2, offset_width), + verticalalignment="center", + horizontalalignment="center", + arrowprops=diameter_line_style, + annotation_clip=False, + ) + self.axes[axes_i].text( + s=custom_text_width or f"{self.cross_section.width:.0f} mm", + x=0, + y=offset_width, + verticalalignment="bottom", + horizontalalignment="center", + fontsize=font_size_dimension, + ) + + # add the height dimension line + offset_height = (-self.cross_section.width / 2) * offset_line_height + self.axes[axes_i].annotate( + text="", + xy=(offset_height, self.cross_section.height / 2), + xytext=(offset_height, -self.cross_section.height / 2), + verticalalignment="center", + horizontalalignment="center", + arrowprops=diameter_line_style, + rotation=90, + annotation_clip=False, + ) + self.axes[axes_i].text( + s=custom_text_height or f"{self.cross_section.height:.0f} mm", + x=offset_height, + y=0, + verticalalignment="center", + horizontalalignment="right", + fontsize=font_size_dimension, + rotation=90, + ) + + def _add_stirrups( + self, + axes_i: int = 0, + ) -> None: + """Adds stirrups to the plot. + + Parameters + ---------- + axes_i: int + Index of the axes to plot on. Default is 0. + """ + for stirrup in self.cross_section.stirrups: + left_bottom = Point(stirrup.geometry.exterior.coords[0]) # left bottom point of the stirrup (center line) + self.axes[axes_i].add_patch( + mplpatches.Rectangle( + xy=(left_bottom.x - stirrup.diameter / 2, left_bottom.y - stirrup.diameter / 2), + width=stirrup.ctc_distance_legs + stirrup.diameter, + height=self.cross_section.height - self.cross_section.covers.upper - self.cross_section.covers.lower, + facecolor=STIRRUP_COLOR, + fill=True, + ) + ) + self.axes[axes_i].add_patch( + mplpatches.Rectangle( + xy=(left_bottom.x + stirrup.diameter / 2, left_bottom.y + stirrup.diameter / 2), + width=stirrup.ctc_distance_legs - stirrup.diameter, + height=self.cross_section.height - self.cross_section.covers.upper - self.cross_section.covers.lower - 2 * stirrup.diameter, + facecolor=RCS_CROSS_SECTION_COLOR, + fill=True, + ) + ) + + def _add_longitudinal_rebars( + self, + axes_i: int = 0, + ) -> None: + """Adds longitudinal rebars to the plot. + + Parameters + ---------- + axes_i: int + Index of the axes to plot on. Default is 0. + """ + for rebar in self.cross_section.longitudinal_rebars: + self.axes[axes_i].add_patch( + mplpatches.Circle( + xy=(rebar.x, rebar.y), + radius=rebar.radius, + linewidth=1, + color=REBAR_COLOR, + ) + ) + + def legend_text(self) -> str: + """Creates the legend text. + + Returns + ------- + str + Legend text. + """ + # start building legend + main_steel_material_used = self.cross_section.get_present_steel_materials()[0].name + legend_text = f"{self.cross_section.concrete_material.concrete_class.value} - {main_steel_material_used}" + + legend_text += self._add_stirrups_to_legend() + legend_text += self._add_longitudinal_rebars_to_legend() + legend_text += self._add_rebar_configurations_to_legend() + legend_text += self._add_single_longitudinal_rebars_to_legend() + legend_text += self._add_covers_info_to_legend() + + return legend_text + + def _add_stirrups_to_legend(self) -> str: + """Adds stirrups to the legend text.""" + stirrups_text = "" + if self.cross_section.stirrups: + stirrups_text += f"\nStirrups ({sum(stirrup.as_w for stirrup in self.cross_section.stirrups):.0f} mm²/m):" + for stirrup in self.cross_section.stirrups: + stirrups_text += ( + f"\n ⌀{stirrup.diameter}-{stirrup.distance} mm (b:{stirrup.ctc_distance_legs:.0f} mm) ({stirrup.as_w:.0f} " f"mm²/m)" + ) + return stirrups_text + + def _add_longitudinal_rebars_to_legend(self) -> str: + """Add longitudinal rebars to the legend text.""" + longitudinal_rebars = "" + if self.cross_section.longitudinal_rebars: + longitudinal_rebars += f"\nReinforcement ({sum(rebar.area for rebar in self.cross_section.longitudinal_rebars):.0f} mm²/m): " + return longitudinal_rebars + + def _add_single_longitudinal_rebars_to_legend(self) -> str: + """Add single longitudinal rebars to legend text.""" + single_longitudinal_text = "" + if self.cross_section._single_longitudinal_rebars: # noqa: SLF001 + rebar_diameters: dict[float, list[Rebar]] = {} + for rebar in self.cross_section._single_longitudinal_rebars: # noqa: SLF001 + rebar_diameters.setdefault(rebar.diameter, []).append(rebar) + for diameter, rebars in rebar_diameters.items(): + single_longitudinal_text += f"\n {len(rebars)}⌀{round(diameter, 2)} ({int(sum(rebar.area for rebar in rebars))} mm²/m)" + return single_longitudinal_text + + def _add_rebar_configurations_to_legend(self) -> str: + """Add rebar configurations to legend text (quantity in line).""" + rebar_configurations_text = "" + if self.cross_section._reinforcement_configurations: # noqa: SLF001 + for _, configuration in self.cross_section._reinforcement_configurations: # noqa: SLF001 + rebar_configurations_text += f"\n {configuration!s} ({int(configuration.area)} mm²/m)" + return rebar_configurations_text + + def _add_covers_info_to_legend(self) -> str: + """Add covers info to legend text.""" + covers_text = "" + if self.cross_section.stirrups or self.cross_section.longitudinal_rebars: + covers_text += "\n" + self.cross_section.covers.get_covers_info() + return covers_text + + def _add_legend( + self, + axes_i: int = 0, + custom_legend_text: str | None = None, + font_size_legend: float = 10.0, + offset_center_line: float = 1.05, + ) -> None: + """Adds the legend to the plot. + + Parameters + ---------- + axes_i: int + Index of the axes to plot on. Default is 0. + custom_legend_text: str + Custom legend text. + font_size_legend: float + Font size of the legend. + offset_center_line: float + Offset of the center line. + """ + legend_text = custom_legend_text or self.legend_text() + self.axes[axes_i].annotate( + text=legend_text, + xy=((self.cross_section.width / 2) * offset_center_line, -self.cross_section.height / 2), + verticalalignment="bottom", + horizontalalignment="left", + fontsize=font_size_legend, + annotation_clip=False, + ) diff --git a/blueprints/structural_sections/concrete/reinforced_concrete_sections/rectangular.py b/blueprints/structural_sections/concrete/reinforced_concrete_sections/rectangular.py new file mode 100644 index 00000000..2fa427a9 --- /dev/null +++ b/blueprints/structural_sections/concrete/reinforced_concrete_sections/rectangular.py @@ -0,0 +1,334 @@ +"""Rectangular reinforced cross-section.""" + +# ruff: noqa: PLR0913 +from typing import Literal + +from matplotlib import pyplot as plt +from shapely import LineString, Point, Polygon + +from blueprints.materials.concrete import ConcreteMaterial +from blueprints.materials.reinforcement_steel import ReinforcementSteelMaterial +from blueprints.structural_sections.concrete.covers import CoversRectangular +from blueprints.structural_sections.concrete.reinforced_concrete_sections.base import ReinforcedCrossSection +from blueprints.structural_sections.concrete.reinforced_concrete_sections.plotters.rectangular import RectangularCrossSectionPlotter +from blueprints.structural_sections.concrete.reinforced_concrete_sections.reinforcement_configurations import ReinforcementByQuantity +from blueprints.structural_sections.concrete.stirrups import StirrupConfiguration +from blueprints.structural_sections.cross_section_shapes import RectangularCrossSection +from blueprints.type_alias import DIMENSIONLESS, MM, RATIO + + +class RectangularReinforcedCrossSection(ReinforcedCrossSection): + """Representation of a reinforced rectangular concrete cross-section like a beam. + + Parameters + ---------- + width : MM + The width of the rectangular cross-section [mm]. + height : MM + The height of the rectangular cross-section [mm]. + concrete_material : ConcreteMaterial + Material properties of the concrete. + covers : CoversRectangular, optional + The reinforcement covers for the cross-section [mm]. The default on all sides is 50 mm. + """ + + def __init__( + self, + width: MM, + height: MM, + concrete_material: ConcreteMaterial, + covers: CoversRectangular = CoversRectangular(), + ) -> None: + """Initialize the rectangular reinforced concrete section.""" + super().__init__( + cross_section=RectangularCrossSection( + width=width, + height=height, + ), + concrete_material=concrete_material, + ) + self.width = width + self.height = height + self.covers = covers + self.plotter = RectangularCrossSectionPlotter(cross_section=self) + + def add_stirrup_along_edges( + self, + diameter: MM, + distance: MM, + material: ReinforcementSteelMaterial, + shear_check: bool = True, + torsion_check: bool = True, + mandrel_diameter_factor: DIMENSIONLESS | None = None, + anchorage_length: MM = 0.0, + relative_start_position: RATIO = 0.0, + relative_end_position: RATIO = 1.0, + ) -> StirrupConfiguration: + """Adds a stirrup configuration along the edges of the cross-section taking the covers into account. The created configuration goes around + the longitudinal rebars (if any). + + Use .add_stirrup_configuration() to add a stirrup configuration of any shape, size, and position (as long as it is inside the cross-section). + + + Parameters + ---------- + diameter: MM + Diameter of the stirrups [mm]. + distance: MM + Longitudinal distance between stirrups [mm]. + material : ReinforcementSteelMaterial + Representation of the properties of reinforcement steel suitable for use with NEN-EN 1992-1-1 + shear_check: bool + Take stirrup into account in shear check + torsion_check: bool + Take stirrup into account in torsion check + mandrel_diameter_factor: DIMENSIONLESS + Inner diameter of mandrel as multiple of stirrup diameter [-] + (default: 4⌀ for ⌀<=16mm and 5⌀ for ⌀>16mm) Tabel 8.1Na NEN-EN 1992-1-1 Dutch National Annex. + anchorage_length: MM + Anchorage length [mm] + relative_start_position: RATIO + Relative position of the start of the stirrup configuration inside the cross-section (longitudinal direction). Value between 0 and 1. + Default is 0 (start). + relative_end_position: RATIO + Relative position of the end of the stirrup configuration inside the cross-section (longitudinal direction). Value between 0 and 1. + Default is 1 (end). + + + Returns + ------- + StirrupConfiguration + Newly created stirrup configuration inside the cross-section. + """ + # get the corners of the cross-section + min_x, min_y, max_x, max_y = self.cross_section.geometry.bounds + + # create the corners of the stirrup configuration based on the covers present + left_bottom_corner = Point(min_x + self.covers.left + (diameter / 2), min_y + self.covers.lower + (diameter / 2)) + left_top_corner = Point(min_x + self.covers.left + (diameter / 2), max_y - self.covers.upper - (diameter / 2)) + right_top_corner = Point(max_x - self.covers.right - (diameter / 2), max_y - self.covers.upper - (diameter / 2)) + right_bottom_corner = Point(max_x - self.covers.right - (diameter / 2), min_y + self.covers.lower + (diameter / 2)) + + return self.add_stirrup_configuration( + StirrupConfiguration( + geometry=Polygon([left_bottom_corner, left_top_corner, right_top_corner, right_bottom_corner]), + diameter=diameter, + distance=distance, + material=material, + shear_check=shear_check, + torsion_check=torsion_check, + mandrel_diameter_factor=mandrel_diameter_factor, + anchorage_length=anchorage_length, + based_on_cover=True, + relative_start_position=relative_start_position, + relative_end_position=relative_end_position, + ) + ) + + def add_stirrup_in_center( + self, + width: MM, + diameter: MM, + distance: MM, + material: ReinforcementSteelMaterial, + shear_check: bool = True, + torsion_check: bool = True, + mandrel_diameter_factor: float | None = None, + anchorage_length: MM = 0.0, + relative_start_position: RATIO = 0.0, + relative_end_position: RATIO = 1.0, + ) -> StirrupConfiguration: + """Add stirrups to the center of the reinforced cross-section based on a given width (ctc of the legs). The created configuration goes around + the longitudinal rebars (if any). + + Use .add_stirrup_configuration() to add a stirrup configuration of any shape, size, and position (as long as it is inside the cross-section). + + Parameters + ---------- + width: MM + Total width of the stirrup taken from the center lines of the legs [mm]. + diameter: MM + Diameter of the stirrups [mm]. + distance: MM + Longitudinal distance between stirrups [mm]. + material : ReinforcementSteelMaterial + Representation of the properties of reinforcement steel suitable for use with NEN-EN 1992-1-1 + shear_check: bool + Take stirrup into account in shear check + torsion_check: bool + Take stirrup into account in torsion check + mandrel_diameter_factor: DIMENSIONLESS + Inner diameter of mandrel as multiple of stirrup diameter [-] + (default: 4⌀ for ⌀<=16mm and 5⌀ for ⌀>16mm) Tabel 8.1Na NEN-EN 1992-1-1 Dutch National Annex. + anchorage_length: MM + Anchorage length [mm] + relative_start_position: RATIO + Relative position of the start of the stirrup configuration inside the cross-section (longitudinal direction). Value between 0 and 1. + Default is 0 (start). + relative_end_position: RATIO + Relative position of the end of the stirrup configuration inside the cross-section (longitudinal direction). Value between 0 and 1. + Default is 1 (end). + + Returns + ------- + StirrupConfiguration + Newly created stirrup configuration inside the cross-section. + """ + # get the corners of the cross-section + _, min_y, _, max_y = self.cross_section.geometry.bounds + + # create the corners of the stirrup configuration based on the covers present + left_bottom_corner = Point(-width / 2, min_y + self.covers.lower + (diameter / 2)) + left_top_corner = Point(-width / 2, max_y - self.covers.upper - (diameter / 2)) + right_top_corner = Point(width / 2, max_y - self.covers.upper - (diameter / 2)) + right_bottom_corner = Point(width / 2, min_y + self.covers.lower + (diameter / 2)) + + return self.add_stirrup_configuration( + StirrupConfiguration( + geometry=Polygon([left_bottom_corner, left_top_corner, right_top_corner, right_bottom_corner]), + diameter=diameter, + distance=distance, + material=material, + shear_check=shear_check, + torsion_check=torsion_check, + mandrel_diameter_factor=mandrel_diameter_factor, + anchorage_length=anchorage_length, + based_on_cover=True, + relative_start_position=relative_start_position, + relative_end_position=relative_end_position, + ) + ) + + def _get_reference_line( + self, + edge: Literal["upper", "right", "lower", "left"], + diameter: MM, + cover: MM | None = None, + corner_offset: MM = 0.0, + ) -> LineString: + """Get the reference line for the given edge of the cross-section. + + Parameters + ---------- + edge: Literal["upper", "right", "lower", "left"] + Edge of the cross-section. + diameter: MM + Diameter of the rebars [mm]. + cover: MM, optional + Cover of the rebars [mm]. If not provided, the default cover on the given edge of the cross-section is used. + corner_offset: MM, optional + The offset of the first and last rebars from the corners of the cross-section towards the center of the cross-section [mm]. If not + provided, the rebars are to be placed at the corners taking into account the present covers and stirrups inside the cross-section. + + Returns + ------- + LineString + Reference line for the given edge of the cross-section. + """ + # get cross-section corners + min_x, min_y, max_x, max_y = self.cross_section.geometry.bounds + + # check if a custom cover is provided + upper_cover = cover if cover is not None else self.covers.upper + lower_cover = cover if cover is not None else self.covers.lower + + # check if there is a stirrup configuration present and adjust the cover + max_stirrups_diameter = 0.0 + if self._stirrups: + max_stirrups_diameter = max([stirrup.diameter for stirrup in self._stirrups]) + + # define corner positions of a bar inside the cross-section + upper_left = ( + min_x + self.covers.left + max_stirrups_diameter + corner_offset + diameter / 2, + max_y - upper_cover - max_stirrups_diameter - diameter / 2, + ) + upper_right = ( + max_x - self.covers.right - max_stirrups_diameter - corner_offset - diameter / 2, + max_y - upper_cover - max_stirrups_diameter - diameter / 2, + ) + lower_left = ( + min_x + self.covers.left + max_stirrups_diameter + corner_offset + diameter / 2, + min_y + lower_cover + max_stirrups_diameter + diameter / 2, + ) + lower_right = ( + max_x - self.covers.right - max_stirrups_diameter - corner_offset - diameter / 2, + min_y + lower_cover + max_stirrups_diameter + diameter / 2, + ) + + match edge.lower(): + case "upper": + start, end = upper_left, upper_right + case "right": + start, end = upper_right, lower_right + case "lower": + start, end = lower_left, lower_right + case "left": + start, end = upper_left, lower_left + case _: # pragma: no cover + msg = f"Edge '{edge}' is not supported. Supported edges are 'upper', 'right', 'lower', and 'left'." + raise ValueError(msg) + + return LineString([start, end]) + + def add_longitudinal_reinforcement_by_quantity( + self, + n: int, + diameter: MM, + material: ReinforcementSteelMaterial, + edge: Literal["upper", "right", "lower", "left"], + cover: MM | None = None, + corner_offset: MM = 0.0, + ) -> None: + """Add longitudinal reinforcement to the cross-section based on the quantity configuration of rebars and a given edge of the cross-section. + + for example: 5⌀12 on upper edge, 4⌀16 on lower edge, etc. + + Parameters + ---------- + n: int + Amount of longitudinal bars. + diameter: MM + Diameter of the rebars [mm]. + material : ReinforcementSteelMaterial + Representation of the properties of reinforcement steel suitable for use with NEN-EN 1992-1-1. + edge: Literal["upper", "right", "lower", "left"] + Edge of the cross-section where the rebars are placed. + cover: MM, optional + Cover of the rebars [mm]. If not provided, the default cover on the given edge of the cross-section is used. + corner_offset: MM, optional + The offset of the first and last rebars from the corners of the cross-section towards the center of the cross-section [mm]. If not + provided, the rebars are to be placed at the corners taking into account the present covers and stirrups inside the cross-section. + """ + line = self._get_reference_line( + edge=edge, + diameter=diameter, + cover=cover, + corner_offset=corner_offset, + ) + assert line + return self.add_reinforcement_configuration( + line=self._get_reference_line, + configuration=ReinforcementByQuantity( + diameter=diameter, + material=material, + n=n, + ), + edge=edge, + cover=cover, + corner_offset=corner_offset, + diameter=diameter, + ) + + def plot(self, *args, **kwargs) -> plt.Figure: + """Plot the cross-section. Making use of the standard plotter. + + If you want to use a custom plotter, use the .plotter attribute to plot the cross-section. + + Parameters + ---------- + *args + Additional arguments passed to the plotter. + **kwargs + Additional keyword arguments passed to the plotter. + """ + return self.plotter.plot(*args, **kwargs) diff --git a/blueprints/structural_sections/concrete/reinforced_concrete_sections/reinforcement_configurations.py b/blueprints/structural_sections/concrete/reinforced_concrete_sections/reinforcement_configurations.py new file mode 100644 index 00000000..e4ba24f1 --- /dev/null +++ b/blueprints/structural_sections/concrete/reinforced_concrete_sections/reinforcement_configurations.py @@ -0,0 +1,214 @@ +"""Module for the representation of reinforcement configurations in reinforced concrete sections.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass + +import numpy as np +from shapely import LineString + +from blueprints.materials.reinforcement_steel import ReinforcementSteelMaterial +from blueprints.structural_sections.concrete.rebar import Rebar +from blueprints.type_alias import DIMENSIONLESS, MM, MM2, MM2_M +from blueprints.unit_conversion import M_TO_MM + + +@dataclass(frozen=True) +class ReinforcementConfiguration(ABC): + """Base class of all reinforcement configurations. + + Parameters + ---------- + diameter : MM + Diameter of the rebar [mm]. + material : ReinforcementSteelMaterial + Representation of the properties of reinforcement steel suitable for use with NEN-EN 1992-1-1. + """ + + diameter: MM + material: ReinforcementSteelMaterial + + def __post_init__(self) -> None: + """Post initialization of the reinforcement configuration.""" + # diameter is a positive number + if self.diameter <= 0: + msg = f"Diameter of the rebar must be a positive number, got {self.diameter}" + raise ValueError(msg) + + @property + @abstractmethod + def area(self) -> MM2: + """Each reinforcement configuration must have a resulting area.""" + + @abstractmethod + def to_rebars(self, line: LineString) -> list[Rebar]: + """Convert the reinforcement configuration to a list of rebars. + + Parameters + ---------- + line : LineString + Representing the path of the reinforcement in the section. + Start of the line defines the first rebar of the configuration, end of the line defines the last rebar. + + Returns + ------- + List[Rebar] + List of Rebar objects. + """ + + +@dataclass(kw_only=True, frozen=True) +class ReinforcementByDistance(ReinforcementConfiguration): + """Representation of a reinforcement configuration given by center-to-center distance. + For example ⌀16-150, ⌀20-200, ⌀25-250, ⌀32-300, etc. + + Parameters + ---------- + center_to_center : MM + Maximum center-to-center distance between rebars [mm]. + """ + + center_to_center: MM + + def __post_init__(self) -> None: + """Post initialization of the reinforcement configuration.""" + super().__post_init__() + self._validations() + + def _validations(self) -> None: + """Validation of the reinforcement configuration.""" + # center-to-center is at least the diameter + if self.center_to_center < self.diameter: + msg = f"Center-to-center distance must be at least the diameter of the rebar, got {self.center_to_center}" + raise ValueError(msg) + + @property + def area(self) -> MM2_M: + """Area of the reinforcement configuration per meter [mm²/m].""" + return 0.25 * np.pi * self.diameter**2 * (M_TO_MM / self.center_to_center) + + @property + def n_rebars_per_meter(self) -> DIMENSIONLESS: + """Number of rebars per meter [1/m].""" + return 1.0 * M_TO_MM / self.center_to_center + + def to_rebars(self, line: LineString) -> list[Rebar]: + """Convert the reinforcement configuration to a list of rebars. + + Parameters + ---------- + line : LineString + Representing the path of the reinforcement in the section. + Start of the line defines the first rebar of the configuration, end of the line defines the last rebar. + + Returns + ------- + List[Rebar] + List of Rebar objects. + """ + # max(int(n), 1) is used to ensure that at least one rebar is placed + rebars = [] + + # define the number of rebars based on the length of the line, minimum 1 + n_rebars = line.length / self.center_to_center + n_rebars_applied = max(int(n_rebars), 1) + + # calculate the space between the rebars + side_buffer = (line.length - (n_rebars_applied - 1) * self.center_to_center) / 2 + distances = np.linspace(start=side_buffer, stop=line.length - side_buffer, num=n_rebars_applied) + + # define the representative diameter of the rebar + reinforcement_area = 0.25 * np.pi * self.diameter**2 * n_rebars + repr_diameter = np.sqrt(reinforcement_area / (0.25 * np.pi * n_rebars_applied)) + + for distance in distances: + point = line.interpolate(distance) + rebars.append( + Rebar( + diameter=repr_diameter, + x=point.x, + y=point.y, + material=self.material, + ) + ) + return rebars + + def __repr__(self) -> str: + """Representation of the reinforcement configuration.""" + return f"{self.__class__.__name__}|{self!s}|{self.area:.0f} mm²/m" + + def __str__(self) -> str: + """String representation of the reinforcement configuration.""" + return f"⌀{self.diameter:.0f}-{self.center_to_center:.0f}" + + +@dataclass(kw_only=True, frozen=True) +class ReinforcementByQuantity(ReinforcementConfiguration): + """Representation of a reinforcement configuration given by quantity of rebars. + For example 4⌀16, 6⌀20, 8⌀25, 10⌀32, etc. + + Parameters + ---------- + n : int + Amount of longitudinal bars. + """ + + n: int + + def __post_init__(self) -> None: + """Post initialization of the reinforcement configuration.""" + super().__post_init__() + self._validations() + + def _validations(self) -> None: + """Validation of the reinforcement configuration.""" + # check that n is an integer + if not isinstance(self.n, int): + msg = f"Number of rebars must be an integer, got {self.n}" + raise TypeError(msg) + + # check that n is at least 2 + minimum_number_of_rebars = 1 + if self.n < minimum_number_of_rebars: + msg = f"Number of rebars must be at least {minimum_number_of_rebars}, got {self.n}" + raise ValueError(msg) + + @property + def area(self) -> MM2: + """Area of the reinforcement configuration [mm²].""" + return 0.25 * np.pi * self.diameter**2 * self.n + + def to_rebars(self, line: LineString) -> list[Rebar]: + """Convert the reinforcement configuration to a list of rebars. + + Parameters + ---------- + line : LineString + Representing the path of the reinforcement in the section. + Start of the line defines the first rebar of the configuration, end of the line defines the last rebar. + + Returns + ------- + List[Rebar] + List of Rebar objects. + """ + rebars = [] + for index in range(self.n): + distance = index * line.length / (self.n - 1) + point = line.interpolate(distance) + rebars.append( + Rebar( + diameter=self.diameter, + x=point.x, + y=point.y, + material=ReinforcementSteelMaterial(), + ) + ) + return rebars + + def __repr__(self) -> str: + """Representation of the reinforcement by quantity.""" + return f"{self.__class__.__name__}|{self!s}|{self.area:.0f} mm²" + + def __str__(self) -> str: + """String representation of the reinforcement by quantity.""" + return f"{self.n}⌀{self.diameter:.0f}" diff --git a/blueprints/structural_sections/concrete/stirrups.py b/blueprints/structural_sections/concrete/stirrups.py new file mode 100644 index 00000000..4a6316d3 --- /dev/null +++ b/blueprints/structural_sections/concrete/stirrups.py @@ -0,0 +1,158 @@ +"""Stirrups module.""" + +import numpy as np +from shapely import Point, Polygon +from shapely.geometry.polygon import orient + +from blueprints.materials.reinforcement_steel import ReinforcementSteelMaterial +from blueprints.type_alias import DIMENSIONLESS, KG_M3, MM, MM2, MM2_M, RATIO +from blueprints.unit_conversion import M_TO_MM, MM3_TO_M3 + +STIRRUP_COLOR = (0.412, 0.412, 0.412) + + +class StirrupConfiguration: + """Representation of a stirrup configuration. + + Parameters + ---------- + geometry: Polygon + Line that represents the center-line of the stirrup configuration (clockwise or counterclockwise). + diameter: MM + Diameter of the rebar making the stirrup [mm]. + distance: MM + Longitudinal distance between stirrups [mm]. + material: ReinforcementSteelMaterial + Reinforcement material. + shear_check: bool + Take stirrup into account in shear check + torsion_check: bool + Take stirrup into account in torsion check + mandrel_diameter_factor: MM + Inner diameter of mandrel as multiple of stirrup diameter [-] + (default: 4⌀ for ⌀<=16mm and 5⌀ for ⌀>16mm) Tabel 8.1Na NEN-EN 1992-1-1 Dutch National Annex. + anchorage_length: MM + Anchorage length [mm] + based_on_cover: bool + Default is False. This helps to categorise stirrups that a created based on the covers present in the cross-section. + relative_start_position: RATIO + Relative position of the start of the stirrup configuration inside the cross-section (longitudinal direction). Value between 0 and 1. + Default is 0 (start). + relative_end_position: RATIO + Relative position of the end of the stirrup configuration inside the cross-section (longitudinal direction). Value between 0 and 1. Default + is 1 (end). + """ + + counter = 1 + + def __init__( # noqa: PLR0913 + self, + geometry: Polygon, + diameter: MM, + distance: MM, + material: ReinforcementSteelMaterial, + shear_check: bool = True, + torsion_check: bool = True, + mandrel_diameter_factor: DIMENSIONLESS | None = None, + anchorage_length: MM = 0.0, + based_on_cover: bool = False, + relative_start_position: RATIO = 0.0, + relative_end_position: RATIO = 1.0, + n_vertices_used: int = 4, + cover_used: MM | None = None, + ) -> None: + """Initialisation of the stirrup.""" + self.geometry = orient(polygon=geometry) + self.diameter = diameter + self.distance = distance + self.material = material + self.shear_check = shear_check + self.torsion_check = torsion_check + self.anchorage_length = anchorage_length + self._mandrel_diameter_factor = mandrel_diameter_factor + self.based_on_cover = based_on_cover + self._id = StirrupConfiguration.counter + self._validation_relative_position(relative_position=relative_start_position) + self._validation_relative_position(relative_position=relative_end_position) + self._relative_start_position = relative_start_position + self._relative_end_position = relative_end_position + self.n_vertices_used = n_vertices_used + self._cover_used = cover_used + self._amount_of_legs = 2 + StirrupConfiguration.counter += 1 + + @property + def mandrel_diameter_factor(self) -> DIMENSIONLESS: + """Diameter factor of mandrel. + Standard values given by Dutch Annex Table 8.1Na - NEN-EN 1992-1-1+C2:2011/NB+A1:2020 + (default: 4⌀ for ⌀<=16mm and 5⌀ for ⌀>16mm). + """ + if self._mandrel_diameter_factor: + return self._mandrel_diameter_factor + return 5.0 if self.diameter > 16.0 else 4.0 + + @property + def as_w(self) -> MM2_M: + """Total cross-sectional area of the stirrup [mm²/m].""" + return self._amount_of_legs * self.area * (M_TO_MM / self.distance) + + @property + def area(self) -> MM2: + """Area of the stirrup bar [mm²].""" + return 0.25 * np.pi * self.diameter**2 + + @property + def radius(self) -> MM: + """Radius of the stirrup bar [mm].""" + return self.diameter / 2 + + @property + def centroid(self) -> Point: + """Centroid of the stirrup bar [mm].""" + return self.geometry.centroid + + @property + def weight_per_meter(self) -> KG_M3: + """Total mass of the stirrup per meter length in the longitudinal direction (concrete+reinforcement) [kg/m³] + (Weight of a single stirrup x amount of stirrups present in one meter length). + """ + return self.material.density * self.geometry.length * self.area * MM3_TO_M3 * M_TO_MM / self.distance + + @property + def ctc_distance_legs(self) -> MM: + """Distance between the legs of the stirrup taken form the center lines of the rebar [mm].""" + min_x, max_x = self.geometry.bounds[0], self.geometry.bounds[2] + return max_x - min_x + + @property + def cover_used(self) -> float: + """Can be used to store the value of the cover used when adding the stirrup to the cross-section [mm].""" + return self._cover_used or 0.0 + + @property + def relative_start_position(self) -> RATIO: + """Relative position of the start of the stirrup configuration inside the cross-section. Value between 0 and 1.""" + return self._relative_start_position + + @property + def relative_end_position(self) -> RATIO: + """Relative position of the end of the stirrup configuration inside the cross-section. Value between 0 and 1.""" + return self._relative_end_position + + @staticmethod + def _validation_relative_position(relative_position: DIMENSIONLESS) -> None: + """Validation of the relative position of the stirrup.""" + if relative_position < 0.0: + msg = "Relative position of the stirrup must be greater than or equal to zero" + raise ValueError(msg) + if relative_position > 1.0: + msg = "Relative position of the stirrup must be less than or equal to one" + raise ValueError(msg) + + def __repr__(self) -> str: + """Representation of the stirrup.""" + return f"Stirrup (id={self._id})|⌀{self.diameter}/{self.material.name}" + + def __str__(self) -> str: + """String representation of the stirrup.""" + return f"Stirrups ⌀{self.diameter}-{self.distance:.0f} mm | {self.material.name} | {self.as_w:.2f} mm²/m" diff --git a/blueprints/structural_sections/concrete/reinforced_concrete_sections/cross_section_shapes.py b/blueprints/structural_sections/cross_section_shapes.py similarity index 100% rename from blueprints/structural_sections/concrete/reinforced_concrete_sections/cross_section_shapes.py rename to blueprints/structural_sections/cross_section_shapes.py diff --git a/blueprints/type_alias.py b/blueprints/type_alias.py index cc317f4d..bffa79bf 100644 --- a/blueprints/type_alias.py +++ b/blueprints/type_alias.py @@ -38,6 +38,7 @@ # MM2 = float +MM2_M = float CM2 = float DM2 = float M2 = float @@ -48,6 +49,7 @@ MM3 = float CM3 = float M3 = float +M3_M = float # # diff --git a/docs/DOCS_README.md b/docs/DOCS_README.md new file mode 100644 index 00000000..b089b555 --- /dev/null +++ b/docs/DOCS_README.md @@ -0,0 +1,11 @@ +# How to make Blueprints docs? + +## 1. Install requirements +```bash +pip install requirements_docs.txt +``` + +## 2. Run the script +```bash +docs\make html +``` \ No newline at end of file diff --git a/docs/data/__init__.py b/docs/data/__init__.py deleted file mode 100644 index 0d9f9f6a..00000000 --- a/docs/data/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Data package for the documentation.""" diff --git a/docs/data/images/__init__.py b/docs/data/images/__init__.py deleted file mode 100644 index 94168924..00000000 --- a/docs/data/images/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Images for the documentation.""" diff --git a/docs/source/data/images/rectangular_reinforced_cross_section.png b/docs/source/data/images/rectangular_reinforced_cross_section.png new file mode 100644 index 00000000..cc888473 Binary files /dev/null and b/docs/source/data/images/rectangular_reinforced_cross_section.png differ diff --git a/docs/source/data/images/rectangular_reinforced_cross_section_custom.png b/docs/source/data/images/rectangular_reinforced_cross_section_custom.png new file mode 100644 index 00000000..6be5ac94 Binary files /dev/null and b/docs/source/data/images/rectangular_reinforced_cross_section_custom.png differ diff --git a/docs/source/examples.rst b/docs/source/examples.rst new file mode 100644 index 00000000..7f1be1e1 --- /dev/null +++ b/docs/source/examples.rst @@ -0,0 +1,23 @@ +Examples +======== + +Here are some examples of how to use the 'Blueprints'. + +Make a Rectangular Reinforced Cross-section +------------------------------------------- +Simply create a rectangular cross-section and add some reinforcement: + +.. literalinclude:: examples\rectangular_reinforced_cross_section.py + +.. image:: data/images/rectangular_reinforced_cross_section.png + :alt: Rectangular Reinforced Cross-section + :align: center + + +And just to show that you can create any RCS you like, here is a custom one: + +.. literalinclude:: examples\rectangular_reinforced_cross_section_custom.py + +.. image:: data/images/rectangular_reinforced_cross_section_custom.png + :alt: Rectangular Reinforced Cross-section + :align: center \ No newline at end of file diff --git a/examples/__init__.py b/docs/source/examples/__init__.py similarity index 100% rename from examples/__init__.py rename to docs/source/examples/__init__.py diff --git a/docs/source/examples/rectangular_reinforced_cross_section.py b/docs/source/examples/rectangular_reinforced_cross_section.py new file mode 100644 index 00000000..6bdad778 --- /dev/null +++ b/docs/source/examples/rectangular_reinforced_cross_section.py @@ -0,0 +1,61 @@ +"""Reinforced concrete cross-section example.""" + +from blueprints.materials.concrete import ConcreteMaterial, ConcreteStrengthClass +from blueprints.materials.reinforcement_steel import ReinforcementSteelMaterial, ReinforcementSteelQuality +from blueprints.structural_sections.concrete.covers import CoversRectangular +from blueprints.structural_sections.concrete.reinforced_concrete_sections.rectangular import RectangularReinforcedCrossSection + +# Define a concrete material +concrete = ConcreteMaterial(concrete_class=ConcreteStrengthClass.C35_45) + +# Define a reinforcement steel material +steel = ReinforcementSteelMaterial(steel_quality=ReinforcementSteelQuality.B500B) + +# Define a rectangular reinforced cross-section +cs = RectangularReinforcedCrossSection( + width=1000, + height=800, + covers=CoversRectangular(upper=45, right=30, lower=35, left=50), + concrete_material=concrete, +) + +# Add reinforcement to the cross-section +cs.add_longitudinal_reinforcement_by_quantity( + n=5, + diameter=14, + edge="upper", + material=steel, +) +cs.add_longitudinal_reinforcement_by_quantity( + n=4, + diameter=40, + edge="lower", + material=steel, +) +cs.add_longitudinal_reinforcement_by_quantity( + n=5, + diameter=14, + edge="right", + material=steel, +) +cs.add_longitudinal_reinforcement_by_quantity( + n=5, + diameter=14, + edge="left", + material=steel, +) + +# Add stirrups to the cross-section +cs.add_stirrup_along_edges( + diameter=8, + distance=150, + material=steel, +) +# Add stirrups to the cross-section +cs.add_stirrup_along_edges( + diameter=12, + distance=300, + material=steel, +) + +cs.plot(show=True) diff --git a/docs/source/examples/rectangular_reinforced_cross_section_custom.py b/docs/source/examples/rectangular_reinforced_cross_section_custom.py new file mode 100644 index 00000000..05f6c2d0 --- /dev/null +++ b/docs/source/examples/rectangular_reinforced_cross_section_custom.py @@ -0,0 +1,78 @@ +"""Custom rectangular reinforced cross-section example.""" + +from shapely import LineString, Polygon + +from blueprints.materials.concrete import ConcreteMaterial, ConcreteStrengthClass +from blueprints.materials.reinforcement_steel import ReinforcementSteelMaterial, ReinforcementSteelQuality +from blueprints.structural_sections.concrete.covers import CoversRectangular +from blueprints.structural_sections.concrete.rebar import Rebar +from blueprints.structural_sections.concrete.reinforced_concrete_sections.rectangular import RectangularReinforcedCrossSection +from blueprints.structural_sections.concrete.reinforced_concrete_sections.reinforcement_configurations import ( + ReinforcementByDistance, + ReinforcementByQuantity, +) +from blueprints.structural_sections.concrete.stirrups import StirrupConfiguration + +# Define a concrete material +concrete = ConcreteMaterial(concrete_class=ConcreteStrengthClass.C30_37) + +# Define a reinforcement steel material +steel = ReinforcementSteelMaterial(steel_quality=ReinforcementSteelQuality.B500B) + +# Define a rectangular reinforced cross-section +cs = RectangularReinforcedCrossSection( + width=600, + height=500, + covers=CoversRectangular(upper=45, right=30, lower=35, left=50), + concrete_material=concrete, +) + +# Add reinforcement to the cross-section +cs.add_longitudinal_reinforcement_by_quantity( + n=5, + diameter=14, + edge="upper", + material=steel, +) + +# add a second layer of reinforcement to the cross-section +cs.add_longitudinal_reinforcement_by_quantity( + n=3, + diameter=16, + edge="upper", + material=steel, + cover=100, +) + +# add reinforcement configurations to the cross-section in any position +cs.add_reinforcement_configuration( + line=LineString([(50, -100), (150, 50)]), + configuration=ReinforcementByQuantity(diameter=20, n=3, material=steel), +) + +cs.add_reinforcement_configuration( + line=LineString([(0, -180), (-250, 0)]), + configuration=ReinforcementByDistance(diameter=12, center_to_center=40, material=steel), +) + +# Add stirrups to the cross-section +cs.add_stirrup_configuration( + stirrup=StirrupConfiguration( + geometry=Polygon([(-200, -200), (-200, 200), (200, 200), (200, -200)]), + diameter=8, + distance=150, + material=steel, + ), +) + +# Add a longitudinal rebar to the cross-section +cs.add_longitudinal_rebar( + rebar=Rebar( + diameter=12, + x=0, + y=0, + material=steel, + ) +) + +cs.plot(show=True) diff --git a/docs/source/index.rst b/docs/source/index.rst index 3e40eac8..9ddcfe0d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -2,6 +2,7 @@ :hidden: :maxdepth: 2 + examples api .. image:: _static/blueprints_banner.png diff --git a/tests/structural_sections/concrete/reinforced_concrete_sections/test_cross_section_shapes.py b/tests/structural_sections/concrete/reinforced_concrete_sections/test_cross_section_shapes.py index b39f57c5..0d5b020d 100644 --- a/tests/structural_sections/concrete/reinforced_concrete_sections/test_cross_section_shapes.py +++ b/tests/structural_sections/concrete/reinforced_concrete_sections/test_cross_section_shapes.py @@ -1,8 +1,9 @@ -"""Tests for cross section shapes.""" +"""Tests for cross-section shapes.""" import pytest +from shapely import Polygon -from blueprints.structural_sections.concrete.reinforced_concrete_sections.cross_section_shapes import CircularCrossSection, RectangularCrossSection +from blueprints.structural_sections.cross_section_shapes import CircularCrossSection, RectangularCrossSection class TestCircularCrossSection: @@ -13,6 +14,10 @@ def circular_cross_section(self) -> CircularCrossSection: """Return a CircularCrossSection instance.""" return CircularCrossSection(diameter=200.0, x=0.0, y=0.0) + def test_geometry(self, circular_cross_section: CircularCrossSection) -> None: + """Test the geometry property of the CircularCrossSection class.""" + assert isinstance(circular_cross_section.geometry, Polygon) + def test_area(self, circular_cross_section: CircularCrossSection) -> None: """Test the area property of the CircularCrossSection class.""" assert circular_cross_section.area == pytest.approx(expected=31415.92653, rel=1e-6) @@ -35,6 +40,11 @@ def test_vertices(self, circular_cross_section: CircularCrossSection) -> None: assert (first_vertex.x, first_vertex.y) == pytest.approx(expected=(100.0, 0.0), rel=1e-6) assert (last_vertex.x, last_vertex.y) == pytest.approx(expected=(100.0, 0.0), rel=1e-6) + def test_wrong_input(self) -> None: + """Test the wrong input for the CircularCrossSection class.""" + with pytest.raises(ValueError): + CircularCrossSection(diameter=-200.0, x=0.0, y=0.0) + class TestRectangularCrossSection: """Tests for the RectangularCrossSection class.""" diff --git a/tests/structural_sections/concrete/reinforced_concrete_sections/test_rectangular.py b/tests/structural_sections/concrete/reinforced_concrete_sections/test_rectangular.py new file mode 100644 index 00000000..71b8bd34 --- /dev/null +++ b/tests/structural_sections/concrete/reinforced_concrete_sections/test_rectangular.py @@ -0,0 +1,215 @@ +"""Tests for Rectangular Reinforced Concrete Sections.""" + +from typing import Literal + +import pytest +from matplotlib import pyplot as plt +from shapely import LineString, Polygon + +from blueprints.materials.concrete import ConcreteMaterial, ConcreteStrengthClass +from blueprints.materials.reinforcement_steel import ReinforcementSteelMaterial, ReinforcementSteelQuality +from blueprints.structural_sections.concrete.covers import CoversRectangular +from blueprints.structural_sections.concrete.rebar import Rebar +from blueprints.structural_sections.concrete.reinforced_concrete_sections.rectangular import RectangularReinforcedCrossSection +from blueprints.structural_sections.concrete.reinforced_concrete_sections.reinforcement_configurations import ReinforcementByQuantity +from blueprints.structural_sections.concrete.stirrups import StirrupConfiguration + + +class TestRectangularReinforcedCrossSection: + """Tests for the RectangularReinforcedCrossSection class.""" + + @pytest.fixture + def rectangular_reinforced_cross_section(self) -> RectangularReinforcedCrossSection: + """Return a rectangular reinforced cross-section.""" + # Define a concrete material + concrete = ConcreteMaterial(concrete_class=ConcreteStrengthClass.C35_45) + + # Define a reinforcement steel material + steel = ReinforcementSteelMaterial(steel_quality=ReinforcementSteelQuality.B500B) + + # Define a rectangular reinforced cross-section + cs = RectangularReinforcedCrossSection( + width=1000, + height=800, + covers=CoversRectangular(upper=45, right=30, lower=35, left=50), + concrete_material=concrete, + ) + + # Add reinforcement to the cross-section + cs.add_longitudinal_reinforcement_by_quantity( + n=5, + diameter=14, + edge="upper", + material=steel, + ) + cs.add_longitudinal_reinforcement_by_quantity( + n=4, + diameter=40, + edge="lower", + material=steel, + ) + + # Add stirrups to the cross-section + cs.add_stirrup_along_edges( + diameter=8, + distance=150, + material=steel, + ) + # Add stirrups to the cross-section + cs.add_stirrup_along_edges( + diameter=12, + distance=300, + material=steel, + ) + + # Add a longitudinal rebar to the cross-section + cs.add_longitudinal_rebar( + rebar=Rebar( + diameter=12, + x=-250, + y=-100, + material=steel, + ) + ) + + return cs + + def test_add_stirrup_along_edges(self, rectangular_reinforced_cross_section: RectangularReinforcedCrossSection) -> None: + """Test the add_stirrup_along_edges method.""" + stirrup = rectangular_reinforced_cross_section.add_stirrup_along_edges( + diameter=8, + distance=150, + material=rectangular_reinforced_cross_section.get_present_steel_materials()[0], + ) + assert stirrup in rectangular_reinforced_cross_section.stirrups + + def test_add_stirrup_in_center(self, rectangular_reinforced_cross_section: RectangularReinforcedCrossSection) -> None: + """Test the add_stirrup_in_center method.""" + stirrup = rectangular_reinforced_cross_section.add_stirrup_in_center( + width=200, + diameter=8, + distance=150, + material=rectangular_reinforced_cross_section.get_present_steel_materials()[0], + ) + assert stirrup in rectangular_reinforced_cross_section.stirrups + + @pytest.mark.parametrize( + "edge", + [ + "upper", + "right", + "lower", + "left", + ], + ) + def test_add_longitudinal_reinforcement_by_quantity( + self, + rectangular_reinforced_cross_section: RectangularReinforcedCrossSection, + edge: Literal["upper", "right", "lower", "left"], + ) -> None: + """Test the add_longitudinal_reinforcement_by_quantity method.""" + rectangular_reinforced_cross_section.add_longitudinal_reinforcement_by_quantity( + n=5, + diameter=14, + material=rectangular_reinforced_cross_section.get_present_steel_materials()[0], + edge=edge, + cover=23, + corner_offset=40, + ) + assert len(rectangular_reinforced_cross_section.longitudinal_rebars) == 15 + + def test_add_longitudinal_reinforcement_by_quantity_wrong_edge( + self, rectangular_reinforced_cross_section: RectangularReinforcedCrossSection + ) -> None: + """Test the add_longitudinal_reinforcement_by_quantity method with wrong edge.""" + with pytest.raises(ValueError): + rectangular_reinforced_cross_section.add_longitudinal_reinforcement_by_quantity( + n=5, + diameter=14, + material=rectangular_reinforced_cross_section.get_present_steel_materials()[0], + edge="wrong", # type: ignore[arg-type] + ) + + def test_plot(self, rectangular_reinforced_cross_section: RectangularReinforcedCrossSection) -> None: + """Test the plot method.""" + plot = rectangular_reinforced_cross_section.plot(show=False, center_line_style={"linewidth": 0.85}) + assert isinstance(plot, plt.Figure) + + def test_reinforcement_weight_longitudinal_bars(self, rectangular_reinforced_cross_section: RectangularReinforcedCrossSection) -> None: + """Test the reinforcement_weight_longitudinal_bars method.""" + expected_weight = 46.38828588400884 # kg/m + assert rectangular_reinforced_cross_section.reinforcement_weight_longitudinal_bars == pytest.approx(expected=expected_weight, rel=1e-4) + + def test_reinforcement_weight_stirrups(self, rectangular_reinforced_cross_section: RectangularReinforcedCrossSection) -> None: + """Test the reinforcement_weight_stirrups method.""" + expected_weight = 18.1087 # kg/m + assert rectangular_reinforced_cross_section.reinforcement_weight_stirrups == pytest.approx(expected=expected_weight, rel=1e-4) + + def test_reinforcement_weight(self, rectangular_reinforced_cross_section: RectangularReinforcedCrossSection) -> None: + """Test the reinforcement_weight method.""" + expected_weight = 64.4969 # kg/m + assert rectangular_reinforced_cross_section.reinforcement_weight == pytest.approx(expected=expected_weight, rel=1e-4) + + def test_reinforcement_area_longitudinal_bars(self, rectangular_reinforced_cross_section: RectangularReinforcedCrossSection) -> None: + """Test the reinforcement_area_longitudinal_bars method.""" + expected_area = 5909.3357 # mm²/m + assert rectangular_reinforced_cross_section.reinforcement_area_longitudinal_bars == pytest.approx(expected=expected_area, rel=1e-4) + + def test_concrete_volume(self, rectangular_reinforced_cross_section: RectangularReinforcedCrossSection) -> None: + """Test the concrete_volume method.""" + expected_volume = 0.8 # m³/m + assert rectangular_reinforced_cross_section.concrete_volume == pytest.approx(expected=expected_volume, rel=1e-4) + + def test_weight_per_volume(self, rectangular_reinforced_cross_section: RectangularReinforcedCrossSection) -> None: + """Test the weight_per_volume method.""" + expected_weight_per_volume = 80.6211 # kg/m³ + assert rectangular_reinforced_cross_section.weight_per_volume == pytest.approx(expected=expected_weight_per_volume, rel=1e-4) + + def test_add_longitudinal_rebar_wrong_position(self, rectangular_reinforced_cross_section: RectangularReinforcedCrossSection) -> None: + """Test the add_longitudinal_rebar method with wrong position.""" + with pytest.raises(ValueError): + rectangular_reinforced_cross_section.add_longitudinal_rebar( + rebar=Rebar( + diameter=12, + x=2500, + y=1000, + material=rectangular_reinforced_cross_section.get_present_steel_materials()[0], + ) + ) + + def test_add_stirrup_configuration_wrong_position(self, rectangular_reinforced_cross_section: RectangularReinforcedCrossSection) -> None: + """Test the add_stirrup_configuration method with wrong position.""" + with pytest.raises(ValueError): + rectangular_reinforced_cross_section.add_stirrup_configuration( + stirrup=StirrupConfiguration( + geometry=Polygon([(0, 0), (0, 100), (5000, 5000), (100, 0)]), + diameter=8, + distance=150, + material=rectangular_reinforced_cross_section.get_present_steel_materials()[0], + ), + ) + + def test_add_reinforcement_configuration_by_linestring(self, rectangular_reinforced_cross_section: RectangularReinforcedCrossSection) -> None: + """Test the add_reinforcement_configuration method with a linestring.""" + linestring = LineString([(-300, 200), (300, 200)]) + rectangular_reinforced_cross_section.add_reinforcement_configuration( + line=linestring, + configuration=ReinforcementByQuantity( + diameter=12, + n=3, + material=rectangular_reinforced_cross_section.get_present_steel_materials()[0], + ), + ) + assert len(rectangular_reinforced_cross_section.longitudinal_rebars) == 13 + + def test_rebar_not_in_cross_section(self, rectangular_reinforced_cross_section: RectangularReinforcedCrossSection) -> None: + """Test the add_longitudinal_rebar method with a rebar not in the cross-section.""" + rebar = Rebar( + diameter=12, + x=2500, + y=1000, + material=rectangular_reinforced_cross_section.get_present_steel_materials()[0], + ) + rectangular_reinforced_cross_section._single_longitudinal_rebars.append(rebar) # noqa: SLF001 + with pytest.raises(ValueError): + _ = rectangular_reinforced_cross_section.longitudinal_rebars diff --git a/tests/structural_sections/concrete/reinforced_concrete_sections/test_reinforcement_configurations.py b/tests/structural_sections/concrete/reinforced_concrete_sections/test_reinforcement_configurations.py new file mode 100644 index 00000000..5e065193 --- /dev/null +++ b/tests/structural_sections/concrete/reinforced_concrete_sections/test_reinforcement_configurations.py @@ -0,0 +1,108 @@ +"""Tests reinforcement configurations.""" + +from typing import Type + +import pytest +from shapely import LineString + +from blueprints.materials.reinforcement_steel import ReinforcementSteelMaterial +from blueprints.structural_sections.concrete.reinforced_concrete_sections.reinforcement_configurations import ( + ReinforcementByDistance, + ReinforcementByQuantity, +) + + +class TestReinforcementByDistance: + """Tests for the reinforcement by distance configuration.""" + + @pytest.fixture + def reinforcement_by_distance(self) -> ReinforcementByDistance: + """Creates a reinforcement by distance configuration.""" + return ReinforcementByDistance( + diameter=12, + center_to_center=100, + material=ReinforcementSteelMaterial(), + ) + + def test_n_rebars_per_meter(self, reinforcement_by_distance: ReinforcementByDistance) -> None: + """Test the number of rebars per meter.""" + n_rebars_per_meter = reinforcement_by_distance.n_rebars_per_meter + assert n_rebars_per_meter == 10 + + def test_area(self, reinforcement_by_distance: ReinforcementByDistance) -> None: + """Test the area of the reinforcement.""" + area = reinforcement_by_distance.area + assert area == pytest.approx(expected=1130.9733, rel=1e-4) + + def test__repr__(self, reinforcement_by_distance: ReinforcementByDistance) -> None: + """Test the representation of the reinforcement.""" + representation = repr(reinforcement_by_distance) + assert representation == "ReinforcementByDistance|⌀12-100|1131 mm²/m" + + def test_wrong_ctc(self) -> None: + """Test the wrong center-to-center distance.""" + with pytest.raises(ValueError): + ReinforcementByDistance( + diameter=12, + center_to_center=0, + material=ReinforcementSteelMaterial(), + ) + + def test_to_rebars(self, reinforcement_by_distance: ReinforcementByDistance) -> None: + """Test the conversion to rebars.""" + line = LineString([(0, 0), (1000, 0)]) + rebars = reinforcement_by_distance.to_rebars(line=line) + assert len(rebars) == 10 + assert all(rebar.diameter == 12 for rebar in rebars) + assert all(rebar.material == ReinforcementSteelMaterial() for rebar in rebars) + + +class TestReinforcementByQuantity: + """Tests for the reinforcement by quantity configuration.""" + + @pytest.fixture + def reinforcement_by_quantity(self) -> ReinforcementByQuantity: + """Creates a reinforcement by quantity configuration.""" + return ReinforcementByQuantity( + diameter=12, + material=ReinforcementSteelMaterial(), + n=10, + ) + + def test_area(self, reinforcement_by_quantity: ReinforcementByQuantity) -> None: + """Test the area of the reinforcement.""" + area = reinforcement_by_quantity.area + assert area == pytest.approx(expected=1130.9733, rel=1e-4) + + @pytest.mark.parametrize("diameter", [0, -1]) + def test_wrong_diameter(self, diameter: int) -> None: + """Test the wrong diameter.""" + with pytest.raises(ValueError): + ReinforcementByQuantity( + diameter=diameter, + material=ReinforcementSteelMaterial(), + n=10, + ) + + @pytest.mark.parametrize(("wrong_n", "expected_error"), [(0, ValueError), (-1, ValueError), (1.5, TypeError)]) + def test_wrong_n_float(self, wrong_n: int, expected_error: Type[BaseException]) -> None: + """Test the wrong number of rebars.""" + with pytest.raises(expected_error): + ReinforcementByQuantity( + diameter=12, + material=ReinforcementSteelMaterial(), + n=wrong_n, + ) + + def test_to_rebars(self, reinforcement_by_quantity: ReinforcementByQuantity) -> None: + """Test the conversion to rebars.""" + line = LineString([(0, 0), (1000, 0)]) + rebars = reinforcement_by_quantity.to_rebars(line=line) + assert len(rebars) == 10 + assert all(rebar.diameter == 12 for rebar in rebars) + assert all(rebar.material == ReinforcementSteelMaterial() for rebar in rebars) + + def test__repr__(self, reinforcement_by_quantity: ReinforcementByQuantity) -> None: + """Test the representation of the reinforcement.""" + representation = repr(reinforcement_by_quantity) + assert representation == "ReinforcementByQuantity|10⌀12|1131 mm²" diff --git a/tests/structural_sections/concrete/test_covers.py b/tests/structural_sections/concrete/test_covers.py new file mode 100644 index 00000000..1e5e7868 --- /dev/null +++ b/tests/structural_sections/concrete/test_covers.py @@ -0,0 +1,53 @@ +"""Tests for CoversRectangular class.""" + +import pytest + +from blueprints.structural_sections.concrete.covers import CoversRectangular +from blueprints.validations import NegativeValueError + + +class TestCoversRectangular: + """Tests for the CoversRectangular class.""" + + def test_get_covers_info_with_all_equal_covers(self) -> None: + """Test if the method `get_covers_info` returns the correct string when all covers are equal.""" + covers = CoversRectangular(upper=50.0, right=50.0, lower=50.0, left=50.0) + assert covers.get_covers_info() == "Cover: 50 mm" + + def test_get_covers_info_with_different_covers(self) -> None: + """Test if the method `get_covers_info` returns the correct string when all covers are different.""" + covers = CoversRectangular(upper=40.0, right=50.0, lower=60.0, left=70.0) + expected_output = "Cover:\n upper: 40 mm\n right: 50 mm\n lower: 60 mm\n left: 70 mm" + assert covers.get_covers_info() == expected_output + + def test_get_covers_info_with_two_equal_covers(self) -> None: + """Test if the method `get_covers_info` returns the correct string when two covers are equal.""" + # Upper and left covers are equal + covers = CoversRectangular(upper=50.0, right=60.0, lower=50.0, left=70.0) + expected_output = "Cover:\n upper|lower: 50 mm\n right: 60 mm\n left: 70 mm" + assert covers.get_covers_info() == expected_output + + # Upper and right covers are equal adnd lower and left covers are equal + covers = CoversRectangular(upper=50.0, right=50.0, lower=60.0, left=60.0) + expected_output = "Cover:\n upper|right: 50 mm\n lower|left: 60 mm" + assert covers.get_covers_info() == expected_output + + def test_get_covers_info_with_three_equal_covers(self) -> None: + """Test if the method `get_covers_info` returns the correct string when three covers are equal.""" + covers = CoversRectangular(upper=50.0, right=50.0, lower=50.0, left=60.0) + expected_output = "Cover:\n upper|right|lower: 50 mm\n left: 60 mm" + assert covers.get_covers_info() == expected_output + + @pytest.mark.parametrize( + "covers", + [ + (50.0, 50.0, 50.0, -60.0), + (50.0, 50.0, -50.0, 60.0), + (50.0, -50.0, 50.0, 60.0), + (-50.0, 50.0, 50.0, 60.0), + ], + ) + def test_covers_negative_value(self, covers: tuple[float, float, float, float]) -> None: + """Test if the method `validate` raises an error when a cover is negative.""" + with pytest.raises(NegativeValueError): + CoversRectangular(upper=covers[0], right=covers[1], lower=covers[2], left=covers[3]) diff --git a/tests/structural_sections/concrete/test_stirrups.py b/tests/structural_sections/concrete/test_stirrups.py new file mode 100644 index 00000000..8edd976a --- /dev/null +++ b/tests/structural_sections/concrete/test_stirrups.py @@ -0,0 +1,110 @@ +"""Test for the stirrups configuration.""" + +import pytest +from shapely import Polygon + +from blueprints.materials.reinforcement_steel import ReinforcementSteelMaterial +from blueprints.structural_sections.concrete.stirrups import StirrupConfiguration + + +class TestStirrup: + """Tests for the stirrups configuration.""" + + @pytest.fixture + def stirrup(self) -> StirrupConfiguration: + """Creates a stirrup configuration.""" + return StirrupConfiguration( + geometry=Polygon([(0, 0), (0, 100), (100, 100), (100, 0)]), + diameter=8, + distance=100, + material=ReinforcementSteelMaterial(), + ) + + def test_mandrel_diameter_factor(self, stirrup: StirrupConfiguration) -> None: + """Test the mandrel diameter factor.""" + mandrel_diameter_factor = stirrup.mandrel_diameter_factor + assert mandrel_diameter_factor == 4 + + def test_mandrel_diameter_factor_none(self) -> None: + """Test the mandrel diameter factor.""" + stirrup = StirrupConfiguration( + geometry=Polygon([(0, 0), (0, 100), (100, 100), (100, 0)]), + diameter=8, + distance=100, + material=ReinforcementSteelMaterial(), + mandrel_diameter_factor=3.5, + ) + assert stirrup.mandrel_diameter_factor == 3.5 + + def test_as_w(self, stirrup: StirrupConfiguration) -> None: + """Test total cross-sectional area of the stirrup [mm²/m].""" + as_w = stirrup.as_w + assert as_w == pytest.approx(expected=1005.3096, rel=1e-4) + + def test_area(self, stirrup: StirrupConfiguration) -> None: + """Test the area of the stirrup.""" + area = stirrup.area + assert area == pytest.approx(expected=50.2655, rel=1e-4) + + def test_radius(self, stirrup: StirrupConfiguration) -> None: + """Test the radius of the stirrup.""" + radius = stirrup.radius + assert radius == pytest.approx(expected=4, rel=1e-4) + + def test_centroid(self, stirrup: StirrupConfiguration) -> None: + """Test the centroid of the stirrup.""" + centroid = stirrup.centroid + assert centroid.x == pytest.approx(expected=50, rel=1e-4) + assert centroid.y == pytest.approx(expected=50, rel=1e-4) + + def test_weight_per_meter(self, stirrup: StirrupConfiguration) -> None: + """Test the weight per meter of the stirrup.""" + weight_per_meter = stirrup.weight_per_meter + assert weight_per_meter == pytest.approx(expected=1.57833, rel=1e-4) + + def test_ctc_distance_legs(self, stirrup: StirrupConfiguration) -> None: + """Test the distance between the legs of the stirrup.""" + ctc_distance_legs = stirrup.ctc_distance_legs + assert ctc_distance_legs == pytest.approx(expected=100, rel=1e-4) + + def test_cover_used(self, stirrup: StirrupConfiguration) -> None: + """Test the cover used.""" + assert stirrup.cover_used == 0 + + def test_cover_used_changed(self) -> None: + """Test the cover used.""" + stirrup = StirrupConfiguration( + geometry=Polygon([(0, 0), (0, 100), (100, 100), (100, 0)]), diameter=8, distance=100, material=ReinforcementSteelMaterial(), cover_used=10 + ) + assert stirrup.cover_used == 10 + + def test_relative_start_position(self, stirrup: StirrupConfiguration) -> None: + """Test the relative start position.""" + assert stirrup.relative_start_position == 0.0 + + def test_relative_end_position(self, stirrup: StirrupConfiguration) -> None: + """Test the relative end position.""" + assert stirrup.relative_end_position == 1.0 + + @pytest.mark.parametrize(("relative_start_position", "relative_end_position"), [(-1, 1), (-5, 1), (0, 2), (0, 5)]) + def test_relative_positions_wrong(self, relative_start_position: float, relative_end_position: float) -> None: + """Test the relative positions.""" + with pytest.raises(ValueError): + StirrupConfiguration( + geometry=Polygon([(0, 0), (0, 100), (100, 100), (100, 0)]), + diameter=8, + distance=100, + material=ReinforcementSteelMaterial(), + relative_start_position=relative_start_position, + relative_end_position=relative_end_position, + ) + + def test__repr__(self, stirrup: StirrupConfiguration) -> None: + """Test the representation of the stirrup.""" + representation = repr(stirrup) + assert representation.endswith("|⌀8/B500B") + + def test__str__(self, stirrup: StirrupConfiguration) -> None: + """Test the string representation of the stirrup.""" + string_representation = str(stirrup) + assert string_representation == "Stirrups ⌀8-100 mm | B500B | 1005.31 mm²/m" diff --git a/tests/utils/test_abc_enum_meta.py b/tests/utils/test_abc_enum_meta.py index a6d3f93b..34f872c9 100644 --- a/tests/utils/test_abc_enum_meta.py +++ b/tests/utils/test_abc_enum_meta.py @@ -73,10 +73,7 @@ class InvalidMockConcreteEnum(MockAbstractEnum): A = "a" B = "b" - assert ( - str(error_info.value) - == "Can't instantiate abstract class 'InvalidMockConcreteEnum' without an implementation for abstract method 'test_method'" - ) + assert str(error_info.value).endswith("without an implementation for abstract method 'test_method'") def test_abc_enum_meta_attribute_error(self) -> None: """Test instantiation of class with ABCEnumMeta where no abstract methods attribute is present."""