Validation#

ROSS results are validated in several complementary ways, and most of that validation lives inside the project itself:

  • Tests anchored to published results — natural frequencies computed by ROSS are checked against worked examples from the literature in ross/tests/test_examples.py, and fluid-film bearing coefficients against analytical solutions in ross/tests/test_fluid_flow.py. These run in CI on every change.

  • Doctests — the examples in every public method’s docstring are executed by the test suite (--doctest-modules), so the documented results are continuously verified.

  • Documentation examples — most of the notebooks in the Discussions and Examples section reproduce worked examples from [Friswell, 2010], and the tutorials include comparisons against results published in the literature.

This page complements those with a few representative comparison cases — first against a textbook, then against a commercial rotordynamics code. It is intentionally a small sample, not an exhaustive benchmark.

Natural frequencies vs. Friswell et al. (2010)#

The rotor of Example 5.9.1 (p. 206) of [Friswell, 2010]: a 1.5 m shaft with 50 mm diameter, two disks and two isotropic bearings (\(k = 1\) MN/m, no damping), modeled with 6 Timoshenko shaft elements as in the book. The book reports the natural frequencies at 0 rpm and at 4000 rpm (where gyroscopic effects split the forward/backward pairs).

import numpy as np
import pandas as pd
import ross as rs
from ross.materials import steel

n_el = 6
shaft = [
    rs.ShaftElement(L=1.5 / n_el, material=steel, n=i, idl=0, odl=0.05)
    for i in range(n_el)
]
disks = [
    rs.DiskElement.from_geometry(n=2, material=steel, width=0.07, i_d=0.05, o_d=0.28),
    rs.DiskElement.from_geometry(n=4, material=steel, width=0.07, i_d=0.05, o_d=0.35),
]
bearings = [
    rs.BearingElement(n=0, kxx=1e6, kyy=1e6, cxx=0, cyy=0),
    rs.BearingElement(n=n_el, kxx=1e6, kyy=1e6, cxx=0, cyy=0),
]
rotor = rs.Rotor(shaft, disks, bearings)

book_0rpm = np.array([86.66, 86.66, 274.31, 274.31, 650.41, 716.78])
book_4000rpm = np.array([85.39, 87.80, 251.78, 294.71, 600.18, 650.41])

ross_0rpm = rotor.run_modal(0).wn[:6]
ross_4000rpm = rotor.run_modal(rs.Q_(4000, "RPM")).wn[:6]

pd.DataFrame(
    {
        "Friswell @ 0 rpm (rad/s)": book_0rpm,
        "ROSS @ 0 rpm (rad/s)": ross_0rpm.round(2),
        "diff (%)": (100 * (ross_0rpm - book_0rpm) / book_0rpm).round(3),
        "Friswell @ 4000 rpm (rad/s)": book_4000rpm,
        "ROSS @ 4000 rpm (rad/s)": ross_4000rpm.round(2),
        "diff (%) ": (100 * (ross_4000rpm - book_4000rpm) / book_4000rpm).round(3),
    },
    index=[f"mode {i + 1}" for i in range(6)],
)
/home/raphaelts/ross/.claude/worktrees/docs-validation-page/.venv/lib/python3.13/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
  from .autonotebook import tqdm as notebook_tqdm
Friswell @ 0 rpm (rad/s) ROSS @ 0 rpm (rad/s) diff (%) Friswell @ 4000 rpm (rad/s) ROSS @ 4000 rpm (rad/s) diff (%)
mode 1 86.66 86.66 -0.002 85.39 85.39 -0.001
mode 2 86.66 86.66 -0.002 87.80 87.80 -0.005
mode 3 274.31 274.31 0.001 251.78 251.78 0.002
mode 4 274.31 274.31 0.001 294.71 294.71 0.001
mode 5 650.41 650.41 -0.000 600.18 600.18 -0.000
mode 6 716.78 716.79 0.001 650.41 650.41 -0.000

The agreement is within the rounding of the values printed in the book. The same holds for the other configurations of that chapter — anisotropic bearings (Example 5.9.2), mixed modes (5.9.3), cross-coupled bearings (5.9.4), damped bearings (5.9.5) and an overhung rotor (5.9.9) — which are permanently pinned as assertions in ross/tests/test_examples.py.

Damped natural frequencies vs. RAPPID (RSR)#

