"""BuildingCase -- aggregate the per-case configuration.
A consulting case keeps its metadata in a ``case_data/`` directory:
global_data.json -- H, L, V0, batch name, body name, wind directions
params_cat*.yaml -- simulation U_H, fluid density, nominal area/volume,
floor heights (HEIGHTS), lever origin, coefficient blocks
wind_analysis_*.csv -- per-direction z0 / Kd / category (optional here)
``BuildingCase`` reads those into one immutable object the notebooks share.
The high-rise sequence extracts the *simulation* mean velocity at the reference
height from the inflow profile and then updates the case with it
(:meth:`with_reference_velocity`) so the Cp step non-dimensionalises with the
measured value rather than the value guessed at config time.
"""
from __future__ import annotations
import json
import pathlib
from typing import Annotated
import numpy as np
from pydantic import BaseModel, ConfigDict, Field
from ruamel.yaml import YAML
_yaml = YAML(typ="safe")
[docs]
class BuildingCase(BaseModel):
"""Immutable aggregate of a high-rise case's post-processing inputs."""
model_config = ConfigDict(frozen=True)
name: str
reference_height: Annotated[float, Field(gt=0, description="H, interest height [m]")]
characteristic_length: Annotated[float, Field(gt=0, description="L / B [m]")]
basic_wind_speed: Annotated[float, Field(gt=0, description="V0 [m/s]")]
fluid_density: Annotated[float, Field(gt=0, description="rho [kg/m^3]")] = 1.225
simul_reference_velocity: Annotated[
float, Field(gt=0, description="U_H from the simulation profile [m/s]")
]
reference_velocity: Annotated[
float | None,
Field(description="Measured U_H at reference height; overrides simul when set."),
] = None
nominal_area: Annotated[float, Field(gt=0, description="reference area for Cf [m^2]")]
nominal_volume: Annotated[float, Field(gt=0, description="reference volume for Cm [m^3]")]
floor_heights: Annotated[
list[float], Field(min_length=2, description="ascending floor Z edges [m]")
]
lever_origin: list[float] = [0.0, 0.0, 0.0]
directions: list[str] = []
body_name: str = "building"
# -- derived -----------------------------------------------------------
@property
def u_h(self) -> float:
"""Reference velocity actually used: measured if set, else simulation."""
return (
self.reference_velocity
if self.reference_velocity is not None
else (self.simul_reference_velocity)
)
@property
def dynamic_pressure(self) -> float:
"""q = 0.5 * rho * U_H^2 (Pa)."""
return 0.5 * self.fluid_density * self.u_h**2
@property
def n_floors(self) -> int:
return len(self.floor_heights) - 1
[docs]
def with_reference_velocity(self, u_ref: float) -> "BuildingCase":
"""Return a copy whose Cp normalisation uses the measured ``u_ref``."""
return self.model_copy(update={"reference_velocity": float(u_ref)})
# -- loading -----------------------------------------------------------
[docs]
@classmethod
def from_case_data(
cls,
case_data_dir: str | pathlib.Path,
params_name: str,
*,
body_name: str | None = None,
) -> "BuildingCase":
"""Build from a ``case_data/`` dir containing global_data.json + a params yaml.
Parses the consulting params layout (top-level ``anchors`` plus
``pressure_coefficient`` / ``force_coefficient`` / ``moment_coefficient``
blocks). Missing optional fields fall back to sensible defaults.
"""
case_data_dir = pathlib.Path(case_data_dir)
gd = json.loads((case_data_dir / "global_data.json").read_text())
with (case_data_dir / params_name).open() as fh:
params = _yaml.load(fh)
base_cp = _first_block(params.get("pressure_coefficient", {}))
force = _first_block(params.get("force_coefficient", {}))
moment = _first_block(params.get("moment_coefficient", {}))
# Floor heights: the per-floor table in alturas.csv is the source of
# truth (one row per storey with z_min/z_max). The yaml z_intervals
# anchor is only a coarse fallback for configs without the CSV.
heights = _floor_heights_from_csv(case_data_dir / "alturas.csv")
if len(heights) < 2:
heights = _floor_heights(force)
lever = _lever_origin(moment)
analysis = gd.get("analysis", {})
resolved_body = (
body_name or analysis.get("body_name") or (force.get("bodies") or [{}])[0].get("name")
)
directions = analysis.get(f"directions_cat{_cat(analysis)}", []) or analysis.get(
"directions", []
)
return cls(
name=case_data_dir.parent.name
if case_data_dir.name == "case_data"
else case_data_dir.name,
reference_height=float(gd["H"]),
characteristic_length=float(gd.get("L", gd.get("L1", 1.0))),
basic_wind_speed=float(gd["V0"]),
fluid_density=float(base_cp.get("fluid_density", 1.225)),
simul_reference_velocity=float(base_cp["simul_U_H"]),
nominal_area=float(force["nominal_area"]),
nominal_volume=float(moment["nominal_volume"]),
floor_heights=[float(z) for z in heights],
lever_origin=[float(v) for v in lever],
directions=list(directions),
body_name=resolved_body or "building",
)
def example_building_case(
mesh_path: str | pathlib.Path,
*,
u_h: float = 0.05,
rho: float = 1.0,
n_floors: int = 3,
) -> BuildingCase:
"""A self-contained BuildingCase tuned to a mesh, for notebook demos/tests.
Geometry fields are derived from the mesh bounding box (frontal area,
volume, floor z-edges). Defaults ``u_h``/``rho`` match the galpao fixture's
dynamic pressure (q = 0.00125) so Cp lands in the same range as the
``cp.yaml`` template. For real work use :meth:`BuildingCase.from_case_data`.
"""
from lnas import LnasFormat
verts = LnasFormat.from_file(pathlib.Path(mesh_path)).geometry.vertices
lo = verts.min(axis=0)
hi = verts.max(axis=0)
lx, ly, lz = hi - lo
return BuildingCase(
name=pathlib.Path(mesh_path).stem,
reference_height=float(max(hi[2], 1e-6)),
characteristic_length=float(max(lx, 1e-6)),
basic_wind_speed=float(u_h),
fluid_density=float(rho),
simul_reference_velocity=float(u_h),
nominal_area=float(max(lx * lz, 1e-6)),
nominal_volume=float(max(lx * lz * ly, 1e-6)),
floor_heights=[float(z) for z in np.linspace(lo[2], hi[2], n_floors + 1)],
)
def _first_block(section: dict) -> dict:
"""Coefficient blocks are keyed by config version (e.g. base_cp); take the first."""
if not section:
return {}
return next(iter(section.values()))
def _floor_heights(force_block: dict) -> list[float]:
bodies = force_block.get("bodies") or [{}]
sub = bodies[0].get("sub_bodies", {}) if bodies else {}
return list(sub.get("z_intervals", []))
def _floor_heights_from_csv(path: pathlib.Path) -> list[float]:
"""Ascending floor z-edges from an ``alturas.csv`` (``Pavimento,z_min,z_max,dz``).
The edges are the sorted union of every storey's ``z_min`` / ``z_max`` so
``n`` contiguous floors yield ``n + 1`` edges. Returns ``[]`` when the file
is absent or has no data rows (a header-only stub), so the caller can fall
back to the yaml ``z_intervals`` anchor.
"""
if not path.exists():
return []
import csv
edges: set[float] = set()
reader = csv.DictReader(path.read_text().splitlines())
for row in reader:
for key in ("z_min", "z_max"):
value = (row.get(key) or "").strip()
if not value:
continue
try:
edges.add(float(value))
except ValueError:
continue
return sorted(edges)
def _lever_origin(moment_block: dict) -> list[float]:
bodies = moment_block.get("bodies") or [{}]
return list(bodies[0].get("lever_origin", [0.0, 0.0, 0.0])) if bodies else [0.0, 0.0, 0.0]
def _cat(analysis: dict) -> str:
cats = analysis.get("categories") or []
return str(cats[0]) if cats else ""