Source code for pybmodes.report

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

"""Generate a human-readable report on a pyBmodes modal analysis.

The entry point is :func:`generate_report`. It builds a structured
representation of the report (sections + tables) once, then renders
that representation in one of three output formats:

* **Markdown** (``format="md"``) — default. Human-readable plain text
  with section headers, tables, and inline code blocks. Useful for
  paste-into-issue or check-in-to-repo workflows.
* **HTML** (``format="html"``) — a self-contained HTML5 document with
  inline CSS. Same content as the markdown version; emitted directly
  rather than via an md→html converter so the report module has no
  runtime dependency on the third-party ``markdown`` package.
* **CSV** (``format="csv"``) — frequencies + polynomial-coefficient
  rows only, suitable for spreadsheet ingestion. The narrative
  sections (assumptions, validation verdict, warnings) are dropped
  because CSV can't represent them faithfully.

The report's content is driven by what the caller supplies. The
minimum is a :class:`ModalResult`; everything else
(``model``, ``campbell``, ``validation``, ``check_warnings``,
``tower_params``, ``blade_params``) is optional and unlocks the
corresponding section when supplied.
"""

from __future__ import annotations

import csv
import datetime
import html as _html
import io
import pathlib
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal

import numpy as np

if TYPE_CHECKING:
    from pybmodes.campbell import CampbellResult
    from pybmodes.checks import ModelWarning
    from pybmodes.elastodyn.params import (
        BladeElastoDynParams,
        TowerElastoDynParams,
    )
    from pybmodes.elastodyn.validate import ValidationResult
    from pybmodes.models.blade import RotatingBlade
    from pybmodes.models.result import ModalResult
    from pybmodes.models.tower import Tower

ReportFormat = Literal["md", "html", "csv"]


# ---------------------------------------------------------------------------
# Internal structured representation
# ---------------------------------------------------------------------------

@dataclass
class _Section:
    """One top-level section of the report. Header text is the section
    title; ``body`` is a list of paragraph strings or table dicts.

    A table dict has shape::

        {"kind": "table", "header": [str, ...], "rows": [[str, ...], ...]}
    """

    title: str
    body: list[Any]


# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------

