Skip to content

Commit 818aee9

Browse files
authored
Merge pull request #101 from SMI-Lab-Inha/feat-95-floating-readiness-guards
feat(checks): floating-model readiness guards (#95)
2 parents d4c401b + 0025aa0 commit 818aee9

3 files changed

Lines changed: 219 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,35 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1111
## [1.11.0] — 2026-05-23
1212

1313
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
14+
suite of **floating-model readiness guards** in `check_model` that catch the
15+
seakeeping mistakes a non-specialist makes when treating a WindIO `.yaml`
16+
(geometry + material only) as sufficient for a floating system — the input
17+
errors behind #95. Additive and backward-compatible; no numerical change to
1618
any existing model.
1719

1820
### Added
1921

22+
- **Floating-model readiness guards in `check_model` (#95).** A WindIO `.yaml`
23+
carries geometry + material — enough for a *land* tower, but **not** for a
24+
floating system, whose rigid-body behaviour is set by hydrodynamics (added
25+
mass + hydrostatic restoring) and mooring that live in companion HydroDyn /
26+
MoorDyn decks, not the yaml. A seakeeping-naive user can silently produce a
27+
meaningless floating result; these gates make that loud. For any
28+
`hub_conn = 2` `PlatformSupport` model, `check_model` now additionally
29+
checks:
30+
- **No hydrodynamic added mass** (`hydro_M` / `A_inf` all zero) — WARN; this
31+
biases every rigid-body frequency high (commonly 10–30 %) and is the most
32+
common omission.
33+
- **No restoring at all** (`hydro_K` *and* `mooring_K` both zero) — WARN; the
34+
rigid-body modes collapse to ~0 Hz (a free body, not a station-kept
35+
floater).
36+
- **Non-physical platform inertia** (non-positive `mass_pform` or a
37+
non-positive `i_matrix` diagonal) — ERROR.
38+
Each message names the fix and points at the validated deck path
39+
(`Tower.from_windio_floating(yaml, hydrodyn_dat=…, moordyn_dat=…,
40+
elastodyn_dat=…)`). Verified to stay silent on every validated floating model
41+
(OC3 Hywind spar, IEA-15 UMaineSemi, the WindIO Morison screening tier).
42+
2043
- **`check_model` gate for an implausibly large horizontal platform CM offset
2144
(#95).** `cm_pform_x` / `cm_pform_y` on a `PlatformSupport` are the CM offset
2245
*from the tower axis*; a value comparable to the platform's own size is

‎src/pybmodes/checks.py‎

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,22 @@
5454
``√(I_yaw / m)`` — a larger value is almost always a coordinate-
5555
origin offset leaking into a field that means "CM offset from the
5656
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
59-
ill-conditioned (cond > 1e4 ⇒ WARN, > 1e6 ⇒ ERROR).
57+
8. Floating platform inertia is physical: positive ``mass_pform`` and
58+
strictly-positive ``i_matrix`` diagonal (ERROR otherwise).
59+
9. Floating model carries hydrodynamic added mass (``hydro_M`` not all
60+
zero) — omitting it biases every rigid-body frequency high (WARN).
61+
10. Floating model has *some* restoring (``hydro_K`` or ``mooring_K``
62+
non-zero); with neither, the rigid-body modes collapse to ~0 Hz
63+
(WARN).
64+
11. The requested ``n_modes`` does not exceed the model's DOF count.
65+
12. The polynomial-fit design matrix on the mesh stations is not
66+
ill-conditioned (cond > 1e4 ⇒ WARN, > 1e6 ⇒ ERROR).
67+
68+
Checks 7–10 are the "floating-model readiness" gates (``hub_conn = 2``
69+
only): they catch the seakeeping omissions a non-specialist makes when
70+
a WindIO ``.yaml`` (geometry + material only) is treated as sufficient
71+
for a floating system — it is not, the way it is for a land tower
72+
(issue #95).
6073
"""
6174

6275
from __future__ import annotations
@@ -152,6 +165,9 @@ def check_model(
152165
_check_rna_vs_tower_mass(bmi, sp, out)
153166
_check_support_conditioning(bmi, out)
154167
_check_platform_cm_offset(bmi, out)
168+
_check_platform_inertia_physical(bmi, out)
169+
_check_added_mass_present(bmi, out)
170+
_check_restoring_present(bmi, out)
155171
if n_modes is not None:
156172
_check_n_modes_vs_dof(bmi, n_modes, out)
157173
_check_polyfit_conditioning(bmi, out)
@@ -447,6 +463,123 @@ def _check_platform_cm_offset(bmi, out: list[ModelWarning]) -> None:
447463
))
448464

449465

466+
_FLOATING_FIX_HINT = (
467+
"A floating model needs more than the WindIO geometry + material: the "
468+
"rigid-body behaviour is set by hydrodynamics (added mass + hydrostatic "
469+
"restoring) and mooring, none of which live in the .yaml. Supply them via "
470+
"the companion decks — Tower.from_windio_floating(yaml, hydrodyn_dat=…, "
471+
"moordyn_dat=…, elastodyn_dat=…) — or an explicit, correctly-assembled "
472+
"PlatformSupport. The deck path reads the WAMIT A_inf / C_hst and the "
473+
"MoorDyn system in their correct reference frames and is the validated "
474+
"(BModes-JJ ≈ 0.0003 %) route."
475+
)
476+
477+
478+
def _check_platform_inertia_physical(bmi, out: list[ModelWarning]) -> None:
479+
"""Flag a non-physical platform inertia (zero / negative mass or
480+
diagonal moment of inertia).
481+
482+
A real floating body has strictly positive mass and rotational
483+
inertia about every axis; a zero or negative diagonal in the 6×6
484+
``i_matrix`` (or a non-positive ``mass_pform``) is a transcription
485+
error that produces meaningless rigid-body modes. ERROR severity.
486+
"""
487+
from pybmodes.io.bmi import PlatformSupport
488+
489+
if not isinstance(bmi.support, PlatformSupport):
490+
return
491+
sup = bmi.support
492+
i_mat = np.asarray(sup.i_matrix, dtype=float)
493+
mass = float(getattr(sup, "mass_pform", 0.0) or 0.0)
494+
if mass <= 0.0 and i_mat.size and i_mat.ndim == 2 \
495+
and np.isfinite(i_mat[0, 0]):
496+
mass = float(i_mat[0, 0]) # i_matrix[0,0] is the platform mass
497+
if mass <= 0.0:
498+
out.append(ModelWarning(
499+
"ERROR",
500+
f"Platform mass is not positive (mass_pform = {mass:.3g} kg). A "
501+
f"floating body must have a strictly positive mass; verify the "
502+
f"PlatformSupport / ElastoDyn PtfmMass.",
503+
"bmi.support.mass_pform",
504+
))
505+
if i_mat.ndim == 2 and i_mat.shape[0] == i_mat.shape[1] and i_mat.size:
506+
diag = np.diag(i_mat)
507+
bad = [int(k) for k in range(diag.size)
508+
if np.isfinite(diag[k]) and diag[k] <= 0.0]
509+
if bad:
510+
out.append(ModelWarning(
511+
"ERROR",
512+
f"Platform inertia matrix has non-positive diagonal entries at "
513+
f"DOF index {bad} (value(s) {[float(diag[k]) for k in bad]}). "
514+
f"Every translational mass and rotational moment of inertia on "
515+
f"the i_matrix diagonal must be > 0 for a physical body.",
516+
"bmi.support.i_matrix",
517+
))
518+
519+
520+
def _check_added_mass_present(bmi, out: list[ModelWarning]) -> None:
521+
"""Warn when a floating model carries no hydrodynamic added mass.
522+
523+
``hydro_M`` (the infinite-frequency added-mass matrix ``A_inf``) is
524+
typically large for a floating platform — often comparable to the
525+
structural mass in surge / sway / heave — so omitting it biases
526+
*every* rigid-body frequency high. An all-zero ``hydro_M`` is the
527+
single most common seakeeping omission for a non-specialist hand-
528+
assembling a PlatformSupport (issue #95). WARN severity (a zero
529+
added mass is occasionally a deliberate screening simplification).
530+
"""
531+
from pybmodes.io.bmi import PlatformSupport
532+
533+
if not isinstance(bmi.support, PlatformSupport):
534+
return
535+
h_m = np.asarray(bmi.support.hydro_M, dtype=float)
536+
has_added_mass = bool(h_m.size and np.any(np.isfinite(h_m) & (h_m != 0.0)))
537+
if not has_added_mass:
538+
out.append(ModelWarning(
539+
"WARN",
540+
"Floating model has no hydrodynamic added mass (hydro_M / A_inf is "
541+
"zero). Added mass is typically large for a floating platform "
542+
"(often comparable to the structural mass in surge / sway / heave); "
543+
"omitting it biases all rigid-body frequencies high (commonly by "
544+
"10–30 %). " + _FLOATING_FIX_HINT,
545+
"bmi.support.hydro_M",
546+
))
547+
548+
549+
def _check_restoring_present(bmi, out: list[ModelWarning]) -> None:
550+
"""Warn when a floating model has no restoring at all.
551+
552+
A floating platform's rigid-body modes are set by hydrostatic
553+
restoring (``hydro_K`` / ``C_hst``, the waterplane + buoyancy) plus
554+
mooring stiffness (``mooring_K``). With neither, surge / sway /
555+
heave / roll / pitch / yaw have no restoring and collapse to ~0 Hz —
556+
a non-physical "free body in vacuum" rather than a station-kept
557+
floater. WARN severity (the solve still completes, just meaningless).
558+
"""
559+
from pybmodes.io.bmi import PlatformSupport
560+
561+
if not isinstance(bmi.support, PlatformSupport):
562+
return
563+
sup = bmi.support
564+
any_restoring = False
565+
for mat in (sup.hydro_K, sup.mooring_K):
566+
arr = np.asarray(mat, dtype=float)
567+
if arr.size and np.any(np.isfinite(arr) & (arr != 0.0)):
568+
any_restoring = True
569+
break
570+
if not any_restoring:
571+
out.append(ModelWarning(
572+
"WARN",
573+
"Floating model has no restoring: both the hydrostatic restoring "
574+
"(hydro_K / C_hst) and the mooring stiffness (mooring_K) are zero. "
575+
"The platform's rigid-body modes are entirely set by this "
576+
"restoring; with none, surge / sway / heave / roll / pitch / yaw "
577+
"collapse to ~0 Hz (a free body, not a station-kept floater). "
578+
+ _FLOATING_FIX_HINT,
579+
"bmi.support.mooring_K",
580+
))
581+
582+
450583
def _check_n_modes_vs_dof(
451584
bmi, n_modes: int, out: list[ModelWarning]
452585
) -> None:

‎tests/test_checks.py‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from __future__ import annotations
1717

18+
import dataclasses
1819
import pathlib
1920

2021
import numpy as np
@@ -357,6 +358,63 @@ def test_zero_cm_offset_is_silent(self) -> None:
357358
tower = self._build_cm_offset_tower(cm_x=0.0, cm_y=0.0)
358359
assert _filter(check_model(tower), "cm_pform_x") == []
359360

361+
# --- floating-model readiness (issue #95) ---------------------------
362+
def test_zero_added_mass_warns(self) -> None:
363+
"""A floating model with no added mass (hydro_M = 0) warns — the
364+
single most common seakeeping omission."""
365+
tower = self._build_cm_offset_tower(cm_x=0.0)
366+
tower._bmi.support = dataclasses.replace(
367+
tower._bmi.support, hydro_M=np.zeros((6, 6)),
368+
)
369+
hits = _filter(check_model(tower), "hydro_M")
370+
assert len(hits) == 1 and hits[0].severity == "WARN"
371+
assert "added mass" in hits[0].message.lower()
372+
373+
def test_added_mass_present_is_silent(self) -> None:
374+
"""A populated hydro_M does not warn."""
375+
tower = self._build_cm_offset_tower(cm_x=0.0) # hydro_M = eye·1e6
376+
assert _filter(check_model(tower), "hydro_M") == []
377+
378+
def test_no_restoring_warns(self) -> None:
379+
"""No hydrostatic restoring AND no mooring stiffness → the
380+
rigid-body modes collapse to ~0 Hz; warn."""
381+
tower = self._build_cm_offset_tower(cm_x=0.0)
382+
tower._bmi.support = dataclasses.replace(
383+
tower._bmi.support,
384+
hydro_K=np.zeros((6, 6)), mooring_K=np.zeros((6, 6)),
385+
)
386+
hits = _filter(check_model(tower), "mooring_K")
387+
assert any(
388+
w.severity == "WARN" and "no restoring" in w.message.lower()
389+
for w in hits
390+
)
391+
392+
def test_restoring_present_is_silent(self) -> None:
393+
"""Mooring stiffness alone (no hydrostatic) still counts as
394+
restoring — no warning."""
395+
tower = self._build_cm_offset_tower(cm_x=0.0)
396+
tower._bmi.support = dataclasses.replace(
397+
tower._bmi.support, hydro_K=np.zeros((6, 6)),
398+
) # mooring_K still populated
399+
hits = [w for w in check_model(tower) if "no restoring" in w.message.lower()]
400+
assert hits == []
401+
402+
def test_non_physical_platform_inertia_errors(self) -> None:
403+
"""A negative i_matrix diagonal entry is non-physical → ERROR."""
404+
tower = self._build_cm_offset_tower(cm_x=0.0)
405+
bad = tower._bmi.support.i_matrix.copy()
406+
bad[4, 4] = -1.0 # negative pitch inertia
407+
tower._bmi.support = dataclasses.replace(tower._bmi.support, i_matrix=bad)
408+
hits = _filter(check_model(tower), "i_matrix")
409+
assert any(w.severity == "ERROR" for w in hits)
410+
411+
def test_physical_platform_inertia_is_silent(self) -> None:
412+
"""A well-formed positive inertia does not error."""
413+
tower = self._build_cm_offset_tower(cm_x=0.0)
414+
hits = [w for w in check_model(tower)
415+
if w.location in ("bmi.support.i_matrix", "bmi.support.mass_pform")]
416+
assert hits == []
417+
360418

361419
# ---------------------------------------------------------------------------
362420
# Auto-run integration on Tower.run / RotatingBlade.run

0 commit comments

Comments
 (0)