Source code for cfdmod.core.recipes.cf

"""Cf recipe -- per-body directional force coefficient (own-area basis).

Two normalisation conventions produce a force coefficient, and they use
different aggregations:

- **Own-area basis** (this recipe): normalise by the body's own wetted
  area ``A_total``. Then

      Cf_i = (1 / A_total) * sum_k cp_k * area_k * n_k,i
           = area_weighted_mean_k(cp_k * n_k,i),

  because the ``area_k`` weights sum to ``A_total`` and cancel. So the
  **area-weighted mean** of the per-direction Cp component *is* the force
  coefficient on the own-area basis. This recipe takes pre-existing
  ``cp_<dir> = cp * n_<dir>`` fields and area-weight-means them per body.

- **Reference-area basis** (the ``fixtures/tests/pressure/templates/cf.yaml``
  template): normalise by an independent ``A_ref`` (e.g. a frontal area).
  Then ``A_ref != A_total`` does not cancel and Cf is a **sum** of
  ``force_contribution`` (``-cp*area*n / A_ref``) per body. Use this when
  a fixed reference area is required so Cf converts back to force
  unambiguously.

Contrast with :mod:`cfdmod.core.recipes.cm`, which always *sums* moment
contributions (there is no own-area cancellation for a moment). The caller
must supply the ``cp_<dir>`` fields for this recipe -- no built-in op
produces them; the reference-area path uses ``force_contribution`` instead.

Given a Cp data source with a ``"body"`` grouping (triangle -> body id)
and per-direction ``cp_<dir>`` fields, the recipe builds a
:class:`GroupsDataSource` with one output field per direction, all on the
same groups data source.
"""

from __future__ import annotations

__all__ = ["CfRecipeConfig", "cf_pipeline"]

from typing import Literal

from pydantic import BaseModel, ConfigDict

from cfdmod.core.data_source import DataSource, GroupsDataSource
from cfdmod.core.ops.data_source_create.field_series_for_groups import (
    FieldSeriesForGroupsParams,
    field_series_for_groups,
)


[docs] class CfRecipeConfig(BaseModel): """Cf recipe parameters. Attributes: grouping: Name of the grouping in ``ds.groupings`` that maps triangles to body ids. directions: Field-name suffixes per force direction. The recipe aggregates ``cp_<dir>`` for each entry. Defaults to the three Cartesian components. prefix: Source field prefix (``"cp"`` for the standard convention). """ model_config = ConfigDict(frozen=True) grouping: str directions: list[Literal["x", "y", "z"]] = ["x", "y", "z"] prefix: str = "cp"
def cf_pipeline(cfg: CfRecipeConfig): """Return a single-arg callable producing a :class:`GroupsDataSource`.""" def run(ds: DataSource) -> GroupsDataSource: result: GroupsDataSource | None = None for d in cfg.directions: field_in = f"{cfg.prefix}_{d}" field_out = f"cf_{d}" partial = field_series_for_groups( ds, FieldSeriesForGroupsParams( grouping=cfg.grouping, field=field_in, agg="area_weighted_mean", out=field_out, ), ) if result is None: result = partial else: arr = partial.fields.read(field_out) meta = partial.field_meta[field_out] result = result.with_field(field_out, arr, meta=meta) assert result is not None return result return run