Source code for vane.viz.damping_plot

# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2026 Jae Hoon Seo, SMI Lab, Inha University
"""Damping-ratio plotting against the operating parameter, with stability signals.

Renders the damping ratio of each tracked mode as a function of the operating
parameter (rotor speed or wind speed), the companion to the Campbell diagram for
assessing stability margins across the operating envelope. :func:`plot_damping` returns
an interactive Plotly figure and :func:`plot_damping_static` a Matplotlib figure for
publication.

The plot makes stability legible at a glance: the negative-damping region is shaded as
**unstable** and a thin band above zero as a low-margin **caution** zone, and any point
that crosses into negative damping is flagged. Tracks use the same styling as the
Campbell diagram: colour, dash and marker by mode family
(:func:`vane.viz.mode_line_style`), line opacity scaled by identification confidence,
and open-diamond markers on ambiguous tracks and points. The static figure names each
track at its line end and explains the encoding in a compact key; the interactive one
has a legend grouped by family.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

import matplotlib as mpl
import numpy as np
import plotly.graph_objects as go

from vane.viz._style import (
    ALERT_COLOR,
    AMBIGUOUS_MPL_MARKER,
    LOW_CONFIDENCE_OPACITY,
    STANDARD_COLORS,
    TRACK_LINEWIDTH,
    VANE_STYLE,
    EncodingKey,
    LegendGroups,
    ambiguous_points,
    confidence_opacity,
    direct_label_text,
    label_line_ends,
    new_axes,
    parameter_label,
    plotly_family_legend,
    plotly_point_symbols,
    resolve_size,
    resolve_template,
    static_style,
    text_color,
    track_name,
    track_styles,
)

if TYPE_CHECKING:
    from pathlib import Path

    import numpy.typing as npt
    from matplotlib.axes import Axes
    from matplotlib.figure import Figure

    from vane.campbell.builder import CampbellDiagram
    from vane.viz.publication import FigureSize

__all__ = ["plot_damping", "plot_damping_static"]

# Default low-damping caution threshold (fraction): modes below this are lightly damped.
_DEFAULT_CAUTION_DAMPING = 0.01
_UNSTABLE_COLOR = ALERT_COLOR
_CAUTION_COLOR = STANDARD_COLORS[5]
_BAND_OPACITY = 0.08


def _check_caution(caution_damping: float) -> None:
    """Reject a negative caution threshold."""
    if caution_damping < 0.0:
        msg = f"caution_damping must be non-negative, got {caution_damping}"
        raise ValueError(msg)


def _minimum_damping(damping: npt.NDArray[np.float64], current: float) -> float:
    """Return the running minimum damping, ignoring NaN (failed) points.

    A plain ``min()`` would return NaN, and ``min(0.0, NaN)`` keeps ``0.0``, hiding the
    track's genuine negative damping.
    """
    finite = damping[np.isfinite(damping)]
    return min(current, float(finite.min())) if finite.size else current


def _band_floor(minimum_damping: float, caution_damping: float, scale: float) -> float:
    """Return the lower edge of the unstable band, in plotted units.

    The band reaches below the lowest plotted damping but stays visible (a 0.5 % floor)
    even when every mode is stable, so the danger zone always shows.
    """
    depth = max(-minimum_damping, caution_damping, 0.005)
    return -depth * scale * 1.05


def _unstable_title(unstable_labels: list[str]) -> str | None:
    """Return a warning title naming the modes that go unstable, or ``None``."""
    if not unstable_labels:
        return None
    unique = ", ".join(dict.fromkeys(unstable_labels))
    return f"UNSTABLE: {unique}"


def _damping_label(as_percent: bool) -> str:
    """Return the damping axis label with its unit."""
    return "Damping ratio (%)" if as_percent else "Damping ratio (-)"


[docs] def plot_damping( diagram: CampbellDiagram, *, as_percent: bool = True, show_stability_bands: bool = True, show_confidence: bool = True, caution_damping: float = _DEFAULT_CAUTION_DAMPING, template: str | go.layout.Template | None = None, title: str | None = None, ) -> go.Figure: """Plot damping ratio versus the operating parameter, with stability signals. Parameters ---------- diagram : CampbellDiagram The diagram whose tracks are plotted. as_percent : bool, optional Plot damping as a percentage (the default) rather than a fraction. show_stability_bands : bool, optional Shade the unstable (damping < 0) region and the low-margin caution band above it. Default ``True``. show_confidence : bool, optional Scale each track's line opacity by its identification confidence and mark an ambiguous track (and the ambiguous points of a clear track) with open diamonds. Default ``True``. caution_damping : float, optional Upper edge of the caution band as a damping *fraction* (default ``0.01``, i.e. 1%); modes below it are flagged as lightly damped. template : str, plotly.graph_objects.layout.Template or None, optional Plotly layout template to apply. ``None`` (the default) applies the VANE template ``"vane"``. title : str or None, optional Figure title. ``None`` (the default) draws a title only as a warning, naming the modes that go unstable. Returns ------- plotly.graph_objects.Figure The damping figure: one line per tracked mode (styled by mode family, with the legend grouped by family), unstable points flagged, and, when enabled, the unstable and caution stability bands. Raises ------ ValueError If ``caution_damping`` is negative. """ _check_caution(caution_damping) scale = 100.0 if as_percent else 1.0 figure = go.Figure() unstable_labels: list[str] = [] minimum_damping = 0.0 titled: set[str] = set() axis_label = parameter_label(diagram.parameter_name) unit = " %" if as_percent else "" hover = ( "<b>%{fullData.name}</b><br>" f"{axis_label}: %{{x:.4g}}<br>Operating point: %{{customdata}}<br>" f"Damping ratio: %{{y:.3g}}{unit}<extra></extra>" ) for track, style in zip(diagram.tracks, track_styles(diagram.tracks), strict=True): x, _, damping = diagram.track_curve(track) minimum_damping = _minimum_damping(damping, minimum_damping) ambiguous = track.is_ambiguous flagged = show_confidence and ambiguous figure.add_trace( go.Scatter( x=x, y=damping * scale, mode="lines+markers", name=track_name(track, ambiguous), opacity=confidence_opacity(track) if show_confidence else 1.0, line={"color": style.color, "dash": style.dash}, marker={ "symbol": plotly_point_symbols( track, style.marker, flagged, show_confidence ), "color": style.color, }, customdata=list(track.operating_points), hovertemplate=hover, **plotly_family_legend(style.family, titled), ) ) unstable = damping < 0.0 if bool(unstable.any()): unstable_labels.append(track.label.label) figure.add_trace( go.Scatter( x=x[unstable], y=damping[unstable] * scale, mode="markers", marker={"symbol": "x", "size": 10, "color": _UNSTABLE_COLOR}, name=f"{track.label.label} (unstable)", legendgroup=style.family.value, showlegend=False, hovertemplate="UNSTABLE<br>damping=%{y:.3g}<extra></extra>", ) ) if show_stability_bands and diagram.tracks: _add_stability_bands(figure, minimum_damping, caution_damping, scale) layout: dict[str, Any] = { "xaxis_title": axis_label, "yaxis_title": _damping_label(as_percent), "legend": {"traceorder": "grouped"}, "template": resolve_template(template), } shown = _unstable_title(unstable_labels) if title is None else title if shown is not None: layout["title"] = shown figure.update_layout(**layout) return figure
[docs] def plot_damping_static( diagram: CampbellDiagram, *, as_percent: bool = True, show_stability_bands: bool = True, show_confidence: bool = True, caution_damping: float = _DEFAULT_CAUTION_DAMPING, ax: Axes | None = None, style: str | dict[str, Any] | Path | None = VANE_STYLE, size: str | float | FigureSize | None = None, direct_labels: bool = True, title: str | None = None, ) -> Figure: """Plot damping ratio versus the operating parameter as a Matplotlib figure. The static counterpart of :func:`plot_damping`: the same tracks, family styling, stability bands and instability flags (red filled crosses). Each mode is named at its line end; a compact key inside the axes explains the encoding. Parameters ---------- diagram : CampbellDiagram The diagram whose tracks are plotted. as_percent : bool, optional Plot damping as a percentage (the default) rather than a fraction. show_stability_bands : bool, optional Shade the unstable (damping < 0) region and the low-margin caution band above it. Default ``True``. show_confidence : bool, optional Scale each track's line opacity by its identification confidence and mark ambiguous tracks and points with open diamonds. Default ``True``. caution_damping : float, optional Upper edge of the caution band as a damping *fraction* (default ``0.01``). ax : matplotlib.axes.Axes or None, optional Draw into these axes instead of creating a new figure; the parent figure is returned. style : str, dict, pathlib.Path or None, optional Matplotlib style in effect while drawing: ``"vane"`` (the default), ``None`` for the current ``rcParams``, or any value accepted by :func:`matplotlib.style.context`. size : str, float, FigureSize or None, optional Figure size and fonts (see :func:`vane.viz.figure_size`); ``None`` uses ``"double"`` for a new figure. direct_labels : bool, optional Name the tracks at their line ends with a compact key (the default); ``False`` draws a per-track legend grouped by family instead. title : str or None, optional Axes title; ``None`` (the default) draws none. Returns ------- matplotlib.figure.Figure The damping figure. Raises ------ ValueError If ``caution_damping`` is negative. """ _check_caution(caution_damping) resolved = resolve_size(size, "double", ax=ax, style=style) with static_style(style, resolved): figure, axes = new_axes(ax) legend = None if direct_labels else LegendGroups() key, ends = draw_damping_static( axes, diagram, as_percent=as_percent, show_stability_bands=show_stability_bands, show_confidence=show_confidence, caution_damping=caution_damping, legend=legend, ) if title is not None: axes.set_title(title) if legend is not None: legend.draw(axes, own_figure=ax is None) else: key.draw(axes, own_figure=ax is None) label_line_ends(axes, ends, inside=ax is not None, grow=ax is None) return figure
def draw_damping_static( axes: Axes, diagram: CampbellDiagram, *, as_percent: bool, show_stability_bands: bool, show_confidence: bool, caution_damping: float, legend: LegendGroups | None, ) -> tuple[EncodingKey, list[tuple[float, float, str, str]]]: """Draw the damping ratios on Matplotlib axes. Returns the encoding key and the line ends ``(x, y, text, colour)`` for the direct labels; when ``legend`` is given, every track is also added to it. Raises ------ ValueError If ``caution_damping`` is negative. """ _check_caution(caution_damping) scale = 100.0 if as_percent else 1.0 key = EncodingKey() ends: list[tuple[float, float, str, str]] = [] minimum_damping = 0.0 any_unstable = False styles = track_styles(diagram.tracks) for track, line_style in zip(diagram.tracks, styles, strict=True): x, _, damping = diagram.track_curve(track) minimum_damping = _minimum_damping(damping, minimum_damping) ambiguous = show_confidence and track.is_ambiguous alpha = confidence_opacity(track) if show_confidence else 1.0 flags = ambiguous_points(track) if show_confidence else None clear = ( None if flags is None or ambiguous else [i for i, flag in enumerate(flags) if not flag] ) (line,) = axes.plot( x, damping * scale, color=line_style.color, linestyle=line_style.linestyle, linewidth=TRACK_LINEWIDTH, marker=AMBIGUOUS_MPL_MARKER if ambiguous else line_style.mpl_marker, markerfacecolor="none" if ambiguous else line_style.color, markevery=clear, alpha=alpha, ) if clear is not None and flags is not None: mask = np.asarray(flags, dtype=np.bool_) axes.plot( x[mask], damping[mask] * scale, linestyle="none", marker=AMBIGUOUS_MPL_MARKER, markerfacecolor="none", markeredgecolor=line_style.color, alpha=alpha, ) key.add_track( track.label, ambiguous=ambiguous or clear is not None, low_confidence=alpha < LOW_CONFIDENCE_OPACITY, ) if legend is not None: legend.add_family( line_style.family, line, track_name(track, track.is_ambiguous) ) finite = np.flatnonzero(np.isfinite(damping)) if finite.size: last = int(finite[-1]) shade = text_color(line_style.color) text = direct_label_text(axes, track.label.label) ends.append((float(x[last]), float(damping[last] * scale), text, shade)) unstable = damping < 0.0 if bool(unstable.any()): any_unstable = True axes.plot( x[unstable], damping[unstable] * scale, linestyle="none", marker="X", markersize=7.0, markeredgecolor="#262626", markeredgewidth=0.5, color=_UNSTABLE_COLOR, zorder=4, ) if any_unstable: key.markers.append(("Unstable", _UNSTABLE_COLOR)) if show_stability_bands and diagram.tracks: floor = _band_floor(minimum_damping, caution_damping, scale) axes.axhspan(floor, 0.0, color=_UNSTABLE_COLOR, alpha=_BAND_OPACITY, lw=0) axes.annotate( "unstable", xy=(1.0, 0.0), xycoords=("axes fraction", "data"), xytext=(-3.0, -2.0), textcoords="offset points", color=_UNSTABLE_COLOR, ha="right", va="top", fontsize=mpl.rcParams["xtick.labelsize"], ) if caution_damping > 0.0: axes.axhspan( 0.0, caution_damping * scale, color=_CAUTION_COLOR, alpha=_BAND_OPACITY, linewidth=0, ) axes.axhline(0.0, color="0.5", linewidth=0.6, zorder=1) axes.set_xlabel(parameter_label(diagram.parameter_name)) axes.set_ylabel(_damping_label(as_percent)) return key, ends def _add_stability_bands( figure: go.Figure, minimum_damping: float, caution_damping: float, scale: float, ) -> None: """Shade the unstable (damping < 0) region and the low-margin caution band.""" figure.add_hrect( y0=_band_floor(minimum_damping, caution_damping, scale), y1=0.0, fillcolor=_UNSTABLE_COLOR, opacity=_BAND_OPACITY, line_width=0, layer="below", annotation_text="unstable", annotation_position="bottom left", ) if caution_damping > 0.0: figure.add_hrect( y0=0.0, y1=caution_damping * scale, fillcolor=_CAUTION_COLOR, opacity=_BAND_OPACITY, line_width=0, layer="below", )