[docs] def generate_report( result: ModalResult, output_path: str | pathlib.Path, *, format: ReportFormat = "md", model: Tower | RotatingBlade | None = None, campbell: CampbellResult | None = None, validation: ValidationResult | None = None, check_warnings: list[ModelWarning] | None = None, tower_params: TowerElastoDynParams | None = None, blade_params: BladeElastoDynParams | None = None, elastodyn_compatible: bool | None = None, source_file: str | pathlib.Path | None = None, status: str | None = None, ) -> pathlib.Path: """Render a modal-analysis report to ``output_path``. ``source_file`` is recorded in the *Model summary* section and the metadata block. When omitted, the function falls back to ``result.metadata["source_file"]`` and then to ``model._bmi.source_file`` if those are populated. ``status`` is an optional completeness stamp shown at the top of the *Model summary* section (``"complete"`` / ``"partial"`` / ``"screening"`` / ``"invalid"``). A workflow that may skip part of its output (e.g. :func:`pybmodes.workflows.windio.run_windio`) passes it so a reader can tell at a glance whether the report is the full analysis or a reduced one. ``None`` omits the row. Returns the resolved output path so the caller can chain. """ # Materialise metadata at report time so the Model summary section # always has pybmodes_version + timestamp + git_hash. The supplied # ``source_file`` (typically the deck path the CLI invoked us on) # wins over any pre-existing metadata.source_file. from pybmodes.io._serialize import _capture_metadata base_meta = dict(result.metadata) if result.metadata else {} fresh = _capture_metadata(source_file=source_file) # Existing metadata wins for everything except source_file when an # explicit source_file was passed. merged: dict[str, Any] = {**fresh, **base_meta} if source_file is not None: merged["source_file"] = str(source_file) elif merged.get("source_file") is None and model is not None: bmi_src = getattr(model._bmi, "source_file", None) if bmi_src is not None: merged["source_file"] = str(bmi_src) result_for_render = result if result_for_render.metadata != merged: # Don't mutate the caller's ModalResult — shallow-copy via the # dataclass.replace pattern would be ideal, but ModalResult # carries large arrays; sharing references is fine. from dataclasses import replace result_for_render = replace(result, metadata=merged) sections = _build_sections( result=result_for_render, model=model, campbell=campbell, validation=validation, check_warnings=check_warnings, tower_params=tower_params, blade_params=blade_params, elastodyn_compatible=elastodyn_compatible, status=status, ) path = pathlib.Path(output_path) if format == "md": text = _render_markdown(sections) elif format == "html": text = _render_html(sections) elif format == "csv": text = _render_csv(result, tower_params, blade_params) else: raise ValueError( f"format must be 'md', 'html', or 'csv'; got {format!r}" ) path.write_text(text, encoding="utf-8") return path
# --------------------------------------------------------------------------- # Section builders # --------------------------------------------------------------------------- def _build_sections( *, result: ModalResult, model: Tower | RotatingBlade | None, campbell: CampbellResult | None, validation: ValidationResult | None, check_warnings: list[ModelWarning] | None, tower_params: TowerElastoDynParams | None, blade_params: BladeElastoDynParams | None, elastodyn_compatible: bool | None, status: str | None = None, ) -> list[_Section]: sections: list[_Section] = [] sections.append(_section_model_summary(result, model, status=status)) sections.append(_section_assumptions(model, elastodyn_compatible)) sections.append(_section_frequencies(result)) sections.append(_section_mode_classification(result)) if tower_params is not None or blade_params is not None: sections.append(_section_polynomial_coefficients( tower_params, blade_params, )) if validation is not None: sections.append(_section_validation(validation)) if check_warnings is not None: sections.append(_section_check_warnings(check_warnings)) if campbell is not None: sections.append(_section_campbell(campbell)) return sections def _section_model_summary( result: ModalResult, model: Tower | RotatingBlade | None, *, status: str | None = None, ) -> _Section: meta = result.metadata or {} body: list[Any] = [] table_rows: list[list[str]] = [] title = "unknown" beam_type = "unknown" source = meta.get("source_file") or "—" if model is not None: bmi = model._bmi title = (bmi.title or "—").strip() beam_type = "Tower" if bmi.beam_type == 2 else "Blade" if bmi.source_file is not None: source = str(bmi.source_file) if status is not None: # Completeness stamp first, so a partial / screening report is # flagged before any numbers are read. table_rows.append(["Report status", str(status)]) table_rows.extend([ ["Turbine / title", title], ["Beam type", beam_type], ["Source file", source], ["pyBmodes version", str(meta.get("pybmodes_version", "—"))], ["Generated at (UTC)", meta.get("timestamp") or datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds")], ["Git hash", str(meta.get("git_hash") or "—")], ]) # Surface any parsed-but-not-modelled physics so a reader knows the # result is an approximation (e.g. distributed added mass parsed but # not assembled). Empty for a full-fidelity solve. if result.ignored_physics: table_rows.append( ["Ignored physics", "; ".join(result.ignored_physics)] ) body.append({"kind": "table", "header": ["Field", "Value"], "rows": table_rows}) return _Section("1. Model summary", body) def _section_assumptions( model: Tower | RotatingBlade | None, elastodyn_compatible: bool | None, ) -> _Section: body: list[Any] = [] if model is None: body.append( "Model object not supplied; assumptions section is limited " "to whatever metadata was attached to the ModalResult." ) return _Section("2. Assumptions", body) bmi = model._bmi bc_map = { 1: "cantilever (`hub_conn = 1`) — axial + lag + flap + slopes + twist locked at root", 2: "free-free (`hub_conn = 2`) — all 6 base DOFs released; reactions via PlatformSupport", 3: "soft monopile (`hub_conn = 3`) — axial + torsion locked; lateral + rocking free", 4: "pinned-free (`hub_conn = 4`) — axial + lag/flap + twist locked; bending slopes free", } bc_text = bc_map.get(bmi.hub_conn, f"hub_conn = {bmi.hub_conn}") rows: list[list[str]] = [ ["Boundary condition", bc_text], ["Beam length", f"{bmi.radius:.3f} m"], ["Hub radius / TowerBsHt", f"{bmi.hub_rad:.3f} m"], ["Rotor speed", f"{bmi.rot_rpm:.4f} rpm"], ["Number of elements", str(bmi.n_elements)], ] # RNA / tip-mass assembly if bmi.tip_mass is not None and bmi.tip_mass.mass > 0.0: tm = bmi.tip_mass rows.extend([ ["Tip mass (RNA, lumped)", f"{tm.mass:,.1f} kg"], ["Tip-mass CM offset (transverse)", f"{tm.cm_offset:.4f} m"], ["Tip-mass CM offset (axial)", f"{tm.cm_axial:.4f} m"], ["Tip-mass inertia (Ixx, Iyy, Izz)", f"{tm.ixx:.3e}, {tm.iyy:.3e}, {tm.izz:.3e} kg·m²"], ]) else: rows.append(["Tip mass", "0 kg (no concentrated tip mass)"]) # ElastoDyn-compatibility flag (blade adapter only) if elastodyn_compatible is not None: rows.append([ "ElastoDyn-compatibility (blade adapter)", "ON — str_tw / tw_iner / offsets zeroed per Jonkman 2015" if elastodyn_compatible else "OFF — full structural model preserved", ]) body.append({"kind": "table", "header": ["Assumption", "Value"], "rows": rows}) return _Section("2. Assumptions", body) def _section_frequencies(result: ModalResult) -> _Section: body: list[Any] = [] rows = [] for i, f in enumerate(result.frequencies, start=1): period = 1.0 / float(f) if float(f) > 0 else float("inf") rows.append([ str(i), f"{float(f):.4f}", f"{period:.4f}" if period != float("inf") else "—", ]) body.append({ "kind": "table", "header": ["Mode #", "Frequency (Hz)", "Period (s)"], "rows": rows, }) return _Section("3. Natural frequencies", body) def _section_mode_classification(result: ModalResult) -> _Section: body: list[Any] = [] if not result.shapes: body.append("No mode shapes available.") return _Section("4. Mode classification", body) rows = [] have_participation = result.participation is not None # Floating models carry per-mode platform rigid-body names # (surge / sway / heave / roll / pitch / yaw); show them as an # extra column. ``None`` for a mode the classifier couldn't # attribute to one DOF (flexible / coupled), and the whole list # is ``None`` for a non-floating model — in which case the column # is omitted entirely so existing reports are unchanged. labels = result.mode_labels have_labels = labels is not None for i, shape in enumerate(result.shapes): flap_n = float(np.dot(shape.flap_disp, shape.flap_disp)) lag_n = float(np.dot(shape.lag_disp, shape.lag_disp)) twist_n = float(np.dot(shape.twist, shape.twist)) total = flap_n + lag_n + twist_n if total <= 0.0: axis = "—" shares = "—" else: f_pct = 100.0 * flap_n / total e_pct = 100.0 * lag_n / total t_pct = 100.0 * twist_n / total shares = f"flap={f_pct:.1f}%, edge/SS={e_pct:.1f}%, twist={t_pct:.1f}%" biggest = max((f_pct, "flap/FA"), (e_pct, "edge/SS"), (t_pct, "twist")) axis = biggest[1] row = [ str(shape.mode_number), f"{float(shape.freq_hz):.4f}", axis, shares, ] if labels is not None: lbl = labels[i] if i < len(labels) else None row.insert(2, lbl if lbl is not None else "—") rows.append(row) header = ["Mode #", "Freq (Hz)", "Dominant axis", "Participation"] if have_labels: header.insert(2, "Platform DOF") body.append({ "kind": "table", "header": header, "rows": rows, }) if have_labels: body.append( "The *Platform DOF* column names the floating-platform " "rigid-body modes (surge / sway / heave / roll / pitch / " "yaw); ``—`` marks a mode no single platform DOF dominates " "(a flexible tower mode, or a strongly coupled pair). The " "same labels are on ``result.mode_labels``." ) if have_participation: body.append( "Per-mode participation array attached to ``result.participation`` " "(N × 3). The table above derives shares from the FEM eigenvector " "energies for self-containedness; the attached array is the " "preferred metric for downstream code." ) return _Section("4. Mode classification", body) def _section_polynomial_coefficients( tower_params: TowerElastoDynParams | None, blade_params: BladeElastoDynParams | None, ) -> _Section: body: list[Any] = [] if tower_params is not None: body.append("**Tower coefficients (C2 .. C6)**") rows = [] for name in ("TwFAM1Sh", "TwFAM2Sh", "TwSSM1Sh", "TwSSM2Sh"): fit = getattr(tower_params, name) rows.append([ name, f"{fit.c2:+.4e}", f"{fit.c3:+.4e}", f"{fit.c4:+.4e}", f"{fit.c5:+.4e}", f"{fit.c6:+.4e}", f"{fit.rms_residual:.4f}", f"{fit.cond_number:.2e}", ]) body.append({ "kind": "table", "header": ["Block", "C2", "C3", "C4", "C5", "C6", "RMS residual", "Cond number"], "rows": rows, }) if blade_params is not None: body.append("**Blade coefficients (C2 .. C6)**") rows = [] for name in ("BldFl1Sh", "BldFl2Sh", "BldEdgSh"): fit = getattr(blade_params, name) rows.append([ name, f"{fit.c2:+.4e}", f"{fit.c3:+.4e}", f"{fit.c4:+.4e}", f"{fit.c5:+.4e}", f"{fit.c6:+.4e}", f"{fit.rms_residual:.4f}", f"{fit.cond_number:.2e}", ]) body.append({ "kind": "table", "header": ["Block", "C2", "C3", "C4", "C5", "C6", "RMS residual", "Cond number"], "rows": rows, }) if not body: body.append("No polynomial coefficients supplied.") return _Section("5. Polynomial coefficients with fit residuals", body) def _section_validation(validation: ValidationResult) -> _Section: body: list[Any] = [] body.append(f"**Overall verdict**: {validation.overall}") rows = [] for block in validation.all_blocks().values(): rows.append([ block.name, f"{block.file_rms:.4f}", f"{block.pybmodes_rms:.4f}", f"{block.ratio:.2f}", block.verdict, ]) if rows: body.append({ "kind": "table", "header": ["Block", "file RMS", "pyBmodes RMS", "ratio (file/pyB)", "verdict"], "rows": rows, }) return _Section("6. Validation verdict", body) def _section_check_warnings( check_warnings: list[ModelWarning], ) -> _Section: body: list[Any] = [] if not check_warnings: body.append("No check_model warnings.") return _Section("7. check_model warnings", body) rows = [ [w.severity, w.location, w.message] for w in check_warnings ] body.append({ "kind": "table", "header": ["Severity", "Location", "Message"], "rows": rows, }) return _Section("7. check_model warnings", body) def _section_campbell(campbell: CampbellResult) -> _Section: body: list[Any] = [] body.append( f"Sweep over {campbell.omega_rpm.size} rotor speed(s) " f"({float(campbell.omega_rpm.min()):.2f} – " f"{float(campbell.omega_rpm.max()):.2f} rpm), " f"{campbell.n_blade_modes} blade mode(s) + " f"{campbell.n_tower_modes} tower mode(s)." ) body.append( "Frequencies at the first and last rotor speed for every mode:" ) rows = [] for k, lbl in enumerate(campbell.labels): rows.append([ lbl, f"{float(campbell.frequencies[0, k]):.4f}", f"{float(campbell.frequencies[-1, k]):.4f}", ]) body.append({ "kind": "table", "header": ["Mode label", f"f @ {float(campbell.omega_rpm[0]):.2f} rpm (Hz)", f"f @ {float(campbell.omega_rpm[-1]):.2f} rpm (Hz)"], "rows": rows, }) return _Section("8. Campbell sweep", body) # --------------------------------------------------------------------------- # Renderers # --------------------------------------------------------------------------- def _render_markdown(sections: list[_Section]) -> str: out = io.StringIO() out.write("# pyBmodes Modal Analysis Report\n\n") for section in sections: out.write(f"## {section.title}\n\n") for item in section.body: if isinstance(item, str): out.write(item) out.write("\n\n") elif isinstance(item, dict) and item.get("kind") == "table": _render_md_table(out, item["header"], item["rows"]) out.write("\n") return out.getvalue() def _render_md_table( out: io.StringIO, header: list[str], rows: list[list[str]], ) -> None: out.write("| " + " | ".join(header) + " |\n") out.write("| " + " | ".join("---" for _ in header) + " |\n") for row in rows: out.write("| " + " | ".join(row) + " |\n") def _render_html(sections: list[_Section]) -> str: out = io.StringIO() out.write( "<!DOCTYPE html>\n" '<html lang="en">\n' '<head>\n' ' <meta charset="utf-8">\n' ' <title>pyBmodes Modal Analysis Report</title>\n' " <style>\n" " body { font-family: -apple-system, BlinkMacSystemFont, " "'Segoe UI', Helvetica, Arial, sans-serif; " "max-width: 960px; margin: 2em auto; padding: 0 1em; }\n" " h1 { border-bottom: 2px solid #333; padding-bottom: 0.2em; }\n" " h2 { border-bottom: 1px solid #ccc; padding-bottom: 0.1em; " "margin-top: 2em; }\n" " table { border-collapse: collapse; margin: 1em 0; }\n" " th, td { border: 1px solid #ccc; padding: 4px 10px; " "text-align: left; }\n" " th { background: #f0f0f0; }\n" " code { background: #f5f5f5; padding: 1px 4px; border-radius: 3px; }\n" " </style>\n" "</head>\n" "<body>\n" "<h1>pyBmodes Modal Analysis Report</h1>\n" ) for section in sections: out.write(f"<h2>{_html.escape(section.title)}</h2>\n") for item in section.body: if isinstance(item, str): out.write(f"<p>{_md_inline_to_html(item)}</p>\n") elif isinstance(item, dict) and item.get("kind") == "table": _render_html_table(out, item["header"], item["rows"]) out.write("</body>\n</html>\n") return out.getvalue() def _md_inline_to_html(text: str) -> str: """Minimal inline-markdown to HTML conversion: ``**bold**``, ``*italic*``, and ```code```. The structured tables are rendered separately so this only needs to handle paragraph text.""" import re s = _html.escape(text) s = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s) s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s) s = re.sub(r"\*(.+?)\*", r"<em>\1</em>", s) return s def _render_html_table( out: io.StringIO, header: list[str], rows: list[list[str]], ) -> None: out.write("<table>\n <thead><tr>") for h in header: out.write(f"<th>{_html.escape(h)}</th>") out.write("</tr></thead>\n <tbody>\n") for row in rows: out.write(" <tr>") for cell in row: out.write(f"<td>{_html.escape(cell)}</td>") out.write("</tr>\n") out.write(" </tbody>\n</table>\n") def _render_csv( result: ModalResult, tower_params: TowerElastoDynParams | None, blade_params: BladeElastoDynParams | None, ) -> str: """CSV emission is intentionally narrower than markdown / HTML — we drop the narrative sections (assumptions, validation, warnings) that CSV cannot represent without faking columns, and emit: 1. A frequencies block (one row per mode). 2. A blank-row separator. 3. A polynomial-coefficient block (one row per coefficient block) with explicit C2..C6 + RMS + cond-number columns — exactly the data spreadsheet workflows need. """ out = io.StringIO() writer = csv.writer(out) # Frequencies block writer.writerow(["section", "mode", "frequency_hz", "period_s"]) for i, f in enumerate(result.frequencies, start=1): period = 1.0 / float(f) if float(f) > 0 else float("nan") writer.writerow(["frequencies", i, float(f), period]) # Blank separator row writer.writerow([]) # Coefficients block writer.writerow([ "section", "block", "C2", "C3", "C4", "C5", "C6", "rms_residual", "cond_number", ]) if tower_params is not None: for name in ("TwFAM1Sh", "TwFAM2Sh", "TwSSM1Sh", "TwSSM2Sh"): fit = getattr(tower_params, name) writer.writerow([ "tower", name, fit.c2, fit.c3, fit.c4, fit.c5, fit.c6, fit.rms_residual, fit.cond_number, ]) if blade_params is not None: for name in ("BldFl1Sh", "BldFl2Sh", "BldEdgSh"): fit = getattr(blade_params, name) writer.writerow([ "blade", name, fit.c2, fit.c3, fit.c4, fit.c5, fit.c6, fit.rms_residual, fit.cond_number, ]) return out.getvalue()