In discussion #911 a user ran the ROSS example rotor (rs.rotor_example()) through the damped eigenvalue module (RAPP) of the commercial code RAPPID (Rotordynamics-Seal Research) and shared the output file: a damped eigenvalue analysis at 500 rpm of the same 7-station model.

The output file records the complete model, which lets us verify that the two codes are solving the same problem: the station masses and inertias, the disk mass and polar and transverse inertias, the bearing coefficients (\(k_{xx} = 1\) MN/m, \(k_{yy} = 0.8\) MN/m, undamped) and the material properties all match rs.rotor_example() to the precision printed in the file.

The table below compares the first six modes (three forward/backward pairs). One note on mode counting: ROSS uses 6 degrees of freedom per node, so its modal analysis also returns a torsional mode (at about 123 Hz for this rotor) that RAPPID’s lateral analysis does not compute; it does not appear among the first six modes and is left out of the comparison.

# First six damped natural frequencies (Hz) at 500 rpm from the RAPPID output
# file shared in https://github.com/petrobras/ross/discussions/911
rappid_hz = np.array([14.6152, 15.3284, 43.3459, 46.9251, 105.902, 112.760])

rotor = rs.rotor_example()
modal = rotor.run_modal(rs.Q_(500, "RPM"))
ross_hz = modal.wn[:6] / (2 * np.pi)

pd.DataFrame(
    {
        "RAPPID (Hz)": rappid_hz,
        "ROSS (Hz)": ross_hz.round(4),
        "diff (%)": (100 * (ross_hz - rappid_hz) / rappid_hz).round(2),
    },
    index=[f"mode {i + 1}" for i in range(6)],
)
RAPPID (Hz) ROSS (Hz) diff (%)
mode 1 14.6152 14.6094 -0.04
mode 2 15.3284 15.3252 -0.02
mode 3 43.3459 43.6761 0.76
mode 4 46.9251 47.2114 0.61
mode 5 105.9020 114.7903 8.39
mode 6 112.7600 122.0011 8.20

The first two forward/backward pairs agree to better than 0.8%, but the third pair differs by about 8%. Since the models were verified to be identical, that difference has to come from the numerical formulation — and it is not a matter of mesh resolution on the ROSS side: refining the model from 6 to 96 shaft elements changes the third pair by less than 0.05%, so the consistent-mass finite element solution above is already converged.

The cause is how the two codes discretize mass. RAPPID’s transfer-matrix method lumps mass and inertia at the 7 stations, while ROSS assembles the consistent mass matrix of Timoshenko beam theory [Friswell, 2010]. These are two distributions of the same physical quantities: lumping ROSS’s own element data at the nodes — half of each shaft element’s mass and inertia at each of its end nodes, plus the disk properties — reproduces RAPPID’s station table, so the mass data of the two models is identical and only its placement in the matrix differs.

We can demonstrate that this single difference accounts for the entire deviation, using nothing but ordinary ROSS elements. We rebuild the same rotor as a transfer-matrix code sees it: the shaft elements get a material with near-zero density — which keeps the beam stiffness but removes the distributed mass, inertia and gyroscopics — and each node gets a DiskElement carrying the lumped station data (half of each adjacent shaft element’s mass and inertia, plus the real disks; no RAPPID values are used).

# Lump ROSS's own element data at the nodes, as a transfer-matrix code does:
# half of each shaft element's mass and inertia at each of its two nodes,
# plus the disk mass and inertias at their mounting nodes
stations = np.zeros((len(rotor.nodes), 3))
for el in rotor.shaft_elements:
    m_half = el.material.rho * np.pi / 4 * (el.odl**2 - el.idl**2) * el.L / 2
    r2 = (el.odl**2 + el.idl**2) / 16
    ip_half = m_half * 2 * r2
    it_half = m_half * ((el.L / 2) ** 2 / 3 + r2)
    for node in (el.n, el.n + 1):
        stations[node] += [m_half, ip_half, it_half]
for d in rotor.disk_elements:
    stations[d.n] += [d.m, d.Ip, d.Id]

# The station table printed in the RAPPID output file (mass kg, IP kg.m2, IT kg.m2)
rappid_stations = np.array(
    [
        [1.91697, 0.00059905, 0.010284],
        [3.83395, 0.0011981, 0.020568],
        [36.4237, 0.33076, 0.19866],
        [3.83395, 0.0011981, 0.020568],
        [36.4237, 0.33076, 0.19866],
        [3.83395, 0.0011981, 0.020568],
        [1.91697, 0.00059905, 0.010284],
    ]
)
dev = np.abs((stations - rappid_stations) / rappid_stations).max()
print(f"lumped ROSS element data vs RAPPID station table: max deviation = {dev:.4%}")

