@@ -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+
96114def _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:
812830def _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
916938def 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 ))
0 commit comments