diff --git a/CHANGELOG.md b/CHANGELOG.md index dab3d0e..530aafa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CITATION.cff b/CITATION.cff index f21b8ec..54aaab9 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -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" diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 5dbe752..9c0f186 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -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:: diff --git a/pyproject.toml b/pyproject.toml index 2b9d9ae..7027b07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/pybmodes/__init__.py b/src/pybmodes/__init__.py index df6a4cf..bd3a19b 100644 --- a/src/pybmodes/__init__.py +++ b/src/pybmodes/__init__.py @@ -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", diff --git a/src/pybmodes/models/tower.py b/src/pybmodes/models/tower.py index ea85515..8ce6a2b 100644 --- a/src/pybmodes/models/tower.py +++ b/src/pybmodes/models/tower.py @@ -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 @@ -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", diff --git a/tests/test_foundation.py b/tests/test_foundation.py index f1ecbb0..954005a 100644 --- a/tests/test_foundation.py +++ b/tests/test_foundation.py @@ -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())