Source code for cfdmod.geometry.grouping.base

"""Core types for the triangle-grouping pipeline.

Holds :class:`GroupingResult` and the :func:`apply_groupings` driver. The
per-kind ``apply_*`` functions live under :mod:`cfdmod.geometry.grouping.kinds`
and are dispatched here by spec type.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING

import numpy as np
from lnas import LnasFormat

if TYPE_CHECKING:
    import pandas as pd

from cfdmod.geometry.grouping.kinds.by_connectivity import (
    ByConnectivityGrouping,
    apply_by_connectivity,
)
from cfdmod.geometry.grouping.kinds.by_custom import CustomGrouping, apply_by_custom
from cfdmod.geometry.grouping.kinds.by_cylindrical import (
    ByCylindricalGrouping,
    apply_by_cylindrical,
)
from cfdmod.geometry.grouping.kinds.by_divisions import (
    ByDivisionsGrouping,
    apply_by_divisions,
)
from cfdmod.geometry.grouping.kinds.by_normal import ByNormalGrouping, apply_by_normal
from cfdmod.geometry.grouping.kinds.by_percentile import (
    ByPercentileGrouping,
    apply_by_percentile,
)
from cfdmod.geometry.grouping.kinds.by_plane import ByPlaneGrouping, apply_by_plane
from cfdmod.geometry.grouping.kinds.by_size import BySizeGrouping, apply_by_size
from cfdmod.geometry.grouping.kinds.by_surface import BySurfaceGrouping, apply_by_surface
from cfdmod.geometry.grouping.kinds.by_zoning import ByZoningGrouping, apply_by_zoning
from cfdmod.geometry.grouping.specs import GroupingSpec
from cfdmod.logger import logger


[docs] @dataclass(frozen=True) class GroupingResult: """Result of applying a chain of groupings to a parent mesh. Triangle indices are into the parent ``LnasFormat.geometry.triangles`` array (0..parent_n_triangles-1). A triangle may appear in zero, one, or many groups. Attributes: parent_n_triangles: Number of triangles in the parent mesh. groups: Mapping of ``group_name -> sorted np.int64 triangle indices``. """ parent_n_triangles: int groups: dict[str, np.ndarray] = field(default_factory=dict)
[docs] def membership_long(self) -> pd.DataFrame: """Long-form ``(triangle_idx, group_name)`` table. One row per ``(triangle, group)`` pair; the table is the natural place to express overlapping or absent group membership. Returns: DataFrame with columns ``triangle_idx`` (int64) and ``group_name`` (str). Empty if no groups were produced. """ import pandas as pd if not self.groups: return pd.DataFrame( { "triangle_idx": np.empty(0, dtype=np.int64), "group_name": pd.Series([], dtype="string"), } ) tri_arrs = [] name_arrs = [] for name, idxs in self.groups.items(): tri_arrs.append(np.asarray(idxs, dtype=np.int64)) name_arrs.append(np.full(len(idxs), name, dtype=object)) return pd.DataFrame( { "triangle_idx": ( np.concatenate(tri_arrs) if tri_arrs else np.empty(0, dtype=np.int64) ), "group_name": pd.array(np.concatenate(name_arrs), dtype="string"), } )
[docs] def to_region_idx(self, sep: str = "|", unassigned: str = "") -> np.ndarray: """Single-label-per-triangle view, sep-joined when overlapping. Triangles in no group are labelled ``unassigned`` (default empty string). Useful for legacy code paths that expect one region label per triangle. Args: sep: Separator used when a triangle belongs to several groups. unassigned: Label for triangles in no group. Returns: Object-dtype array of length ``parent_n_triangles``. """ out = np.full(self.parent_n_triangles, unassigned, dtype=object) if not self.groups: return out # Per-triangle list of group names, in insertion order of `groups`. per_tri: list[list[str]] = [[] for _ in range(self.parent_n_triangles)] for name, idxs in self.groups.items(): for t in idxs: per_tri[int(t)].append(name) for t, names in enumerate(per_tri): if names: out[t] = sep.join(names) return out
[docs] def apply_groupings( mesh: LnasFormat, groupings: list[GroupingSpec], ) -> GroupingResult: """Apply a chain of grouping specs to a parent mesh. Specs are applied left to right. Each spec produces a fresh ``name -> indices`` map which is merged into the running result; duplicate group names raise ``ValueError`` (no silent merging). A spec may carry a ``restrict_to: list[str] | None`` field. When set, that spec only considers triangles whose index is in the union of the named earlier groups; this is how the legacy ``surface -> sub_body`` nesting is expressed. Args: mesh: Parent mesh. groupings: Specs in application order. Returns: :class:`GroupingResult` over ``mesh``. Raises: ValueError: If ``groupings`` is empty, two specs produce the same group name, or a ``restrict_to`` references an unknown group. """ if not groupings: raise ValueError("apply_groupings: groupings list is empty (no-op)") parent_n = int(mesh.geometry.triangles.shape[0]) groups: dict[str, np.ndarray] = {} logger.info( f"apply_groupings: {len(groupings)} grouping(s) on mesh with " f"{parent_n} triangle(s) and {len(mesh.surfaces)} surface(s)" ) for i, spec in enumerate(groupings): restrict_to = getattr(spec, "restrict_to", None) if restrict_to: missing = [n for n in restrict_to if n not in groups] if missing: raise ValueError( f"apply_groupings[{i}] {type(spec).__name__}: " f"restrict_to references unknown groups: {missing}. " f"Available: {sorted(groups)}" ) mask = np.zeros(parent_n, dtype=bool) for n in restrict_to: mask[groups[n]] = True allowed = np.flatnonzero(mask) else: allowed = None # no restriction -> consider all parent triangles new_groups = _dispatch(spec, mesh, allowed) collisions = [n for n in new_groups if n in groups] if collisions: raise ValueError( f"apply_groupings[{i}] {type(spec).__name__}: " f"group name collision with earlier specs: {collisions}" ) logger.info( f" [{i}] {type(spec).__name__} produced {len(new_groups)} group(s): " f"{list(new_groups.keys())[:5]}{'...' if len(new_groups) > 5 else ''}" ) groups.update(new_groups) return GroupingResult(parent_n_triangles=parent_n, groups=groups)
def _dispatch( spec: GroupingSpec, mesh: LnasFormat, allowed: np.ndarray | None, ) -> dict[str, np.ndarray]: if isinstance(spec, BySurfaceGrouping): return apply_by_surface(spec, mesh, allowed) if isinstance(spec, ByZoningGrouping): return apply_by_zoning(spec, mesh, allowed) if isinstance(spec, ByDivisionsGrouping): return apply_by_divisions(spec, mesh, allowed) if isinstance(spec, BySizeGrouping): return apply_by_size(spec, mesh, allowed) if isinstance(spec, ByConnectivityGrouping): return apply_by_connectivity(spec, mesh, allowed) if isinstance(spec, ByNormalGrouping): return apply_by_normal(spec, mesh, allowed) if isinstance(spec, ByPlaneGrouping): return apply_by_plane(spec, mesh, allowed) if isinstance(spec, ByPercentileGrouping): return apply_by_percentile(spec, mesh, allowed) if isinstance(spec, ByCylindricalGrouping): return apply_by_cylindrical(spec, mesh, allowed) if isinstance(spec, CustomGrouping): return apply_by_custom(spec, mesh, allowed) raise TypeError(f"unknown grouping kind: {type(spec).__name__}")