Source code for simdesign.rcmrf.geometry.custom

"""
Custom geometry module for defining frame structures from Excel input files.

This module provides the :class:`CustomGeometry` class, which reads structural
geometry data (points, lines, and rectangles) from a user-supplied Excel
workbook and builds a complete frame model via the base geometry framework.
"""
# Imports from installed packages
import numpy as np
import pandas as pd
from pathlib import Path
from typing import Dict, List, Literal, Union

# Imports from geometry library
from .base import GeometryBase, Point, SystemGridData

# Imports from utils library
from ...utils.tags import TYPE_UNIT


# TODO: Use pydantic to set schema for CustomGeometry
[docs] class CustomGeometry(GeometryBase): """ Class representing a custom frame structure. This class inherits from :class:`~simdesign.rcmrf.geometry.base.GeometryBase` and extends it to represent a custom frame structure. It initializes the frame with data from an Excel file specified by the provided path. Attributes ---------- xlsx_path : str | Path The path to the Excel file containing the frame data. input_tags : Dict[str, Dict[int, int]] Tags assigned to the points, lines and rectangles of the workbook, keyed by object type ('points', 'lines', 'rectangles') and by workbook tag. """ xlsx_path: Union[str, Path] input_tags: Dict[Literal['points', 'lines', 'rectangles'], Dict[int, int]] __str: str """The private attribute for string representation of the CustomGeometry. """ def __init__(self, xlsx_path: Union[str, Path], retag: bool = False) -> None: """ Initialize the CustomGeometry with data from the specified Excel file. Parameters ---------- xlsx_path : Union[str, Path] The path to the Excel file containing the frame data. retag : bool, optional If ``True``, the points, lines and rectangles are renumbered with the tag encoding of :mod:`simdesign.utils.tags`, so that their grid location and type can be read from their tags (see :meth:`retag`). If ``False``, the tags of the workbook are kept. By default ``False``. """ tag = Path(xlsx_path).name.removeprefix(".xlsx") self.__str = f"CustomGeometry-{tag}" self.xlsx_path = xlsx_path # The workbook tags are used while reading the workbook self.encoded_tags = False self.input_tags = {'points': {}, 'lines': {}, 'rectangles': {}} self._build_base() self._check_for_any_not_allowed_lines() self._check_for_any_not_allowed_rectangles() if retag: new_tags = self.retag() self.input_tags = { name: {input_tag: new_tags[name][tag] for input_tag, tag in tags.items()} for name, tags in self.input_tags.items() } else: self._check_tag_ranges() def __str__(self) -> str: """ Return a string representation of the CustomGeometry. Returns ------- str String representation of the CustomGeometry. """ return self.__str @staticmethod def _check_tags(tags: List[int], name: str) -> None: """Check that the tags read from the workbook are unique. Parameters ---------- tags : List[int] Tags read from the workbook. name : str Name of the objects, used in the error message. Raises ------ ValueError If the tags are not unique. """ if len(set(tags)) != len(tags): raise ValueError(f"Duplicate {name} tags in the workbook.") def _check_tag_ranges(self) -> None: """Check that the kept workbook tags can be used in the model. Raises ------ ValueError If any point, line or rectangle tag is not in ``[0, 10**7)``, which is required by the node and sub-object tag encoding (see :mod:`simdesign.utils.tags`). """ for name, tags in (('point', self.point_tags), ('line', self.line_tags), ('rectangle', self.rectangle_tags)): invalid = [tag for tag in tags if not 0 <= tag < TYPE_UNIT] if invalid: raise ValueError( f"{name.capitalize()} tags must be in [0, {TYPE_UNIT}); " f"invalid tags: {invalid}.") def _find_input_point(self, input_tag: int) -> Point: """Find a point by its workbook tag. Parameters ---------- input_tag : int Tag of the point in the workbook. Returns ------- Point The point with the specified workbook tag. Raises ------ ValueError If there is no point with the specified tag in the workbook. """ try: tag = self.input_tags['points'][int(input_tag)] except (KeyError, TypeError, ValueError): raise ValueError( f"Point {input_tag} is not defined in the workbook.") from None return self.find_point_by_tag(tag) def _initialise_points(self) -> None: """ Read point data from the Excel workbook and populate ``self.points``. Reads the ``POINTS_SHEET`` worksheet, derives grid indices from the sorted unique coordinate values, and creates a :class:`~simdesign.utils.mesh.Point` for each row. Sets ``self.system_grid_data`` once all points are built. """ # Get the data from excel sheet containing points tags and coordinates df = pd.read_excel(self.xlsx_path, sheet_name=self.POINTS_SHEET) self._check_tags(df['tag'].astype(int).tolist(), 'point') # Set the grid coordinates (sorted, as in SystemGridData) unique_xs = np.unique(df['x-coord'].astype(float)).tolist() unique_ys = np.unique(df['y-coord'].astype(float)).tolist() unique_zs = np.unique(df['z-coord'].astype(float)).tolist() # Start creating points for _, row in df.iterrows(): coords = [float(row['x-coord']), float(row['y-coord']), float(row['z-coord'])] i = unique_xs.index(coords[0]) j = unique_ys.index(coords[1]) k = unique_zs.index(coords[2]) tag = int(row['tag']) if self.find_point_by_coordinates(coords): raise ValueError( f"Point {tag} has the same coordinates as another point " "in the workbook.") self.input_tags['points'][tag] = tag grid = [float(i), float(j), float(k)] point = Point(grid, coords, tag) self.points.append(point) # Set the system grid data self.system_grid_data = SystemGridData(self.points) def _initialise_lines(self) -> None: """ Read line connectivity from the Excel workbook and populate frame lines. Reads the ``LINES_SHEET`` worksheet and calls :meth:`add_new_line` for each row, assigning the component type and section rotation angle. """ # Get the data from excel sheet containing lines connectivity df = pd.read_excel(self.xlsx_path, sheet_name=self.LINES_SHEET) self._check_tags(df['tag'].astype(int).tolist(), 'line') df = df.where(pd.notna(df), None) # Start creating lines for _, row in df.iterrows(): tag = int(row['tag']) point1 = self._find_input_point(row['point-1']) point2 = self._find_input_point(row['point-2']) if self.find_line_by_points([point1, point2]): raise ValueError( f"Line {tag} overlaps another line in the workbook.") self.input_tags['lines'][tag] = tag rot_angle = float(row['sec-rotation']) component = row['component'] if component: component = str(component).lower().capitalize() self.add_new_line([point1, point2], tag, component, rot_angle) def _initialise_rectangles(self) -> None: """ Read rectangle data from the Excel workbook and populate floor slabs. Reads the ``RECTANGLES_SHEET`` worksheet and calls :meth:`add_new_rectangle` for each row, assigning the component type and infill strength. """ # Dataframe containing rectangles of floor slabs df = pd.read_excel(self.xlsx_path, sheet_name=self.RECTANGLES_SHEET) self._check_tags(df['tag'].astype(int).tolist(), 'rectangle') df = df.where(pd.notna(df), None) # Go through rectangle shapes for _, row in df.iterrows(): # Find points on the rectangle tag = int(row['tag']) points = [self._find_input_point(row[f'point-{n}']) for n in range(1, 5)] if self.find_rectangle_by_points(points): raise ValueError( f"Rectangle {tag} overlaps another rectangle in the " "workbook.") self.input_tags['rectangles'][tag] = tag # Component type component = row['component'] if component: component = str(component).lower().capitalize() # infill strength - type strength = row['infill-type'] if strength: strength = str(strength).lower().capitalize() # Add new rectangle self.add_new_rectangle(points, tag, component, strength)