Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,44 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

## [1.15.1] — 2026-05-30

A focused ergonomic patch addressing the only piece of feedback on
1.15.0. Adds a one-call wiring from `MudlineFoundation` into a
clamped monopile model so users can convert a `from_windio_with_monopile`
or `from_elastodyn_with_subdyn` tower to the `hub_conn = 3` soft
monopile path without hand-building a `PlatformSupport` or mutating
private BMI fields. Closes #117 and partially addresses #118 (the
ergonomic half; distributed Winkler distribution along the embedded
length stays as a separate scope).

### Added

- **`Tower.attach_mudline_foundation(foundation)`** wires a
`pybmodes.MudlineFoundation` into the tower's BMI in one call.
Creates a fresh `PlatformSupport` carrying the foundation's 6 x 6
`mooring_K` block (with zero hydro, zero platform inertia, and
empty distributed arrays), sets `tow_support = 1`, and flips
`hub_conn` to `3`. Returns `self` for chaining, so the canonical
pattern is one expression: `Tower.from_windio_with_monopile(yaml,
tip_mass=rna).attach_mudline_foundation(foundation).run(n_modes=4)`.
Refuses to wire onto a free-base floating model (`hub_conn = 2`)
or a pinned-free cable model (`hub_conn = 4`) with a clear
`ValueError`. The mudline stiffness affects the coupled-system
frequency only; ElastoDyn polynomial coefficient generation
continues to use the cantilever path regardless of soil
flexibility, the same architectural reason
`src/pybmodes/_examples/reference_decks/FLOATING_CASES.md` records
for floating platforms.

### Documentation

- Quickstart's soft-monopile recipe now demonstrates the canonical
`Tower.from_windio_with_monopile(...).attach_mudline_foundation(f)`
pattern as the primary path, with `as_mooring_K()` kept as the
compose-it-yourself option for callers wiring into an existing
`PlatformSupport` (the `CS_Monopile.bmi` deck pattern).

## [1.15.0] — 2026-05-29

Two additive features on the soft-monopile and floating coupling
Expand Down
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ authors:
email: jaehoon.seo@inha.ac.kr
affiliation: "Marine Structural Mechanics and Integrity Lab (SMI Lab), Inha University"
orcid: "https://orcid.org/0000-0003-1047-2119"
version: 1.15.0
date-released: "2026-05-29"
version: 1.15.1
date-released: "2026-05-30"
license: Apache-2.0
repository-code: "https://github.com/SMI-Lab-Inha/pyBModes"
url: "https://github.com/SMI-Lab-Inha/pyBModes"
Expand Down
22 changes: 22 additions & 0 deletions docs/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,28 @@ that drops into ``PlatformSupport.mooring_K`` of a
formula="shadlou",
)

The ergonomic wiring uses
:meth:`~pybmodes.models.Tower.attach_mudline_foundation` to swap a
clamped monopile model to the ``hub_conn = 3`` soft-monopile path
without hand-building a ``PlatformSupport``:

.. code-block:: python

from pybmodes.models import Tower

tower = Tower.from_windio_with_monopile(
"IEA-15-240-RWT.yaml", tip_mass=991000.0,
)
tower.attach_mudline_foundation(f) # mutates BMI to hub_conn = 3
modal = tower.run(n_modes=4)

If you only need the 6 x 6 stiffness block to compose with an
existing ``PlatformSupport`` you have already built (the
``CS_Monopile.bmi`` deck pattern, say), the raw matrix is also
available:

.. code-block:: python

K6 = f.as_mooring_K() # 6 x 6 in OpenFAST DOF order

.. note::
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "pybmodes"
version = "1.15.0"
version = "1.15.1"
description = "Python finite-element library for wind turbine blade and tower modal analysis (OpenFAST/ElastoDyn)"
readme = "README.md"
license = { file = "LICENSE" }
Expand Down
2 changes: 1 addition & 1 deletion src/pybmodes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@
except PackageNotFoundError:
# Fallback for an uninstalled source tree (no package metadata).
# Keep in step with ``pyproject.toml`` ``[project] version``.
__version__ = "1.15.0"
__version__ = "1.15.1"

