Source code for vane.io.fst_reader

"""Readers for OpenFAST model input decks (``.fst`` and ElastoDyn).

These parsers extract only the information VANE needs to drive linearization
post-processing: the linearization configuration block from the primary ``.fst``
file (so the expected set of ``.lin`` files is known), the active module
switches and referenced sub-file paths, and the rotor/tower geometry from
ElastoDyn (used to scale mode shapes).

OpenFAST input files use the convention ``<value(s)> <Keyword> - <description>``;
the value(s) precede the keyword on each line. Quoted string values and
multi-value array lines (e.g. ``LinTimes``) are both handled.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from pathlib import Path

__all__ = [
    "FastModel",
    "FstFileError",
    "LinearizationConfig",
    "TurbineGeometry",
    "read_elastodyn_geometry",
    "read_fst_file",
]

# Module switches read from the primary .fst file.
_COMP_SWITCHES = (
    "CompElast",
    "CompInflow",
    "CompAero",
    "CompServo",
    "CompSeaSt",
    "CompHydro",
    "CompSub",
    "CompMooring",
    "CompIce",
    "CompSoil",
)

# Referenced module sub-file keywords, in primary-file order.
_SUB_FILE_KEYWORDS = (
    "EDFile",
    "BDBldFile(1)",
    "BDBldFile(2)",
    "BDBldFile(3)",
    "InflowFile",
    "AeroFile",
    "ServoFile",
    "SeaStFile",
    "HydroFile",
    "SubFile",
    "MooringFile",
    "IceFile",
    "SoilFile",
)

_TRUE_TOKENS = frozenset({"true", "t", ".true."})
_FALSE_TOKENS = frozenset({"false", "f", ".false."})


[docs] class FstFileError(ValueError): """Raised when an OpenFAST input file is malformed or missing a field."""
[docs] @dataclass class LinearizationConfig: """Linearization settings parsed from a primary ``.fst`` file. Parameters ---------- linearize : bool Master linearization switch (``Linearize``). calc_steady : bool Whether a periodic steady-state trim precedes linearization (``CalcSteady``); when ``True``, ``n_lin_times`` azimuth snapshots are produced per operating point. trim_case : int Trim target ``{1: yaw, 2: torque, 3: pitch}`` (``TrimCase``). trim_tol : float Rotor-speed convergence tolerance (``TrimTol``). trim_gain : float Proportional trim gain (``TrimGain``). n_lin_times : int Number of linearization snapshots (``NLinTimes``); the number of ``.lin`` files written per operating point. lin_times : list[float] Absolute linearization times in seconds (``LinTimes``); meaningful only when ``calc_steady`` is ``False``. lin_inputs : int Inputs included ``{0: none, 1: standard, 2: all}`` (``LinInputs``). lin_outputs : int Outputs included ``{0: none, 1: from OutList, 2: all}`` (``LinOutputs``). lin_out_jac : bool Whether full Jacobians are written (``LinOutJac``). lin_out_mod : bool Whether per-module ``.lin`` files are written (``LinOutMod``). """ linearize: bool calc_steady: bool trim_case: int trim_tol: float trim_gain: float n_lin_times: int lin_times: list[float] lin_inputs: int lin_outputs: int lin_out_jac: bool lin_out_mod: bool
[docs] @dataclass class TurbineGeometry: """Rotor and tower geometry parsed from an ElastoDyn input file. Parameters ---------- num_blades : int Number of blades (``NumBl``). tip_radius : float Rotor apex-to-tip distance in metres (``TipRad``). hub_radius : float Rotor apex-to-blade-root distance in metres (``HubRad``). tower_height : float Tower-top height above its reference datum in metres (``TowerHt``). tower_base_height : float Tower-base height above the same datum in metres (``TowerBsHt``). """ num_blades: int tip_radius: float hub_radius: float tower_height: float tower_base_height: float @property def blade_length(self) -> float: """Return the flexible blade length ``TipRad - HubRad`` in metres.""" return self.tip_radius - self.hub_radius @property def tower_length(self) -> float: """Return the flexible tower length ``TowerHt - TowerBsHt`` in metres.""" return self.tower_height - self.tower_base_height
[docs] @dataclass class FastModel: """Subset of a primary ``.fst`` model relevant to linearization. Parameters ---------- path : pathlib.Path Path to the primary ``.fst`` file. comp : dict[str, int] Active-module switches keyed by name (e.g. ``"CompHydro"``). files : dict[str, pathlib.Path] Referenced sub-file paths resolved relative to ``path``'s directory, keyed by their keyword (e.g. ``"EDFile"``). Entries marked ``"unused"`` in the deck are omitted. lin : LinearizationConfig Parsed linearization configuration. """ path: Path comp: dict[str, int] files: dict[str, Path] lin: LinearizationConfig @property def ed_file(self) -> Path | None: """Return the resolved ElastoDyn input path, or ``None`` if unset.""" return self.files.get("EDFile") @property def is_offshore(self) -> bool: """Return whether the model includes sea-state or hydrodynamic modules.""" return self.comp.get("CompSeaSt", 0) >= 1 or self.comp.get("CompHydro", 0) >= 1 @property def is_floating(self) -> bool: """Return a heuristic for a floating platform (has mooring, no SubDyn). Notes ----- This is an approximation based on module switches: floating platforms use a mooring system (``CompMooring >= 1``) and no SubDyn substructure (``CompSub == 0``). Detailed system classification (spar/semi/TLP) is the responsibility of the configuration layer, not this reader. """ return self.comp.get("CompMooring", 0) >= 1 and self.comp.get("CompSub", 0) == 0
[docs] def read_fst_file(path: Path) -> FastModel: """Read a primary OpenFAST ``.fst`` file. Parameters ---------- path : pathlib.Path Path to the ``.fst`` file. Returns ------- FastModel Parsed module switches, referenced file paths, and linearization config. Raises ------ FstFileError If the file cannot be read. Missing module switches and linearization settings are tolerated and fall back to their defaults. """ lines = _read_lines(path) # Module switches vary across OpenFAST versions and deck types (a newer deck # may add MHK or drop CompSoil), so every switch is optional and defaults to 0. comp = {name: _get_int(lines, name, path, default=0) for name in _COMP_SWITCHES} files: dict[str, Path] = {} for keyword in _SUB_FILE_KEYWORDS: tokens = _field_tokens(lines, keyword) if tokens is None: continue # A quoted path may contain spaces, so it can span several tokens; rejoin # them before stripping the surrounding quotes. value = " ".join(tokens).strip().strip('"') if value and value.lower() != "unused": files[keyword] = path.parent / value lin = _parse_linearization(lines, path) return FastModel(path=path, comp=comp, files=files, lin=lin)
[docs] def read_elastodyn_geometry(path: Path) -> TurbineGeometry: """Read rotor and tower geometry from an ElastoDyn input file. Parameters ---------- path : pathlib.Path Path to the ElastoDyn ``.dat`` file. Returns ------- TurbineGeometry The parsed rotor/tower geometry. Raises ------ FstFileError If the file cannot be read or a required field is missing. """ lines = _read_lines(path) return TurbineGeometry( num_blades=_get_int(lines, "NumBl", path), tip_radius=_get_float(lines, "TipRad", path), hub_radius=_get_float(lines, "HubRad", path), tower_height=_get_float(lines, "TowerHt", path), tower_base_height=_get_float(lines, "TowerBsHt", path), )
def _parse_linearization(lines: list[str], path: Path) -> LinearizationConfig: """Build a :class:`LinearizationConfig` from primary-file lines. Every field is optional: a deck not configured for linearization (or written by a version that omits some settings) yields the natural defaults rather than a parse error. """ return LinearizationConfig( linearize=_get_bool(lines, "Linearize", path, default=False), calc_steady=_get_bool(lines, "CalcSteady", path, default=False), trim_case=_get_int(lines, "TrimCase", path, default=0), trim_tol=_get_float(lines, "TrimTol", path, default=0.0), trim_gain=_get_float(lines, "TrimGain", path, default=0.0), n_lin_times=_get_int(lines, "NLinTimes", path, default=0), lin_times=_parse_lin_times(_field_tokens(lines, "LinTimes") or []), lin_inputs=_get_int(lines, "LinInputs", path, default=0), lin_outputs=_get_int(lines, "LinOutputs", path, default=0), lin_out_jac=_get_bool(lines, "LinOutJac", path, default=False), lin_out_mod=_get_bool(lines, "LinOutMod", path, default=False), ) def _parse_lin_times(tokens: list[str]) -> list[float]: """Parse a ``LinTimes`` value list into floats. Accepts space-separated (``30 60 90``), spaced-comma (``30, 60, 90``), and compact comma-only (``30,60,90``) forms by normalising commas to whitespace before splitting. Parameters ---------- tokens : list[str] The raw value tokens preceding the ``LinTimes`` keyword. Returns ------- list[float] The parsed linearization times. """ joined = " ".join(tokens).replace(",", " ") return [float(value) for value in joined.split()] def _read_lines(path: Path) -> list[str]: """Read a text input file into a list of lines. Parameters ---------- path : pathlib.Path File to read. Returns ------- list[str] The file's lines. Raises ------ FstFileError If the file does not exist or cannot be decoded as text. """ try: return path.read_text(encoding="utf-8").splitlines() except OSError as exc: msg = f"Cannot read OpenFAST input file {path}: {exc}" raise FstFileError(msg) from exc def _field_tokens(lines: list[str], keyword: str) -> list[str] | None: """Return the value tokens preceding ``keyword`` on its line, if present. Parameters ---------- lines : list[str] File lines to search. keyword : str The exact keyword (field name) to locate. Returns ------- list[str] or None The whitespace-delimited tokens before the keyword, or ``None`` if the keyword is not found. """ for line in lines: head = line.split(" - ", 1)[0] tokens = head.split() if len(tokens) >= 2 and tokens[-1] == keyword: return tokens[:-1] return None def _get_int( lines: list[str], keyword: str, path: Path, default: int | None = None ) -> int: """Return the integer value of ``keyword``, or ``default`` if it is absent. A ``default`` of ``None`` marks the field as required; any other value makes it optional, so module switches and linearization settings that a given OpenFAST version omits do not cause a parse failure. """ tokens = _field_tokens(lines, keyword) if tokens is None: if default is None: msg = f"Keyword {keyword!r} not found in {path}" raise FstFileError(msg) return default return int(tokens[0]) def _get_float( lines: list[str], keyword: str, path: Path, default: float | None = None ) -> float: """Return the float value of ``keyword``, or ``default`` if it is absent.""" tokens = _field_tokens(lines, keyword) if tokens is None: if default is None: msg = f"Keyword {keyword!r} not found in {path}" raise FstFileError(msg) return default return float(tokens[0]) def _get_bool( lines: list[str], keyword: str, path: Path, default: bool | None = None ) -> bool: """Return the boolean value of ``keyword`` (True/False/T/F), or ``default``.""" tokens = _field_tokens(lines, keyword) if tokens is None: if default is None: msg = f"Keyword {keyword!r} not found in {path}" raise FstFileError(msg) return default token = tokens[0].lower() if token in _TRUE_TOKENS: return True if token in _FALSE_TOKENS: return False msg = f"Cannot parse {keyword!r} value {token!r} as boolean in {path}" raise FstFileError(msg)