Skip to content

Commit 1d89bf1

Browse files
committed
fix: place coned rotor at its true CM before the parallel-axis shift
The parallel-axis theorem I_O = I_C + m(|r|^2 I - r outer r) only holds when the tensor is taken about the body centre of mass. The rotor tensor was formed about the hub apex, but a coned rotor's mass sits off the hub plane along the shaft by span*sin(cone), so its CM is offset from the apex. Using the apex both as the tensor reference and as the shift origin double-counts that offset and drops the cross terms for a rotor with real overhang and shaft tilt. Compute the axial first moment (exact per-segment Simpson), take the shaft-axis CM offset as first-moment / mass, reduce the axial second moment to that CM (a mass-weighted variance, always non-negative), and place the rotor body at apex + offset along the shaft axis. A flat rotor (sin(cone) = 0) leaves the offset and axial term zero, so the planar I_polar/2 split about the apex is unchanged. For a coned rotor this raises the tower-top vertical CM by the precone term real ElastoDyn also carries (~0.06 m on IEA-22); the deck adapter approximates the blades as a point mass at the apex and omits it, so the cross-check test now documents that ~1 % divergence.
1 parent 4be1d83 commit 1d89bf1

2 files changed

Lines changed: 103 additions & 21 deletions

File tree

src/pybmodes/io/windio.py

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,24 @@ def _second_moment(w: np.ndarray, c: np.ndarray, x: np.ndarray) -> float:
9393
return float(np.sum(dx / 6.0 * (f0 + 4.0 * fm + f1)))
9494

9595

