Source code for pybmodes.io.out_parser

# Copyright 2024-2026 Jae Hoon Seo
# Marine Structural Mechanics and Integrity Lab (SMI Lab), Inha University
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Parser for modal analysis output files (.out).

Output format:
  - Header / title
  - "rotating blade frequencies & mode shapes"   OR
    "tower frequencies & mode shapes"
  - For each mode:
      -------- Mode No.  N  (freq = X.XXXE+XX Hz)
      <column-header line>
      <blank line>
      <data rows: span_loc + 5 columns>
      <blank line>

Column order:
  Blade : span_loc  flap_disp  flap_slope  lag_disp  lag_slope  twist
  Tower : span_loc  ss_disp    ss_slope    fa_disp   fa_slope   twist
"""

from __future__ import annotations

import pathlib
import re
from dataclasses import dataclass, field

import numpy as np

from pybmodes.io.errors import ParseError as _ParseError


[docs] class BModeOutParseError(_ParseError): """Raised by :func:`read_out` under ``strict=True`` when a ``.out`` file is not cleanly parseable. The default (``strict=False``) parser is intentionally tolerant — it skips malformed rows so a partially-corrupt file still yields whatever modes it can. Validation / cross-solver-comparison workflows that must trust every value should pass ``strict=True``; the message then carries the source file, the 1-based line number, and the mode context so the offending location is unambiguous. Inherits :class:`pybmodes.io.errors.ParseError` (which in turn inherits :class:`ValueError`), so existing ``except ValueError`` callers still catch this exception unchanged — the inheritance addition is backward-compatible. New callers can ``except ParseError`` to get a typed handler that works across every ``pybmodes.io.*`` parser. """
# --------------------------------------------------------------------------- # Data structures # ---------------------------------------------------------------------------
[docs] @dataclass class ModeShape: """Mode shape data for a single mode.""" mode_number: int frequency: float # Hz span_loc: np.ndarray # normalised span (0–1) col1: np.ndarray # flap_disp (blade) or ss_disp (tower) col2: np.ndarray # flap_slope (blade) or ss_slope (tower) col3: np.ndarray # lag_disp (blade) or fa_disp (tower) col4: np.ndarray # lag_slope (blade) or fa_slope (tower) twist: np.ndarray col_names: list[str] = field(default_factory=list) # Convenience properties — blade @property def flap_disp(self) -> np.ndarray: return self.col1 @property def flap_slope(self) -> np.ndarray: return self.col2 @property def lag_disp(self) -> np.ndarray: return self.col3 @property def lag_slope(self) -> np.ndarray: return self.col4 # Convenience properties — tower (fore-aft / side-side) @property def ss_disp(self) -> np.ndarray: return self.col1 @property def ss_slope(self) -> np.ndarray: return self.col2 @property def fa_disp(self) -> np.ndarray: return self.col3 @property def fa_slope(self) -> np.ndarray: return self.col4
[docs] @dataclass class BModeOutput: """All mode shapes parsed from a .out file.""" title: str beam_type: str # 'blade' or 'tower' modes: list[ModeShape] source_file: pathlib.Path | None = None def __len__(self) -> int: return len(self.modes) def __getitem__(self, index: int) -> ModeShape: return self.modes[index]
[docs] def frequencies(self) -> np.ndarray: """Return array of natural frequencies (Hz) in mode order.""" return np.array([m.frequency for m in self.modes])
# --------------------------------------------------------------------------- # Regex patterns # --------------------------------------------------------------------------- _RE_MODE_HEADER = re.compile( r'-+\s*Mode\s+No\.\s*(\d+)\s*\(freq\s*=\s*([0-9Ee.+\-]+)\s*Hz\)', re.IGNORECASE, ) _RE_BEAM_TYPE_BLADE = re.compile(r'rotating\s+blade\s+frequencies', re.IGNORECASE) _RE_BEAM_TYPE_TOWER = re.compile(r'tower\s+frequencies', re.IGNORECASE) # --------------------------------------------------------------------------- # Public API # ---------------------------------------------------------------------------
[docs] def read_out( path: str | pathlib.Path, *, strict: bool = False ) -> BModeOutput: """Parse a .out file and return a :class:`BModeOutput`. With ``strict=False`` (default, unchanged behaviour) malformed data rows are silently skipped so a partially-corrupt file still yields whatever modes it can. With ``strict=True`` the parser raises :class:`BModeOutParseError` — with the source file, the 1-based line number, and the mode context — on a short data row, an unparseable number, a non-finite (``NaN`` / ``inf``) value, a duplicate mode number, or a file that yields no modes at all. Use ``strict=True`` for validation / cross-solver comparison. """ path = pathlib.Path(path) lines = path.read_text(encoding='latin-1').splitlines() return _parse(lines, source_file=path, strict=strict)
def _parse( lines: list[str], source_file: pathlib.Path | None = None, *, strict: bool = False, ) -> BModeOutput: title = '' beam_type = 'blade' modes: list[ModeShape] = [] i = 0 n = len(lines) # --- scan header for title and beam type --- while i < n: ln = lines[i] if _RE_BEAM_TYPE_BLADE.search(ln): beam_type = 'blade' elif _RE_BEAM_TYPE_TOWER.search(ln): beam_type = 'tower' m = _RE_MODE_HEADER.search(ln) if m: break # The second non-empty, non-separator line is the title stripped = ln.strip() if stripped and not stripped.startswith('=') and not title: # Generated-by line comes first; the second such line is title title = stripped i += 1 src = source_file.name if source_file is not None else "<.out>" seen_modes: set[int] = set() def _err(lineno: int, mode_no: int, what: str) -> BModeOutParseError: return BModeOutParseError( f"{src}: line {lineno} (Mode No. {mode_no}): {what}" ) # --- parse mode blocks --- while i < n: ln = lines[i] m = _RE_MODE_HEADER.search(ln) if not m: i += 1 continue mode_no = int(m.group(1)) freq = float(m.group(2)) hdr_lineno = i + 1 # 1-based, for error context if strict: if mode_no in seen_modes: raise _err(hdr_lineno, mode_no, "duplicate mode number") seen_modes.add(mode_no) # consume column-header line (skip blank lines before it) i += 1 while i < n and not lines[i].strip(): i += 1 col_names = lines[i].split() if i < n else [] i += 1 # collect data rows rows: list[list[float]] = [] while i < n: row_line = lines[i].strip() i += 1 if not row_line: continue # stop at next mode header if _RE_MODE_HEADER.search(row_line) or row_line.startswith('='): i -= 1 # put the header line back break tokens = row_line.split() if len(tokens) < 6: if strict: raise _err( i, mode_no, f"short data row — expected >= 6 columns, got " f"{len(tokens)}: {row_line!r}", ) continue try: vals = [float(t) for t in tokens[:6]] except ValueError as exc: if strict: raise _err( i, mode_no, f"non-numeric value in data row: {row_line!r}", ) from exc continue # Finite check per row, with the *offending row's* line # number — checking the assembled block reports the next # header / EOF line instead, which is useless for # validation against a reference solver. if strict and not all(np.isfinite(vals)): raise _err( i, mode_no, f"non-finite (NaN / inf) value in data row: " f"{row_line!r}", ) rows.append(vals) if strict and not rows: # A detected mode header that yields zero data rows must # not silently vanish just because another block parsed — # that contradicts the fail-loud strict contract. raise _err( hdr_lineno, mode_no, "mode header has no data rows (empty or malformed " "mode block)", ) if rows: arr = np.array(rows, dtype=float) modes.append(ModeShape( mode_number=mode_no, frequency=freq, span_loc=arr[:, 0], col1=arr[:, 1], col2=arr[:, 2], col3=arr[:, 3], col4=arr[:, 4], twist=arr[:, 5], col_names=col_names, )) if strict and not modes: raise BModeOutParseError( f"{src}: no parseable mode blocks found " f"(file is empty, truncated, or not a BModes .out)" ) return BModeOutput( title=title, beam_type=beam_type, modes=modes, source_file=source_file, )