Source code for cfdmod.dynamics.structural

"""Structural-data ingest for the building dynamic-response recipe.

These are out-of-paradigm *inputs* (like the S1 standard-profile
catalog): they read mode / floor / mode-shape data from CSV and build the
mass-normalized numpy arrays that
:func:`cfdmod.core.recipes.dynamic.build_building_dynamic_response`
consumes. They are not pipeline stages.

CSV layouts (ported from the legacy HFPI readers):

- modes CSV: columns ``mode``, ``period`` (frequency ``= 1/period``,
  angular ``wp = 2*pi*frequency``).
- floors CSV: columns ``Z, M, I, XR, YR`` (radius of gyration
  ``R = sqrt(I/M)`` is derived).
- floor mode-shape CSV (one per mode): columns ``DX, DY, RZ``.
"""

from __future__ import annotations

__all__ = [
    "read_modes_csv",
    "read_floors_csv",
    "read_mode_shape_csv",
    "mass_normalize_mode_shapes",
    "BuildingStructuralData",
]

import pathlib
from typing import Any

import numpy as np
import pandas as pd
from pydantic import BaseModel, ConfigDict

from cfdmod import utils


def read_modes_csv(csv_path: pathlib.Path) -> pd.DataFrame:
    """Read the modes CSV. Adds ``frequency = 1/period`` and ``wp = 2*pi*frequency``."""
    df = pd.read_csv(csv_path, index_col=None)
    req_keys = ["mode", "period"]
    if not utils.validate_keys_df(df, req_keys):
        raise KeyError(
            f"Not all required keys ({req_keys}) present in modes CSV "
            f"{csv_path.as_posix()}. Found only keys {list(df.columns)}"
        )
    df = df[req_keys].copy()
    df["frequency"] = 1 / df["period"]
    df["wp"] = 2 * np.pi * df["frequency"]
    df.sort_values(by="mode", inplace=True)
    return df.reset_index(drop=True)


def read_floors_csv(csv_path: pathlib.Path) -> pd.DataFrame:
    """Read the floors CSV. Derives radius of gyration ``R = sqrt(I/M)``."""
    df = pd.read_csv(csv_path, index_col=None)
    req_keys = ["Z", "M", "I", "XR", "YR"]
    if not utils.validate_keys_df(df, req_keys):
        raise KeyError(
            f"Not all required keys ({req_keys}) present in floors CSV "
            f"{csv_path.as_posix()}. Found only keys {list(df.columns)}"
        )
    df = df[req_keys].copy()
    df["R"] = (df["I"] / df["M"]) ** 0.5
    df.sort_values(by="Z", inplace=True)
    return df.reset_index(drop=True)


def read_mode_shape_csv(csv_path: pathlib.Path) -> pd.DataFrame:
    """Read a single mode's floor mode-shape CSV (columns ``DX, DY, RZ``)."""
    df = pd.read_csv(csv_path, index_col=None)
    req_keys = ["DX", "DY", "RZ"]
    if not utils.validate_keys_df(df, req_keys):
        raise KeyError(
            f"Not all required keys ({req_keys}) present in mode-shape CSV "
            f"{csv_path.as_posix()}. Found only keys {list(df.columns)}"
        )
    return df[req_keys].copy()


def mass_normalize_mode_shapes(
    mode_shapes: np.ndarray, floors_mass: np.ndarray, floors_radius: np.ndarray
) -> np.ndarray:
    """Mass-normalize mode shapes so each mode has unit generalized mass.

    For each mode the generalized mass is::

        M_gen = sum_floor M * (DX^2 + DY^2 + (R*RZ)^2)

    and the mode's ``[DX, DY, RZ]`` are divided by ``sqrt(M_gen)``. A
    zero-generalized-mass mode is zeroed (matches the legacy behavior).
    This is the precondition the SDOF solver assumes.

    Args:
        mode_shapes: ``(n_floors, n_modes, 3)`` array of ``[DX, DY, RZ]``.
        floors_mass: ``(n_floors,)`` floor masses.
        floors_radius: ``(n_floors,)`` floor radii of gyration.

    Returns:
        Normalized ``(n_floors, n_modes, 3)`` array (new array; input untouched).
    """
    phi = np.asarray(mode_shapes, dtype=np.float64)
    m = np.asarray(floors_mass, dtype=np.float64)[:, None]  # (n_floors, 1)
    r = np.asarray(floors_radius, dtype=np.float64)[:, None]
    dx, dy, rz = phi[:, :, 0], phi[:, :, 1], phi[:, :, 2]

    # Generalized mass per mode -> (n_modes,)
    m_gen = (m * (dx**2 + dy**2 + (r * rz) ** 2)).sum(axis=0)
    out = phi.copy()
    nonzero = m_gen > 0
    out[:, nonzero, :] /= np.sqrt(m_gen[nonzero])[None, :, None]
    out[:, ~nonzero, :] = 0.0
    return out


