diff --git a/docs/cdb/test_setup.rst b/docs/cdb/test_setup.rst index 1aac544..10c9ae1 100644 --- a/docs/cdb/test_setup.rst +++ b/docs/cdb/test_setup.rst @@ -91,4 +91,5 @@ the temporary environment variable approach, open an MSYS2 MINGW64 shell, naviga tests/cable_load tests/cable_result tests/spring_data + tests/spring_result tests/truss_data diff --git a/docs/cdb/tests/spring_result.rst b/docs/cdb/tests/spring_result.rst new file mode 100644 index 0000000..f4e1fb7 --- /dev/null +++ b/docs/cdb/tests/spring_result.rst @@ -0,0 +1,43 @@ +SpringResult +------------ + +Related test suite: ``test_spring_result.py`` + +Expected CDB file name: ``SPRING_RESULT.cdb`` + +Runs with: SOFiSTiK 2025 + +Version: 1 + +.. code-block:: text + + +PROG SOFIMSHA + HEAD GEOMETRY REV-1-SOF-2025 + SYST 3D GDIR NEGZ GDIV 10 + + NODE NO 01 X 0.0 Y 0.0 Z 0.0 FIX F + NODE NO 12 X 1.0 Y 0.0 Z 0.0 FIX F + NODE NO 09 X 0.0 Y 1.0 Z 0.0 FIX MM + + GRP 10 TITL 'SPRING 1' + SPRI NO 2 NA 01 NE 09 CP 1.0E2 CT 0.0 CM 0.0 + GRP 11 TITL 'SPRING 2' + SPRI NO 3 NA 09 NE 12 CP 0.0 CT 1.0E2 CM 1.0E5 + END + + +PROG SOFILOAD + HEAD LOADS + LC 10 TITL 'LOAD PXX' + NODE 9 TYPE PXX +1.0 + LC 11 TITL 'LOAD PYY' + NODE 9 TYPE PYY +10.0 + END + + +PROG ASE + HEAD LINEAR + SYST PROB LINE + + LC 1000 DLZ 0.0 + LCC 10 FACT 1.0 + LCC 11 FACT 1.0 + END diff --git a/src/py_sofistik_utils/cdb_reader/_internal/spring_results.py b/src/py_sofistik_utils/cdb_reader/_internal/spring_results.py index 0d34591..ca122e3 100644 --- a/src/py_sofistik_utils/cdb_reader/_internal/spring_results.py +++ b/src/py_sofistik_utils/cdb_reader/_internal/spring_results.py @@ -2,172 +2,241 @@ from ctypes import byref, c_int, sizeof # third party library imports -from numpy import array, float64 -from numpy.typing import NDArray +from pandas import concat, DataFrame # local library specific imports +from . group_data import _GroupData from . sofistik_dll import SofDll from . sofistik_classes import CSPRI_RES class _SpringResults: - """The ``_SpringResults`` class provides methods and data structure to: + """This class provides methods and a data structure to: - * access and load the keys ``170/LC`` of the CDB file; - * store these data in a convenient format; - * provide access to these data. + * access keys ``170/LC`` 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: + + * ``LOAD_CASE`` load case number + * ``GROUP`` element group + * ``ELEM_ID`` element number + * ``FORCE`` axial force + * ``TRANSVERSAL_FORCE``: transversal force + * ``MOMENT``: axial moment + * ``DISPLACEMENT``: axial displacement + * ``TRANSVERSAL_DISPLACEMENT``: transversal displacement + * ``ROTATION``: axial rotation + + The ``DataFrame`` uses a MultiIndex with levels ``ELEM_ID`` and ``LOAD_CASE`` + (in this specific order) to enable fast lookups via the `get` method. The + index columns are not dropped from the ``DataFrame``. + + .. note:: + + Not all available quantities are retrieved and stored. In particular: + + * the three components along the global X, Y and Z axes for: + - spring force + - spring displacement + * nonlinear effects + * all quantities available if a workload has beed defined + + are currently not included. 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 ``SpringResults`` class. - """ + self._data = DataFrame( + columns = [ + "LOAD_CASE", + "GROUP", + "ELEM_ID", + "FORCE", + "TRANSVERSAL_FORCE", + "MOMENT", + "DISPLACEMENT", + "TRANSVERSAL_DISPLACEMENT", + "ROTATION" + ] + ) self._dll = dll - + self._echo_level = 0 self._loaded_lc: set[int] = set() - self._displacements: dict[int, dict[int, dict[int, NDArray[float64]]]] = {} - self._forces: dict[int, dict[int, NDArray[float64]]] = {} - self._moment: dict[int, dict[int, dict[int, dict[int, float]]]] = {} - self._rotation: dict[int, dict[int, dict[int, float]]] = {} - def clear(self, load_case: int) -> None: - """Clear the results for for the given load cases and all the springs. + """Clear the loaded data for the given ``load_case`` number. """ if load_case not in self._loaded_lc: - raise RuntimeError(f"No displacements loaded for load case {load_case}!") - - self._displacements[load_case].clear() - self._forces[load_case].clear() - self._moment[load_case].clear() - self._rotation[load_case].clear() + return + self._data = self._data[ + self._data.index.get_level_values("LOAD_CASE") != load_case + ] self._loaded_lc.remove(load_case) def clear_all(self) -> None: - """Clear the results for all the load cases and all the springs. + """Clear the loaded data for all the load cases. """ - self._displacements.clear() - self._forces.clear() - self._moment.clear() - self._rotation.clear() - + self._data = self._data[0:0] self._loaded_lc.clear() - def load(self, load_case: int, grp_divisor: int = 10000) -> None: - """Load the results for the given ``load_case``. - """ - if self._dll.key_exist(170, load_case): - spring = CSPRI_RES() - record_length = c_int(sizeof(spring)) - return_value = c_int(0) - - if load_case not in self._loaded_lc: - self._displacements[load_case] = {} - self._forces[load_case] = {} - self._moment[load_case] = {} - self._rotation[load_case] = {} - - else: - self.clear(load_case) - - count = 0 - while return_value.value < 2: - return_value.value = self._dll.get( - 1, - 170, - load_case, - byref(spring), - byref(record_length), - 0 if count == 0 else 1 - ) - - spring_nmb: int = spring.m_nr - if spring_nmb == 0: - record_length = c_int(sizeof(spring)) - count += 1 - continue - - grp_nmp = spring.m_nr // grp_divisor - - if grp_nmp not in self._displacements[load_case]: - self._displacements[load_case].update({grp_nmp: {}}) - self._forces[load_case].update({grp_nmp: {}}) - self._moment[load_case].update({grp_nmp: {}}) - self._rotation[load_case].update({grp_nmp: {}}) - - self._displacements[load_case][grp_nmp].update( - {spring_nmb: array([spring.m_v, - spring.m_vt, - spring.m_vtx, - spring.m_vty, - spring.m_vtz], dtype = float64)}) - - self._rotation[load_case][grp_nmp].update({spring_nmb: spring.m_phi}) - - self._forces[load_case].update( - {spring_nmb: array( - [spring.m_p, - spring.m_pt, - spring.m_ptx, - spring.m_pty, - spring.m_ptz], dtype = float64)}) - - self._moment[load_case].update({spring_nmb: spring.m_m}) - - record_length = c_int(sizeof(spring)) - count += 1 + def data(self, deep: bool = True) -> DataFrame: + """Return the :class:`pandas.DataFrame` containing the loaded keys ``170/LC``. - self._loaded_lc.add(load_case) + Parameters + ---------- + 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). + """ + return self._data.copy(deep=deep) - def get_element_displacements( + def get( self, + element_id: int, load_case: int, - spring_nmb: int - ) -> NDArray[float64]: - """Return the spring displacements for the given ``load_case``. + quantity: str = "FORCE", + default: float | None = None + ) -> float: + """Retrieve the requested cable result. Parameters ---------- - ``load_case``: int + element_id : int + Cable element number + load_case : int Load case number - ``spring_nmb``: int - Spring number + quantity : str, default "FORCE" + Quantity to retrieve. Must be one of: + + - ``FORCE`` + - ``TRANSVERSAL_FORCE`` + - ``MOMENT`` + - ``DISPLACEMENT`` + - ``TRANSVERSAL_DISPLACEMENT`` + - ``ROTATION`` + + default : float or None, default None + Value to return if the requested quantity is not found + + Returns + ------- + value : float + The requested value if found. If not found, returns ``default`` when it is not + None. Raises ------ - RuntimeError - If the given ``load_case`` or ``spring_nmb`` are not found. + LookupError + If the requested result is not found and ``default`` is None. """ - if load_case not in self._loaded_lc: - raise RuntimeError(f"Load case {load_case} not found!") + try: + return self._data.at[(element_id, load_case), quantity] # type: ignore + except (KeyError, ValueError) as e: + if default is not None: + return default + raise LookupError( + f"Spring result entry not found for element id {element_id}, " + f"load case {load_case}, and quantity {quantity}!" + ) from e + + def load(self, load_cases: int | list[int]) -> None: + """Retrieve spring results for the given ``load_cases``. If a load case is not + found, a warning is raised only if ``echo_level > 0``. + + Parameters + ---------- + load_cases : int | list[int] + load case numbers + """ + if isinstance(load_cases, int): + load_cases = [load_cases] + else: + load_cases = list(set(load_cases)) # remove duplicated entries + + # load data + temp_list: list[dict[str, float | int | str]] = [] + for load_case in load_cases: + if self._dll.key_exist(170, load_case): + self.clear(load_case) + temp_list.extend(self._load(load_case)) - for group_result in self._displacements[load_case].values(): - if spring_nmb in group_result: - return group_result[spring_nmb] + # assigning groups + group_data = _GroupData(self._dll) + group_data.load() - err_msg = f"Element number {spring_nmb} not found in load case {load_case}!" - raise RuntimeError(err_msg) + temp_df = DataFrame(temp_list).sort_values("ELEM_ID", kind="mergesort") + elem_ids = temp_df["ELEM_ID"] - def get_element_rotation( self, load_case: int, spring_nmb: int) -> float: - """Return the spring rotation for the given ``load_case``. + for grp, grp_range in group_data.iterator_spring(): + if grp_range.stop == 0: + continue + + 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 + + # set indices for fast lookup + temp_df = temp_df.set_index(["ELEM_ID", "LOAD_CASE"], drop=False) + + # merge data + if self._data.empty: + self._data = temp_df + else: + self._data = concat([self._data, temp_df]) + self._loaded_lc.update(load_cases) + + def set_echo_level(self, echo_level: int) -> None: + """Set the echo level. Parameters ---------- - ``load_case``: int - Load case number - ``spring_nmb``: int - Spring number - - Raises - ------ - RuntimeError - If the given ``load_case`` or ``spring_nmb`` are not found. + echo_level : int + the new echo level """ - if load_case not in self._loaded_lc: - raise RuntimeError(f"Load case {load_case} not found!") + self._echo_level = echo_level - for group_result in self._rotation[load_case].values(): - if spring_nmb in group_result: - return group_result[spring_nmb] + def _load(self, load_case: int) -> list[dict[str, float | int | str]]: + """Retrieve key ``170/load_case`` using SOFiSTiK dll. + """ + spri_res = CSPRI_RES() + record_length = c_int(sizeof(spri_res)) + return_value = c_int(0) + + data: list[dict[str, float | int | str]] = [] + first_call = True + while return_value.value < 2: + return_value.value = self._dll.get( + 1, + 170, + load_case, + byref(spri_res), + byref(record_length), + 0 if first_call else 1 + ) + + record_length = c_int(sizeof(spri_res)) + first_call = False + if return_value.value >= 2: + break + + if spri_res.m_nr > 0: + data.append( + { + "LOAD_CASE": load_case, + "GROUP": 0, + "ELEM_ID": spri_res.m_nr, + "FORCE": spri_res.m_p, + "TRANSVERSAL_FORCE": spri_res.m_pt, + "MOMENT": spri_res.m_m, + "DISPLACEMENT": spri_res.m_v, + "TRANSVERSAL_DISPLACEMENT": spri_res.m_vt, + "ROTATION": spri_res.m_phi + } + ) - err_msg = f"Spring number {spring_nmb} not found in load case {load_case}!" - raise RuntimeError(err_msg) + return data diff --git a/tests/cdb_reader/test_spring_result.py b/tests/cdb_reader/test_spring_result.py new file mode 100644 index 0000000..f22ccc2 --- /dev/null +++ b/tests/cdb_reader/test_spring_result.py @@ -0,0 +1,136 @@ +# 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") + + +_COLUMNS = [ + "LOAD_CASE", + "GROUP", + "ELEM_ID", + "FORCE", + "TRANSVERSAL_FORCE", + "MOMENT", + "DISPLACEMENT", + "TRANSVERSAL_DISPLACEMENT", + "ROTATION", +] + +_DATA = [ + (1000, 10, 102, 9.0, 0.0, 1.0000000031710769e-29, 0.09000000357627869, 0.07000000029802322, 0.0), + (1000, 11, 113, 1.0000000031710769e-29, 1.4142135381698608, 1.0000000031710769e-29, 0.11313708126544952, 0.01414213515818119, 0.0), +] + + +@skipUnless(all([CDB_PATH, DLL_PATH, VERSION]), "SOFiSTiK environment variables not set!") +class SOFiSTiKCDBReaderSpringResultTestSuite(TestCase): + """Tests for the `SOFiSTiKCDBReader`, `_SpringResult` module. + """ + def setUp(self) -> None: + self.expected_data = DataFrame( + _DATA, columns=_COLUMNS).set_index(["ELEM_ID", "LOAD_CASE"], drop=False) + self.load_cases = [1000] + + self.cdb = SOFiSTiKCDBReader(CDB_PATH, "SPRING_RESULT", DLL_PATH, int(VERSION)) # type: ignore + self.cdb.initialize() + self.cdb.spring_res.load(self.load_cases) + + 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 rtol 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_res.data(), rtol=1E-7) + + def test_get(self) -> None: + """Test for the `get` method. + """ + with self.subTest(msg="Axial force"): + self.assertEqual(self.cdb.spring_res.get(102, 1000, "FORCE"), 9) + + with self.subTest(msg="Transversal force"): + self.assertEqual(self.cdb.spring_res.get(102, 1000, "TRANSVERSAL_FORCE"), 0) + + with self.subTest(msg="Moment"): + self.assertEqual( + self.cdb.spring_res.get(113, 1000, "MOMENT"), + 1.0000000031710769e-29 + ) + + with self.subTest(msg="Displacement"): + self.assertEqual( + self.cdb.spring_res.get(102, 1000, "DISPLACEMENT"), + 0.09000000357627869 + ) + + with self.subTest(msg="Transversal displacement"): + self.assertEqual( + self.cdb.spring_res.get(102, 1000, "TRANSVERSAL_DISPLACEMENT"), + 0.07000000029802322 + ) + + with self.subTest(msg="Rotation"): + self.assertEqual(self.cdb.spring_res.get(113, 1000, "ROTATION"), 0) + + with self.subTest(msg="Non existing entry without default"): + with self.assertRaises(LookupError): + self.cdb.spring_res.get(102, 1000, "NON-EXISTING") + + with self.subTest(msg="Non existing entry with default"): + self.assertEqual(self.cdb.spring_res.get(102, 1000, "NON-EXISTING", 5), 5) + + def test_get_after_clear(self) -> None: + """Test for the `get` method after a `clear` call. + """ + self.cdb.spring_res.clear(1000) + with self.subTest(msg="Check clear method"): + with self.assertRaises(LookupError): + self.cdb.spring_res.get(113, 1000, "MOMENT") + + self.cdb.spring_res.load(1000) + with self.subTest(msg="Check indexes management"): + self.assertEqual( + self.cdb.spring_res.get(113, 1000, "MOMENT"), + 1.0000000031710769e-29 + ) + + def test_get_after_clear_all(self) -> None: + """Test for the `get` method after a `clear_all` call. + """ + self.cdb.spring_res.clear_all() + with self.subTest(msg="Check clear_all method"): + with self.assertRaises(LookupError): + self.cdb.spring_res.get(113, 1000, "MOMENT") + + self.cdb.spring_res.load(self.load_cases) + with self.subTest(msg="Check indexes management"): + self.assertEqual( + self.cdb.spring_res.get(113, 1000, "MOMENT"), + 1.0000000031710769e-29 + ) + + def test_load_with_duplicated_load_cases(self) -> None: + """Test for the `load` method with duplicated entries. + """ + self.cdb.spring_res.clear_all() + self.cdb.spring_res.load(self.load_cases + [1000]) + self.assertEqual( + self.cdb.spring_res.get(113, 1000, "MOMENT"), + 1.0000000031710769e-29 + )