Campbell diagrams

A Campbell diagram plots the natural frequency of each tracked mode against an operating parameter, rotor speed or wind speed, together with the per-revolution (nP) excitation lines. VANE builds it from the mode tracks of Mode identification, finds where the tracks cross the excitation lines, plots it, and exports it as a table. The theory is on Campbell diagram, resonance, and uncertainty.

From the pipeline

ModalPipeline.run returns the diagram as result.campbell and the resonance crossings as result.resonances:

from vane import ModalPipeline, discover_operating_points

operating_points = discover_operating_points("lin_files")
result = ModalPipeline().run(operating_points)

diagram = result.campbell
for track in diagram.tracks:
    print(track.label.label, track.natural_frequencies_hz)

for crossing in result.resonances:
    print(
        f"track {crossing.track_index}: {crossing.harmonic}P at "
        f"{crossing.rotor_speed_rpm:.2f} rpm, {crossing.frequency_hz:.3f} Hz, "
        f"{crossing.severity.value}"
    )

The pipeline sorts the operating points by the operating parameter, so the input order does not matter. ModalPipeline(harmonics=...) sets the excitation orders searched for crossings (default (1, 3, 6, 9)).

Choosing the sweep axis

The operating parameter is the axis along which the sweep is sorted, tracked and plotted: rotor speed (parameter_name="rotor_speed_rpm", the default; CLI --parameter rpm) or wind speed (parameter_name="wind_speed"; CLI --parameter wind).

Above rated, the trimmed rotor speed is constant, so the rotor-speed values of those points are equal up to round-off and ordering by them would scramble the sweep. The pipeline therefore rejects a sweep axis on which two operating points lie within parameter_rtol (default 1e-4, relative to the largest-magnitude value on the axis) of each other. The ValueError names the two values and recommends the wind-speed axis. The axis is never switched automatically; for a sweep that extends past rated, run on wind speed:

result = ModalPipeline().run(operating_points, parameter_name="wind_speed")
$ vane campbell lin_files --parameter wind

parameter_rtol is accepted by run() and run_study(); 0 keeps only the check for exact duplicates. Resonance detection needs the rotor-speed axis, so a wind-speed run returns an empty result.resonances.

Building a diagram directly

build_campbell() pairs an IdentificationResult with the parameter value of each operating point. The values must be finite and strictly increasing, one per operating point, in the order of the solutions that were tracked:

from vane.campbell import build_campbell
from vane.io import mbc3_transform
from vane.modal import identify_modes, modes_from_mbc

mbc_results = sorted(
    (mbc3_transform(point) for point in operating_points),
    key=lambda result: result.rotor_speed_rpm,
)
solutions = [modes_from_mbc(result) for result in mbc_results]
rpm = [result.rotor_speed_rpm for result in mbc_results]

diagram = build_campbell(identify_modes(solutions), rpm)

parameter_name (default "rotor_speed_rpm") names the axis, and include_heavily_damped=False leaves the heavily damped tracks out of the diagram. campbell_from_solutions() runs the tracking and the assembly in one call.

A CampbellDiagram holds parameter_values, parameter_name, n_operating_points, the tracks, and the heavily_damped_tracks. track_curve() returns the parameter values, natural frequencies and damping ratios along one track:

for track in diagram.tracks:
    x, frequency_hz, damping = diagram.track_curve(track)

Heavily damped tracks (damping ratio at or above 0.99, see Mode identification) are kept apart from tracks. They are drawn distinctly and never enter the resonance search, since a mode damped near critically cannot resonate.

Excitation lines and resonance crossings

A rotor turning at rpm excites the structure at n * rpm / 60 Hz, the nP lines. excitation_frequencies() evaluates them, and find_resonances() finds where a track’s natural frequency crosses one, interpolating linearly between operating points:

from vane.campbell import find_resonances

crossings = find_resonances(diagram, harmonics=(1, 3, 6), operating_range=(6.9, 12.1))

harmonics defaults to DEFAULT_HARMONICS, (1, 3, 6, 9): the 1P rotor imbalance and the blade-passing lines of a three-bladed rotor. operating_range keeps only the crossings whose rotor speed lies in the given (min_rpm, max_rpm) window. The function raises ValueError for a diagram whose parameter is not "rotor_speed_rpm".