[docs] class BuildingStructuralData(BaseModel): """Assembled structural inputs for the building dynamic-response recipe. Holds numpy arrays ready to pass to :class:`~cfdmod.core.recipes.dynamic.BuildingDynamicConfig`. Mode shapes are stored mass-normalized. """ model_config = ConfigDict(arbitrary_types_allowed=True) mode_shapes: Any # (n_floors, n_modes, 3), mass-normalized natural_frequencies: Any # (n_modes,) angular wp = 2*pi*f floor_points: Any # (n_floors, 3) cm_positions: Any # (n_floors, 2) [XR, YR] floors_mass: Any # (n_floors,) floors_radius: Any # (n_floors,) # Non-numeric per-floor metadata carried through for reporting (not used by # the recipe): storey names, plus any extra source columns keyed by name. floor_labels: list[str] | None = None # (n_floors,) storey names floor_metadata: dict[str, list] | None = None # column name -> (n_floors,) values @property def n_modes(self) -> int: return int(np.asarray(self.mode_shapes).shape[1]) @property def n_floors(self) -> int: return int(np.asarray(self.mode_shapes).shape[0])
[docs] def with_multipliers( self, *, mass_multiplier: float = 1.0, frequency_multiplier: float = 1.0 ) -> "BuildingStructuralData": """Apply per-case mass / frequency multipliers. Mirrors the legacy HFPI case knobs: scaling every floor mass by ``mass_multiplier`` (``mm``) scales the generalized mass uniformly, so mass-normalized shapes divide by ``sqrt(mm)``; natural frequencies shift by ``frequency_multiplier / sqrt(mm)`` (the ``1/sqrt(mm)`` from the softer/heavier structure, the explicit ``frequency_multiplier`` applied on top). Radius of gyration is left unchanged (matches the legacy path). Returns a new :class:`BuildingStructuralData`. """ mm = float(mass_multiplier) fm = float(frequency_multiplier) return BuildingStructuralData( mode_shapes=np.asarray(self.mode_shapes, dtype=np.float64) / np.sqrt(mm), natural_frequencies=np.asarray(self.natural_frequencies, dtype=np.float64) * fm / np.sqrt(mm), floor_points=self.floor_points, cm_positions=self.cm_positions, floors_mass=np.asarray(self.floors_mass, dtype=np.float64) * mm, floors_radius=self.floors_radius, floor_labels=self.floor_labels, floor_metadata=self.floor_metadata, )
[docs] def to_config(self, damping_ratio: float, **field_names: str): """Build a :class:`BuildingDynamicConfig` from these structural arrays. ``field_names`` overrides the load-coefficient field names (``field_x`` / ``field_y`` / ``field_mz``). """ from cfdmod.core.recipes.dynamic import BuildingDynamicConfig return BuildingDynamicConfig( mode_shapes=self.mode_shapes, floor_points=self.floor_points, cm_positions=self.cm_positions, floors_mass=self.floors_mass, floors_radius=self.floors_radius, natural_frequencies=self.natural_frequencies, damping_ratio=damping_ratio, **field_names, )
[docs] @classmethod def from_csvs( cls, modes_csv: pathlib.Path, floors_csv: pathlib.Path, mode_shape_csvs: list[pathlib.Path], *, active_modes: list[int] | None = None, ) -> "BuildingStructuralData": """Build from the modes / floors / per-mode mode-shape CSVs. Args: active_modes: 1-based mode numbers to keep. ``None`` keeps every mode that has a mode-shape CSV. """ df_modes = read_modes_csv(modes_csv) df_floors = read_floors_csv(floors_csv) shapes = [read_mode_shape_csv(p) for p in mode_shape_csvs] n_floors = len(df_floors) for i, s in enumerate(shapes): if len(s) != n_floors: raise ValueError( f"mode-shape CSV index {i} has {len(s)} floors; floors CSV has {n_floors}" ) if len(df_modes) < len(shapes): raise ValueError("fewer modes than mode-shape CSVs provided") keep = ( list(range(len(shapes))) if active_modes is None else [m - 1 for m in active_modes] # 1-based -> 0-based ) phi = np.stack( [np.column_stack([shapes[i]["DX"], shapes[i]["DY"], shapes[i]["RZ"]]) for i in keep], axis=1, ) # (n_floors, n_modes, 3) floors_mass = df_floors["M"].to_numpy(dtype=np.float64) floors_radius = df_floors["R"].to_numpy(dtype=np.float64) phi = mass_normalize_mode_shapes(phi, floors_mass, floors_radius) heights = df_floors["Z"].to_numpy(dtype=np.float64) floor_points = np.column_stack([np.zeros(n_floors), np.zeros(n_floors), heights]) return cls( mode_shapes=phi, natural_frequencies=df_modes["wp"].to_numpy(dtype=np.float64)[keep], floor_points=floor_points, cm_positions=df_floors[["XR", "YR"]].to_numpy(dtype=np.float64), floors_mass=floors_mass, floors_radius=floors_radius, )