Source code for cfdmod.building.dynamic

"""v3 recipe wiring for the high-rise dynamic-response stage.

Thin composition of the library dynamic recipe -- no new structural maths
lives here. It bridges the per-floor Cf/Cm produced by the pressure stage
(:mod:`cfdmod.building.pressure`) into the building dynamic-response recipe
(:func:`cfdmod.core.recipes.dynamic.build_building_dynamic_response`) and the
comfort acceleration recipe (:func:`build_point_accelerations`).

The pipeline the stage assembles:

    per-floor Cf/Cm timeseries (GroupsDataSource, from cfdmod.building.pressure)
        -> floor-load PointsDataSource (cf_x / cf_y / cm_z, dimensionalised)
        -> generalized modal loads -> SDOF RK45 -> floor response
           (disp_x / disp_y / rot_z + static-equivalent feq_x / feq_y / meq_z)
        -> off-centre horizontal accelerations (acc_x / acc_y / acc_mag)

Structural inputs (mode shapes, floor masses, radii, natural frequencies)
come from a case's modes/floors/mode-shape CSVs via
:func:`structure_from_csvs`. For the headless demo (no CSVs on disk) a
self-contained :func:`example_building_structure` synthesises plausible
cantilever sway + torsion modes tuned to the case geometry, so the whole
chain runs on the in-repo fixtures.
"""

from __future__ import annotations

import pathlib

import numpy as np
import pandas as pd

from cfdmod.adapters.memory import MemoryFieldStore
from cfdmod.core import ElementMeta, PointsDataSource, Topology
from cfdmod.core.data_source import DataSource
from cfdmod.core.recipes import (
    ComfortConfig,
    build_building_dynamic_response,
    build_point_accelerations,
)
from cfdmod.dynamics import BuildingStructuralData, mass_normalize_mode_shapes

from .case import BuildingCase

# Ellis (1980) empirical fundamental frequency: f1 ~ 46 / H [Hz], H in metres.
_ELLIS_COEFF = 46.0


def floor_load_source(
    cf: DataSource,
    cm: DataSource,
    case: BuildingCase,
    *,
    dimensionalize: bool = True,
) -> PointsDataSource:
    """Merge per-floor Cf/Cm groups into one floor-load ``PointsDataSource``.

    ``cf`` carries ``cf_x`` / ``cf_y`` and ``cm`` carries ``cm_z``, each a
    ``(n_floors, n_t)`` groups source from :func:`cfdmod.building.pressure.cf_per_floor` /
    :func:`cfdmod.building.pressure.cm_per_floor`. The result has the three fields the
    building recipe expects (``cf_x`` / ``cf_y`` / ``cm_z``) on floor points
    stacked along Z.

    With ``dimensionalize`` (default) the force coefficients are scaled back
    to physical loads with the case dynamic pressure and reference area /
    volume (``F = cf * q * A``, ``M = cm_z * q * V``) so the response comes
    out in metres / newtons. Set it ``False`` to feed the raw coefficients
    (the response is then linear in an arbitrary scale).
    """
    cf_x = np.asarray(cf.fields.read("cf_x"), dtype=np.float64)
    cf_y = np.asarray(cf.fields.read("cf_y"), dtype=np.float64)
    cm_z = np.asarray(cm.fields.read("cm_z"), dtype=np.float64)
    n_floors = cf_x.shape[0]
    if not (cf_y.shape[0] == cm_z.shape[0] == n_floors):
        raise ValueError(
            f"Cf/Cm floor counts disagree: cf_x={cf_x.shape[0]}, cf_y={cf_y.shape[0]}, "
            f"cm_z={cm_z.shape[0]}"
        )

    if dimensionalize:
        q = case.dynamic_pressure
        cf_x = cf_x * q * case.nominal_area
        cf_y = cf_y * q * case.nominal_area
        cm_z = cm_z * q * case.nominal_volume

    pts = np.zeros((n_floors, 3), dtype=np.float64)
    pts[:, 2] = _floor_mid_heights(case, n_floors)

    fields = {"cf_x": cf_x, "cf_y": cf_y, "cm_z": cm_z}
    return PointsDataSource(
        time=cf.time,
        topology=Topology.points(pts),
        elements=ElementMeta(position=pts),
        fields=MemoryFieldStore(fields),
    )