# Rebuild the rotor as a transfer-matrix code sees it: massless shaft elements
# (stiffness only) plus one DiskElement per node carrying the station data
massless = rs.Material(name="massless_steel", rho=1e-30, E=211e9, G_s=81.2e9)
shaft = [
    rs.ShaftElement(n=el.n, L=el.L, idl=el.idl, odl=el.odl, material=massless)
    for el in rotor.shaft_elements
]
disks = [
    rs.DiskElement(n=n, m=m, Ip=ip, Id=it) for n, (m, ip, it) in enumerate(stations)
]
lumped_rotor = rs.Rotor(shaft, disks, rotor.bearing_elements)

# First eight lateral modes of each rotor. Both mode lists also contain the
# torsional mode at ~123 Hz (7th mode), which RAPPID's lateral analysis does
# not compute — remove it before comparing
rappid_8_hz = np.array(
    [14.6152, 15.3284, 43.3459, 46.9251, 105.902, 112.760, 150.907, 155.810]
)
speed = rs.Q_(500, "RPM")
consistent_hz = np.delete(rotor.run_modal(speed, num_modes=24).wn[:9], 6) / (2 * np.pi)
lumped_hz = np.delete(lumped_rotor.run_modal(speed, num_modes=24).wn[:9], 6) / (
    2 * np.pi
)

pd.DataFrame(
    {
        "RAPPID (Hz)": rappid_8_hz,
        "ROSS consistent mass (Hz)": consistent_hz.round(3),
        "ROSS lumped mass (Hz)": lumped_hz.round(3),
        "lumped vs RAPPID (%)": (100 * (lumped_hz - rappid_8_hz) / rappid_8_hz).round(
            2
        ),
    },
    index=[f"mode {i + 1}" for i in range(8)],
)
lumped ROSS element data vs RAPPID station table: max deviation = 0.0081%
RAPPID (Hz) ROSS consistent mass (Hz) ROSS lumped mass (Hz) lumped vs RAPPID (%)
mode 1 14.6152 14.609 14.617 0.01
mode 2 15.3284 15.325 15.330 0.01
mode 3 43.3459 43.676 43.356 0.02
mode 4 46.9251 47.211 46.938 0.03
mode 5 105.9020 114.790 105.911 0.01
mode 6 112.7600 122.001 112.770 0.01
mode 7 150.9070 170.229 151.032 0.08
mode 8 155.8100 175.656 155.932 0.08

With the lumped mass distribution, ROSS reproduces every RAPPID mode — including the fourth pair, which the consistent-mass solution places 13% higher — to better than 0.1%. Note that the lumped rotor also lumps the gyroscopic effects (the massless shaft contributes none; the station DiskElements contribute theirs), which is exactly what the point matrices of a transfer-matrix code do. The two codes therefore agree on the model and its stiffness, and differ only in this discretization choice. Refining the mesh makes the lumped formulation converge from below to the same values as the consistent formulation (which, as noted, is already converged with 6 elements), so the consistent-mass frequencies are the better answer for this model at this discretization — but for the first two pairs, which is what matters at operating speeds near them, the 7-station lumped model is already within 0.8%.

Other comparisons in the documentation#

  • Tutorial - MultiRotor System compares the time response of a geared multi-rotor system against results published by [Yang et al., 2016].

  • The fluid-film bearing notebooks (user_guide/fluid_flow_theory, user_guide/fluid_flow_short_bearing, user_guide/fluid_flow_elliptical_bearing) derive and check the implemented models against the classical lubrication literature.

If you have benchmark data from another rotordynamics code that you are able to share — as in discussion #911 — a contribution extending this page is very welcome (see issue #913).

References#

[Fri10] (1,2,3)

Michael I Friswell. Dynamics of rotating machines. Cambridge University Press, 2010.

[YWWD16]

Yi Yang, Jiaying Wang, Xurong Wang, and Yiping Dai. A general method to predict unbalance responses of geared rotor systems. Journal of Sound and Vibration, 381:246–263, 2016. doi:https://doi.org/10.1016/j.jsv.2016.06.031.