Source code for cfdmod.building.loadcases

"""Per-floor structural-handoff load-case tables.

Reduces a multi-direction result container (see :mod:`cfdmod.dynamics.cases`)
into the two deliverables the structural engineer receives, faithful to the
consulting ``hfpi_analysis`` notebook:

1. ``effective_load_stats`` -- the per-floor Fx / Fy / Mz tables, one column per
   wind direction, for ``peak`` / ``min`` / ``max`` (and ``mean``). ``min`` /
   ``max`` are the signed per-floor effective-load envelopes; ``peak`` is the
   *governing* envelope (whichever of min / max has the larger mean magnitude
   per direction / axis -- the notebook's ``r_peak`` selection).
2. ``generate_load_cases`` + ``invert_load_cases`` -- the Eberick load cases:
   for each principal axis the critical direction is picked, then the principal
   sign (max / min) is combined with all four companion-axis sign combinations
   (the companion-load method). ``invert_load_cases`` reorganises the resulting
   cases into per-axis DataFrames (it does *not* negate).

"Effective" loads combine the dynamic static-equivalent loads with the applied
quasi-static loads (see :func:`cfdmod.dynamics.get_stats_forces_effective`); the
fan-out driver attaches the applied loads (``fs_*`` / ``ms_z``) so the
combination happens automatically. This is the fuller reducer;
:func:`cfdmod.dynamics.plotting.effective_peak_loads_per_direction` is the
presentation-layer sibling that reads ``feq_*`` alone (no applied-static
combination) and is what the current consulting notebooks call. Both default to
the same exact tonne-force divisor ``1 / 9806.65`` (1000 kgf * standard gravity
9.80665 m/s^2); pass your own to byte-match an old deliverable that used a
rounded ``9800`` / ``9806``.
"""

from __future__ import annotations

__all__ = [
    "LoadStat",
    "directional_envelopes",
    "effective_load_stats",
    "generate_load_cases",
    "invert_load_cases",
    "save_load_case_tables",
]

import pathlib
from typing import Literal

import numpy as np
import pandas as pd

from cfdmod.dynamics.cases import get_stats_forces_effective, join_by_direction

LoadStat = Literal["peak", "min", "max", "mean"]

# static-equivalent field -> structural load name
_LOAD_NAMES: tuple[str, str, str] = ("Fx", "Fy", "Mz")
_AXES: tuple[str, str, str] = ("x", "y", "z")

# N -> tonne-force (1 tf = 1000 kgf * 9.80665 m/s^2); moments N.m -> tf.m
_N_PER_TF = 9806.65


def directional_envelopes(
    container,
    *,
    feq_fields: tuple[str, str, str] = ("feq_x", "feq_y", "meq_z"),
) -> tuple[dict[str, dict[str, np.ndarray]], dict[str, dict[str, np.ndarray]]]:
    """Per-direction effective ``max`` / ``min`` envelopes ``{direction: {axis: arr}}``.

    Exactly one case per direction is required; pre-filter the container (e.g.
    ``cfdmod.dynamics.filter_by_xi``) so each direction maps to a single case.
    """
    max_dict: dict[str, dict[str, np.ndarray]] = {}
    min_dict: dict[str, dict[str, np.ndarray]] = {}
    for direction, sub in join_by_direction(container).items():
        if len(sub) != 1:
            raise ValueError(
                f"direction {direction} maps to {len(sub)} cases; pre-filter the container "
                "to a single case per direction (e.g. one xi / recurrence period)"
            )
        response = next(iter(sub.values()))
        max_dict[direction] = get_stats_forces_effective(response, "max", feq_fields=feq_fields)
        min_dict[direction] = get_stats_forces_effective(response, "min", feq_fields=feq_fields)
    return max_dict, min_dict


