"""v3 recipe/op wiring for the high-rise pressure stages.
Thin composition of library ops -- no new maths lives here. The high-rise
choices baked in:
Cp = (p - p_ref) / q, q = 0.5 * rho * U_H^2 (from BuildingCase)
Cf per floor: force_contribution (explicit reference area) summed per floor.
Cm per floor: moment_contribution about the case lever origin, summed per
floor (normalised by the reference volume).
Per-floor partitioning has two methods, chosen by ``method``:
- ``"face_cut"`` (default) -- geometrically slice each triangle at the floor
z-edges so a triangle straddling a boundary contributes its *real partial
area* to each floor. Exact force/moment by floor.
- ``"centroid"`` -- the fast/approximate ``zoning_grouping``: assign each whole
triangle to one floor by its centroid. Cheaper, but a triangle spanning a
boundary lands entirely on one side.
Both attach a ``"floor"`` grouping (raster region id == floor index, since x/y
are open) and sum the per-triangle contributions with
``field_series_for_groups(agg="sum")``. The reference-area normalisation (vs the
legacy per-region bounding-box area) is the convention chosen for 3.2.
"""
from __future__ import annotations
from typing import Literal
import numpy as np
from cfdmod.core.data_source import DataSource, GroupsDataSource
from cfdmod.core.dtypes import FIELD_DTYPE
from cfdmod.core.ops.data_source_create.face_cut import FaceCutParams, face_cut
from cfdmod.core.ops.data_source_create.field_series_for_groups import (
FieldSeriesForGroupsParams,
field_series_for_groups,
)
from cfdmod.core.ops.field.force_contribution import ForceContributionParams, force_contribution
from cfdmod.core.ops.field.moment_contribution import (
MomentContributionParams,
moment_contribution,
)
from cfdmod.core.ops.geometric.mesh_attach import MeshAttachParams, mesh_attach
from cfdmod.core.ops.geometric.zoning_grouping import ZoningGroupingParams, zoning_grouping
from cfdmod.core.recipes import CpRecipeConfig, build_cp
from .case import BuildingCase
_FLOOR = "floor"
FloorMethod = Literal["face_cut", "centroid"]
[docs]
def cp_from_pressure(
body: DataSource,
p_ref,
case: BuildingCase,
*,
statistics: list[str] | None = None,
time_rescale_factor: float | None = None,
) -> DataSource:
"""Cp time series (or stats) non-dimensionalised by the case dynamic pressure.
``p_ref`` is a scalar reference pressure or a points/surface DataSource
(broadcast per timestep). Pass ``statistics`` to collapse the time axis to
mean/rms/peak fields instead of the full series.
"""
cfg = CpRecipeConfig(
dynamic_pressure=case.dynamic_pressure,
statistics=statistics or [],
time_rescale_factor=time_rescale_factor,
)
return build_cp(body, p_ref=p_ref, cfg=cfg)
def _partition_floors(
ds: DataSource, mesh_path: str, case: BuildingCase, method: FloorMethod
) -> DataSource:
"""Attach a per-floor partition; ``face_cut`` slices, ``centroid`` groups whole triangles.
``face_cut`` returns a new (fragmented) surface whose fields are inherited
from the parent, so force/moment must be computed *after* it to pick up the
partial fragment areas. ``centroid`` attaches a grouping to the same surface.
"""
if method == "face_cut":
return face_cut(ds, FaceCutParams(z_intervals=list(case.floor_heights), name=_FLOOR))
if method == "centroid":
return zoning_grouping(
ds,
ZoningGroupingParams(
mesh=mesh_path, z_intervals=list(case.floor_heights), name=_FLOOR
),
)
raise ValueError(f"unknown floor method {method!r}; expected 'face_cut' or 'centroid'")
def _sum_per_floor(ds: DataSource, fields: list[str]) -> GroupsDataSource:
"""field_series_for_groups (agg=sum) for each field, merged onto one groups source."""
result: GroupsDataSource | None = None
for field in fields:
partial = field_series_for_groups(
ds,
FieldSeriesForGroupsParams(grouping=_FLOOR, field=field, agg="sum", out=field),
)
if result is None:
result = partial
else:
result = result.with_field(
field, partial.fields.read(field), meta=partial.field_meta[field]
)
assert result is not None
return result
def _attach_and_partition(
cp_ds: DataSource, mesh_path: str, case: BuildingCase, method: FloorMethod
) -> DataSource:
"""mesh_attach + per-floor partition, shared by the Cf / Cm recipes.
``face_cut`` returns a fragmented surface with fragment areas/normals;
``centroid`` attaches a grouping to the mesh-attached surface. Either way the
result is ready for :func:`force_contribution`.
"""
ds = mesh_attach(cp_ds, MeshAttachParams(mesh=mesh_path))
return _partition_floors(ds, mesh_path, case, method)
def _force(ds: DataSource, case: BuildingCase, directions: list[str]) -> DataSource:
return force_contribution(
ds, ForceContributionParams(nominal_area=case.nominal_area, directions=directions)
)
def _moment(ds: DataSource, case: BuildingCase, directions: list[str]) -> DataSource:
return moment_contribution(
ds,
MomentContributionParams(
lever_origin=tuple(case.lever_origin),
nominal_area=case.nominal_area,
nominal_volume=case.nominal_volume,
directions=directions,
),
)
[docs]
def cf_per_floor(
cp_ds: DataSource,
mesh_path: str,
case: BuildingCase,
*,
directions: tuple[str, ...] = ("x", "y"),
method: FloorMethod = "centroid",
) -> GroupsDataSource:
"""Per-floor force coefficients cf_<dir>, one row per floor slice.
``method="centroid"`` (default) assigns each whole triangle to a floor by its
centroid -- fast, bounded memory, and matching the v2 sub-body grouping.
``method="face_cut"`` slices triangles at the floor edges for an exact
partial-area split, but fragments the mesh (much heavier); prefer centroid at
production sizes. See :func:`cf_cm_per_floor` when you need both Cf and Cm.
Precision follows the Cp field's dtype (float32 for solver output).
"""
ds = _attach_and_partition(cp_ds, mesh_path, case, method)
ds = _force(ds, case, list(directions))
return _sum_per_floor(ds, [f"cf_{d}" for d in directions])
[docs]
def cm_per_floor(
cp_ds: DataSource,
mesh_path: str,
case: BuildingCase,
*,
directions: tuple[str, ...] = ("z",),
method: FloorMethod = "centroid",
) -> GroupsDataSource:
"""Per-floor moment coefficients cm_<dir> about the case lever origin.
See :func:`cf_per_floor` for the ``method`` trade-off.
"""
ds = _attach_and_partition(cp_ds, mesh_path, case, method)
# moment_contribution reads all three force components, so produce them all.
ds = _force(ds, case, ["x", "y", "z"])
ds = _moment(ds, case, list(directions))
return _sum_per_floor(ds, [f"cm_{d}" for d in directions])
def cf_cm_per_floor(
cp_ds: DataSource,
mesh_path: str,
case: BuildingCase,
*,
cf_directions: tuple[str, ...] = ("x", "y"),
cm_directions: tuple[str, ...] = ("z",),
method: FloorMethod = "centroid",
) -> tuple[GroupsDataSource, GroupsDataSource]:
"""Per-floor Cf and Cm from a **single** mesh-attach + partition + force pass.
Computing Cf and Cm separately (``cf_per_floor`` + ``cm_per_floor``) runs the
heavy ``mesh_attach`` + partition + ``force_contribution`` twice. When both
are needed -- the normal high-rise case -- this fuses them: the three force
components are computed once and reused for the Cf sums and the moment.
Returns ``(cf, cm)``.
"""
ds = _attach_and_partition(cp_ds, mesh_path, case, method)
ds = _force(ds, case, ["x", "y", "z"])
cf = _sum_per_floor(ds, [f"cf_{d}" for d in cf_directions])
ds = _moment(ds, case, list(cm_directions))
cm = _sum_per_floor(ds, [f"cm_{d}" for d in cm_directions])
return cf, cm
def per_floor_loads(
body: DataSource,
p_ref,
mesh_path: str,
case: BuildingCase,
*,
cf_directions: tuple[str, ...] = ("x", "y"),
cm_directions: tuple[str, ...] = ("z",),
method: FloorMethod = "centroid",
chunk_size: int | None = None,
compute_dtype=FIELD_DTYPE,
) -> tuple[GroupsDataSource, GroupsDataSource]:
"""Memory-bounded per-floor Cf / Cm straight from body + reference pressure.
Fuses ``Cp -> force -> moment -> per-floor sum`` and streams it over time
windows of ``chunk_size`` timesteps, so the full ``(n_triangles,
n_timesteps)`` Cp / force arrays never materialise at once: peak memory is
``O(n_triangles * chunk_size)`` while the returned per-floor series is small.
With ``chunk_size=None`` (default) it runs whole-series (identical result);
pass a chunk (e.g. a few thousand) for production-size cases. The source
pressure is cast to ``compute_dtype`` (float32 default) so the whole
per-triangle chain runs in that precision. Returns ``(cf, cm)`` with the full
time axis, ready for :func:`cfdmod.building.floor_load_source`.
"""
from cfdmod.core.chunked import concat_time, slice_time, time_windows
dt = np.dtype(compute_dtype)
def _cast(ds: DataSource) -> DataSource:
# Cast the source pressure so Cp (and everything downstream) inherits the
# precision; the ops then preserve this dtype through force / moment / sum.
return ds.with_field("pressure", np.asarray(ds.fields.read("pressure"), dtype=dt))
def _window(b: DataSource, r) -> tuple[GroupsDataSource, GroupsDataSource]:
cp = cp_from_pressure(_cast(b), _cast(r), case)
return cf_cm_per_floor(
cp,
mesh_path,
case,
cf_directions=cf_directions,
cm_directions=cm_directions,
method=method,
)
n_t = body.time.n_timesteps
if chunk_size is None or n_t == 0 or chunk_size >= n_t:
return _window(body, p_ref)
cf_parts: list[GroupsDataSource] = []
cm_parts: list[GroupsDataSource] = []
for sl in time_windows(n_t, chunk_size):
cf_w, cm_w = _window(slice_time(body, sl), slice_time(p_ref, sl))
cf_parts.append(cf_w)
cm_parts.append(cm_w)
return concat_time(cf_parts), concat_time(cm_parts)