Source code for cfdmod.dynamics.imports.portico

"""Read a TQS "Portico" per-floor modal export (the PAVIMENTO variant).

Some TQS deliveries ship a per-floor summary instead of (or alongside) the
nodal ``PORTELS(SE)`` set, so no nodal aggregation is needed. Files (TAB
separated, decimal point, Latin-1; floor names may contain spaces):

- ``PORTICO_MASSAS_PAVIMENTO.TXT`` -- one row per floor:
  ``Pavimento | Elevacao (cm) | Massa X | Massa Y | Massa Z |
  Momento de inercia da massa | Xcg (cm) | Ycg (cm)``.
- ``PORTICO_MODOS_PAVIMENTO.TXT`` -- a ``//Modo ... DX DY RZ`` header, then
  per mode a single-token mode-number line followed by per-floor
  ``Pavimento | DX (cm) | DY (cm) | RZ (rad)`` rows.
- ``modes.csv`` -- ``mode,period[,wp,freq]`` (the natural periods; the
  MODOS file carries only shapes).

Lengths (cm) and technical mass (``tf.s^2/cm``) are converted to m / kg via
:class:`~cfdmod.dynamics.imports.eberick.EberickUnits` (shared with the
Eberick reader, which uses the same units).
"""

from __future__ import annotations

__all__ = ["read_tqs_portico"]

import csv
import pathlib

import numpy as np

from cfdmod.dynamics.imports._textnum import iter_data_rows, norm_text, to_float
from cfdmod.dynamics.imports.eberick import EberickUnits
from cfdmod.dynamics.structural import BuildingStructuralData, mass_normalize_mode_shapes


def _resolve(source: pathlib.Path, *needles: str, ext: str) -> pathlib.Path:
    for p in sorted(source.iterdir()):
        if p.suffix.lower() == ext and all(n in norm_text(p.name) for n in needles):
            return p
    raise FileNotFoundError(f"no {ext} file matching {needles} in {source}")


def _read_masses(path: pathlib.Path) -> dict[str, tuple[float, float, float, float, float]]:
    """{floor: (elevation, mass_x, inertia, xcg, ycg)} from MASSAS_PAVIMENTO."""
    rows = list(iter_data_rows(path, sep="\t"))
    header_i = next(
        (
            i
            for i, r in enumerate(rows)
            if norm_text(r[0]) == "pavimento" and any("massa" in norm_text(c) for c in r)
        ),
        None,
    )
    if header_i is None:
        raise ValueError(f"{path.name}: no 'Pavimento ... Massa' header row")
    out: dict[str, tuple[float, float, float, float, float]] = {}
    for r in rows[header_i + 1 :]:
        if len(r) < 8 or not r[0]:
            continue
        # 0 name, 1 elev, 2 MassaX, 3 MassaY, 4 MassaZ, 5 inercia, 6 Xcg, 7 Ycg
        out[r[0]] = (
            to_float(r[1]),
            to_float(r[2]),
            to_float(r[5]),
            to_float(r[6]),
            to_float(r[7]),
        )
    return out


def _read_modos(path: pathlib.Path) -> dict[int, dict[str, tuple[float, float, float]]]:
    """{mode_number: {floor: (DX, DY, RZ)}} from MODOS_PAVIMENTO (blocks)."""
    blocks: dict[int, dict[str, tuple[float, float, float]]] = {}
    current: dict[str, tuple[float, float, float]] | None = None
    for r in iter_data_rows(path, sep="\t"):
        if len(r) == 1:
            current = {}
            blocks[int(r[0])] = current
        elif len(r) >= 4 and current is not None:
            current[r[0]] = (to_float(r[1]), to_float(r[2]), to_float(r[3]))
    return blocks


def _read_periods(path: pathlib.Path) -> dict[int, float]:
    """{mode_number: period} from a modes.csv (columns mode, period)."""
    out: dict[int, float] = {}
    with path.open("r", encoding="latin-1", newline="") as fh:
        for row in csv.DictReader(fh):
            keys = {k.strip().lower(): k for k in row}
            out[int(float(row[keys["mode"]]))] = float(row[keys["period"]])
    return out


[docs] def read_tqs_portico( source: str | pathlib.Path, *, masses_file: str | pathlib.Path | None = None, modos_file: str | pathlib.Path | None = None, modes_file: str | pathlib.Path | None = None, units: EberickUnits | None = None, active_modes: list[int] | None = None, ) -> BuildingStructuralData: """Read a TQS Portico per-floor export into a :class:`BuildingStructuralData`. Args: source: Directory containing the ``PORTICO_*_PAVIMENTO.TXT`` files and ``modes.csv``. masses_file / modos_file / modes_file: Explicit paths overriding the in-directory lookup (for renamed files). units: Unit conversions (default cm -> m, tf.s^2/cm -> kg). active_modes: 1-based mode numbers to keep (``None`` keeps all). Returns: Per-floor structural data (floors ascending by elevation, mass-normalized mode shapes). """ u = units or EberickUnits() source = pathlib.Path(source) masses_p = ( pathlib.Path(masses_file) if masses_file else _resolve(source, "massas", "pavimento", ext=".txt") ) modos_p = ( pathlib.Path(modos_file) if modos_file else _resolve(source, "modos", "pavimento", ext=".txt") ) modes_p = pathlib.Path(modes_file) if modes_file else _resolve(source, "modes", ext=".csv") masses = _read_masses(masses_p) modos = _read_modos(modos_p) periods_by_mode = _read_periods(modes_p) mode_nos = sorted(m for m in modos if m in periods_by_mode) if active_modes is not None: mode_nos = [m for m in mode_nos if m in set(active_modes)] if not masses or not mode_nos: raise ValueError("Portico export parsed no floors or no usable modes") names = sorted(masses, key=lambda n: masses[n][0]) elev = np.array([masses[n][0] for n in names]) * u.length_to_m mass = np.array([masses[n][1] for n in names]) * u.mass_to_kg inertia = np.array([masses[n][2] for n in names]) xcg = np.array([masses[n][3] for n in names]) * u.length_to_m ycg = np.array([masses[n][4] for n in names]) * u.length_to_m radius = np.sqrt(inertia / np.array([masses[n][1] for n in names])) * u.length_to_m phi = np.zeros((len(names), len(mode_nos), 3), dtype=np.float64) for mi, mode_no in enumerate(mode_nos): block = modos[mode_no] for fi, name in enumerate(names): dx, dy, rz = block.get(name, (0.0, 0.0, 0.0)) phi[fi, mi] = (dx * u.length_to_m, dy * u.length_to_m, rz) phi = mass_normalize_mode_shapes(phi, mass, radius) wp = 2.0 * np.pi / np.array([periods_by_mode[m] for m in mode_nos], dtype=np.float64) return BuildingStructuralData( mode_shapes=phi, natural_frequencies=wp, floor_points=np.column_stack([xcg, ycg, elev]), cm_positions=np.column_stack([xcg, ycg]), floors_mass=mass, floors_radius=radius, floor_labels=list(names), )