96+
def _first_moment(w: np.ndarray, c: np.ndarray, x: np.ndarray) -> float:
97+
"""Exact ``integral w(x)*c(x) dx`` for piecewise-linear ``w`` and ``c``.
98+
99+
The integrand is quadratic on each segment (a product of two linear
100+
factors), which Simpson's rule integrates exactly. Used for the rotor's
101+
axial first moment, i.e. its shaft-axis CM offset, so a coned rotor body
102+
is placed at its centre of mass before the parallel-axis shift.
103+
"""
104+
w = np.asarray(w, dtype=float)
105+
c = np.asarray(c, dtype=float)
106+
x = np.asarray(x, dtype=float)
107+
dx = np.diff(x)
108+
w0, w1 = w[:-1], w[1:]
109+
c0, c1 = c[:-1], c[1:]
110+
fm = 0.5 * (w0 + w1) * 0.5 * (c0 + c1)
111+
return float(np.sum(dx / 6.0 * (w0 * c0 + 4.0 * fm + w1 * c1)))
112+
113+
96114
def _require_yaml():
97115
"""Import PyYAML or raise the documented friendly error.
98116
@@ -812,7 +830,7 @@ def _blade_reference_axis(six: dict, comp: dict, blade_component: str) -> dict:
812830
def _blade_span_mass_inertia(
813831
six: dict, ref: dict, blade_component: str,
814832
hub_r: float, cone_cos: float, cone_sin: float,
815-
) -> tuple[float, float, float]:
833+
) -> tuple[float, float, float, float]:
816834
"""Integrate a blade's span into ``(mass, polar, axial)`` second moments.
817835
818836
``six`` is
@@ -825,11 +843,14 @@ def _blade_span_mass_inertia(
825843
(prebend / sweep) are optional and default to zero (a straight span).
826844
827845
Returns the single-blade mass, the polar second moment about the rotor
828-
axis ``∫ (dm/ds) · r(s)² ds`` (radial ``r = hub_r + z·cone_cos``), and
829-
the axial second moment ``∫ (dm/ds) · a(s)² ds`` (axial offset
830-
``a = z·cone_sin``). The caller builds the rotor's polar inertia from
831-
the former and the coned transverse (diametral) inertia
832-
``polar/2 + N_bl·axial`` from both (issue #130).
846+
axis ``∫ (dm/ds) · r(s)² ds`` (radial ``r = hub_r + z·cone_cos``), the
847+
axial second moment ``∫ (dm/ds) · a(s)² ds`` (axial offset
848+
``a = z·cone_sin``), and the axial first moment ``∫ (dm/ds) · a(s) ds``.
849+
The caller builds the rotor's polar inertia from the second radial
850+
moment, the coned transverse (diametral) inertia from the radial and
851+
axial second moments, and places the rotor body at its coned centre of
852+
mass (shaft-axis offset ``axial_first / mass``) before the parallel-axis
853+
shift (issue #130).
833854
"""
834855
im = _require_mapping(six.get("inertia_matrix"), "blade six_x_six.inertia_matrix")
835856
grid = np.asarray(_require_key(im, "grid", "blade inertia_matrix"), dtype=float)
@@ -910,7 +931,8 @@ def _blade_span_mass_inertia(
910931
axial = span * cone_sin
911932
polar_second_moment = _second_moment(mpl, radial, s)
912933
axial_second_moment = _second_moment(mpl, axial, s)
913-
return mass, polar_second_moment, axial_second_moment
934+
axial_first_moment = _first_moment(mpl, axial, s)
935+
return mass, polar_second_moment, axial_second_moment, axial_first_moment
914936

915937

916938
def read_windio_rna(
@@ -1071,6 +1093,7 @@ def _nac_geom(key: str) -> float:
10711093
m_blade_each = 0.0
10721094
i_polar_each = 0.0
10731095
i_axial_each = 0.0
1096+
m_axial1_each = 0.0
10741097
if n_blades > 0:
10751098
blade = _require_mapping(
10761099
comps.get(component_blade), f"components.{component_blade}"
@@ -1084,21 +1107,35 @@ def _nac_geom(key: str) -> float:
10841107
f"components.{component_blade}.elastic_properties_mb.six_x_six",
10851108
)
10861109
ref = _blade_reference_axis(six, blade, component_blade)
1087-
m_blade_each, i_polar_each, i_axial_each = _blade_span_mass_inertia(
1110+
(
1111+
m_blade_each,
1112+
i_polar_each,
1113+
i_axial_each,
1114+
m_axial1_each,
1115+
) = _blade_span_mass_inertia(
10881116
six, ref, component_blade, hub_r, cone_cos, cone_sin,
10891117
)
10901118
m_blades = n_blades * m_blade_each
10911119

10921120
# Rotor inertia from the spanwise blade mass (issue #130). In the shaft
1093-
# frame the rotor tensor about the hub is diag([I_polar, I_diam, I_diam]):
1094-
# the polar term ``I_polar = N_bl·∫dm·r²`` (radial), and the transverse
1095-
# (diametral) term ``I_diam = I_polar/2 + N_bl·∫dm·a²``. The ``I_polar/2``
1096-
# is the perpendicular-axis split for a planar rotor; the ``N_bl·∫dm·a²``
1097-
# adds the coned rotor's shaft-axis (``a = span·sin(cone)``) offset, which
1098-
# a flat-disc assumption would drop. Lumping the blades as a bare point
1099-
# mass at the apex would drop the whole tensor.
1121+
# frame the rotor tensor is diag([I_polar, I_diam, I_diam]): the polar
1122+
# term ``I_polar = N_bl·∫dm·r²`` (radial ``r = hub_r + span·cos(cone)``),
1123+
# and the transverse (diametral) term. For a coned rotor the blade mass
1124+
# sits off the hub plane by ``a = span·sin(cone)``, so the rotor CM shifts
1125+
# along the shaft axis by ``offset = ∫dm·a / m_blades`` and the tensor is
1126+
# formed about that CM: ``I_diam = I_polar/2 + N_bl·∫dm·a² − m_blades·
1127+
# offset²`` (the axial second moment reduced to the CM by the parallel-
1128+
# axis theorem, a mass-weighted variance that stays non-negative). The
1129+
# body is placed at that CM below so the shift to the tower top is exact.
1130+
# A flat rotor (``sin(cone) = 0``) leaves the offset and the axial term
1131+
# zero, recovering the planar ``I_polar/2`` split about the apex. Lumping
1132+
# the blades as a bare point mass at the apex would drop the whole tensor.
11001133
i_polar_rotor = n_blades * i_polar_each
1101-
i_diam_rotor = 0.5 * i_polar_rotor + n_blades * i_axial_each
1134+
cm_axial_offset = (m_axial1_each / m_blade_each) if m_blade_each > 0.0 else 0.0
1135+
i_axial_cm = max(
1136+
0.0, n_blades * i_axial_each - m_blades * cm_axial_offset * cm_axial_offset
1137+
)
1138+
i_diam_rotor = 0.5 * i_polar_rotor + i_axial_cm
11021139
i_blades = np.diag([i_polar_rotor, i_diam_rotor, i_diam_rotor])
11031140

11041141
# Rotor apex relative to the tower top. An upwind rotor sits at negative
@@ -1124,10 +1161,17 @@ def _nac_geom(key: str) -> float:
11241161
i_hub = r_tilt @ i_hub @ r_tilt.T
11251162
i_blades = r_tilt @ i_blades @ r_tilt.T
11261163

1164+
# The coned rotor CM sits ``cm_axial_offset`` beyond the apex along the
1165+
# shaft axis (r_tilt's first column, the outboard unit vector the overhang
1166+
# already runs along), so a precone displaces the blade mass further from
1167+
# the tower in the same sense for both rotor orientations.
1168+
shaft_axis = r_tilt[:, 0]
1169+
rotor_cm = apex + cm_axial_offset * shaft_axis
1170+
11271171
bodies = [
11281172
(m_nac, r_nac, i_nac),
11291173
(m_hub, apex, i_hub),
1130-
(m_blades, apex.copy(), i_blades),
1174+
(m_blades, rotor_cm, i_blades),
11311175
]
11321176

11331177
m_total = float(sum(m for m, _, _ in bodies))

tests/test_windio_rna.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,35 @@ def _mk(cone: float):
200200
assert coned.mass == pytest.approx(flat.mass)
201201

202202

203+
def test_rna_coned_cm_shift_with_uptilt(tmp_path: pathlib.Path) -> None:
204+
"""A coned rotor's CM sits off the hub apex along the (tilted) shaft, so
205+
with uptilt the tower-top vertical CM shifts by m_blades·offset·sin(uptilt)
206+
over the total mass. Pins that the rotor body is placed at its coned CM,
207+
not the apex, before the parallel-axis shift (Codex review on #130)."""
208+
pytest.importorskip("yaml")
209+
import numpy as np
210+
211+
from pybmodes.io.windio import read_windio_rna
212+
213+
uptilt = 0.10
214+
215+
def _mk(cone: float):
216+
o = _base_ontology()
217+
o["components"]["hub"]["cone_angle"] = cone
218+
o["components"]["nacelle"]["drivetrain"]["uptilt"] = uptilt
219+
return read_windio_rna(_write(o, tmp_path, f"tc_{cone}.yaml"))
220+
221+
flat = _mk(0.0)
222+
coned = _mk(0.15)
223+
# base blade: uniform 100 kg/m over 0..50 m -> mass-weighted mean span 25 m,
224+
# so the coned shaft-axis CM offset is sin(cone)·25.
225+
offset = float(np.sin(0.15)) * 25.0
226+
m_blades = 3 * 5000.0
227+
expected = m_blades * offset * float(np.sin(uptilt)) / flat.mass
228+
assert coned.mass == pytest.approx(flat.mass)
229+
assert coned.cm_axial - flat.cm_axial == pytest.approx(expected, rel=1e-6)
230+
231+
203232
def test_rna_diagonal_inertia_accepted(tmp_path: pathlib.Path) -> None:
204233
"""A diagonal 3-vector system_inertia [Ixx, Iyy, Izz] is accepted as a
205234
diagonal tensor, matching the equivalent zero-product 6-vector (Codex
@@ -558,9 +587,15 @@ def test_from_windio_with_monopile_lumped_rna_cal(tmp_path: pathlib.Path) -> Non
558587
reason="IEA-22 WindIO ontology + ElastoDyn deck not present",
559588
)
560589
def test_rna_iea22_matches_elastodyn_deck() -> None:
561-
"""The WindIO auto-RNA mass and CM match the IEA-22 ElastoDyn deck's
562-
tower-top assembly to <0.5 % (issue #82). Inertia is WindIO-native and
563-
is not expected to byte-match the deck's NacYIner (ecosystem drift)."""
590+
"""The WindIO auto-RNA mass matches the IEA-22 ElastoDyn deck's tower-top
591+
assembly to <0.5 % (issue #82), and its CM agrees to ~1 %. The small CM
592+
divergence is a documented, intentional one: the deck adapter lumps the
593+
blades as a point mass at the rotor apex, whereas the WindIO rigid-rotor
594+
path (issue #130) places the coned rotor at its true centre of mass,
595+
which sits ~2.6 m outboard of the apex for the 4 deg precone and so
596+
raises the tower-top vertical CM by ~0.06 m through the 6 deg shaft tilt
597+
(real ElastoDyn carries the same precone term). Inertia is WindIO-native
598+
and is not expected to byte-match the deck's NacYIner (ecosystem drift)."""
564599
pytest.importorskip("yaml")
565600
from pybmodes.io._elastodyn.adapter import _tower_top_assembly_mass
566601
from pybmodes.io.elastodyn_reader import (
@@ -576,7 +611,10 @@ def test_rna_iea22_matches_elastodyn_deck() -> None:
576611
)
577612

578613
assert rna.mass == pytest.approx(deck.mass, rel=5e-3) # <0.5 %
579-
assert rna.cm_axial == pytest.approx(deck.cm_axial, rel=5e-3)
614+
# The precone CM offset the deck adapter drops raises the WindIO CM
615+
# slightly; it stays within ~1.5 % and lies above the apex-lumped value.
616+
assert rna.cm_axial == pytest.approx(deck.cm_axial, rel=1.5e-2)
617+
assert rna.cm_axial >= deck.cm_axial
580618
# WindIO-native inertia carries the fore-aft rotary term the same order
581619
# of magnitude as the deck, but not byte-identical (documented drift).
582620
assert rna.iyy > 0.0

0 commit comments

Comments
 (0)