Skip to content

Commit d4c401b

Browse files
authored
Merge pull request #99 from SMI-Lab-Inha/fix-95-platform-cm-offset-guard
feat(checks): platform CM-offset guard + release 1.11.0 (#95)
2 parents f9d059f + 562c8fc commit d4c401b

7 files changed

Lines changed: 180 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,31 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
88

99
## [Unreleased]
1010

11+
## [1.11.0] — 2026-05-23
12+
13+
A minor feature release: a WindIO monopile + tower constructor (#92) and a
14+
`check_model` guard that catches the implausible-platform-CM-offset input
15+
error behind #95. Additive and backward-compatible; no numerical change to
16+
any existing model.
17+
1118
### Added
1219

20+
- **`check_model` gate for an implausibly large horizontal platform CM offset
21+
(#95).** `cm_pform_x` / `cm_pform_y` on a `PlatformSupport` are the CM offset
22+
*from the tower axis*; a value comparable to the platform's own size is
23+
almost always a coordinate-origin offset leaking into the field, which
24+
injects spurious surge/sway↔yaw coupling, shifts the rigid-body frequencies,
25+
and mislabels the modes. The new check warns (WARN) when the horizontal
26+
offset magnitude exceeds the platform's yaw radius of gyration `√(I_yaw/m)`,
27+
turning a previously silent input error into an actionable message at the
28+
call site. New `CheckOptions.platform_cm_offset_gyradius_factor` (default
29+
`1.0`) tunes the threshold; the `PlatformSupport.cm_pform_x` / `cm_pform_y`
30+
docs now state explicitly that they are tower-axis-relative (not a global
31+
coordinate). Surfaced the diagnosis on #95: the reported "yaw on the first
32+
mode" was this input error (a ~39 m CM offset on the IEA-15 VolturnUS-S),
33+
not a classifier regression — with `cm_pform_x ≈ 0` the rigid-body modes
34+
label cleanly.
35+
1336
- **`Tower.from_windio_with_monopile(yaml, …)` — WindIO monopile + tower
1437
splice (#92).** `from_windio` reduces a *single* tube; there was no WindIO
1538
path that joined a monopile and tower into one fixed-bottom system. The new

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "pybmodes"
7-
version = "1.10.1"
7+
version = "1.11.0"
88
description = "Python finite-element library for wind turbine blade and tower modal analysis (OpenFAST/ElastoDyn)"
99
readme = "README.md"
1010
license = { file = "LICENSE" }

‎src/pybmodes/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@
182182
try:
183183
__version__ = version("pybmodes")
184184
except PackageNotFoundError:
185-
__version__ = "1.10.1"
185+
__version__ = "1.11.0"
186186

187187
__all__ = [
188188
"CheckOptions",

‎src/pybmodes/checks.py‎

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,13 @@
4949
``1e-6 · max|A|``). Rank deficiency is **not** flagged — surge /
5050
sway / yaw hydrostatic restoring is legitimately zero on most
5151
floaters and mooring layouts can be low-rank by design.
52-
7. The requested ``n_modes`` does not exceed the model's DOF count.
53-
8. The polynomial-fit design matrix on the mesh stations is not
52+
7. The horizontal platform CM offset (``cm_pform_x`` / ``cm_pform_y``)
53+
does not exceed the platform's yaw radius of gyration
54+
``√(I_yaw / m)`` — a larger value is almost always a coordinate-
55+
origin offset leaking into a field that means "CM offset from the
56+
tower axis", which mislabels the rigid-body modes (issue #95).
57+
8. The requested ``n_modes`` does not exceed the model's DOF count.
58+
9. The polynomial-fit design matrix on the mesh stations is not
5459
ill-conditioned (cond > 1e4 ⇒ WARN, > 1e6 ⇒ ERROR).
5560
"""
5661

@@ -146,6 +151,7 @@ def check_model(
146151
_check_ei_ratio(sp, out)
147152
_check_rna_vs_tower_mass(bmi, sp, out)
148153
_check_support_conditioning(bmi, out)
154+
_check_platform_cm_offset(bmi, out)
149155
if n_modes is not None:
150156
_check_n_modes_vs_dof(bmi, n_modes, out)
151157
_check_polyfit_conditioning(bmi, out)
@@ -385,6 +391,62 @@ def _check_support_conditioning(bmi, out: list[ModelWarning]) -> None:
385391
))
386392

387393

394+
def _check_platform_cm_offset(bmi, out: list[ModelWarning]) -> None:
395+
"""Flag an implausibly large horizontal platform CM offset.
396+
397+
``cm_pform_x`` / ``cm_pform_y`` are the platform CM offset *from the
398+
tower axis* — for a standard floater the tower sits at or near the
399+
platform centroid, so these are small. A horizontal offset that
400+
rivals the platform's own size (its yaw radius of gyration
401+
``√(I_yaw / m)``) is almost always a coordinate-origin value leaking
402+
into the field; it injects spurious surge/sway↔yaw coupling through
403+
the rigid-arm transform, shifts the rigid-body frequencies, and
404+
mislabels the modes (issue #95). Emitted as WARN, not ERROR — a
405+
genuinely large offset is physically representable, just unusual.
406+
"""
407+
from pybmodes.io.bmi import PlatformSupport
408+
409+
if not isinstance(bmi.support, PlatformSupport):
410+
return
411+
sup = bmi.support
412+
cm_x = float(getattr(sup, "cm_pform_x", 0.0) or 0.0)
413+
cm_y = float(getattr(sup, "cm_pform_y", 0.0) or 0.0)
414+
offset = float(np.hypot(cm_x, cm_y))
415+
if offset == 0.0:
416+
return
417+
418+
# Yaw radius of gyration r_g = √(I_yaw / m). Use the platform mass
419+
# and the 6×6 inertia's yaw (5,5) term; bail quietly if either is
420+
# unavailable / non-positive (can't form a meaningful scale).
421+
mass = float(getattr(sup, "mass_pform", 0.0) or 0.0)
422+
i_mat = np.asarray(sup.i_matrix, dtype=float)
423+
if mass <= 0.0 and i_mat.shape == (6, 6) and np.isfinite(i_mat[0, 0]):
424+
mass = float(i_mat[0, 0]) # i_matrix[0,0] is the platform mass
425+
if mass <= 0.0 or i_mat.shape != (6, 6):
426+
return
427+
i_yaw = float(i_mat[5, 5])
428+
if not np.isfinite(i_yaw) or i_yaw <= 0.0:
429+
return
430+
r_g = float(np.sqrt(i_yaw / mass))
431+
if r_g <= 0.0:
432+
return
433+
434+
if offset > _CHECK_OPTIONS.platform_cm_offset_gyradius_factor * r_g:
435+
out.append(ModelWarning(
436+
"WARN",
437+
f"Horizontal platform CM offset (cm_pform_x={cm_x:.3g} m, "
438+
f"cm_pform_y={cm_y:.3g} m, magnitude {offset:.3g} m) exceeds the "
439+
f"platform's yaw radius of gyration √(I_yaw/m) = {r_g:.3g} m. "
440+
f"cm_pform_x / cm_pform_y are the CM offset FROM THE TOWER AXIS — "
441+
f"a value this large is usually a coordinate-origin offset leaking "
442+
f"into the field; it injects spurious surge/sway↔yaw coupling that "
443+
f"shifts the rigid-body frequencies and mislabels the modes. Set "
444+
f"them to the CM offset relative to the tower axis (≈0 for a tower "
445+
f"on the platform centroid).",
446+
"bmi.support.cm_pform_x",
447+
))
448+
449+
388450
def _check_n_modes_vs_dof(
389451
bmi, n_modes: int, out: list[ModelWarning]
390452
) -> None:

‎src/pybmodes/io/bmi.py‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,21 @@ class PlatformSupport:
9393
distr_k_z: np.ndarray
9494
distr_k: np.ndarray
9595
wires: TensionWireSupport | None = None
96-
# Horizontal offset of the platform centre of mass from the tower
97-
# axis, in the tower-base frame (x = downwind / surge-aligned,
98-
# y = lateral / sway-aligned), metres. Non-zero only for an
99-
# asymmetric floating substructure; both default to 0.0 so every
100-
# existing deck / sample (axisymmetric spars, symmetric semis) is
101-
# byte-identical. Consumed by ``pybmodes.fem.nondim.nondim_platform``
102-
# as the horizontal components of the CM → tower-base rigid arm
103-
# (the vertical component stays ``cm_pform``). Added 1.2.0.
96+
# Horizontal offset of the platform centre of mass from the TOWER
97+
# AXIS, in the tower-base frame (x = downwind / surge-aligned,
98+
# y = lateral / sway-aligned), metres. This is a *local* offset of
99+
# the CM relative to the tower centreline — NOT a position in any
100+
# global / OpenFAST / WAMIT coordinate frame. For a standard floater
101+
# the tower sits on the platform centroid, so these are ≈ 0 (every
102+
# axisymmetric spar / symmetric semi: exactly 0). A value comparable
103+
# to the platform's own size (its yaw radius of gyration) is almost
104+
# always a coordinate-origin offset leaking in; it injects spurious
105+
# surge/sway↔yaw coupling and mislabels the rigid-body modes — the
106+
# ``check_model`` platform-CM-offset gate warns on it (issue #95).
107+
# Both default to 0.0 so every existing deck / sample is byte-
108+
# identical. Consumed by ``pybmodes.fem.nondim.nondim_platform`` as
109+
# the horizontal components of the CM → tower-base rigid arm (the
110+
# vertical component stays ``cm_pform``). Added 1.2.0.
104111
cm_pform_x: float = 0.0
105112
cm_pform_y: float = 0.0
106113

‎src/pybmodes/options.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,15 @@ class CheckOptions:
144144
fit_cond_fail : float, default 1e6
145145
Polynomial-fit design-matrix condition above which check_model
146146
emits a FAIL. Mirrors :class:`FitOptions.fit_cond_fail`.
147+
platform_cm_offset_gyradius_factor : float, default 1.0
148+
The horizontal platform CM offset (``cm_pform_x`` / ``cm_pform_y``
149+
on a ``PlatformSupport``) is flagged when its magnitude exceeds
150+
this factor times the platform's yaw radius of gyration
151+
``√(I_yaw / m)``. ``cm_pform_x`` / ``cm_pform_y`` are the CM
152+
offset *from the tower axis*; a value comparable to the
153+
platform's own size is almost always a coordinate-origin error
154+
leaking into the field, which injects spurious surge/sway↔yaw
155+
coupling and mislabels the rigid-body modes (issue #95).
147156
"""
148157

149158
stiffness_jump_factor: float = 5.0
@@ -152,6 +161,7 @@ class CheckOptions:
152161
support_asymmetry_rtol: float = 1.0e-6
153162
fit_cond_warn: float = 1.0e4
154163
fit_cond_fail: float = 1.0e6
164+
platform_cm_offset_gyradius_factor: float = 1.0
155165

156166

157167
# Module-level default instances. Internal call sites that don't (yet)

‎tests/test_checks.py‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,72 @@ def test_polyfit_cond_number_warn(self, tmp_path: pathlib.Path) -> None:
291291
assert hits[0].severity in ("WARN", "ERROR")
292292
assert "polynomial-fit design matrix" in hits[0].message
293293

294+
# --- platform CM offset (issue #95) ---------------------------------
295+
def _build_cm_offset_tower(self, cm_x: float, cm_y: float = 0.0) -> Tower:
296+
"""A hub_conn=2 PlatformSupport tower with a full 6×6 inertia and
297+
a configurable horizontal CM offset. Yaw radius of gyration is
298+
√(I_yaw/m) = √(2.0e10 / 1.8e7) ≈ 33.3 m."""
299+
n = 5
300+
span = np.linspace(0.0, 1.0, n)
301+
sp = SectionProperties(
302+
title="t", n_secs=n,
303+
span_loc=span, str_tw=np.zeros(n), tw_iner=np.zeros(n),
304+
mass_den=np.full(n, 100.0),
305+
flp_iner=np.full(n, 10.0), edge_iner=np.full(n, 10.0),
306+
flp_stff=np.full(n, 1.0e9), edge_stff=np.full(n, 1.0e9),
307+
tor_stff=np.full(n, 1.0e8), axial_stff=np.full(n, 1.0e10),
308+
cg_offst=np.zeros(n), sc_offst=np.zeros(n), tc_offst=np.zeros(n),
309+
)
310+
m = 1.8e7
311+
i6 = np.zeros((6, 6))
312+
i6[0, 0] = i6[1, 1] = i6[2, 2] = m
313+
i6[3, 3] = i6[4, 4] = 1.3e10
314+
i6[5, 5] = 2.0e10 # I_yaw → r_g ≈ 33.3 m
315+
platform = PlatformSupport(
316+
draft=10.0, cm_pform=0.0, mass_pform=m, i_matrix=i6, ref_msl=0.0,
317+
hydro_M=np.eye(6) * 1.0e6, hydro_K=np.eye(6) * 1.0e7,
318+
mooring_K=np.diag([1.0e6] * 6),
319+
distr_m_z=np.array([]), distr_m=np.array([]),
320+
distr_k_z=np.array([]), distr_k=np.array([]), wires=None,
321+
cm_pform_x=cm_x, cm_pform_y=cm_y,
322+
)
323+
bmi = BMIFile(
324+
title="t", echo=False, beam_type=2, rot_rpm=0.0, rpm_mult=1.0,
325+
radius=90.0, hub_rad=0.0, precone=0.0, bl_thp=0.0, hub_conn=2,
326+
n_modes_print=20, tab_delim=True, mid_node_tw=False,
327+
tip_mass=TipMassProps(
328+
mass=100_000.0, cm_offset=0.0, cm_axial=0.0,
329+
ixx=0.0, iyy=0.0, izz=0.0, ixy=0.0, izx=0.0, iyz=0.0,
330+
),
331+
id_mat=1, sec_props_file="", scaling=ScalingFactors(),
332+
n_elements=10, el_loc=np.linspace(0.0, 1.0, 11),
333+
tow_support=1, support=platform, source_file=None,
334+
)
335+
tower = Tower.__new__(Tower)
336+
tower._bmi = bmi
337+
tower._sp = sp
338+
return tower
339+
340+
def test_large_horizontal_cm_offset_warns(self) -> None:
341+
"""A horizontal CM offset exceeding the platform's yaw radius of
342+
gyration (the issue #95 input error) flags WARN."""
343+
tower = self._build_cm_offset_tower(cm_x=-39.0) # > r_g ≈ 33.3 m
344+
hits = _filter(check_model(tower), "cm_pform_x")
345+
assert len(hits) == 1 and hits[0].severity == "WARN"
346+
assert "radius of gyration" in hits[0].message
347+
assert "tower axis" in hits[0].message.lower()
348+
349+
def test_small_horizontal_cm_offset_is_silent(self) -> None:
350+
"""A modest CM offset (well under the gyration radius) is a
351+
legitimate asymmetric floater and must not warn."""
352+
tower = self._build_cm_offset_tower(cm_x=-4.0) # < r_g ≈ 33.3 m
353+
assert _filter(check_model(tower), "cm_pform_x") == []
354+
355+
def test_zero_cm_offset_is_silent(self) -> None:
356+
"""The axisymmetric / symmetric default (offset 0) never warns."""
357+
tower = self._build_cm_offset_tower(cm_x=0.0, cm_y=0.0)
358+
assert _filter(check_model(tower), "cm_pform_x") == []
359+
294360

295361
# ---------------------------------------------------------------------------
296362
# Auto-run integration on Tower.run / RotatingBlade.run

0 commit comments

Comments
 (0)