"""Dynamic-analysis recipe -- per-element loads -> modal solution -> physical response.
Per the odt::
container of Cf + modes data -> solution as modal displacements
container of solutions -> displacement / acceleration / loads
in original coordinates
This recipe is the small-data analogue of the legacy
``cfdmod.hfpi.dynamic`` pipeline. It wires three of the Phase 6
primitives together:
1. :func:`modal_projection` -- physical-space load timeseries ``f``
times mode shapes ``phi`` -> generalized loads ``Q``.
2. A user-supplied modal solver (``Q -> q``) -- the linear ODE
``Mq'' + Cq' + Kq = Q`` is the SDOF case per mode and lives outside
the algebra layer. We accept any callable that maps a
:class:`ModesDataSource` to another :class:`ModesDataSource`.
3. :func:`modal_recomposition` -- modal coordinates back into the
physical mesh.
A trivial ``identity`` solver is provided for tests and for the
"already solved" path (Q == q, e.g. quasi-static modal scaling).
"""
from __future__ import annotations
__all__ = [
"DynamicAnalysisConfig",
"build_dynamic_response",
"identity_solver",
"sdof_rk45_solver",
"BuildingDynamicConfig",
"build_building_dynamic_response",
"ComfortConfig",
"build_point_accelerations",
]
from typing import Any, Callable
import numpy as np
from pydantic import BaseModel, ConfigDict
from scipy import integrate
from scipy.interpolate import interp1d
from cfdmod.adapters.memory import MemoryFieldStore
from cfdmod.core.data_source import DataSource, ModesDataSource, PointsDataSource
from cfdmod.core.field_meta import FieldMeta
from cfdmod.core.ops.data_source_create.generalized_building_load import (
GeneralizedBuildingLoadParams,
generalized_building_load,
)
from cfdmod.core.ops.data_source_create.modal_projection import (
ModalProjectionParams,
modal_projection,
)
from cfdmod.core.ops.data_source_create.modal_recomposition import (
ModalRecompositionParams,
modal_recomposition,
)
from cfdmod.core.ops.field.derivative import DerivativeParams, derivative
from cfdmod.core.topology import ElementMeta, Topology
ModalSolver = Callable[[ModesDataSource], ModesDataSource]
def identity_solver(modes: ModesDataSource) -> ModesDataSource:
"""Pass-through solver. Useful when the caller already has the modal
response (e.g. quasi-static scaling) or for tests."""
return modes
def _solve_sdof_rk45(gen_force: np.ndarray, dt: float, wp: float, xi: float) -> np.ndarray:
"""Integrate one mode's single-degree-of-freedom modal ODE with RK45.
Solves the mass-normalized modal equation for the generalized
displacement ``x(t)``::
x'' + 2 * xi * wp * x' + wp^2 * x = Q(t)
where ``Q`` is the (mass-normalized) generalized-load timeseries
``gen_force``. The equation assumes unit generalized mass -- the
mode shapes feeding the projection must be mass-normalized (see
:func:`sdof_rk45_solver`).
Args:
gen_force: Generalized-load history ``Q`` for one mode, shape ``(n_t,)``.
dt: Timestep size (seconds).
wp: Angular natural frequency ``wp = 2 * pi * f`` (rad/s).
xi: Damping ratio (e.g. 0.01 - 0.02).
Returns:
Generalized-displacement history ``x`` for the mode, shape ``(n_t,)``.
"""
end_step = (len(gen_force) - 1) * dt
t_eval = np.linspace(0, end_step, len(gen_force))
f_func = interp1d(t_eval, gen_force, kind="linear", fill_value="extrapolate")
def system(t, y):
f_t = f_func(t)
x, v = y
# x' = v ; v' = Q(t) - 2*xi*wp*v - wp^2*x
return [v, f_t - 2 * xi * wp * v - wp**2 * x]
# Seed the ODE near steady state to suppress a spurious startup transient:
# x0 balances the mean forcing, v0 tracks the mean forcing rate.
x0 = gen_force.mean() / (wp**2)
dfdt = (gen_force[1:] - gen_force[:-1]).mean() / dt
v0 = dfdt / (2 * xi * wp) if xi * wp != 0 else 0.0
sol = integrate.solve_ivp(
system, (t_eval[0], t_eval[-1]), [x0, v0], t_eval=t_eval, method="RK45"
)
return sol.y[0]
def sdof_rk45_solver(
*,
natural_frequencies: Any,
damping_ratio: Any,
) -> ModalSolver:
"""Build a :class:`ModalSolver` that integrates each mode as an SDOF oscillator.
The returned solver reads the generalized-load timeseries from field
``"q"`` of the :class:`ModesDataSource` (shape ``(n_modes, n_t)``),
integrates the mass-normalized modal ODE per mode with
:func:`_solve_sdof_rk45`, and returns the modes source with ``"q"``
replaced by the generalized-displacement response.
Precondition (not silently assumed): the mode shapes used to build
the generalized load must be mass-normalized (unit generalized mass),
since the SDOF ODE carries no explicit mass term. Build the modal
load from mass-normalized shapes upstream.
Args:
natural_frequencies: Angular natural frequencies ``wp = 2*pi*f``
(rad/s), one per mode. Length must equal ``n_modes``.
damping_ratio: Damping ratio ``xi``. A scalar is broadcast across
all modes; an array must have one entry per mode.
Returns:
A callable ``ModesDataSource -> ModesDataSource``.
"""
wps = np.atleast_1d(np.asarray(natural_frequencies, dtype=np.float64))
def solver(modes: ModesDataSource) -> ModesDataSource:
q = np.asarray(modes.fields.read("q"), dtype=np.float64)
if q.ndim != 2:
raise ValueError(f"modes field 'q' must be 2-D (n_modes, n_t); got {q.shape}")
n_modes = q.shape[0]
if wps.shape[0] != n_modes:
raise ValueError(
f"natural_frequencies has {wps.shape[0]} entries; expected n_modes={n_modes}"
)
xi = np.broadcast_to(
np.atleast_1d(np.asarray(damping_ratio, dtype=np.float64)), (n_modes,)
)
dt = float(modes.time.timestep_size)
disp = np.empty_like(q)
for i in range(n_modes):
disp[i, :] = _solve_sdof_rk45(q[i, :], dt=dt, wp=float(wps[i]), xi=float(xi[i]))
return modes.with_field("q", disp)
return solver
[docs]
class DynamicAnalysisConfig(BaseModel):
"""Dynamic-analysis recipe parameters.
Attributes:
mode_shapes: ``(n_load_elements, n_modes)`` mode-shape matrix at
the load points (used to compute ``Q``). For most cases the
same mode shapes also drive recomposition (see
``recomposition_mode_shapes``).
recomposition_mode_shapes: Optional ``(n_target_elements,
n_modes)`` matrix evaluated at the *target* coordinates. If
``None``, ``mode_shapes`` is reused (load and target
coincide).
target_points: ``(n_target_elements, 3)`` coordinates for the
recomposed response.
load_field: Field name carrying the load timeseries on the
input data source. Defaults to ``"force"``.
response_field: Field name on the output points data source.
Defaults to ``"u"``.
"""
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
mode_shapes: Any
target_points: Any
recomposition_mode_shapes: Any | None = None
load_field: str = "force"
response_field: str = "u"
def build_dynamic_response(
load_source: DataSource,
cfg: DynamicAnalysisConfig,
*,
solver: ModalSolver = identity_solver,
) -> PointsDataSource:
"""Assemble the recipe end-to-end."""
phi = np.asarray(cfg.mode_shapes, dtype=np.float64)
modes = modal_projection(
load_source,
ModalProjectionParams(mode_shapes=phi, field=cfg.load_field, out="q"),
)
solved = solver(modes)
phi_target = (
phi
if cfg.recomposition_mode_shapes is None
else np.asarray(cfg.recomposition_mode_shapes, dtype=np.float64)
)
return modal_recomposition(
solved,
ModalRecompositionParams(
mode_shapes=phi_target,
target_points=np.asarray(cfg.target_points, dtype=np.float64),
field="q",
out=cfg.response_field,
),
)
class BuildingDynamicConfig(BaseModel):
"""Building dynamic-response recipe parameters.
Ports the legacy ``cfdmod.hfpi`` high-frequency-pressure-integration
pipeline: per-floor force/moment coefficients -> generalized modal
loads -> SDOF modal displacements -> physical floor displacements and
static-equivalent floor forces.
Attributes:
mode_shapes: ``(n_floors, n_modes, 3)`` per-floor ``[DX, DY, RZ]``
components. Must be mass-normalized (unit generalized mass).
floor_points: ``(n_floors, 3)`` floor coordinates for the output.
cm_positions: ``(n_floors, 2)`` CM offsets ``[XR, YR]`` per floor.
floors_mass: ``(n_floors,)`` floor masses (for static-equivalent forces).
floors_radius: ``(n_floors,)`` floor radii of gyration.
natural_frequencies: ``(n_modes,)`` angular natural frequencies
``wp = 2*pi*f`` (rad/s).
damping_ratio: Damping ratio ``xi``; scalar (broadcast) or per-mode array.
field_x / field_y / field_mz: Load-coefficient field names on the input.
"""
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
mode_shapes: Any
floor_points: Any
cm_positions: Any
floors_mass: Any
floors_radius: Any
natural_frequencies: Any
damping_ratio: Any = 0.02
field_x: str = "cf_x"
field_y: str = "cf_y"
field_mz: str = "cm_z"
def build_building_dynamic_response(
load_source: DataSource, cfg: BuildingDynamicConfig
) -> PointsDataSource:
"""Assemble the building dynamic-response recipe end-to-end.
Returns a :class:`PointsDataSource` over the floors carrying six
fields: floor displacements ``disp_x`` / ``disp_y`` / ``rot_z`` and
static-equivalent floor loads ``feq_x`` / ``feq_y`` / ``meq_z``.
"""
phi = np.asarray(cfg.mode_shapes, dtype=np.float64)
wps = np.atleast_1d(np.asarray(cfg.natural_frequencies, dtype=np.float64))
# 1. Physical loads -> generalized modal loads (CM lever arm).
modes = generalized_building_load(
load_source,
GeneralizedBuildingLoadParams(
mode_shapes=phi,
cm_positions=np.asarray(cfg.cm_positions, dtype=np.float64),
field_x=cfg.field_x,
field_y=cfg.field_y,
field_mz=cfg.field_mz,
out="q",
),
)
# 2. Per-mode SDOF integration -> generalized modal displacements.
solver = sdof_rk45_solver(natural_frequencies=wps, damping_ratio=cfg.damping_ratio)
solved = solver(modes)
# 3. Recompose physical floor response + static-equivalent loads.
q = np.asarray(solved.fields.read("q"), dtype=np.float64) # (n_modes, n_t)
dx = phi[:, :, 0] # (n_floors, n_modes)
dy = phi[:, :, 1]
rz = phi[:, :, 2]
disp_x = dx @ q # (n_floors, n_t)
disp_y = dy @ q
rot_z = rz @ q
mass = np.asarray(cfg.floors_mass, dtype=np.float64)[:, None]
radius = np.asarray(cfg.floors_radius, dtype=np.float64)[:, None]
qw = (wps**2)[:, None] * q # (n_modes, n_t)
feq_x = mass * (dx @ qw)
feq_y = mass * (dy @ qw)
meq_z = mass * radius**2 * (rz @ qw)
pts = np.asarray(cfg.floor_points, dtype=np.float64)
fields = {
"disp_x": disp_x,
"disp_y": disp_y,
"rot_z": rot_z,
"feq_x": feq_x,
"feq_y": feq_y,
"meq_z": meq_z,
}
return PointsDataSource(
time=solved.time,
topology=Topology.points(pts),
elements=ElementMeta(position=pts),
fields=MemoryFieldStore(fields),
field_meta={k: FieldMeta(name=k) for k in fields},
)
class ComfortConfig(BaseModel):
"""Point-acceleration (comfort) recipe parameters.
Evaluates the horizontal acceleration a building occupant feels at an
off-center point ``point`` on each floor. The point translates with the
floor and swings with its torsion, so the perceived displacement adds a
rotational lever-arm term before differentiation:
displ_angle = atan2(point - CM) + rot_z
px = disp_x + cos(displ_angle) * r
py = disp_y + sin(displ_angle) * r
with ``r = |point - CM|``; accelerations are the second time-derivative
of ``px`` / ``py``.
Attributes:
cm_positions: ``(n_floors, 2)`` CM offsets ``[XR, YR]`` per floor.
point: ``(x, y)`` query point (same frame as ``cm_positions``).
disp_x_field / disp_y_field / rot_z_field: Input field names.
"""
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
cm_positions: Any
point: Any = (0.0, 0.0)
disp_x_field: str = "disp_x"
disp_y_field: str = "disp_y"
rot_z_field: str = "rot_z"
def build_point_accelerations(response: PointsDataSource, cfg: ComfortConfig) -> PointsDataSource:
"""Per-floor horizontal accelerations at an off-center point.
Consumes a building-response :class:`PointsDataSource` (floor
displacements ``disp_x`` / ``disp_y`` / ``rot_z``) and returns it
augmented with time-resolved ``acc_x`` / ``acc_y`` / ``acc_mag``. Peak /
comfort reduction is a separate step: apply
:func:`cfdmod.core.ops.data_source_create.extreme_value.extreme_value`
to ``acc_mag`` (or per axis).
"""
dx = np.asarray(response.fields.read(cfg.disp_x_field), dtype=np.float64)
dy = np.asarray(response.fields.read(cfg.disp_y_field), dtype=np.float64)
rz = np.asarray(response.fields.read(cfg.rot_z_field), dtype=np.float64)
cm = np.asarray(cfg.cm_positions, dtype=np.float64) # (n_floors, 2)
point = np.asarray(cfg.point, dtype=np.float64) # (2,)
rel = point[None, :] - cm # (n_floors, 2)
point_angle = np.arctan2(rel[:, 1], rel[:, 0])[:, None] # (n_floors, 1)
r = np.hypot(rel[:, 0], rel[:, 1])[:, None] # (n_floors, 1)
displ_angle = point_angle + rz # (n_floors, n_t)
px = dx + np.cos(displ_angle) * r
py = dy + np.sin(displ_angle) * r
work = response.with_field("_px", px).with_field("_py", py)
work = derivative(work, DerivativeParams(order=2, field="_px", out="acc_x"))
work = derivative(work, DerivativeParams(order=2, field="_py", out="acc_y"))
acc_x = np.asarray(work.fields.read("acc_x"), dtype=np.float64)
acc_y = np.asarray(work.fields.read("acc_y"), dtype=np.float64)
acc_mag = np.hypot(acc_x, acc_y)
return (
response.with_field("acc_x", acc_x)
.with_field("acc_y", acc_y)
.with_field("acc_mag", acc_mag)
)