Source code for vane.export

"""Tabular export of modal, Campbell, and uncertainty results.

The analysis products — a per-operating-point modal solution and a Campbell diagram
of tracked modes — are flattened into :class:`pandas.DataFrame` tables and written to
CSV, JSON, or Excel for downstream tools (spreadsheets, reports, notebooks). The
tables optionally carry the physical labels, per-mode confidence, and azimuth-spread
uncertainty produced by the rest of the library.
"""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

import numpy as np
import pandas as pd

if TYPE_CHECKING:
    from collections.abc import Sequence

    import numpy.typing as npt

    from vane.campbell.builder import CampbellDiagram
    from vane.modal.eigensolver import ModalSolution
    from vane.modal.labeler import ModeLabel
    from vane.modal.uncertainty import AzimuthSpread

__all__ = ["campbell_table", "modes_table", "write_table"]

_FORMAT_SUFFIXES = (".csv", ".json", ".xlsx")
_CAMPBELL_RESERVED_COLUMNS = frozenset(
    {
        "track",
        "label",
        "multiblade",
        "natural_frequency_hz",
        "damping_ratio",
        "confidence",
        "is_ambiguous",
    }
)


[docs] def modes_table( solution: ModalSolution, *, labels: Sequence[ModeLabel] | None = None, confidence: npt.NDArray[np.float64] | Sequence[float] | None = None, spread: AzimuthSpread | None = None, ) -> pd.DataFrame: """Flatten a modal solution into a per-mode table. Parameters ---------- solution : ModalSolution The modal solution to export. labels : Sequence[ModeLabel] or None, optional Per-mode physical labels; their name, category, and multi-blade type are added as columns. confidence : npt.NDArray[np.float64] or Sequence[float] or None, optional Per-mode unified confidence, added as a column. spread : AzimuthSpread or None, optional Per-mode azimuth spread; its frequency and damping standard deviations are added as columns. Returns ------- pandas.DataFrame One row per mode, ordered as in ``solution``. Raises ------ ValueError If any optional argument's length does not match the number of modes. """ n_modes = solution.n_modes if labels is not None and len(labels) != n_modes: msg = f"labels has {len(labels)} entries, expected {n_modes}" raise ValueError(msg) if confidence is not None and len(confidence) != n_modes: msg = f"confidence has {len(confidence)} entries, expected {n_modes}" raise ValueError(msg) if spread is not None and spread.natural_frequency_std.shape[0] != n_modes: msg = ( f"spread has {spread.natural_frequency_std.shape[0]} entries, " f"expected {n_modes}" ) raise ValueError(msg) data: dict[str, object] = { "mode": list(range(n_modes)), "natural_frequency_hz": solution.natural_frequencies_hz, "damped_frequency_hz": solution.damped_frequencies_hz, "damping_ratio": solution.damping_ratios, } if solution.condition_numbers.shape[0] == n_modes: data["condition_number"] = solution.condition_numbers if solution.is_degenerate.shape[0] == n_modes: data["is_degenerate"] = solution.is_degenerate if labels is not None: data["label"] = [label.label for label in labels] data["category"] = [label.category.name for label in labels] data["multiblade"] = [label.multiblade for label in labels] if confidence is not None: data["confidence"] = np.asarray(confidence, dtype=np.float64) if spread is not None: data["frequency_std_hz"] = spread.natural_frequency_std data["damping_std"] = spread.damping_ratio_std return pd.DataFrame(data)
[docs] def campbell_table(diagram: CampbellDiagram) -> pd.DataFrame: """Flatten a Campbell diagram into a long-format table of tracked modes. Parameters ---------- diagram : CampbellDiagram The Campbell diagram to export. Returns ------- pandas.DataFrame One row per (track, operating point): the track index and label, the operating-parameter value, natural frequency, damping ratio, track confidence, and ambiguity flag. The operating-parameter column is named after ``diagram.parameter_name``. Raises ------ ValueError If ``diagram.parameter_name`` collides with a fixed export column. """ parameter = diagram.parameter_name if parameter in _CAMPBELL_RESERVED_COLUMNS: msg = ( f"parameter_name '{parameter}' collides with a reserved export column; " f"rename it (reserved: {', '.join(sorted(_CAMPBELL_RESERVED_COLUMNS))})" ) raise ValueError(msg) rows: list[dict[str, object]] = [] for track_id, track in enumerate(diagram.tracks): for point, frequency, damping in zip( track.operating_points, track.natural_frequencies_hz, track.damping_ratios, strict=True, ): rows.append( { "track": track_id, "label": track.label.label, "multiblade": track.label.multiblade, parameter: float(diagram.parameter_values[point]), "natural_frequency_hz": frequency, "damping_ratio": damping, "confidence": track.confidence, "is_ambiguous": track.is_ambiguous, } ) columns = [ "track", "label", "multiblade", parameter, "natural_frequency_hz", "damping_ratio", "confidence", "is_ambiguous", ] return pd.DataFrame(rows, columns=columns)
[docs] def write_table(table: pd.DataFrame, path: str | Path) -> None: """Write a table to CSV, JSON, or Excel, inferred from the file suffix. Parameters ---------- table : pandas.DataFrame The table to write. path : str or pathlib.Path Destination path; the suffix selects the format (``.csv``, ``.json``, or ``.xlsx``). Raises ------ ValueError If the suffix is not a supported format, or Excel is requested without the optional ``openpyxl`` dependency installed. """ destination = Path(path) suffix = destination.suffix.lower() if suffix == ".csv": table.to_csv(destination, index=False) elif suffix == ".json": table.to_json(destination, orient="records", indent=2) elif suffix == ".xlsx": try: import openpyxl # noqa: F401 except ImportError as exc: msg = "Excel export requires the optional 'openpyxl' package" raise ValueError(msg) from exc table.to_excel(destination, index=False) else: msg = ( f"unsupported export format '{suffix}'; " f"use one of {', '.join(_FORMAT_SUFFIXES)}" ) raise ValueError(msg)