def effective_load_stats(
    container,
    *,
    feq_fields: tuple[str, str, str] = ("feq_x", "feq_y", "meq_z"),
    stats: tuple[LoadStat, ...] = ("peak", "min", "max"),
    unit_conversion: float = 1.0 / _N_PER_TF,
) -> dict[str, dict[str, pd.DataFrame]]:
    """Per-direction, per-floor Fx / Fy / Mz effective-load tables per statistic.

    Returns ``{stat: {"Fx": df, "Fy": df, "Mz": df}}`` where each frame has
    rows = floors and one column per direction (label ``f"{direction:.1f}"``).
    ``min`` / ``max`` are the signed effective envelopes, ``mean`` the average,
    and ``peak`` the governing envelope (larger |mean| of min / max per
    direction / axis). All values scaled by ``unit_conversion``.
    """
    need_mean = "mean" in stats
    tables: dict[str, dict[str, dict[str, np.ndarray]]] = {
        stat: {name: {} for name in _LOAD_NAMES} for stat in stats
    }
    # Partition by direction once; reduce each single-case response inline.
    for direction, sub in join_by_direction(container).items():
        if len(sub) != 1:
            raise ValueError(
                f"direction {direction} maps to {len(sub)} cases; pre-filter the container "
                "to a single case per direction (e.g. one xi / recurrence period)"
            )
        response = next(iter(sub.values()))
        r_max = get_stats_forces_effective(response, "max", feq_fields=feq_fields)
        r_min = get_stats_forces_effective(response, "min", feq_fields=feq_fields)
        r_mean = (
            get_stats_forces_effective(response, "mean", feq_fields=feq_fields)
            if need_mean
            else None
        )
        col = f"{float(direction):.1f}"
        for name, axis in zip(_LOAD_NAMES, _AXES):
            mx, mn = r_max[axis], r_min[axis]
            r_peak = mn if abs(mn.mean()) > abs(mx.mean()) else mx
            per_stat = {"max": mx, "min": mn, "peak": r_peak}
            if need_mean:
                per_stat["mean"] = r_mean[axis]
            for stat in stats:
                tables[stat][name][col] = per_stat[stat] * unit_conversion

    return {
        stat: {name: pd.DataFrame(cols) for name, cols in loads.items()}
        for stat, loads in tables.items()
    }


[docs] def generate_load_cases( max_dict: dict[str, dict[str, np.ndarray]], min_dict: dict[str, dict[str, np.ndarray]], *, unit_conversion: float = 1.0 / _N_PER_TF, ) -> dict[int, dict[str, np.ndarray]]: """Eberick companion-load cases from the per-direction envelopes. For each principal axis the critical direction is the one with the largest mean ``max`` load; the principal sign (max / min) is then combined with all four companion-axis sign combinations. Returns ``{case_id: {"Fx","Fy","Mz": per-floor array}}`` (loads scaled by ``unit_conversion``). Mirrors the ``hfpi_analysis`` notebook's ``generate_load_cases``. """ axes = list(_AXES) companion_combinations = [("max", "max"), ("max", "min"), ("min", "max"), ("min", "min")] picked = {"max": max_dict, "min": min_dict} def axis_name(axis: str) -> str: return ("F" + axis) if axis in ("x", "y") else ("M" + axis) load_cases: dict[int, dict[str, np.ndarray]] = {} case_id = 0 for principal in axes: theta_star = max(max_dict.keys(), key=lambda d: max_dict[d][principal].mean()) b, c = [a for a in axes if a != principal] for principal_sign in ("max", "min"): for comb in companion_combinations: load: dict[str, np.ndarray] = {} load[axis_name(principal)] = ( picked[principal_sign][theta_star][principal] * unit_conversion ) for axis, sign in zip((b, c), comb): load[axis_name(axis)] = picked[sign][theta_star][axis] * unit_conversion load_cases[case_id] = load case_id += 1 return load_cases
def invert_load_cases(cases: dict[int, dict[str, np.ndarray]]) -> dict[str, pd.DataFrame]: """Reorganise load cases into per-axis DataFrames (rows = floors, cols = case). Mirrors the notebook's ``invert_load_cases`` -- a transpose of ``{case_id: {axis: arr}}`` into ``{axis: DataFrame}``; it does not negate. """ data: dict[str, dict[int, np.ndarray]] = {axis: {} for axis in _LOAD_NAMES} for case_id, load in cases.items(): for axis in _LOAD_NAMES: data[axis][case_id] = load[axis] return {axis: pd.DataFrame(data[axis]) for axis in _LOAD_NAMES}
[docs] def save_load_case_tables( stats: dict[str, dict[str, pd.DataFrame]], writer, *, deliverable: bool = True, floor_heights: np.ndarray | None = None, prefix: str = "loadcase", skip_if_exists: bool = False, ) -> dict[str, pathlib.Path]: """Write each ``{prefix}_{stat}_{Fx|Fy|Mz}.csv`` via ``writer.save_csv``. A leading ``floor`` column (and ``z`` when ``floor_heights`` is given) is materialized because ``DebugWriter.save_csv`` defaults to ``index=False``. Returns ``{csv name: written path}``. """ written: dict[str, pathlib.Path] = {} for stat, loads in stats.items(): for name, df in loads.items(): out = df.copy() out.insert(0, "floor", np.arange(len(out))) if floor_heights is not None: if len(floor_heights) != len(out): raise ValueError( f"floor_heights has {len(floor_heights)} entries; expected {len(out)}" ) out.insert(1, "z", np.asarray(floor_heights, dtype=np.float64)) csv_name = f"{prefix}_{stat}_{name}.csv" written[csv_name] = writer.save_csv( out, csv_name, deliverable=deliverable, skip_if_exists=skip_if_exists ) return written