Each ResonanceCrossing carries the track_index, the harmonic, the interpolated rotor_speed_rpm, frequency_hz and damping_ratio, and a ResonanceSeverity from the damping ratio at the crossing:

Severity

Damping ratio at the crossing

HIGH

below 0.01

MEDIUM

from 0.01 to below 0.05

LOW

0.05 or more

Plotting

plot_campbell_static() draws the tracks, the nP lines and the crossings as a Matplotlib figure for publication, and plot_damping_static() plots the damping ratio of every track against the operating parameter with stability bands. plot_campbell_panels() puts the two side by side, and plot_campbell() and plot_damping() draw them as interactive Plotly figures:

from vane.viz import (
    plot_campbell,
    plot_campbell_panels,
    plot_campbell_static,
    save_figure,
)

fig = plot_campbell_static(diagram, operating_range=(6.9, 12.1), size="single")
save_figure(fig, "campbell", size="single")      # campbell.pdf, .svg and .png
save_figure(plot_campbell_panels(diagram, size="double"), "campbell_panels.pdf")
plot_campbell(diagram, operating_range=(6.9, 12.1)).write_html("campbell.html")

Each track takes the colour of its mode family (blades green, tower black, platform red, drivetrain magenta, other modes cyan), the line style cycles within a family, and the marker shows the multi-blade type (circle collective, downward triangle regressive, upward triangle progressive). The nP lines are thin, blue (“Blade Passing”) and tagged inline. The static figure names each mode at its line end and keeps the key to the families and the overlays; the operating window, when given, is shaded and marked. Resonance crossings are filled crosses: red for high severity, orange for medium and grey for low. The excitation lines and crossings are drawn only for a rotor-speed diagram. See Visualization for the plot options, the encoding, the figure sizes and how to change the style, and Plot gallery for examples.

Export and the command line

campbell_table() flattens a diagram into a long-format pandas.DataFrame, one row per track and operating point, with the columns track, label, multiblade, the operating parameter (named after parameter_name), natural_frequency_hz, damping_ratio, confidence and is_ambiguous. resonance_table() does the same for the crossings, and write_table() writes either table to .csv, .json, .xlsx (with the excel extra) or .mat, chosen by the file suffix:

from vane.export import campbell_table, resonance_table, write_table

write_table(campbell_table(result.campbell), "campbell.csv")
write_table(resonance_table(result.resonances), "resonances.csv")

The vane campbell command runs the pipeline over a directory of .lin files and prints the Campbell table, or writes it with --output (-o):

$ vane campbell lin_files --output campbell.csv
$ vane campbell lin_files --parameter wind -o campbell.xlsx

--parameter (-p) is rpm (the default) or wind. The vane study command writes campbell.csv together with the provenance record and an HTML report that contains the plotted diagram.

Azimuth-spread uncertainty

The averaged model leaves out how the rotor’s state matrix varies with azimuth. azimuth_spread() estimates, per mode, the standard deviation of natural frequency and damping ratio across the sampled azimuths. It needs the per-azimuth matrices, which the pipeline does not keep, so the MBC transform is run again with retain_per_azimuth=True. The plots have no built-in uncertainty band; the spread can be added as error bars:

import numpy as np
import plotly.graph_objects as go

from vane.io import mbc3_transform
from vane.modal import azimuth_spread

# The same order as the pipeline: ascending rotor speed.
ordered = sorted(operating_points, key=lambda p: np.mean([f.rotor_speed for f in p]))
spreads = [azimuth_spread(mbc3_transform(p, retain_per_azimuth=True)) for p in ordered]

fig = plot_campbell(result.campbell)
for track in result.campbell.tracks:
    x, frequency_hz, _ = result.campbell.track_curve(track)
    sigma = [
        spreads[op].natural_frequency_std[mode]
        for op, mode in zip(track.operating_points, track.mode_indices)
    ]
    fig.add_trace(
        go.Scatter(
            x=x, y=frequency_hz, mode="markers", showlegend=False,
            error_y={"type": "data", "array": sigma},
        )
    )

The spread is zero for an isotropic rotor and grows with rotor anisotropy. For the exact treatment of the periodic system, see Azimuth averaging and Floquet analysis of the periodic system.