__all__ = [
"CheckOptions",
Expand Down
69 changes: 69 additions & 0 deletions src/pybmodes/models/tower.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@

from pybmodes.checks import OnError
from pybmodes.elastodyn.validate import ValidationResult
from pybmodes.foundation import MudlineFoundation
from pybmodes.io.bmi import PlatformSupport, TipMassProps


Expand Down Expand Up @@ -1074,6 +1075,74 @@ def from_elastodyn_with_subdyn(
obj._sp = sp
return obj

def attach_mudline_foundation(
self, foundation: MudlineFoundation,
) -> Tower:
"""Attach a mudline coupled-spring soil foundation to a clamped
monopile model and switch the boundary condition to
``hub_conn = 3`` (soft monopile, axial + torsion clamped,
lateral + rocking free).

Wires the foundation's 6 x 6 ``mooring_K`` block into a fresh
:class:`~pybmodes.io.bmi.PlatformSupport` carrying zero hydro
and zero platform inertia, sets ``tow_support = 1`` (inline
platform block) and flips ``hub_conn`` to ``3``. The tower's
section properties and tip mass are preserved. Returns ``self``
for chaining.

Use this to convert a rigid-clamped monopile model built via
:meth:`from_windio_with_monopile`, :meth:`from_elastodyn_with_subdyn`,
or any other ``hub_conn = 1`` constructor into a soft monopile
with the soil-pile interaction computed from
:class:`pybmodes.MudlineFoundation`. The mudline stiffness
affects the coupled-system frequency only; ElastoDyn polynomial
coefficient generation continues to use the cantilever path
regardless of soil flexibility, for the same architectural
reason
``src/pybmodes/_examples/reference_decks/FLOATING_CASES.md``
records for floating platforms.

Raises ``ValueError`` if the tower already carries a free-base
floating model (``hub_conn = 2``) or a pinned-free cable BC
(``hub_conn = 4``). Replaces any existing ``support`` on the
BMI; use a fresh ``Tower.from_*`` build if you need to preserve
a pre-existing support block.
"""
import numpy as np

from pybmodes.io.bmi import PlatformSupport

if self._bmi.hub_conn == 2:
raise ValueError(
"Cannot attach a mudline foundation to a free-base "
"floating model (hub_conn = 2). MudlineFoundation is "
"for soft monopiles; floating platforms use HydroDyn + "
"MoorDyn through Tower.from_elastodyn_with_mooring."
)
if self._bmi.hub_conn == 4:
raise ValueError(
"Cannot attach a mudline foundation to a pinned-free "
"cable model (hub_conn = 4); the BC has no lateral "
"spring DOF to wire the mudline stiffness into."
)
self._bmi.support = PlatformSupport(
draft=0.0,
cm_pform=0.0,
mass_pform=0.0,
i_matrix=np.zeros((6, 6)),
ref_msl=0.0,
hydro_M=np.zeros((6, 6)),
hydro_K=np.zeros((6, 6)),
mooring_K=foundation.as_mooring_K(),
distr_m_z=np.zeros(0),
distr_m=np.zeros(0),
distr_k_z=np.zeros(0),
distr_k=np.zeros(0),
)
self._bmi.tow_support = 1
self._bmi.hub_conn = 3
return self

def run(
self, n_modes: int = 20, *, check_model: bool = True,
on_error: OnError = "raise",
Expand Down
120 changes: 120 additions & 0 deletions tests/test_foundation.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,3 +389,123 @@ def test_dataclass_fields_preserved() -> None:
assert f.pile_behaviour == "flexible"
assert f.pile_behavior == "flexible"
assert f.formula == "shadlou"


# -----------------------------------------------------------------------------
# Tower.attach_mudline_foundation
# -----------------------------------------------------------------------------


def _synthetic_monopile_tower():
"""Build a small uniform-tube tower for the attach-method tests."""
from pybmodes.models import Tower

station_grid = np.linspace(0.0, 80.0, 9)
outer_diameter = np.full_like(station_grid, 6.0)
wall_thickness = np.full_like(station_grid, 0.05)
return Tower.from_geometry(
station_grid=station_grid,
outer_diameter=outer_diameter,
wall_thickness=wall_thickness,
flexible_length=80.0,
tip_mass=350000.0,
hub_conn=1,
)


def _default_foundation() -> MudlineFoundation:
return MudlineFoundation.from_soil_properties(
**_default_geometry(),
pile_behaviour="flexible",
)


def test_attach_mudline_foundation_flips_hub_conn_to_three() -> None:
"""A clamped tower becomes a soft monopile after the attach call."""
tower = _synthetic_monopile_tower()
foundation = _default_foundation()
assert tower._bmi.hub_conn == 1
assert tower._bmi.support is None

returned = tower.attach_mudline_foundation(foundation)

assert returned is tower
assert tower._bmi.hub_conn == 3
assert tower._bmi.tow_support == 1


def test_attach_mudline_foundation_writes_mooring_K() -> None:
"""The foundation's 6 x 6 mooring_K lands on the new PlatformSupport."""
from pybmodes.io.bmi import PlatformSupport

tower = _synthetic_monopile_tower()
foundation = _default_foundation()
expected = foundation.as_mooring_K()

tower.attach_mudline_foundation(foundation)

assert isinstance(tower._bmi.support, PlatformSupport)
np.testing.assert_array_equal(tower._bmi.support.mooring_K, expected)


def test_attach_mudline_foundation_zeroes_other_platform_blocks() -> None:
"""Hydro and inertia stay at zero; distributed arrays stay empty."""
tower = _synthetic_monopile_tower()
foundation = _default_foundation()
tower.attach_mudline_foundation(foundation)
s = tower._bmi.support

np.testing.assert_array_equal(s.i_matrix, np.zeros((6, 6)))
np.testing.assert_array_equal(s.hydro_M, np.zeros((6, 6)))
np.testing.assert_array_equal(s.hydro_K, np.zeros((6, 6)))
assert s.mass_pform == 0.0
assert s.cm_pform == 0.0
assert s.draft == 0.0
assert s.distr_m.size == 0
assert s.distr_k.size == 0


def test_attach_mudline_foundation_supports_chaining() -> None:
"""``attach_mudline_foundation`` returns self and chains with ``.run``."""
tower = _synthetic_monopile_tower()
foundation = _default_foundation()
result = tower.attach_mudline_foundation(foundation).run(
n_modes=4, check_model=False,
)
assert result.frequencies.shape == (4,)
assert np.all(np.isfinite(result.frequencies))
assert np.all(result.frequencies > 0.0)


def test_attach_mudline_foundation_softens_first_frequency_vs_clamped() -> None:
"""The same tower with soil flexibility lands below the clamped baseline.

A finite mudline stiffness can only relax the lateral / rocking
boundary condition vs the rigid clamp, so the soft-monopile 1st
fore-aft frequency must come out lower than the clamped one.
"""
clamped = _synthetic_monopile_tower().run(n_modes=4, check_model=False)
soft = (
_synthetic_monopile_tower()
.attach_mudline_foundation(_default_foundation())
.run(n_modes=4, check_model=False)
)
assert soft.frequencies[0] < clamped.frequencies[0]


def test_attach_mudline_foundation_rejects_floating_model() -> None:
"""A free-base floating tower (``hub_conn = 2``) is refused."""
tower = _synthetic_monopile_tower()
tower._bmi.hub_conn = 2

with pytest.raises(ValueError, match="hub_conn = 2"):
tower.attach_mudline_foundation(_default_foundation())


def test_attach_mudline_foundation_rejects_pinned_free_model() -> None:
"""A pinned-free cable model (``hub_conn = 4``) is refused."""
tower = _synthetic_monopile_tower()
tower._bmi.hub_conn = 4

with pytest.raises(ValueError, match="hub_conn = 4"):
tower.attach_mudline_foundation(_default_foundation())
Loading