From 45ac337f3fdf0d655f7d12ee9fd753f0423c9737 Mon Sep 17 00:00:00 2001 From: StudioWEngineers Date: Mon, 16 Feb 2026 15:35:16 +0100 Subject: [PATCH 01/10] update __init__ --- src/py_sofistik_utils/cdb_reader/_internal/truss_results.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py b/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py index 2444762..78ef37b 100644 --- a/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py +++ b/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py @@ -19,15 +19,13 @@ class _TrussResult: * provide access to these data. """ def __init__(self, dll: SofDll) -> None: - """The initializer of the ``_TrussResult`` class. - """ self._data = DataFrame( - columns = [ + columns=[ "LOAD_CASE", "GROUP", "ELEM_ID", "AXIAL_FORCE", - "AXIAL_DISP" + "AXIAL_DISPLACEMENT" ] ) self._dll = dll From e82ca592ae43af6c600695aca7f123ee70d3e723 Mon Sep 17 00:00:00 2001 From: StudioWEngineers Date: Mon, 16 Feb 2026 15:36:25 +0100 Subject: [PATCH 02/10] update clear and clear_all --- .../cdb_reader/_internal/truss_results.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py b/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py index 78ef37b..cdd843d 100644 --- a/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py +++ b/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py @@ -33,16 +33,18 @@ def __init__(self, dll: SofDll) -> None: self._loaded_lc: set[int] = set() def clear(self, load_case: int) -> None: - """Clear the results for the given ``load_case`` number. + """Clear the loaded data for the given ``load_case`` number. """ if load_case not in self._loaded_lc: return - self._data = self._data.loc[~(self._data["LOAD_CASE"] == load_case), :] + 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 all group data. + """Clear the loaded data for all the load cases. """ self._data = self._data[0:0] self._loaded_lc.clear() From 43d1723ee4b0b5646190b5528e54234354a4bbd7 Mon Sep 17 00:00:00 2001 From: StudioWEngineers Date: Mon, 16 Feb 2026 15:37:24 +0100 Subject: [PATCH 03/10] add data method --- .../cdb_reader/_internal/truss_results.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py b/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py index cdd843d..7ae1c42 100644 --- a/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py +++ b/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py @@ -49,6 +49,20 @@ def clear_all(self) -> None: self._data = self._data[0:0] self._loaded_lc.clear() + def data(self, deep: bool = True) -> DataFrame: + """Return the :class:`pandas.DataFrame` containing the loaded keys + ``152/LC``. + + 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_force(self, element_number: int, load_case: int) -> float: """Return the cable connectivity for the given ``element_number``. From e8146928f9acde49f849f2cc6b509586b868c0c8 Mon Sep 17 00:00:00 2001 From: StudioWEngineers Date: Mon, 16 Feb 2026 15:38:09 +0100 Subject: [PATCH 04/10] private methods at the end of the file --- .../cdb_reader/_internal/truss_results.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py b/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py index 7ae1c42..170dd0f 100644 --- a/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py +++ b/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py @@ -123,6 +123,11 @@ def load(self, load_cases: int | list[int]) -> None: for grp, truss_range in group_data.iterator_truss(): self._data.loc[self._data.ELEM_ID.isin(truss_range), "GROUP"] = grp + def set_echo_level(self, echo_level: int) -> None: + """Set the echo level. + """ + self._echo_level = echo_level + def _load(self, load_case: int) -> list[dict[str, Any]]: """ """ @@ -159,8 +164,3 @@ def _load(self, load_case: int) -> list[dict[str, Any]]: ) return data - - def set_echo_level(self, echo_level: int) -> None: - """Set the echo level. - """ - self._echo_level = echo_level From d990a023f726b39ed02d3f0107c98fee112ac07b Mon Sep 17 00:00:00 2001 From: StudioWEngineers Date: Mon, 16 Feb 2026 15:43:01 +0100 Subject: [PATCH 05/10] update get --- .../cdb_reader/_internal/truss_results.py | 53 +++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py b/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py index 170dd0f..9ec7962 100644 --- a/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py +++ b/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py @@ -63,29 +63,52 @@ def data(self, deep: bool = True) -> DataFrame: """ return self._data.copy(deep=deep) - def get_element_force(self, element_number: int, load_case: int) -> float: - """Return the cable connectivity for the given ``element_number``. + def get( + self, + element_id: int, + load_case: int, + quantity: str = "AXIAL_FORCE", + default: float | None = None + ) -> float: + """Retrieve the requested truss result. Parameters ---------- - ``element_number``: int - The cable element number - ``load_case``: int - The load case number + element_id : int + Truss element number + load_case : int + Load case number + quantity : str, default "AXIAL_FORCE" + Quantity to retrieve. Must be one of: + + - ``"AXIAL_FORCE"`` + - ``"AXIAL_DISPLACEMENT"`` + + 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 ------ LookupError - If the requested data is not found. + If the requested result is not found and ``default`` is None. """ - e_mask = self._data["ELEM_ID"] == element_number - lc_mask = self._data["LOAD_CASE"] == load_case - - if (e_mask & lc_mask).eq(False).all(): - err_msg = f"LC {load_case}, EL_ID {element_number} not found!" - raise LookupError(err_msg) - - return float(self._data.AXIAL_FORCE[e_mask & lc_mask].values[0]) + 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"Truss 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: """Load cable element loads for the given the ``load_cases``. From 51e433af5e8f6c4f3948a77d0fdd5ea17b92417b Mon Sep 17 00:00:00 2001 From: StudioWEngineers Date: Mon, 16 Feb 2026 20:55:40 +0100 Subject: [PATCH 06/10] update load --- .../cdb_reader/_internal/truss_results.py | 81 ++++++++++--------- 1 file changed, 45 insertions(+), 36 deletions(-) diff --git a/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py b/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py index 9ec7962..aea091d 100644 --- a/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py +++ b/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py @@ -1,6 +1,5 @@ # standard library imports from ctypes import byref, c_int, sizeof -from typing import Any # third party library imports from pandas import concat, DataFrame @@ -111,55 +110,65 @@ def get( ) from e def load(self, load_cases: int | list[int]) -> None: - """Load cable element loads for the given the ``load_cases``. - - If a load case is not found, a warning is raised only if ``echo_level`` is ``> 0``. + """Retrieve cable 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 + 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 = [] for load_case in load_cases: - if self._dll.key_exist(151, load_case): + if self._dll.key_exist(152, load_case): self.clear(load_case) + temp_list.extend(self._load(load_case)) - # load data - data = DataFrame(self._load(load_case)) + # assigning groups + group_data = _GroupData(self._dll) + group_data.load() - # merge data - if self._data.empty: - self._data = data - else: - self._data = concat([self._data, data], ignore_index=True) - self._loaded_lc.add(load_case) + temp_df = DataFrame(temp_list).sort_values("ELEM_ID", kind="mergesort") + elem_ids = temp_df["ELEM_ID"] - else: + for grp, grp_range in group_data.iterator_truss(): + if grp_range.stop == 0: continue - # assigning groups - group_data = _GroupData(self._dll) - group_data.load() + 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) - for grp, truss_range in group_data.iterator_truss(): - self._data.loc[self._data.ELEM_ID.isin(truss_range), "GROUP"] = grp + # 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. """ self._echo_level = echo_level - def _load(self, load_case: int) -> list[dict[str, Any]]: - """ + def _load(self, load_case: int) -> list[dict[str, float | int]]: + """Retrieve key ``162/load_case`` using SOFiSTiK dll. """ trus = CTRUS_RES() record_length = c_int(sizeof(trus)) return_value = c_int(0) - data: list[dict[str, Any]] = [] - count = 0 + data: list[dict[str, float | int]] = [] + first_call = True while return_value.value < 2: return_value.value = self._dll.get( 1, @@ -167,23 +176,23 @@ def _load(self, load_case: int) -> list[dict[str, Any]]: load_case, byref(trus), byref(record_length), - 0 if count == 0 else 1 + 0 if first_call else 1 ) record_length = c_int(sizeof(trus)) - count += 1 - + first_call = False if return_value.value >= 2: break - data.append( - { - "LOAD_CASE": load_case, - "GROUP": 0, - "ELEM_ID": trus.m_nr, - "AXIAL_FORCE": trus.m_n, - "AXIAL_DISP": trus.m_v - } - ) + if trus.m_nr != 0: + data.append( + { + "LOAD_CASE": load_case, + "GROUP": 0, + "ELEM_ID": trus.m_nr, + "AXIAL_FORCE": trus.m_n, + "AXIAL_DISPLACEMENT": trus.m_v + } + ) return data From 94e19636a1ccc830d95f9967c1b7b8243ebf2565 Mon Sep 17 00:00:00 2001 From: StudioWEngineers Date: Mon, 16 Feb 2026 20:55:57 +0100 Subject: [PATCH 07/10] add tests --- tests/cdb_reader/test_truss_result.py | 112 ++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/cdb_reader/test_truss_result.py diff --git a/tests/cdb_reader/test_truss_result.py b/tests/cdb_reader/test_truss_result.py new file mode 100644 index 0000000..0ef94e9 --- /dev/null +++ b/tests/cdb_reader/test_truss_result.py @@ -0,0 +1,112 @@ +# 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", + "AXIAL_FORCE", + "AXIAL_DISPLACEMENT" +] + +_DATA = [ + (1000, 2, 23, 150.0, 0.0012631341814994812), + (1000, 3, 31, 200.0, 0.0016841789474710822) +] + + +@skipUnless( + all([CDB_PATH, DLL_PATH, VERSION]), + "SOFiSTiK environment variables not set!" +) +class SOFiSTiKCDBReaderTrussResultTestSuite(TestCase): + def setUp(self) -> None: + self.cdb = SOFiSTiKCDBReader( + CDB_PATH, # type: ignore + "TRUSS_RESULT", + DLL_PATH, # type: ignore + int(VERSION) # type: ignore + ) + self.cdb.initialize() + self.cdb.truss_results.load(1000) + + def tearDown(self) -> None: + self.cdb.close() + + def test_data(self) -> None: + data = ( + DataFrame(_DATA, columns=_COLUMNS) + .set_index(["ELEM_ID", "LOAD_CASE"], drop=False) + ) + assert_frame_equal(data, self.cdb.truss_results.data(), rtol=1E-10) + + def test_get(self) -> None: + with self.subTest(msg="Axial force"): + self.assertEqual( + self.cdb.truss_results.get(23, 1000, "AXIAL_FORCE"), + 150 + ) + + with self.subTest(msg="Axial displacement"): + self.assertEqual( + self.cdb.truss_results.get(31, 1000, "AXIAL_DISPLACEMENT"), + 0.0016841789474710822 + ) + + with self.subTest(msg="Non existing entry without default"): + with self.assertRaises(LookupError): + self.cdb.truss_results.get(31, 1000, "NON-EXISTING") + + with self.subTest(msg="Non existing entry with default"): + self.assertEqual( + self.cdb.truss_results.get(31, 1000, "NON-EXISTING", 5), + 5 + ) + + def test_get_after_clear(self) -> None: + self.cdb.truss_results.clear(1000) + with self.subTest(msg="Check clear method"): + with self.assertRaises(LookupError): + self.cdb.truss_results.get(23, 1000, "AXIAL_FORCE") + + self.cdb.truss_results.load(1000) + with self.subTest(msg="Check indexes management"): + self.assertEqual( + self.cdb.truss_results.get(23, 1000, "AXIAL_FORCE"), + 150 + ) + + def test_get_after_clear_all(self) -> None: + self.cdb.truss_results.clear_all() + with self.subTest(msg="Check clear_all method"): + with self.assertRaises(LookupError): + self.cdb.truss_results.get(23, 1000, "AXIAL_FORCE") + + self.cdb.truss_results.load(1000) + with self.subTest(msg="Check indexes management"): + self.assertEqual( + self.cdb.truss_results.get(23, 1000, "AXIAL_FORCE"), + 150 + ) + + def test_load_with_duplicated_load_cases(self) -> None: + self.cdb.truss_results.clear_all() + self.cdb.truss_results.load([1000, 1000]) + self.assertEqual( + self.cdb.truss_results.get(31, 1000, "AXIAL_FORCE"), + 200 + ) From 9548c355763a6ae9c6eeeacb9930bea8baf588cc Mon Sep 17 00:00:00 2001 From: StudioWEngineers Date: Tue, 17 Feb 2026 07:19:57 +0100 Subject: [PATCH 08/10] align conditional check to other classes --- src/py_sofistik_utils/cdb_reader/_internal/truss_results.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py b/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py index aea091d..a70b9ca 100644 --- a/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py +++ b/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py @@ -184,7 +184,7 @@ def _load(self, load_case: int) -> list[dict[str, float | int]]: if return_value.value >= 2: break - if trus.m_nr != 0: + if trus.m_nr > 0: data.append( { "LOAD_CASE": load_case, From 08e2513645de7cbae97be363b58d0159608b52e7 Mon Sep 17 00:00:00 2001 From: StudioWEngineers Date: Tue, 17 Feb 2026 09:17:46 +0100 Subject: [PATCH 09/10] add tests to docs --- docs/cdb/test_setup.rst | 1 + docs/cdb/tests/truss_result.rst | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 docs/cdb/tests/truss_result.rst diff --git a/docs/cdb/test_setup.rst b/docs/cdb/test_setup.rst index 45baab0..8c57707 100644 --- a/docs/cdb/test_setup.rst +++ b/docs/cdb/test_setup.rst @@ -94,3 +94,4 @@ the temporary environment variable approach, open an MSYS2 MINGW64 shell, naviga tests/spring_result tests/truss_data tests/truss_load + tests/truss_result diff --git a/docs/cdb/tests/truss_result.rst b/docs/cdb/tests/truss_result.rst new file mode 100644 index 0000000..2c60e06 --- /dev/null +++ b/docs/cdb/tests/truss_result.rst @@ -0,0 +1,47 @@ +TrussResult +----------- + +Related test suite: ``test_truss_result.py`` + +Expected CDB file name: ``TRUSS_RESULT.cdb`` + +Runs with: SOFiSTiK 2025 + +Version: 1 + +.. code-block:: text + + +PROG AQUA + HEAD MATERIAL AND SECTIONS + NORM EN 199X-200X + STEE NO 1 TYPE S TITL 'S355' + PROF NO 1 TYPE CHS 100.0 10.0 MNO 1 + END + + +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 PP + NODE NO 02 X 0.0 Y 1.0 Z 0.0 FIX PP + NODE NO 03 X 5.0 Y 0.0 Z 0.0 FIX PYPZ + NODE NO 04 X 5.0 Y 1.0 Z 0.0 FIX PYPZ + + GRP 2 TITL 'TRUSS 1' + TRUS NO 3 NA 1 NE 3 NCS 1 + GRP 3 TITL 'TRUSS 1' + TRUS NO 1 NA 4 NE 2 NCS 1 + END + + +PROG SOFILOAD + HEAD NODAL LOAD + LC 1 TITL 'LOAD PXX' + NODE 3 TYPE PXX 150.0 + NODE 4 TYPE PXX 200.0 + END + + +PROG ASE + HEAD LINEAR ANALYSIS + SYST PROB LINE + LC 1000 DLZ 0.0 TITL 'LINEAR ANALYSIS' + LCC 1 FACT 1.0 + END From 5132b6bff4e94421a518b5eb0de17e437195f67d Mon Sep 17 00:00:00 2001 From: StudioWEngineers Date: Tue, 17 Feb 2026 09:23:29 +0100 Subject: [PATCH 10/10] update docstring --- .../cdb_reader/_internal/truss_results.py | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py b/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py index a70b9ca..86e1a79 100644 --- a/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py +++ b/src/py_sofistik_utils/cdb_reader/_internal/truss_results.py @@ -11,11 +11,35 @@ class _TrussResult: - """The ``_TrussResult`` class provides methods and data structure to: + """This class provides methods and a data structure to: - * access and load the keys ``152/LC`` of the CDB file; - * store these data in a convenient format; - * provide access to these data. + * access keys ``152/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 + * ``AXIAL_FORCE`` axial force + * ``AXIAL_DISPLACEMENT``: axial displacement + + 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: + + * damage parameter + * nonlinear effects + + 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: self._data = DataFrame(