Source code for cfdmod.core.recipes.s1

"""S1 recipe -- velocity-profile ratio against a reference.

Per the odt::

    volume field + probe positions  -> extracted velocity profiles
    profiles + interpolation scheme -> profiles on common heights
    profiles / reference profiles   -> S1

The recipe takes a :class:`PointsDataSource` (the CFD profile sampled
along a vertical column) and a reference :class:`PointsDataSource`
(the standard / target profile). It interpolates the CFD profile onto
the reference heights, then divides element-wise (broadcasting rule 4
for time-resolved profiles, rule 3 row-wise when one side is
time-aggregated). Wall samples are dropped.
"""

from __future__ import annotations

__all__ = ["S1RecipeConfig", "s1_pipeline", "build_s1"]

import numpy as np
from pydantic import BaseModel, ConfigDict

from cfdmod.adapters.memory import MemoryFieldStore
from cfdmod.core import algebra
from cfdmod.core.data_source import PointsDataSource
from cfdmod.core.ops.data_source_create.profile_interpolation import (
    ProfileInterpolationParams,
    profile_interpolation,
)
from cfdmod.core.topology import ElementMeta, Topology


[docs] class S1RecipeConfig(BaseModel): """S1 recipe parameters. Attributes: field: Velocity field on both profiles. Defaults to ``"u"``. out: Output field name. Defaults to ``"s1"``. wall_threshold: Reference values whose absolute value falls below this threshold (and the wall row at z=0) are dropped from the output. Mirrors the legacy ``Profile.__truediv__`` check. """ model_config = ConfigDict(frozen=True) field: str = "u" out: str = "s1" wall_threshold: float = 1e-6
def build_s1( cfd_profile: PointsDataSource, reference: PointsDataSource, cfg: S1RecipeConfig, ) -> PointsDataSource: """Compute the S1 profile. The reference's heights determine the common axis; the CFD profile is reinterpolated onto them. """ ref_z = reference.elements.position[:, 2] cfd_on_ref = profile_interpolation( cfd_profile, ProfileInterpolationParams(target_heights=ref_z, field=cfg.field), ) # Reference may carry a wall sample at z=0 that we drop afterwards; the # division warning would be noise. with np.errstate(divide="ignore", invalid="ignore"): s1_full = algebra.div(cfd_on_ref, reference, field=cfg.field, out=cfg.out) ref_arr = reference.fields.read(cfg.field) if ref_arr.ndim == 2: # mask = column-wise OR of "above threshold" -- conservatively # keep heights where the reference is non-trivial at *any* time. mask = (np.abs(ref_arr) > cfg.wall_threshold).any(axis=1) else: mask = np.abs(ref_arr) > cfg.wall_threshold if ref_z.size > 0 and ref_z[0] == 0.0: mask[0] = False if not mask.any(): raise ValueError("S1: every reference sample fell below the wall threshold") keep = np.flatnonzero(mask) s1_arr = s1_full.fields.read(cfg.out)[keep] new_pos = s1_full.elements.position[keep] return PointsDataSource( time=s1_full.time, topology=Topology.points(new_pos), elements=ElementMeta(position=new_pos), fields=MemoryFieldStore({cfg.out: s1_arr}), field_meta=( {cfg.out: s1_full.field_meta.get(cfg.out)} if cfg.out in s1_full.field_meta else {} ), ) def s1_pipeline(cfg: S1RecipeConfig, reference: PointsDataSource): """Curry :func:`build_s1` with a fixed reference profile.""" def run(cfd_profile: PointsDataSource) -> PointsDataSource: return build_s1(cfd_profile, reference, cfg) return run