Source code for cfdmod.building.comfort

"""Wind-induced occupant-comfort acceleration limits.

Tall buildings sway under wind; occupant comfort is judged by comparing the
peak horizontal acceleration at a floor against a standard's acceptance limit.
Three criteria are supported:

- ``"nbr"`` -- ABNT NBR 6123 serviceability limit, a decreasing power law in the
  fundamental sway frequency ``f0`` with separate residential / commercial
  coefficients.
- ``"melbourne"`` -- Melbourne & Palmer (1992) serviceable-acceleration curve,
  a function of ``f0`` and the return period in years.
- ``"nbcc"`` -- NBCC occupant-comfort criterion, a flat (frequency-independent)
  limit at a 10-year return period: 15 milli-g residential, 25 milli-g office /
  commercial.

All functions return the limit in SI ``m/s^2``; converting to milli-g is left to
callers (use :func:`mps2_to_milli_g`). ``f0`` may be a scalar or an
``np.ndarray``; the return matches the input shape, so a limit curve can be
evaluated over a frequency sweep in one call.
"""

from __future__ import annotations

from typing import Literal

import numpy as np

Occupancy = Literal["residential", "commercial"]
Standard = Literal["nbr", "melbourne", "nbcc"]

# Standard-of-record constants, VERIFIED against the primary sources:
# - NBR 6123 (Projeto NBR 6123, sec. 9.6.2 "Aceleracao limite para garantia do
#   conforto humano"): a_lim = 0.01 * k_c * f^-0.445 in m/s^2, with k_c = 4.08
#   (residential) / 6.12 (commercial and office). Valid over 0.06-1.00 Hz, at a
#   1-year return period. The perception curve is ISO 2631-2 / ISO 10137 Annex D
#   (residential ~8x, commercial ~12x the lower perception threshold).
# - Melbourne & Palmer (1992), "Accelerations and comfort criteria for buildings
#   undergoing complex motions", Eq. 3: a = sqrt(2 ln(n0 T)) * (0.68 + ln(R)/5)
#   * exp(-3.65 - 0.41 ln(n0)), with T = 600 s, in m/s^2. Valid over
#   0.06 < n0 < 1.0 Hz and 0.5 < R < 10 years.
# - NBCC: 15 / 25 milli-g (residential / office) at a 10-year return period.
# All three also match AeroSim's production hfpi_analysis notebook.
_NBR_C_RESIDENTIAL = 4.08
_NBR_C_COMMERCIAL = 6.12
_NBR_EXP = -0.445
_CM_TO_M = 0.01

_MELB_AVG_DURATION_S = 600.0
_MELB_A = 0.68
_MELB_B = 5.0
_MELB_C0 = -3.65
_MELB_C1 = -0.41

# NBCC flat occupant-comfort limits (milli-g, 10-year return period):
# residential 15, office / commercial 25.
_NBCC_MG_RESIDENTIAL = 15.0
_NBCC_MG_COMMERCIAL = 25.0

# Standard gravity used for milli-g <-> m/s^2 conversion (m/s^2).
_G = 9.806


def milli_g_to_mps2(milli_g: float | np.ndarray) -> float | np.ndarray:
    """Convert an acceleration from milli-g to ``m/s^2``."""
    return milli_g * _G / 1000.0


def mps2_to_milli_g(mps2: float | np.ndarray) -> float | np.ndarray:
    """Convert an acceleration from ``m/s^2`` to milli-g."""
    return mps2 * 1000.0 / _G


def _require_positive_f0(f0: float | np.ndarray) -> np.ndarray:
    arr = np.asarray(f0, dtype=np.float64)
    if np.any(arr <= 0.0):
        raise ValueError(f"f0 must be positive (got {f0!r})")
    return arr


def _match_shape(f0: float | np.ndarray, value: np.ndarray) -> float | np.ndarray:
    """Return a Python float for scalar input, else the array itself."""
    if np.ndim(f0) == 0:
        return float(value)
    return value


[docs] def nbr6123_acceleration_limit( f0: float | np.ndarray, occupancy: Occupancy = "residential", ) -> float | np.ndarray: """ABNT NBR 6123 serviceability acceleration limit (sec. 9.6.2). ``a_lim = 0.01 * coeff * f0**-0.445`` with ``coeff`` 4.08 (residential) or 6.12 (commercial); ``f0`` the fundamental sway frequency in Hz. The ``0.01`` converts the standard's cm/s^2 expression to m/s^2. Returns m/s^2. The standard states this over 0.06-1.00 Hz at a 1-year return period. """ arr = _require_positive_f0(f0) if occupancy == "residential": coeff = _NBR_C_RESIDENTIAL elif occupancy == "commercial": coeff = _NBR_C_COMMERCIAL else: raise ValueError(f"unknown occupancy {occupancy!r}") value = _CM_TO_M * coeff * arr**_NBR_EXP return _match_shape(f0, value)
[docs] def melbourne1992_acceleration_limit( f0: float | np.ndarray, return_period_years: float = 10.0, ) -> float | np.ndarray: """Melbourne & Palmer (1992) serviceable peak-acceleration limit (Eq. 3). ``a_lim = sqrt(2 ln(600 f0)) * (0.68 + ln(R) / 5) * exp(-3.65 - 0.41 ln f0)`` with ``f0`` the fundamental sway frequency (Hz), ``R`` the return period in years and ``600`` the averaging window (s). Returns m/s^2. The paper states this over 0.06 < f0 < 1.0 Hz and 0.5 < R < 10 years. """ arr = _require_positive_f0(f0) if return_period_years <= 0.0: raise ValueError(f"return_period_years must be positive (got {return_period_years})") if np.any(_MELB_AVG_DURATION_S * arr <= 1.0): raise ValueError(f"600 * f0 must exceed 1 for the sqrt term (got f0={f0!r})") value = ( np.sqrt(2.0 * np.log(_MELB_AVG_DURATION_S * arr)) * (_MELB_A + np.log(return_period_years) / _MELB_B) * np.exp(_MELB_C0 + _MELB_C1 * np.log(arr)) ) return _match_shape(f0, value)
[docs] def nbcc_acceleration_limit(occupancy: Occupancy = "residential") -> float: """NBCC flat occupant-comfort limit (10-year return period), in m/s^2. Frequency-independent: 15 milli-g residential, 25 milli-g office / commercial. """ if occupancy == "residential": milli_g = _NBCC_MG_RESIDENTIAL elif occupancy == "commercial": milli_g = _NBCC_MG_COMMERCIAL else: raise ValueError(f"unknown occupancy {occupancy!r}") return float(milli_g_to_mps2(milli_g))
[docs] def comfort_limit( f0: float | np.ndarray, standard: Standard, *, occupancy: Occupancy = "residential", return_period_years: float = 10.0, ) -> float | np.ndarray: """Dispatch to an acceleration-limit curve by ``standard``. Args: f0: fundamental sway frequency (Hz); scalar or array. standard: ``"nbr"``, ``"melbourne"`` or ``"nbcc"``. occupancy: ``"residential"`` or ``"commercial"`` (NBR / NBCC). return_period_years: return period (Melbourne only). Returns the limit in m/s^2 (matching the shape of ``f0``, except the flat NBCC limit which is a scalar). """ if standard == "nbr": return nbr6123_acceleration_limit(f0, occupancy=occupancy) if standard == "melbourne": return melbourne1992_acceleration_limit(f0, return_period_years=return_period_years) if standard == "nbcc": return nbcc_acceleration_limit(occupancy=occupancy) raise ValueError(f"unknown standard {standard!r}")