Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/cdb/test_setup.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
47 changes: 47 additions & 0 deletions docs/cdb/tests/truss_result.rst
Original file line number Diff line number Diff line change
@@ -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
202 changes: 136 additions & 66 deletions src/py_sofistik_utils/cdb_reader/_internal/truss_results.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -12,141 +11,212 @@


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:
"""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
self._echo_level = 0
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()

def get_element_force(self, element_number: int, load_case: int) -> float:
"""Return the cable connectivity for the given ``element_number``.
def data(self, deep: bool = True) -> DataFrame:
"""Return the :class:`pandas.DataFrame` containing the loaded keys
``152/LC``.

Parameters
----------
``element_number``: int
The cable element number
``load_case``: int
The load case number
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(
self,
element_id: int,
load_case: int,
quantity: str = "AXIAL_FORCE",
default: float | None = None
) -> float:
"""Retrieve the requested truss result.

Parameters
----------
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``.

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 _load(self, load_case: int) -> list[dict[str, Any]]:
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, 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,
152,
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

def set_echo_level(self, echo_level: int) -> None:
"""Set the echo level.
"""
self._echo_level = echo_level
Loading