Source code for pybmodes.workflows.validate

# 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.

"""``pybmodes validate`` workflow as a typed library function.

Wraps :func:`pybmodes.elastodyn.validate_dat_coefficients` with a
:class:`ValidateResult` carrying the validation report plus
CLI-mapping fields (exit code, messages, errors).
"""
from __future__ import annotations

import pathlib
from dataclasses import dataclass
from typing import TYPE_CHECKING

from pybmodes.workflows._base import WorkflowResult

if TYPE_CHECKING:
    from pybmodes.elastodyn.validate import (
        CoeffBlockResult,
        ValidationResult,
    )


def _fmt_ratio(ratio: float) -> str:
    """Right-align a coefficient-block ratio for the printed report."""
    if ratio != ratio:  # NaN
        return "  nan"
    if ratio == float("inf"):
        return "   inf"
    if ratio >= 100.0:
        return f"{ratio:>5.0f}"
    if ratio >= 10.0:
        return f"{ratio:>5.1f}"
    return f"{ratio:>5.2f}"


def _format_block_row(block: CoeffBlockResult) -> str:
    """One per-block line for the printed report."""
    flag = ""
    if block.verdict == "FAIL":
        flag = "  FAIL <-"
    elif block.verdict == "WARN":
        flag = "  WARN"
    elif block.verdict == "PASS":
        flag = "  PASS"
    return (
        f"  {block.name:<8}  file RMS={block.file_rms:7.4f}  "
        f"pyB RMS={block.pybmodes_rms:7.4f}  "
        f"ratio={_fmt_ratio(block.ratio)}  {flag}"
    )


def _render_validation_report(result: ValidationResult) -> list[str]:
    """Render a :class:`ValidationResult` as the line-by-line CLI
    report. Returned as a list of lines (no trailing newlines);
    the workflow caller / CLI joins with ``\\n`` and prints."""
    lines: list[str] = []
    lines.append("pyBmodes coefficient validator")
    lines.append("==============================")
    lines.append(f"File: {result.dat_path.name}")
    lines.append("")
    lines.append("Tower modes:")
    for block in result.tower_results.values():
        lines.append(_format_block_row(block))
    lines.append("")
    lines.append("Blade modes:")
    for block in result.blade_results.values():
        lines.append(_format_block_row(block))
    lines.append("")

    n_fail = len(result.failing_blocks())
    n_warn = len(result.warning_blocks())
    if result.overall == "FAIL":
        lines.append(f"Overall: FAIL ({n_fail} block(s))")
        lines.append(
            f"Recommendation: run `pybmodes patch {result.dat_path}` to "
            "update coefficients from structural inputs."
        )
    elif result.overall == "WARN":
        lines.append(f"Overall: WARN ({n_warn} block(s))")
    else:
        lines.append("Overall: PASS")
    return lines


[docs] @dataclass class ValidateResult(WorkflowResult): """Result of :func:`run_validate`. Attributes ---------- validation : pybmodes.elastodyn.validate.ValidationResult | None The full validation report — per-block PASS / WARN / FAIL verdicts, file-RMS and pyBmodes-RMS values, and the overall verdict. ``None`` only if the workflow short- circuited before validation could run (currently no such path; reserved for future strict-mode failures). """ validation: ValidationResult | None = None
[docs] def run_validate(dat_path: str | pathlib.Path) -> ValidateResult: """Validate the coefficient blocks in an ElastoDyn ``.dat`` deck. Library entry point for the :command:`pybmodes validate` subcommand. Parses the deck's polynomial blocks, re-fits each block from the structural inputs in the same file, scores file-RMS against pyBmodes-RMS, and returns a structured verdict. Parameters ---------- dat_path : str or pathlib.Path Path to an ElastoDyn main ``.dat`` file. Resolved to an absolute path before validation. Returns ------- ValidateResult Carries the full :class:`~pybmodes.elastodyn.ValidationResult` plus ``exit_code`` mapping (``0`` for PASS / WARN, ``1`` for FAIL) and the per-block printable lines in ``messages``. Raises ------ FileNotFoundError When ``dat_path`` does not exist or is not a regular file. Callers (CLI or library) should treat this as a usage / IO error rather than a verdict failure. Examples -------- From a notebook:: from pybmodes.workflows import run_validate res = run_validate("NRELOffshrBsline5MW_Onshore_ElastoDyn.dat") if res.validation.overall == "FAIL": print("polynomial blocks are stale — run patch") for line in res.messages: print(line) """ from pybmodes.elastodyn import validate_dat_coefficients p = pathlib.Path(dat_path).resolve() if not p.is_file(): raise FileNotFoundError(f"file not found: {p}") validation = validate_dat_coefficients(p) messages = _render_validation_report(validation) if validation.overall == "FAIL": exit_code = 1 else: # WARN counts as 0 — warnings are informational, not a hard # failure. Same convention as the legacy CLI. exit_code = 0 return ValidateResult( exit_code=exit_code, messages=messages, validation=validation, )