"""Read a TQS "Portico Espacial" modal export.
TQS exports the spatial-frame modal analysis as a set of Latin-1,
TAB-separated, comma-decimal text files with ``//`` comment lines. The
file-name prefix is ``PORTELS_`` (older) or ``PORTELSSE_`` (newer); the
reader matches by the ``_<SUFFIX>.TXT`` tail so either works:
- ``*_MODOS.TXT`` -- ``Modo; Periodo(s); Freq angular(rad/s); Freq(Hz)``,
preceded by a single line with the mode count.
- ``*_NOS.TXT`` -- ``No; X; Y; Z`` (m), preceded by the node count.
- ``*_MASSAS.TXT`` -- ``No; Massa X; Massa Y; Massa Z`` (lumped nodal mass).
- ``*_FORMAS2.TXT``-- per-mode blocks ``// Modo / <n> / No; DX; DY; RZ``
(nodal mode shape carrying the ``RZ`` rotation). ``*_FORMAS.TXT`` is the
same but with ``DZ`` instead of ``RZ`` -- we use ``FORMAS2``.
- ``*_PISOS.TXT`` -- optional ``Piso; Nome; Nivel(m)`` floor table (newer
exports). When present it defines the real slab elevations, so the many
intermediate FE node levels (beams, landings) collapse onto actual floors.
:func:`read_tqs_portels` parses these into a :class:`NodalModel` and
aggregates it per floor via :func:`aggregate_to_building`.
"""
from __future__ import annotations
__all__ = ["read_tqs_portels", "PORTELS_FILES"]
import pathlib
import numpy as np
from cfdmod.dynamics.imports._textnum import iter_data_rows, to_float
from cfdmod.dynamics.imports.nodal import NodalModel, aggregate_to_building
from cfdmod.dynamics.structural import BuildingStructuralData
# Role -> file-name suffix. TQS names these ``PORTELS_<SUFFIX>.TXT`` (older) or
# ``PORTELSSE_<SUFFIX>.TXT`` (newer), so we match by the ``_<SUFFIX>.TXT`` tail
# rather than a fixed prefix.
PORTELS_FILES = {
"modes": "MODOS",
"nodes": "NOS",
"masses": "MASSAS",
"shapes": "FORMAS2",
}
# Newer exports carry a floor table too; read for validation when present.
PISOS_SUFFIX = "PISOS"
def _resolve(source: str | pathlib.Path, suffix: str, *, required: bool = True):
"""Find the ``*_<suffix>.TXT`` file in ``source`` (case-insensitive).
Matches both the ``PORTELS_`` and ``PORTELSSE_`` prefixes (preferring the
newer ``PORTELSSE_`` when both exist). Returns ``None`` when ``required``
is false and no file matches.
"""
source = pathlib.Path(source)
tail = f"_{suffix.lower()}.txt"
matches = [p for p in source.iterdir() if p.is_file() and p.name.lower().endswith(tail)]
if not matches:
if required:
raise FileNotFoundError(f"no *{tail.upper()} file in PORTELS export dir {source}")
return None
# Prefer PORTELSSE_ (newer), then PORTELS_, then anything, then shortest name.
matches.sort(
key=lambda p: (
0
if p.name.upper().startswith("PORTELSSE_")
else 1
if p.name.upper().startswith("PORTELS_")
else 2,
len(p.name),
)
)
return matches[0]
def _read_modes(path: pathlib.Path) -> np.ndarray:
"""Periods (s), one per mode, ordered by mode number."""
rows = [r for r in iter_data_rows(path) if len(r) >= 4]
# Rows have 4 columns; the lone count line (1 token) is filtered out above.
modes = sorted((int(r[0]), to_float(r[1])) for r in rows)
return np.asarray([period for _, period in modes], dtype=np.float64)
def _read_nodes(path: pathlib.Path) -> tuple[np.ndarray, np.ndarray]:
"""Node ids and ``(n, 3)`` coordinates, filtered to 4-column data rows."""
ids, coords = [], []
for r in iter_data_rows(path):
if len(r) < 4:
continue # the count line
ids.append(int(r[0]))
coords.append([to_float(r[1]), to_float(r[2]), to_float(r[3])])
return np.asarray(ids, dtype=np.int64), np.asarray(coords, dtype=np.float64)
def _read_masses(path: pathlib.Path) -> dict[int, float]:
"""Map node id -> translational mass (the X-direction lumped mass)."""
out: dict[int, float] = {}
for r in iter_data_rows(path):
if len(r) < 2:
continue
out[int(r[0])] = to_float(r[1])
return out
def _read_shapes(path: pathlib.Path) -> dict[int, dict[int, tuple[float, float, float]]]:
"""Parse per-mode blocks -> {mode_number: {node_id: (DX, DY, RZ)}}.
Blocks are delimited by a single-token line carrying the mode number
(the ``// Modo`` comment above it is stripped by the row iterator);
subsequent 4-column lines are that mode's nodal shape rows.
"""
blocks: dict[int, dict[int, tuple[float, float, float]]] = {}
current: dict[int, tuple[float, float, float]] | None = None
for r in iter_data_rows(path):
if len(r) == 1:
current = {}
blocks[int(r[0])] = current
elif len(r) >= 4 and current is not None:
current[int(r[0])] = (to_float(r[1]), to_float(r[2]), to_float(r[3]))
return blocks
def _read_pisos(path: pathlib.Path) -> tuple[list[float], list[str]]:
"""(levels, names) from a PISOS table (``Piso; Nome; Nivel(m)``).
The level is the *last* token and the piso index the first; the name is
everything in between (it may contain spaces), rejoined with a space.
"""
levels: list[float] = []
names: list[str] = []
for r in iter_data_rows(path):
if len(r) >= 3 and r[0].isdigit():
levels.append(to_float(r[-1]))
names.append(" ".join(r[1:-1]))
return levels, names
[docs]
def read_tqs_portels(
source: str | pathlib.Path,
*,
active_modes: list[int] | None = None,
tol_z: float = 0.05,
) -> BuildingStructuralData:
"""Read a TQS PORTELS export directory into a :class:`BuildingStructuralData`.
Handles both the older ``PORTELS_*.TXT`` and newer ``PORTELSSE_*.TXT``
file names (matched by suffix). When a ``*_PISOS.TXT`` floor table is
present it is used to sanity-check the recovered floor count.
Args:
source: Directory containing the ``PORTELS(SE)_*.TXT`` files.
active_modes: 1-based mode numbers to keep (``None`` keeps all).
tol_z: Slab elevation clustering tolerance (m).
Returns:
Per-floor structural data ready for the building dynamic recipe.
"""
modes_p = _resolve(source, PORTELS_FILES["modes"])
nodes_p = _resolve(source, PORTELS_FILES["nodes"])
masses_p = _resolve(source, PORTELS_FILES["masses"])
shapes_p = _resolve(source, PORTELS_FILES["shapes"])
periods = _read_modes(modes_p)
node_ids, coords = _read_nodes(nodes_p)
mass_by_id = _read_masses(masses_p)
shape_blocks = _read_shapes(shapes_p)
n_modes = periods.shape[0]
mode_numbers = sorted(shape_blocks)
if len(mode_numbers) < n_modes:
raise ValueError(
f"{shapes_p.name} has {len(mode_numbers)} mode blocks but "
f"{modes_p.name} declares {n_modes} modes"
)
mass = np.asarray([mass_by_id.get(int(nid), 0.0) for nid in node_ids], dtype=np.float64)
shapes = np.zeros((len(node_ids), n_modes, 3), dtype=np.float64)
for mi, mode_no in enumerate(mode_numbers[:n_modes]):
block = shape_blocks[mode_no]
for ni, nid in enumerate(node_ids):
dx, dy, rz = block.get(int(nid), (0.0, 0.0, 0.0))
shapes[ni, mi] = (dx, dy, rz)
# A PISOS floor table (newer exports), when present, defines the real slab
# levels -- the FE model otherwise has many intermediate node elevations
# (beams, landings) that naive Z-clustering would mistake for floors.
pisos_p = _resolve(source, PISOS_SUFFIX, required=False)
floor_levels, floor_labels = _read_pisos(pisos_p) if pisos_p is not None else (None, None)
model = NodalModel(coords=coords, mass=mass, periods=periods, shapes=shapes, node_ids=node_ids)
sd = aggregate_to_building(
model,
tol_z=tol_z,
floor_levels=floor_levels,
floor_labels=floor_labels,
active_modes=active_modes,
)
if floor_levels is not None and sd.n_floors != len(floor_levels):
import warnings
warnings.warn(
f"{pisos_p.name} lists {len(floor_levels)} floors but {sd.n_floors} carry mass "
f"(zero-mass levels such as foundation/roof are dropped).",
stacklevel=2,
)
return sd