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