"""Classification of OpenFAST degree-of-freedom descriptions.
OpenFAST embeds an internal degree-of-freedom (DOF) token in each ElastoDyn and
BeamDyn state description (e.g. ``DOF_TFA1`` for the first tower fore-aft mode or
``DOF_BF(1,1)`` for the first flapwise mode of blade 1). These tokens are stable
across versions and are used here to map a state description onto a physical DOF
category, the originating module, the blade index for rotating DOFs, and whether
the state is a velocity. This classification underpins physics-aware mode
labeling.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from enum import Enum
__all__ = ["DofCategory", "DofInfo", "classify_dof"]
[docs]
class DofCategory(Enum):
"""Physical category of a degree of freedom (string-valued, 3.10 compatible)."""
PLATFORM_SURGE = "platform_surge"
PLATFORM_SWAY = "platform_sway"
PLATFORM_HEAVE = "platform_heave"
PLATFORM_ROLL = "platform_roll"
PLATFORM_PITCH = "platform_pitch"
PLATFORM_YAW = "platform_yaw"
TOWER_FORE_AFT_1 = "tower_fore_aft_1"
TOWER_SIDE_SIDE_1 = "tower_side_side_1"
TOWER_FORE_AFT_2 = "tower_fore_aft_2"
TOWER_SIDE_SIDE_2 = "tower_side_side_2"
NACELLE_YAW = "nacelle_yaw"
GENERATOR_AZIMUTH = "generator_azimuth"
DRIVETRAIN_TORSION = "drivetrain_torsion"
ROTOR_FURL = "rotor_furl"
TAIL_FURL = "tail_furl"
TEETER = "teeter"
BLADE_FLAP_1 = "blade_flap_1"
BLADE_FLAP_2 = "blade_flap_2"
BLADE_EDGE_1 = "blade_edge_1"
BLADE_PITCH = "blade_pitch"
UNKNOWN = "unknown"
[docs]
@dataclass(frozen=True)
class DofInfo:
"""Classification of a single state description.
Parameters
----------
category : DofCategory
The physical DOF category (``UNKNOWN`` if not recognized).
module : str
The originating module prefix (e.g. ``"ED"`` or ``"BD_1"``), or an empty
string if absent.
blade : int or None
The 1-based blade index for rotating blade DOFs, otherwise ``None``.
is_velocity : bool
Whether the state is the time derivative (velocity) of a DOF.
"""
category: DofCategory
module: str
blade: int | None
is_velocity: bool
_FIXED_TOKENS: dict[str, DofCategory] = {
"DOF_Sg": DofCategory.PLATFORM_SURGE,
"DOF_Sw": DofCategory.PLATFORM_SWAY,
"DOF_Hv": DofCategory.PLATFORM_HEAVE,
"DOF_R": DofCategory.PLATFORM_ROLL,
"DOF_P": DofCategory.PLATFORM_PITCH,
"DOF_Y": DofCategory.PLATFORM_YAW,
"DOF_TFA1": DofCategory.TOWER_FORE_AFT_1,
"DOF_TSS1": DofCategory.TOWER_SIDE_SIDE_1,
"DOF_TFA2": DofCategory.TOWER_FORE_AFT_2,
"DOF_TSS2": DofCategory.TOWER_SIDE_SIDE_2,
"DOF_Yaw": DofCategory.NACELLE_YAW,
"DOF_GeAz": DofCategory.GENERATOR_AZIMUTH,
"DOF_DrTr": DofCategory.DRIVETRAIN_TORSION,
"DOF_RFrl": DofCategory.ROTOR_FURL,
"DOF_TFrl": DofCategory.TAIL_FURL,
"DOF_Teet": DofCategory.TEETER,
}
# Blade DOF tokens: DOF_BF(blade, mode), DOF_BE(blade, mode), DOF_BP(blade).
_BLADE_FLAP_RE = re.compile(r"DOF_BF\((\d+),(\d+)\)")
_BLADE_EDGE_RE = re.compile(r"DOF_BE\((\d+),(\d+)\)")
_BLADE_PITCH_RE = re.compile(r"DOF_BP\((\d+)\)")
# A fixed token is ``DOF_`` followed by word characters, stopping before any '('.
_FIXED_TOKEN_RE = re.compile(r"DOF_(\w+)")
_MODULE_RE = re.compile(r"^([A-Za-z]+(?:_\d+)?)\s")
_DERIV_MARKER = "First time derivative of"
_FLAP_MODE = {1: DofCategory.BLADE_FLAP_1, 2: DofCategory.BLADE_FLAP_2}
[docs]
def classify_dof(description: str) -> DofInfo:
"""Classify a state description into a :class:`DofInfo`.
Parameters
----------
description : str
A state description from a linearization file (e.g.
``"ED 1st tower fore-aft ... (internal DOF index = DOF_TFA1), m"``).
Returns
-------
DofInfo
The classification; ``category`` is :attr:`DofCategory.UNKNOWN` when no
known DOF token is present.
"""
module_match = _MODULE_RE.match(description)
module = module_match.group(1) if module_match is not None else ""
is_velocity = _DERIV_MARKER in description
category, blade = _classify_token(description)
return DofInfo(
category=category, module=module, blade=blade, is_velocity=is_velocity
)
def _classify_token(description: str) -> tuple[DofCategory, int | None]:
"""Return the DOF category and blade index encoded in ``description``."""
flap = _BLADE_FLAP_RE.search(description)
if flap is not None:
blade, mode = int(flap.group(1)), int(flap.group(2))
return _FLAP_MODE.get(mode, DofCategory.UNKNOWN), blade
edge = _BLADE_EDGE_RE.search(description)
if edge is not None:
return DofCategory.BLADE_EDGE_1, int(edge.group(1))
pitch = _BLADE_PITCH_RE.search(description)
if pitch is not None:
return DofCategory.BLADE_PITCH, int(pitch.group(1))
fixed = _FIXED_TOKEN_RE.search(description)
if fixed is not None:
category = _FIXED_TOKENS.get(f"DOF_{fixed.group(1)}")
if category is not None:
return category, None
return DofCategory.UNKNOWN, None