From 653322c2aef7bd821d142f0c6d7c0bf56974c2c1 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Fri, 10 Jul 2026 20:12:10 +0900 Subject: [PATCH 1/4] feat: integrated soil-pile interaction on the WindIO monopile path (#118) Tower.from_windio_with_monopile gains a `soil` keyword (a pre-built MudlineFoundation) and a `soil_E`/`soil_nu`/`soil_profile`/`pile_behaviour` /`soil_formula` auto-build path. When given, the rigid mudline clamp is replaced by the coupled-spring soil foundation and the model switches to a soft monopile (hub_conn=3), lowering the coupled frequency relative to the rigid clamp. New MudlineFoundation.from_windio(yaml, soil_E=...) classmethod extracts the pile diameter, embedded length and EI at the mudline from the ontology's monopile component, so only the soil is specified. The wiring reuses the validated MudlineFoundation (#97) and attach_mudline_foundation, removing the manual _bmi.support / hub_conn poking the feature request showed. hub_conn=3 carries only the lumped mudline mooring_K; the check_model floating gates stay silent (they gate on hub_conn=2). Fully distributed Winkler distr_k springs remain a separate higher-fidelity follow-up. --- CHANGELOG.md | 14 ++++ src/pybmodes/foundation.py | 71 ++++++++++++++++ src/pybmodes/models/tower.py | 60 +++++++++++++- tests/test_windio_soil_foundation.py | 119 +++++++++++++++++++++++++++ 4 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 tests/test_windio_soil_foundation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3da75d0..2d34b8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **Integrated soil-pile interaction on the WindIO monopile path (#118).** + `Tower.from_windio_with_monopile` gains a `soil` keyword (a pre-built + `MudlineFoundation`) and a `soil_E` / `soil_nu` / `soil_profile` / + `pile_behaviour` / `soil_formula` auto-build path. When given, the rigid + mudline clamp is replaced by the coupled-spring soil foundation and the + model switches to a soft monopile (`hub_conn=3`), lowering the coupled + frequency relative to the rigid clamp. A new + `MudlineFoundation.from_windio(yaml, soil_E=...)` classmethod extracts the + pile diameter, embedded length and EI at the mudline from the ontology, so + only the soil is specified. Reuses the validated `MudlineFoundation` + (#97) and `attach_mudline_foundation` wiring, removing the manual + BMI-poking the feature request showed. Fully distributed Winkler `distr_k` + springs remain a separate higher-fidelity follow-up. + - **Material / outfitting overrides on the WindIO tower paths (#133).** `Tower.from_windio` and `Tower.from_windio_with_monopile` accept optional `E`, `rho`, `nu` and `outfitting_factor` keywords. Each defaults to diff --git a/src/pybmodes/foundation.py b/src/pybmodes/foundation.py index 352e4d8..9a33d3d 100644 --- a/src/pybmodes/foundation.py +++ b/src/pybmodes/foundation.py @@ -62,6 +62,7 @@ from __future__ import annotations import math +import pathlib import warnings from dataclasses import dataclass from typing import Literal @@ -411,3 +412,73 @@ def from_soil_properties( soil_profile=soil_profile, formula=formula, ) + + @classmethod + def from_windio( + cls, + yaml_path: str | pathlib.Path, + *, + soil_E: float, + soil_nu: float = 0.3, + soil_profile: SoilProfile = "homogeneous", + pile_behaviour: str = "auto", + formula: FormulaFamily = "shadlou", + component_monopile: str = "monopile", + water_depth: float | None = None, + ) -> MudlineFoundation: + """Build the mudline foundation from a WindIO monopile ontology plus + soil properties, auto-extracting the pile geometry (issue #118). + + Only the soil is specified; the pile terms the coupled-spring model + needs are read from the ontology's ``monopile`` component: + + - **pile diameter** — the monopile outer diameter at the mudline. + - **embedded length** — the mudline down to the pile base + (``-water_depth`` minus the monopile ``reference_axis.z`` base). + - **pile EI** — ``E * pi/64 (D^4 - (D - 2 t)^4)`` at the mudline, with + ``E`` the monopile material modulus from the ontology. + + then defers to :meth:`from_soil_properties`. Pair the result with + :meth:`pybmodes.models.Tower.attach_mudline_foundation`, or let + :meth:`~pybmodes.models.Tower.from_windio_with_monopile` build and + attach it for you via its ``soil_E`` keyword. + + ``water_depth`` (m, positive) locates the mudline and defaults to the + ontology's ``environment.water_depth``. Raises ``ValueError`` when the + mudline is unknown or the monopile does not extend below it (no + embedded length to model soil reaction over). Requires the optional + ``[windio]`` extra (PyYAML). + """ + from pybmodes.io.windio import _read_water_depth, read_windio_tubular + + mp = read_windio_tubular(yaml_path, component=component_monopile) + wd = _read_water_depth(yaml_path, water_depth) + if wd is None: + raise ValueError( + "water_depth is required to place the mudline for the soil " + "springs; pass water_depth=... or set environment.water_depth " + "in the ontology." + ) + mudline = -wd + if not (mp.z_base < mudline < mp.z_top): + raise ValueError( + f"the monopile (reference_axis.z {mp.z_base:g}..{mp.z_top:g} m) " + f"does not extend below the mudline (z = {mudline:g} m), so " + f"there is no embedded length to model soil reaction over. " + f"Check water_depth and the monopile reference_axis.z." + ) + z_phys = mp.z_base + mp.station_grid * (mp.z_top - mp.z_base) + pile_diameter = float(np.interp(mudline, z_phys, mp.outer_diameter)) + wall = float(np.interp(mudline, z_phys, mp.wall_thickness)) + inner = pile_diameter - 2.0 * wall + pile_EI = mp.E * math.pi / 64.0 * (pile_diameter**4 - inner**4) + return cls.from_soil_properties( + pile_diameter=pile_diameter, + pile_length_embedded=mudline - mp.z_base, + pile_EI=pile_EI, + soil_E=soil_E, + soil_nu=soil_nu, + soil_profile=soil_profile, + pile_behaviour=pile_behaviour, + formula=formula, + ) diff --git a/src/pybmodes/models/tower.py b/src/pybmodes/models/tower.py index b8357c2..84f26ae 100644 --- a/src/pybmodes/models/tower.py +++ b/src/pybmodes/models/tower.py @@ -466,6 +466,12 @@ def from_windio_with_monopile( rho: float | None = None, nu: float | None = None, outfitting_factor: float | None = None, + soil: MudlineFoundation | None = None, + soil_E: float | None = None, + soil_nu: float = 0.3, + soil_profile: str = "homogeneous", + pile_behaviour: str = "auto", + soil_formula: str = "shadlou", ) -> Tower: """Build a combined **monopile + tower** fixed-bottom cantilever from a WindIO ontology ``.yaml`` (issue #92). @@ -519,16 +525,36 @@ def from_windio_with_monopile( the monopile and the tower segment, so a single value drives a whole-structure sensitivity sweep off a WindIO file. For per-segment materials, edit the ontology instead. + soil : optional pre-built :class:`pybmodes.MudlineFoundation` + (issue #118). When given, the rigid mudline clamp is replaced by + the foundation's coupled springs and the model is switched to a + soft monopile (``hub_conn = 3``) via + :meth:`attach_mudline_foundation`. Mutually exclusive with + ``soil_E``. + soil_E, soil_nu, soil_profile, pile_behaviour, soil_formula : + soil properties to **auto-build** the foundation (issue #118). + Pass ``soil_E`` (soil Young's modulus, Pa) and optionally the + others to have the pile geometry (diameter, embedded length and + EI at the mudline) read from the ontology and a + :class:`pybmodes.MudlineFoundation` built via + :meth:`MudlineFoundation.from_windio`, then attached. Needs a + ``water_depth`` (or ``environment.water_depth``) and a monopile + that extends below the mudline. Defaults reproduce + ``from_soil_properties``' defaults (``soil_nu=0.3``, + ``soil_profile="homogeneous"``, ``pile_behaviour="auto"``, + ``soil_formula="shadlou"``). Notes ----- - Requires the optional ``[windio]`` extra (PyYAML). This is the + Requires the optional ``[windio]`` extra (PyYAML). Defaults to the **rigid fixed-base** monopile path: the pile is clamped at the mudline with no soil flexibility, matching :meth:`from_elastodyn_with_subdyn` and the bundled monopile - samples. Distributed soil springs (a Winkler ``distr_k`` / - ``hub_conn = 3`` foundation) and Morison hydrodynamics are out of - scope here and tracked separately. Raises ``ValueError`` if the + samples. Pass ``soil`` or ``soil_E`` for the soft-monopile tier + (lumped mudline coupled springs, ``hub_conn = 3``); this lowers the + coupled frequency relative to the rigid clamp. Fully distributed + Winkler ``distr_k`` springs and Morison hydrodynamics remain a + separate higher-fidelity follow-up. Raises ``ValueError`` if the monopile top and tower base do not meet at a common transition-piece elevation. """ @@ -576,6 +602,32 @@ def from_windio_with_monopile( obj._bmi = bmi obj._sp = mt.section_props obj.coeff_validation = None + + # Optional soil-pile interaction (issue #118): replace the rigid + # mudline clamp with a coupled-spring foundation (hub_conn = 3). Pass + # a pre-built ``soil`` MudlineFoundation, or ``soil_E`` to auto-build + # it from the ontology's pile geometry. + if soil is not None and soil_E is not None: + raise ValueError( + "pass either soil (a MudlineFoundation) or soil_E (auto-build " + "from the ontology), not both." + ) + foundation = soil + if soil_E is not None: + from pybmodes.foundation import MudlineFoundation + + foundation = MudlineFoundation.from_windio( + yaml_path, + soil_E=soil_E, + soil_nu=soil_nu, + soil_profile=soil_profile, # type: ignore[arg-type] + pile_behaviour=pile_behaviour, + formula=soil_formula, # type: ignore[arg-type] + component_monopile=component_monopile, + water_depth=water_depth, + ) + if foundation is not None: + obj.attach_mudline_foundation(foundation) return obj @classmethod diff --git a/tests/test_windio_soil_foundation.py b/tests/test_windio_soil_foundation.py new file mode 100644 index 0000000..e6f2c13 --- /dev/null +++ b/tests/test_windio_soil_foundation.py @@ -0,0 +1,119 @@ +"""Integrated soil-pile interaction on the WindIO monopile path (issue #118). + +`Tower.from_windio_with_monopile(..., soil=... | soil_E=...)` replaces the +rigid mudline clamp with a `MudlineFoundation` coupled-spring soil model +(`hub_conn=3`), and `MudlineFoundation.from_windio` auto-extracts the pile +geometry from the ontology. Self-contained: a tiny hand-written ontology in +`tmp_path`, no external data. +""" +from __future__ import annotations + +import pathlib +import textwrap +import warnings + +import pytest + +from pybmodes.foundation import MudlineFoundation +from pybmodes.models import Tower + +# monopile z -30..10 (base to transition), tower 10..110; a mudline placed by +# water_depth between -30 and 10 leaves an embedded pile below it. +_ONTO = textwrap.dedent("""\ + environment: + water_depth: 20.0 + components: + monopile: + outer_shape: + outer_diameter: {grid: [0.0, 1.0], values: [9.0, 9.0]} + structure: + outfitting_factor: 1.0 + layers: + - {name: monopile_wall, material: steel, + thickness: {grid: [0.0, 1.0], values: [0.09, 0.09]}} + reference_axis: {z: {grid: [0.0, 1.0], values: [-30.0, 10.0]}} + tower: + outer_shape: + outer_diameter: {grid: [0.0, 0.5, 1.0], values: [9.0, 7.5, 6.0]} + structure: + outfitting_factor: 1.0 + layers: + - {name: tower_wall, material: steel, + thickness: {grid: [0.0, 1.0], values: [0.05, 0.02]}} + reference_axis: {z: {grid: [0.0, 1.0], values: [10.0, 110.0]}} + materials: + - {name: steel, E: 2.0e11, rho: 7850.0, nu: 0.3} + """) + + +def _yaml(tmp_path: pathlib.Path) -> pathlib.Path: + pytest.importorskip("yaml") + p = tmp_path / "mp.yaml" + p.write_text(_ONTO, encoding="utf-8") + return p + + +def _f1(model: Tower, **run_kw) -> float: + return float(model.run(n_modes=4, check_model=False, **run_kw).frequencies[0]) + + +def test_soil_lowers_frequency_vs_rigid_clamp(tmp_path: pathlib.Path) -> None: + """Soil flexibility softens the base, so the coupled 1st frequency is + lower than the rigid mudline clamp (issue #118).""" + p = _yaml(tmp_path) + rigid = Tower.from_windio_with_monopile(p, water_depth=20.0) + soft = Tower.from_windio_with_monopile(p, water_depth=20.0, soil_E=60e6) + assert rigid._bmi.hub_conn == 1 + assert soft._bmi.hub_conn == 3 + assert _f1(soft) < _f1(rigid) + + +def test_soil_auto_matches_explicit_foundation(tmp_path: pathlib.Path) -> None: + """`soil_E` auto-build and an explicit `soil=MudlineFoundation.from_windio` + give the same model (issue #118).""" + p = _yaml(tmp_path) + auto = Tower.from_windio_with_monopile(p, water_depth=20.0, soil_E=60e6) + found = MudlineFoundation.from_windio(p, soil_E=60e6, water_depth=20.0) + explicit = Tower.from_windio_with_monopile(p, water_depth=20.0, soil=found) + assert _f1(auto) == pytest.approx(_f1(explicit)) + + +def test_soil_no_spurious_check_warnings(tmp_path: pathlib.Path) -> None: + """A soft-monopile (hub_conn=3, only mooring_K) triggers no floating- + readiness check warnings (those gate on hub_conn=2) (issue #118).""" + p = _yaml(tmp_path) + with warnings.catch_warnings(): + warnings.simplefilter("error") + Tower.from_windio_with_monopile( + p, water_depth=20.0, soil_E=60e6 + ).run(n_modes=4) # check_model on (default) + + +def test_soil_and_soil_E_mutually_exclusive(tmp_path: pathlib.Path) -> None: + p = _yaml(tmp_path) + found = MudlineFoundation.from_windio(p, soil_E=60e6, water_depth=20.0) + with pytest.raises(ValueError, match="not both"): + Tower.from_windio_with_monopile( + p, water_depth=20.0, soil=found, soil_E=60e6 + ) + + +def test_from_windio_requires_water_depth(tmp_path: pathlib.Path) -> None: + """No mudline (no water_depth and no environment block) -> clear error.""" + pytest.importorskip("yaml") + # strip the environment block so there is no ontology water depth + p = tmp_path / "no_env.yaml" + p.write_text(_ONTO.split("environment:")[0] + _ONTO.split("\n", 2)[2], + encoding="utf-8") + with pytest.raises(ValueError, match="water_depth is required"): + MudlineFoundation.from_windio(p, soil_E=60e6) + + +def test_from_windio_requires_embedded_pile(tmp_path: pathlib.Path) -> None: + """A mudline below the monopile base leaves no embedded length to model + soil over (issue #118).""" + p = _yaml(tmp_path) + # monopile base is z=-30; water_depth=35 places the mudline at -35, below + # the pile base, so there is no embedded pile between them. + with pytest.raises(ValueError, match="does not extend below the mudline"): + MudlineFoundation.from_windio(p, soil_E=60e6, water_depth=35.0) From 0bd355018e61d99188d23628fd5f3de40f43a8a8 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Fri, 10 Jul 2026 20:19:25 +0900 Subject: [PATCH 2/4] fix: honour E / thickness_interp overrides in the auto-built soil springs When soil_E auto-build was combined with the material override (E=...) or a non-default thickness_interp, the beam used them but MudlineFoundation.from_windio re-read the monopile raw, so the mudline-spring pile_EI could differ from the beam's actual monopile stiffness. Thread E and thickness_interp through from_windio (E=None keeps the ontology value) and forward them from from_windio_with_monopile, so the springs stay consistent with the beam under a material or wall-schedule sensitivity run. A flexible-pile test pins that the E override changes the foundation. --- src/pybmodes/foundation.py | 22 +++++++++++----- src/pybmodes/models/tower.py | 4 +++ tests/test_windio_soil_foundation.py | 39 ++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/pybmodes/foundation.py b/src/pybmodes/foundation.py index 9a33d3d..15ba853 100644 --- a/src/pybmodes/foundation.py +++ b/src/pybmodes/foundation.py @@ -425,6 +425,8 @@ def from_windio( formula: FormulaFamily = "shadlou", component_monopile: str = "monopile", water_depth: float | None = None, + E: float | None = None, + thickness_interp: str = "linear", ) -> MudlineFoundation: """Build the mudline foundation from a WindIO monopile ontology plus soil properties, auto-extracting the pile geometry (issue #118). @@ -444,14 +446,21 @@ def from_windio( attach it for you via its ``soil_E`` keyword. ``water_depth`` (m, positive) locates the mudline and defaults to the - ontology's ``environment.water_depth``. Raises ``ValueError`` when the - mudline is unknown or the monopile does not extend below it (no - embedded length to model soil reaction over). Requires the optional - ``[windio]`` extra (PyYAML). + ontology's ``environment.water_depth``. ``E`` and ``thickness_interp`` + override the monopile modulus and wall-thickness interpolation used + for ``pile_EI`` (``E=None`` uses the ontology value); pass the same + values here as the beam so the mudline springs stay consistent with + it under a material or wall-schedule sensitivity run. Raises + ``ValueError`` when the mudline is unknown or the monopile does not + extend below it (no embedded length to model soil reaction over). + Requires the optional ``[windio]`` extra (PyYAML). """ from pybmodes.io.windio import _read_water_depth, read_windio_tubular - mp = read_windio_tubular(yaml_path, component=component_monopile) + mp = read_windio_tubular( + yaml_path, component=component_monopile, + thickness_interp=thickness_interp, + ) wd = _read_water_depth(yaml_path, water_depth) if wd is None: raise ValueError( @@ -471,7 +480,8 @@ def from_windio( pile_diameter = float(np.interp(mudline, z_phys, mp.outer_diameter)) wall = float(np.interp(mudline, z_phys, mp.wall_thickness)) inner = pile_diameter - 2.0 * wall - pile_EI = mp.E * math.pi / 64.0 * (pile_diameter**4 - inner**4) + pile_E = mp.E if E is None else E + pile_EI = pile_E * math.pi / 64.0 * (pile_diameter**4 - inner**4) return cls.from_soil_properties( pile_diameter=pile_diameter, pile_length_embedded=mudline - mp.z_base, diff --git a/src/pybmodes/models/tower.py b/src/pybmodes/models/tower.py index 84f26ae..044d7a4 100644 --- a/src/pybmodes/models/tower.py +++ b/src/pybmodes/models/tower.py @@ -625,6 +625,10 @@ def from_windio_with_monopile( formula=soil_formula, # type: ignore[arg-type] component_monopile=component_monopile, water_depth=water_depth, + # keep the mudline springs consistent with the beam's monopile + # under a material / wall-schedule override (Codex review #118) + E=E, + thickness_interp=thickness_interp, ) if foundation is not None: obj.attach_mudline_foundation(foundation) diff --git a/tests/test_windio_soil_foundation.py b/tests/test_windio_soil_foundation.py index e6f2c13..dd22ce0 100644 --- a/tests/test_windio_soil_foundation.py +++ b/tests/test_windio_soil_foundation.py @@ -89,6 +89,45 @@ def test_soil_no_spurious_check_warnings(tmp_path: pathlib.Path) -> None: ).run(n_modes=4) # check_model on (default) +_SLENDER = textwrap.dedent("""\ + environment: + water_depth: 20.0 + components: + monopile: + outer_shape: + outer_diameter: {grid: [0.0, 1.0], values: [2.0, 2.0]} + structure: + outfitting_factor: 1.0 + layers: + - {name: monopile_wall, material: steel, + thickness: {grid: [0.0, 1.0], values: [0.04, 0.04]}} + reference_axis: {z: {grid: [0.0, 1.0], values: [-50.0, 0.0]}} + tower: + outer_shape: + outer_diameter: {grid: [0.0, 1.0], values: [2.0, 1.5]} + structure: + outfitting_factor: 1.0 + layers: + - {name: tower_wall, material: steel, + thickness: {grid: [0.0, 1.0], values: [0.03, 0.02]}} + reference_axis: {z: {grid: [0.0, 1.0], values: [0.0, 80.0]}} + materials: + - {name: steel, E: 2.0e11, rho: 7850.0, nu: 0.3} + """) + + +def test_soil_auto_build_honors_E_override(tmp_path: pathlib.Path) -> None: + """The E override reaches the mudline springs through pile_EI, not just the + beam, so a flexible pile's foundation changes with E (Codex review #118).""" + pytest.importorskip("yaml") + p = tmp_path / "slender.yaml" + p.write_text(_SLENDER, encoding="utf-8") + base = MudlineFoundation.from_windio(p, soil_E=60e6, water_depth=20.0) + stiff = MudlineFoundation.from_windio(p, soil_E=60e6, water_depth=20.0, E=4.0e11) + assert base.pile_behaviour == "flexible" # a flexible pile: EI (hence E) matters + assert stiff.K_rr != pytest.approx(base.K_rr) + + def test_soil_and_soil_E_mutually_exclusive(tmp_path: pathlib.Path) -> None: p = _yaml(tmp_path) found = MudlineFoundation.from_windio(p, soil_E=60e6, water_depth=20.0) From b857356ca7613aad24c3610c7949b8eaec7cd667 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Fri, 10 Jul 2026 20:29:47 +0900 Subject: [PATCH 3/4] fix: reject auto-RNA with soil, and honour piecewise-constant wall at mudline Two Codex findings on the soil integration: - lumped_rna_cal + soil/soil_E is now rejected. The auto-RNA tip mass is built in the clamped-base (hub_conn=1) convention, which a soft monopile (hub_conn=3) interprets differently and would misplace the rotary inertia; mirrors the single-tower from_windio guard. Pass an explicit tip_mass for a soil-flexible base. - MudlineFoundation.from_windio honours thickness_interp when reading the wall at the mudline: for piecewise_constant it takes the lower-station step value rather than a linear blend, matching the beam reduction so pile_EI is consistent on a tapered/stepped wall schedule. --- src/pybmodes/foundation.py | 9 ++++++++- src/pybmodes/models/tower.py | 12 ++++++++++++ tests/test_windio_soil_foundation.py | 27 ++++++++++++++++++++++++++- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/pybmodes/foundation.py b/src/pybmodes/foundation.py index 15ba853..81e71ed 100644 --- a/src/pybmodes/foundation.py +++ b/src/pybmodes/foundation.py @@ -478,7 +478,14 @@ def from_windio( ) z_phys = mp.z_base + mp.station_grid * (mp.z_top - mp.z_base) pile_diameter = float(np.interp(mudline, z_phys, mp.outer_diameter)) - wall = float(np.interp(mudline, z_phys, mp.wall_thickness)) + if thickness_interp == "piecewise_constant": + # Match the beam reduction: the wall is constant within a segment, + # taking the lower-station step value rather than a linear blend. + idx = int(np.searchsorted(z_phys, mudline, side="right")) - 1 + idx = max(0, min(idx, mp.wall_thickness.size - 1)) + wall = float(mp.wall_thickness[idx]) + else: + wall = float(np.interp(mudline, z_phys, mp.wall_thickness)) inner = pile_diameter - 2.0 * wall pile_E = mp.E if E is None else E pile_EI = pile_E * math.pi / 64.0 * (pile_diameter**4 - inner**4) diff --git a/src/pybmodes/models/tower.py b/src/pybmodes/models/tower.py index 044d7a4..ef7ed11 100644 --- a/src/pybmodes/models/tower.py +++ b/src/pybmodes/models/tower.py @@ -561,6 +561,18 @@ def from_windio_with_monopile( from pybmodes.io._elastodyn.adapter import _build_bmi_skeleton from pybmodes.io.windio import read_windio_monopile_tower + if lumped_rna_cal and (soil is not None or soil_E is not None): + raise ValueError( + "lumped_rna_cal is not supported together with a soil " + "foundation (soil / soil_E). The auto-RNA tip mass is " + "expressed in the clamped-base (hub_conn = 1) convention, " + "which a soft monopile (hub_conn = 3) interprets differently " + "and would misplace the rotary inertia. Pass an explicit " + "tip_mass for the soil-flexible base, or drop the soil for the " + "rigid clamped model (the basis ElastoDyn uses for tower mode " + "shapes regardless of soil)." + ) + if lumped_rna_cal: if tip_mass is not None: raise ValueError( diff --git a/tests/test_windio_soil_foundation.py b/tests/test_windio_soil_foundation.py index dd22ce0..bebe1cb 100644 --- a/tests/test_windio_soil_foundation.py +++ b/tests/test_windio_soil_foundation.py @@ -100,7 +100,7 @@ def test_soil_no_spurious_check_warnings(tmp_path: pathlib.Path) -> None: outfitting_factor: 1.0 layers: - {name: monopile_wall, material: steel, - thickness: {grid: [0.0, 1.0], values: [0.04, 0.04]}} + thickness: {grid: [0.0, 1.0], values: [0.06, 0.02]}} reference_axis: {z: {grid: [0.0, 1.0], values: [-50.0, 0.0]}} tower: outer_shape: @@ -128,6 +128,31 @@ def test_soil_auto_build_honors_E_override(tmp_path: pathlib.Path) -> None: assert stiff.K_rr != pytest.approx(base.K_rr) +def test_soil_from_windio_honors_thickness_interp(tmp_path: pathlib.Path) -> None: + """A piecewise-constant wall schedule takes the step value at the mudline, + not a linear blend, so pile_EI (and the springs) match the beam reduction + on a tapered pile (Codex review on #118).""" + pytest.importorskip("yaml") + p = tmp_path / "slender.yaml" + p.write_text(_SLENDER, encoding="utf-8") + lin = MudlineFoundation.from_windio(p, soil_E=60e6, water_depth=20.0) + pwc = MudlineFoundation.from_windio( + p, soil_E=60e6, water_depth=20.0, thickness_interp="piecewise_constant" + ) + assert lin.pile_behaviour == "flexible" + assert pwc.K_rr != pytest.approx(lin.K_rr) + + +def test_soil_rejects_lumped_rna_cal(tmp_path: pathlib.Path) -> None: + """The auto-RNA tip mass is clamped-base (hub_conn=1); it cannot be + combined with a soil-flexible base (hub_conn=3) (Codex review on #118).""" + p = _yaml(tmp_path) + with pytest.raises(ValueError, match="lumped_rna_cal is not supported"): + Tower.from_windio_with_monopile( + p, water_depth=20.0, soil_E=60e6, lumped_rna_cal=True + ) + + def test_soil_and_soil_E_mutually_exclusive(tmp_path: pathlib.Path) -> None: p = _yaml(tmp_path) found = MudlineFoundation.from_windio(p, soil_E=60e6, water_depth=20.0) From 6400599cf61591c95ab7acd3c9b9da64c5c633b7 Mon Sep 17 00:00:00 2001 From: Jae Hoon Seo Date: Fri, 10 Jul 2026 20:36:37 +0900 Subject: [PATCH 4/4] fix: require a resolved mudline depth for the explicit soil path The soil springs act at the mudline, so the beam must be truncated there. read_windio_monopile_tower only truncates when a water depth resolves; the soil_E path already requires one, but an explicit soil= foundation attached to a file with an embedded monopile and no water_depth left the beam clamped at the pile toe (embedded pile as a free beam, springs at the toe). Require a resolved water depth for the explicit-soil path too. --- src/pybmodes/models/tower.py | 17 +++++++++++++++++ tests/test_windio_soil_foundation.py | 14 ++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/pybmodes/models/tower.py b/src/pybmodes/models/tower.py index ef7ed11..86133db 100644 --- a/src/pybmodes/models/tower.py +++ b/src/pybmodes/models/tower.py @@ -643,6 +643,23 @@ def from_windio_with_monopile( thickness_interp=thickness_interp, ) if foundation is not None: + # The springs act at the mudline, so the beam must be truncated + # there. read_windio_monopile_tower only truncates when a water + # depth resolves; without one it clamps at the pile toe, which + # would leave the embedded pile as a free beam and place the + # springs at the toe. The soil_E path already requires the depth + # (via MudlineFoundation.from_windio); enforce it for the explicit + # ``soil`` path too (Codex review #118). + from pybmodes.io.windio import _read_water_depth + + if _read_water_depth(yaml_path, water_depth) is None: + raise ValueError( + "a soil foundation needs a resolved water depth so the " + "beam is truncated to the mudline (and the springs act " + "there, not at the monopile toe with the embedded pile " + "left as a free beam). Pass water_depth=... or set " + "environment.water_depth in the ontology." + ) obj.attach_mudline_foundation(foundation) return obj diff --git a/tests/test_windio_soil_foundation.py b/tests/test_windio_soil_foundation.py index bebe1cb..e9fa527 100644 --- a/tests/test_windio_soil_foundation.py +++ b/tests/test_windio_soil_foundation.py @@ -162,6 +162,20 @@ def test_soil_and_soil_E_mutually_exclusive(tmp_path: pathlib.Path) -> None: ) +def test_explicit_soil_requires_water_depth(tmp_path: pathlib.Path) -> None: + """The explicit-soil path also needs a resolved mudline depth, else the + beam clamps at the pile toe and the springs act there, not at the mudline + (Codex review on #118).""" + pytest.importorskip("yaml") + # ontology without environment.water_depth + p = tmp_path / "no_env.yaml" + p.write_text(_ONTO.split("environment:")[0] + _ONTO.split("\n", 2)[2], + encoding="utf-8") + found = MudlineFoundation.from_windio(p, soil_E=60e6, water_depth=20.0) + with pytest.raises(ValueError, match="resolved water depth"): + Tower.from_windio_with_monopile(p, soil=found) # no water_depth + + def test_from_windio_requires_water_depth(tmp_path: pathlib.Path) -> None: """No mudline (no water_depth and no environment block) -> clear error.""" pytest.importorskip("yaml")