diff --git a/docs/cdb/test_setup.rst b/docs/cdb/test_setup.rst index 09a6b81..1aac544 100644 --- a/docs/cdb/test_setup.rst +++ b/docs/cdb/test_setup.rst @@ -90,4 +90,5 @@ the temporary environment variable approach, open an MSYS2 MINGW64 shell, naviga tests/cable_data tests/cable_load tests/cable_result + tests/spring_data tests/truss_data diff --git a/docs/cdb/tests/spring_data.rst b/docs/cdb/tests/spring_data.rst new file mode 100644 index 0000000..7692500 --- /dev/null +++ b/docs/cdb/tests/spring_data.rst @@ -0,0 +1,25 @@ +SpringData +---------- + +Related test suite: ``test_spring_data.py`` + +Expected CDB file name: ``SPRING_DATA.cdb`` + +Runs with: SOFiSTiK 2025 + +Version: 1 + +.. code-block:: text + + +PROG SOFIMSHA + HEAD GEOMETRY REV-1-SOF-2025 + SYST 3D GDIR NEGZ GDIV 100 + NODE NO 01 X 00.0 Y 0.0 Z +0.0 + NODE NO 02 X 05.0 Y 0.0 Z -0.5 + NODE NO 03 X 10.0 Y 0.0 Z -1.0 + + GRP 10 TITL 'GRP 1' + SPRI NO 1 NA 1 NE 2 CP 1.0 CT 2.5 + GRP 20 TITL 'GRP 2' + SPRI NO 20 NA 2 DX 0.2 DY 0.3 DZ 1.0 CM 1.5 + END diff --git a/src/py_sofistik_utils/cdb_reader/_internal/spring_data.py b/src/py_sofistik_utils/cdb_reader/_internal/spring_data.py index 3288b9a..fe9cbb7 100644 --- a/src/py_sofistik_utils/cdb_reader/_internal/spring_data.py +++ b/src/py_sofistik_utils/cdb_reader/_internal/spring_data.py @@ -2,192 +2,222 @@ from ctypes import byref, c_int, sizeof # third party library imports +from pandas import concat, DataFrame # local library specific imports -from . sofistik_dll import SofDll +from . group_data import _GroupData from . sofistik_classes import CSPRI +from . sofistik_dll import SofDll class _SpringData: - """The ``_SpringData`` class provides methods and data structure to: - - * access and load the key ``170/00`` of the CDB file; - * store these data in a convenient format; - * provide access to these data. + """This class provides methods and a data structure to: + + * access keys ``170/00`` of the CDB file; + * store the retrieved data in a convenient format; + * provide access to the data after the CDB is closed. + + The underlying data structure is a :class:`pandas.DataFrame` with the following + columns: + + * ``GROUP`` element group + * ``ELEM_ID`` element number + * ``N1`` id of the first node + * ``N2``: id of the second node + * ``CP``: axial stiffness + * ``CT``: lateral stiffness + * ``CM``: rotational stiffness + + The ``DataFrame`` uses a MultiIndex with level ``ELEM_ID`` to enable fast lookups + via the `get` method. The index column is not dropped from the ``DataFrame``. + + .. note:: + + Not all available quantities are retrieved and stored. In particular: + + * material or work law number + * normal direction + * reference area + * prestress + * slip + * maximum tension force + * yielding load + * reference axis + * friction coefficient + * cohesion coefficient + * dilatancy factor + * transversal slip + + are currently not included, together with quantities for coupled damping + elements. + + This is a deliberate design choice and may be changed in the future without + breaking the existing API. """ def __init__(self, dll: SofDll) -> None: - """The initializer of the ``_SpringData`` class. - """ + self._data: DataFrame = DataFrame( + columns = [ + "GROUP", + "ELEM_ID", + "N1", + "N2", + "CP", + "CT", + "CM" + ] + ) self._dll = dll - - self._axial_stiffness: dict[int, dict[int, float]] = {} - self._connectivity: dict[int, dict[int, list[int]]] = {} - self._lateral_stiffness: dict[int, dict[int, float]] = {} - self._rotational_stiffness: dict[int, dict[int, float]] = {} + self._echo_level = 0 def clear(self) -> None: - """Clear all the spring data for all the spring elements and groups. - """ - self._axial_stiffness.clear() - self._connectivity.clear() - self._lateral_stiffness.clear() - self._rotational_stiffness.clear() - - def load(self, group_divisor: int = 10000) -> None: - """Load the spring data. + """Clear all the loaded data. """ - if self._dll.key_exist(170, 0): - spring = CSPRI() - rec_length = c_int(sizeof(spring)) - return_value = 0 - - self.clear() - - count = 0 - while return_value < 2: - return_value = self._dll.get( - 1, - 170, - 0, - byref(spring), - byref(rec_length), - 0 if count == 0 else 1 - ) - - spring_nmb: int = spring.m_nr - grp_nmb = spring_nmb // group_divisor - - if grp_nmb not in self._connectivity: - self._axial_stiffness[grp_nmb] = {} - self._connectivity[grp_nmb] = {} - self._lateral_stiffness[grp_nmb] = {} - self._rotational_stiffness[grp_nmb] = {} - - self._axial_stiffness[grp_nmb].update({spring_nmb: spring.m_cp}) - self._connectivity[grp_nmb].update({spring_nmb: list(spring.m_node)}) - self._lateral_stiffness[grp_nmb].update({spring_nmb: spring.m_cq}) - self._rotational_stiffness[grp_nmb].update({spring_nmb: spring.m_cm}) + self._data = self._data[0:0] - rec_length = c_int(sizeof(spring)) - count += 1 - - def get_element_connectivity(self, spring_nmb: int) -> list[int]: - """Return the connectivity for the given ``spring_nmb``. + def data(self, deep: bool = True) -> DataFrame: + """Return the :class:`pandas.DataFrame` containing the loaded key ``170/00``. Parameters ---------- - ``spring_nmb``: int - The sprig element number - - Raises - ------ - RuntimeError - If the given ``spring_nmb`` is not found. + deep : bool, default True + When ``deep=True``, a new object will be created with a copy of the calling + object's data and indices. Modifications to the data or indices of the + copy will not be reflected in the original object (refer to + :meth:`pandas.DataFrame.copy` documentation for details). """ - for group_data in self._connectivity.values(): - if spring_nmb in group_data: - return group_data[spring_nmb] + return self._data.copy(deep=deep) - raise RuntimeError(f"Element number {spring_nmb} not found!") - - def get_element_axial_stiffness(self, spring_nmb: int) -> float: - """Return the spring axial stiffness for the given ``spring_nmb``. + def get( + self, + element_id: int, + quantity: str = "CP", + default: float | int | None = None + ) -> float | int: + """Retrieve the requested spring quantity. Parameters ---------- - ``spring_nmb``: int - The sprig element number + element_id : int + Spring element number + quantity : str, default "CP" + Quantity to retrieve. Must be one of: + + - ``"N1"`` + - ``"N2"`` + - ``"CP"`` + - ``"CT"`` + - ``"CM"`` + + default : float or int or None, default None + Value to return if the requested quantity is not found + + Returns + ------- + value : float or int + The requested quantity if found. Otherwise, returns ``default`` when it is not + None. Raises ------ - RuntimeError - If the given ``spring_nmb`` is not found. + LookupError + If the requested quantity is not found and ``default`` is None. """ - for group_data in self._axial_stiffness.values(): - if spring_nmb in group_data: - return group_data[spring_nmb] - - raise RuntimeError(f"Element number {spring_nmb} not found!") - - def get_element_lateral_stiffness(self, spring_nmb: int) -> float: - """Return the spring lateral stiffness for the given ``spring_nmb``. + try: + return self._data.at[element_id, quantity] # type: ignore + except (KeyError, ValueError) as e: + if default is not None: + return default + raise LookupError( + f"Spring data entry not found for element id {element_id}, " + f"and quantity {quantity}!" + ) from e + + def has_stiffness(self, element_id: int, component: str = "CP") -> bool: + """Return whether the specified stiffness component of a spring element is + non-zero. Parameters ---------- - ``spring_nmb``: int - The sprig element number - - Raises - ------ - RuntimeError - If the given ``spring_nmb`` is not found. + element_id : int + Spring element number + component : str, default "CP" + Stiffness component to test. Must be one of: + + - ``"CP"`` + - ``"CT"`` + - ``"CM"`` + + Returns + ------- + bool + True if the requested stiffness component exists and is non-zero. False if the + component is zero or the element is not found. """ - for group_data in self._lateral_stiffness.values(): - if spring_nmb in group_data: - return group_data[spring_nmb] - - raise RuntimeError(f"Element number {spring_nmb} not found!") - - def get_element_rotational_stiffness(self, spring_nmb: int) -> float: - """Return the spring rotational stiffness for the given ``spring_nmb``. - - Parameters - ---------- - ``spring_nmb``: int - The sprig element number - - Raises - ------ - RuntimeError - If the given ``spring_nmb`` is not found. + try: + return self._data.at[element_id, component] != 0.0 + except (KeyError, ValueError): + return False + + def load(self) -> None: + """Retrieve all spring data. If the key does not exist or it is empty, a warning + is raised only if ``echo_level > 0``. """ - for group_data in self._rotational_stiffness.values(): - if spring_nmb in group_data: - return group_data[spring_nmb] - - raise RuntimeError(f"Element number {spring_nmb} not found!") + if self._dll.key_exist(170, 0): + spring = CSPRI() + record_length = c_int(sizeof(spring)) + return_value = c_int(0) - def has_axial_stiffness(self, spring_nmb: int) -> bool: - """Return `True` if the spring has an axial stiffness `!= 0`. + self.clear() - Parameters - ---------- - ``spring_nmb``: int - The spring number + data: list[dict[str, float | int]] = [] + first_call = True + while return_value.value < 2: + return_value.value = self._dll.get( + 1, + 170, + 0, + byref(spring), + byref(record_length), + 0 if first_call else 1 + ) - Raises - ------ - RuntimeError - If the given ``spring_nmb`` is not found. - """ - return self.get_element_axial_stiffness(spring_nmb) != 0.0 + record_length = c_int(sizeof(spring)) + first_call = False + if return_value.value >= 2: + break + + data.append( + { + "GROUP": 0, + "ELEM_ID": spring.m_nr, + "N1": spring.m_node[0], + "N2": spring.m_node[1], + "CP": spring.m_cp, + "CT": spring.m_cq, + "CM": spring.m_cm + } + ) - def has_lateral_stiffness(self, spring_nmb: int) -> bool: - """Return `True` if the spring has a lateral stiffness `!= 0`. + # assigning groups + group_data = _GroupData(self._dll) + group_data.load() - Parameters - ---------- - ``spring_nmb``: int - The spring number + temp_df = DataFrame(data).sort_values("ELEM_ID", kind="mergesort") + elem_ids = temp_df["ELEM_ID"] - Raises - ------ - RuntimeError - If the given ``spring_nmb`` is not found. - """ - return self.get_element_lateral_stiffness(spring_nmb) != 0.0 + for grp, grp_range in group_data.iterator_spring(): + if grp_range.stop == 0: + continue - def has_rotational_stiffness(self, spring_nmb: int) -> bool: - """Return `True` if the spring has a rotational stiffness != 0. + left = elem_ids.searchsorted(grp_range.start, side="left") + right = elem_ids.searchsorted(grp_range.stop - 1, side="right") + temp_df.loc[temp_df.index[left:right], "GROUP"] = grp - Parameters - ---------- - ``spring_nmb``: int - The spring number + # set indices for fast lookup + temp_df = temp_df.set_index(["ELEM_ID"], drop=False) - Raises - ------ - RuntimeError - If the given ``spring_nmb`` is not found. - """ - return self.get_element_rotational_stiffness(spring_nmb) != 0.0 + # merge data + if self._data.empty: + self._data = temp_df + else: + self._data = concat([self._data, temp_df]) diff --git a/tests/cdb_reader/test_spring_data.py b/tests/cdb_reader/test_spring_data.py new file mode 100644 index 0000000..43a5780 --- /dev/null +++ b/tests/cdb_reader/test_spring_data.py @@ -0,0 +1,95 @@ +# standard library imports +from os import environ +from unittest import skipUnless, TestCase + +# third party library imports +from pandas import DataFrame +from pandas.testing import assert_frame_equal + +# local library specific imports +from py_sofistik_utils import SOFiSTiKCDBReader + + +CDB_PATH = environ.get("SOFISTIK_CDB_PATH") +DLL_PATH = environ.get("SOFISTIK_DLL_PATH") +VERSION = environ.get("SOFISTIK_VERSION") + + +@skipUnless(all([CDB_PATH, DLL_PATH, VERSION]), "SOFiSTiK environment variables not set!") +class SOFiSTiKCDBReaderTrussDataTestSuite(TestCase): + """Tests for the `SOFiSTiKCDBReader`, `_SpringData` module. + """ + def setUp(self) -> None: + self.expected_data = DataFrame( + { + "GROUP": [10, 20], + "ELEM_ID": [1001, 2020], + "N1": [1, 2], + "N2": [2, 0], + "CP": [1.0, 0.0], + "CT": [2.5, 0.0], + "CM": [0.0, 1.5], + } + ).set_index("ELEM_ID", drop=False) + + self.cdb = SOFiSTiKCDBReader(CDB_PATH, "SPRING_DATA", DLL_PATH, int(VERSION)) # type: ignore + self.cdb.initialize() + self.cdb.spring_data.load() + + def tearDown(self) -> None: + self.cdb.close() + + def test_data(self) -> None: + """Test for the `data` method. + """ + # NOTE: + # Float values loaded from the CDB contain inherent numerical noise. The chosen + # tolerance is stricter than pandas default and reflects the maximum relative + # error observed in practice, ensuring stable and reproducible comparisons. + assert_frame_equal(self.expected_data, self.cdb.spring_data.data(), rtol=1E-7) + + def test_get(self) -> None: + """Test for the `get` method. + """ + with self.subTest(msg="First node id"): + self.assertEqual(self.cdb.spring_data.get(1001, "N1"), 1) + + with self.subTest(msg="Second node id"): + self.assertEqual(self.cdb.spring_data.get(2020, "N2"), 0) + + with self.subTest(msg="CP"): + self.assertEqual(self.cdb.spring_data.get(1001, "CP"), 1.0) + + with self.subTest(msg="CT"): + self.assertEqual(self.cdb.spring_data.get(1001, "CT"), 2.5) + + with self.subTest(msg="CM"): + self.assertEqual(self.cdb.spring_data.get(2020, "CM"), 1.5) + + with self.subTest(msg="Non existing entry without default"): + with self.assertRaises(LookupError): + self.cdb.spring_data.get(505, "N3") + + with self.subTest(msg="Non existing entry with default"): + self.assertEqual(self.cdb.spring_data.get(2021, "CM", 9), 9) + + def test_get_after_clear(self) -> None: + """Test for the `get` method after a `clear` call. + """ + self.cdb.spring_data.clear() + with self.subTest(msg="Check clear method"): + with self.assertRaises(LookupError): + self.cdb.spring_data.get(1001, "CM") + + self.cdb.spring_data.load() + with self.subTest(msg="Check indexes management"): + self.test_get() + + def test_has_stiffness(self) -> None: + """Test for the `has_stiffness` method. + """ + with self.subTest(msg="Positive check"): + self.assertTrue(self.cdb.spring_data.has_stiffness(1001, "CP")) + + with self.subTest(msg="Positive check"): + self.assertFalse(self.cdb.spring_data.has_stiffness(1001, "CM"))