"""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)