Source code for cfdmod.dynamics.imports.nodal

"""Format-agnostic nodal modal model -> per-floor BuildingStructuralData.

The finite-element structural export (TQS PORTELS, and any other tool
that reports at the node level) gives modal data per *node*: nodal
coordinates, lumped nodal masses, and per-mode nodal displacements
``[DX, DY, RZ]``. The building dynamic-response recipe instead wants
per-*floor* rigid-diaphragm properties. This module bridges the two.

Aggregation (ports the legacy ``gen_nodes.py`` throwaway script, but
uses the tool's direct nodal ``RZ`` instead of reconstructing rotation
from two reference nodes): group nodes by slab elevation, then per floor

    M   = sum_node m
    XG  = sum_node m*x / M          (centre of mass)
    YG  = sum_node m*y / M
    I   = sum_node m*((x-XG)^2 + (y-YG)^2)     (polar inertia about CoM)
    R   = sqrt(I / M)               (radius of gyration)
    DX  = sum_node m*DX_node / M    (mass-weighted rigid-diaphragm shape)
    DY  = sum_node m*DY_node / M
    RZ  = sum_node m*RZ_node / M

Floors with zero total mass (non-slab levels: bare columns/beams) are
dropped by default.
"""

from __future__ import annotations

__all__ = ["NodalModel", "aggregate_to_building"]

from typing import Any

import numpy as np
from pydantic import BaseModel, ConfigDict

from cfdmod.dynamics.structural import BuildingStructuralData, mass_normalize_mode_shapes


class NodalModel(BaseModel):
    """Raw nodal modal model, as parsed from a structural export.

    Attributes:
        coords: ``(n_nodes, 3)`` nodal coordinates ``[X, Y, Z]``.
        mass: ``(n_nodes,)`` lumped translational nodal mass.
        periods: ``(n_modes,)`` modal natural periods (seconds).
        shapes: ``(n_nodes, n_modes, 3)`` nodal mode shapes ``[DX, DY, RZ]``.
        node_ids: optional ``(n_nodes,)`` original node identifiers (for reference).
    """

    model_config = ConfigDict(arbitrary_types_allowed=True)

    coords: Any
    mass: Any
    periods: Any
    shapes: Any
    node_ids: Any | None = None

    @property
    def n_nodes(self) -> int:
        return int(np.asarray(self.coords).shape[0])

    @property
    def n_modes(self) -> int:
        return int(np.asarray(self.periods).shape[0])


[docs] def aggregate_to_building( model: NodalModel, *, tol_z: float = 0.05, floor_levels: list[float] | None = None, floor_labels: list[str] | None = None, active_modes: list[int] | None = None, drop_massless: bool = True, ) -> BuildingStructuralData: """Aggregate a :class:`NodalModel` into a per-floor :class:`BuildingStructuralData`. Args: tol_z: Elevation clustering tolerance (m) for the fallback grouping; nodes whose Z rounds to the same multiple of ``tol_z`` belong to one slab. Ignored when ``floor_levels`` is given. floor_levels: Authoritative slab elevations (e.g. from a TQS PISOS table). When given, every node is assigned to its nearest level, which collapses the many intermediate FE node elevations (beams, landings) onto the real floors. When ``None``, elevations are discovered by clustering node Z with ``tol_z``. active_modes: 1-based mode numbers to keep (``None`` keeps all). drop_massless: Drop levels with zero total mass (non-slab levels: foundation, roof). When ``False`` they raise instead. Returns: A :class:`BuildingStructuralData` with floors ordered by ascending elevation, mass-normalized mode shapes, and ``cm_positions`` set to the per-floor centre of mass. """ coords = np.asarray(model.coords, dtype=np.float64) mass = np.asarray(model.mass, dtype=np.float64) periods = np.asarray(model.periods, dtype=np.float64) shapes = np.asarray(model.shapes, dtype=np.float64) n_modes = periods.shape[0] if shapes.shape != (coords.shape[0], n_modes, 3): raise ValueError( f"shapes must be (n_nodes, n_modes, 3)=({coords.shape[0]}, {n_modes}, 3); " f"got {shapes.shape}" ) keep = list(range(n_modes)) if active_modes is None else [m - 1 for m in active_modes] # Assign each node to a floor: nearest authoritative level, or Z-cluster. label_of: dict[int, str] | None = None if floor_levels is not None: order = np.argsort(np.asarray(floor_levels, dtype=np.float64)) levels = np.asarray(floor_levels, dtype=np.float64)[order] z_key = np.argmin(np.abs(coords[:, 2][:, None] - levels[None, :]), axis=1) group_keys = list(range(len(levels))) level_of = {k: float(levels[k]) for k in group_keys} if floor_labels is not None: sorted_labels = [floor_labels[i] for i in order] label_of = {k: sorted_labels[k] for k in group_keys} else: z_key = np.round(coords[:, 2] / tol_z).astype(np.int64) group_keys = sorted(np.unique(z_key)) level_of = None floor_props: list[tuple[float, float, float, float, float, float]] = [] floor_shapes: list[np.ndarray] = [] # each (n_kept_modes, 3) kept_labels: list[str] = [] for key in group_keys: sel = z_key == key m = mass[sel] total = float(m.sum()) elev = ( level_of[key] if level_of is not None else (float(coords[sel, 2].mean()) if sel.any() else float("nan")) ) if total <= 0.0: if drop_massless: continue raise ValueError(f"slab at z={elev:.3f} has zero total mass") if label_of is not None: kept_labels.append(label_of[key]) x, y = coords[sel, 0], coords[sel, 1] xg = float((m * x).sum() / total) yg = float((m * y).sum() / total) inertia = float((m * ((x - xg) ** 2 + (y - yg) ** 2)).sum()) radius = float((inertia / total) ** 0.5) floor_props.append((elev, total, inertia, radius, xg, yg)) # Mass-weighted rigid-diaphragm shape per kept mode. node_shapes = shapes[sel][:, keep, :] # (n_sel, n_kept, 3) weighted = (m[:, None, None] * node_shapes).sum(axis=0) / total # (n_kept, 3) floor_shapes.append(weighted) if not floor_props: raise ValueError("no slabs with positive mass were found in the nodal model") props = np.asarray(floor_props, dtype=np.float64) # (n_floors, 6) elevations, floors_mass, _, floors_radius, xg, yg = props.T phi = np.stack(floor_shapes, axis=0) # (n_floors, n_kept_modes, 3) floors_mass = np.asarray(floors_mass, dtype=np.float64) floors_radius = np.asarray(floors_radius, dtype=np.float64) phi = mass_normalize_mode_shapes(phi, floors_mass, floors_radius) freqs = 1.0 / periods[keep] wp = 2.0 * np.pi * freqs return BuildingStructuralData( mode_shapes=phi, natural_frequencies=wp, floor_points=np.column_stack([xg, yg, elevations]), cm_positions=np.column_stack([xg, yg]), floors_mass=floors_mass, floors_radius=floors_radius, floor_labels=kept_labels or None, )