def example_building_structure(
    case: BuildingCase,
    n_floors: int,
    *,
    n_modes: int = 3,
    frequencies_hz: list[float] | None = None,
    floor_mass: float | None = None,
    mass_density: float = 250.0,
) -> BuildingStructuralData:
    """A self-contained :class:`BuildingStructuralData` tuned to the case.

    Synthesises three canonical mode shapes -- sway-X, sway-Y and torsion,
    each growing linearly with height like a cantilever's first mode -- for a
    building of ``n_floors`` uniform floors. Natural frequencies default to
    the Ellis ``46/H`` fundamental with higher modes at 1.1x / 1.25x. For real
    work use :func:`structure_from_csvs`.

    The per-floor mass defaults to a physically-scaled value derived from the
    building volume (``mass_density`` kg/m^3 of enclosed volume, split evenly
    across floors -- ``250`` is a typical reinforced-concrete tower average),
    so displacements/accelerations under real physical loads land in a
    believable range rather than blowing up against a unit mass. Pass an
    explicit ``floor_mass`` to override.

    ``n_modes`` is clamped to ``[1, 3]`` (only three canonical shapes are
    defined).
    """
    n_modes = int(np.clip(n_modes, 1, 3))
    z = _floor_mid_heights(case, n_floors)
    z_norm = z / max(float(z.max()), 1e-9)  # 0..1 up the height

    # Canonical shapes: [sway-X, sway-Y, torsion], each linear with height.
    shape_defs = [
        (z_norm, np.zeros(n_floors), np.zeros(n_floors)),  # sway X
        (np.zeros(n_floors), z_norm, np.zeros(n_floors)),  # sway Y
        (np.zeros(n_floors), np.zeros(n_floors), z_norm),  # torsion
    ]
    phi = np.stack(
        [np.column_stack(shape_defs[m]) for m in range(n_modes)], axis=1
    )  # (n_floors, n_modes, 3)

    if floor_mass is None:
        floor_mass = mass_density * case.nominal_volume / max(n_floors, 1)
    floors_mass = np.full(n_floors, float(floor_mass), dtype=np.float64)
    # Radius of gyration ~ 0.4 * plan dimension (rectangular-plan rule of thumb).
    floors_radius = np.full(n_floors, 0.4 * max(case.characteristic_length, 1e-6))
    phi = mass_normalize_mode_shapes(phi, floors_mass, floors_radius)

    if frequencies_hz is None:
        f1 = _ELLIS_COEFF / max(case.reference_height, 1e-6)
        factors = [1.0, 1.1, 1.25][:n_modes]
        frequencies_hz = [f1 * k for k in factors]
    wp = 2.0 * np.pi * np.asarray(frequencies_hz[:n_modes], dtype=np.float64)

    floor_points = np.column_stack([np.zeros(n_floors), np.zeros(n_floors), z])
    cm_positions = np.zeros((n_floors, 2), dtype=np.float64)

    return BuildingStructuralData(
        mode_shapes=phi,
        natural_frequencies=wp,
        floor_points=floor_points,
        cm_positions=cm_positions,
        floors_mass=floors_mass,
        floors_radius=floors_radius,
    )


def structure_from_csvs(
    modes_csv: str | pathlib.Path,
    floors_csv: str | pathlib.Path,
    mode_shape_csvs: list[str | pathlib.Path],
    *,
    active_modes: list[int] | None = None,
) -> BuildingStructuralData:
    """Load real structural data from the modes / floors / mode-shape CSVs.

    Thin passthrough to :meth:`BuildingStructuralData.from_csvs`; see its
    docstring for the CSV column layouts. Mode shapes come back
    mass-normalised, ready for :func:`solve_building_response`.
    """
    return BuildingStructuralData.from_csvs(
        pathlib.Path(modes_csv),
        pathlib.Path(floors_csv),
        [pathlib.Path(p) for p in mode_shape_csvs],
        active_modes=active_modes,
    )


[docs] def solve_building_response( load_source: PointsDataSource, structure: BuildingStructuralData, *, damping_ratio: float = 0.02, ) -> PointsDataSource: """Floor loads + structure -> per-floor dynamic response. Returns a ``PointsDataSource`` over the floors with displacement fields ``disp_x`` / ``disp_y`` / ``rot_z`` and static-equivalent load fields ``feq_x`` / ``feq_y`` / ``meq_z`` (each ``(n_floors, n_t)``). """ cfg = structure.to_config(damping_ratio=damping_ratio) return build_building_dynamic_response(load_source, cfg)
[docs] def floor_accelerations( response: PointsDataSource, structure: BuildingStructuralData, *, point: tuple[float, float] = (0.0, 0.0), ) -> PointsDataSource: """Per-floor horizontal accelerations at an off-centre occupant point. Augments ``response`` with ``acc_x`` / ``acc_y`` / ``acc_mag`` for the comfort assessment. ``point`` is in the same frame as the structure's CM offsets. """ cfg = ComfortConfig(cm_positions=structure.cm_positions, point=point) return build_point_accelerations(response, cfg)
[docs] def peak_response_table( response: PointsDataSource, accelerations: PointsDataSource, case: BuildingCase, ) -> pd.DataFrame: """Per-floor peak magnitudes for the engineer-facing deliverable table. One row per floor: mid-height Z, peak absolute displacement / rotation, peak static-equivalent loads, and peak acceleration magnitude. Peaks are the maximum absolute value over the time record. """ def peak_abs(ds: PointsDataSource, field: str) -> np.ndarray: return np.nanmax(np.abs(np.asarray(ds.fields.read(field), dtype=np.float64)), axis=1) n_floors = np.asarray(response.fields.read("disp_x")).shape[0] return pd.DataFrame( { "floor": np.arange(n_floors), "z_mid": _floor_mid_heights(case, n_floors), "disp_x_peak": peak_abs(response, "disp_x"), "disp_y_peak": peak_abs(response, "disp_y"), "rot_z_peak": peak_abs(response, "rot_z"), "feq_x_peak": peak_abs(response, "feq_x"), "feq_y_peak": peak_abs(response, "feq_y"), "meq_z_peak": peak_abs(response, "meq_z"), "acc_mag_peak": peak_abs(accelerations, "acc_mag"), } )
def _floor_mid_heights(case: BuildingCase, n_floors: int) -> np.ndarray: """Floor mid-heights from the case z-edges, or a unit ladder if they disagree. The pressure stage may return fewer floor rows than the case has z-edges (empty slices are dropped), so fall back to an integer ladder when the edge count does not yield exactly ``n_floors`` mid-points. """ edges = np.asarray(case.floor_heights, dtype=np.float64) if edges.size == n_floors + 1: return 0.5 * (edges[:-1] + edges[1:]) return np.arange(n_floors, dtype=np.float64)