"""
LEGACY Op^3 foundation factory (frozen at v1.0).
This module is the original ``op3.foundations`` module moved verbatim
into a submodule of the new ``op3.foundations`` package. The public
API (``Foundation``, ``FoundationMode``, ``build_foundation``,
``apply_scour_relief``, ``foundation_from_pisa``) is preserved for
backwards compatibility and re-exported by ``op3.foundations.__init__``.
A :class:`DeprecationWarning` is emitted when ``build_foundation`` or
``FoundationMode`` is used, pointing to the new
:mod:`op3.foundations.types` API (``Monopile``, ``Tripod``, ``Jacket``,
``SuctionBucket``) introduced in v1.1+. The legacy path will remain
functional until the next major version bump.
The four original modes (A/B/C/D) represent **SSI fidelity levels**
(fixed / 6x6 / lumped-BNWF / dissipation-weighted BNWF) — they are
NOT foundation types. The new API separates the axes:
FoundationType (Monopile, Tripod, Jacket, ...) x
SSIStrategy (Fixed, Stiffness6x6, BNWFLumped, BNWFPhysical, CraigBampton)
Concrete foundation topologies with their own mass, geometry, and
coupling interface now live under :mod:`op3.foundations.types`; SSI
strategies live under :mod:`op3.ssi`.
"""
from __future__ import annotations
import warnings
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Optional
import numpy as np
import pandas as pd
[docs]
class FoundationMode(str, Enum):
"""The five Op^3 foundation representations (LEGACY, frozen at v1.0).
The first four are the original v1.0 fidelity ladder (Modes A-D).
``distributed_bnwf_nonlinear`` (v1.1+) is the blueprint Q1(a) primary:
a physically-distributed skirt column with per-depth PySimple1/
TzSimple1 backbones.
**Deprecation notice (v1.1+):** these values represent SSI fidelity
levels, not foundation types. New code should use the type/SSI
split under :mod:`op3.foundations.types` and :mod:`op3.ssi`. The
legacy ``build_foundation(mode=...)`` path remains functional
throughout v1.x.
"""
FIXED = "fixed"
STIFFNESS_6X6 = "stiffness_6x6"
DISTRIBUTED_BNWF = "distributed_bnwf"
DISSIPATION_WEIGHTED = "dissipation_weighted"
DISTRIBUTED_BNWF_NONLINEAR = "distributed_bnwf_nonlinear"
[docs]
@dataclass
class Foundation:
"""Opaque handle returned by `build_foundation`.
The composer calls `attach_to_opensees(base_node)` at model-build
time. The foundation is therefore described declaratively here and
only touches OpenSees tags when the composer passes it a base
node.
"""
mode: FoundationMode
# Depth-resolved spring table (Modes C, D)
spring_table: Optional[pd.DataFrame] = None
# 6x6 matrix (Mode B)
stiffness_matrix: Optional[np.ndarray] = None
# Dissipation weights (Mode D only)
dissipation_weights: Optional[pd.DataFrame] = None
# Mode D weighting parameters: w = beta + (1-beta) * (1 - D/D_max) ** alpha
# --- NOT a calibration ---
# ``mode_d_alpha`` and ``mode_d_beta`` are SENSITIVITY-SWEEP parameters,
# not fitted values. The pair (alpha=1.0, beta=0.05) is a nominal
# starting point chosen for dimensional convenience (linear shape
# function with a 5% floor so zero-dissipation zones retain numerical
# stiffness). Users are expected to run a parameter study over alpha
# in [0.5, 3] and beta in [0.01, 0.2] and report sensitivities. See
# docs/MODE_D_DISSIPATION_WEIGHTED.md for the intended usage pattern.
# Reference calibration against OptumG2 plastic-dissipation fields
# remains future work (blueprint Week 11).
mode_d_alpha: float = 1.0
mode_d_beta: float = 0.05
# Scour depth applied at build time
scour_depth: float = 0.0
# --- Physical-skirt geometry knobs (Modes C/D opt-in, C_nonlinear required) ---
# Bucket outer diameter at skirt. Defaults to SiteA 4 MW reference (D=8 m).
diameter_m: float = 8.0
# Skirt embed length below mudline. If None, derived from
# max(abs(depth_m)) of the spring table.
skirt_length_m: Optional[float] = None
# Skirt wall thickness (steel tube).
skirt_thickness_m: float = 0.025
# Opt-in flag: when True for Mode C / D, build the new physical
# distributed-skirt model instead of the legacy lumped zero-length.
# DISTRIBUTED_BNWF_NONLINEAR always uses the physical model.
physical: bool = False
# --- Physical-skirt PROXY knobs ---
# The following ratios set base / shaft boundary conditions when an
# OptumG2-calibrated base probe / t-z axial probe are not yet
# available. Leaving ALL of them at None triggers a UserWarning at
# attach time and falls back to the historical defaults below.
# When the OptumG2 base probe and t-z axial probe are wired in
# (blueprint Week 5) these will become required inputs.
#
# base_H_stiffness_fraction: k_H_base / integrated_k_lateral
# (default 0.1 — rigid-base proxy)
# base_V_to_H_ratio: k_V_base / k_H_base (default 3.0)
# shaft_t_to_p_ratio: t_ult / p_ult (default 0.5)
# shaft_kz_to_kx_ratio: k_vertical / k_lateral (default 0.5)
# missing_pult_fallback_factor: p_ult = factor * k_ini when p_ult
# column is absent from the spring table
# (default 10.0)
base_H_stiffness_fraction: Optional[float] = None
base_V_to_H_ratio: Optional[float] = None
shaft_t_to_p_ratio: Optional[float] = None
shaft_kz_to_kx_ratio: Optional[float] = None
missing_pult_fallback_factor: Optional[float] = None
# Provenance: where did the data come from
source: str = "analytical"
# Diagnostic info filled at attach time
diagnostics: dict = field(default_factory=dict)
[docs]
def attach_to_opensees(self, base_node: int) -> dict:
"""Instantiate the foundation as OpenSees elements and return
a diagnostics dict.
This is called by the composer at model-build time. The
concrete OpenSees commands live in the opensees_foundations
submodule because they require an active OpenSees model.
Parameters
----------
base_node : int
The OpenSees node tag at the tower base that the
foundation attaches to.
Returns
-------
dict
Diagnostic info: number of springs, integrated stiffness,
energy balance check, etc.
"""
# Delayed import so that pure-data use of Foundation objects
# does not require OpenSeesPy to be installed.
from op3.opensees_foundations import attach_foundation
diag = attach_foundation(self, base_node)
# Persist into the dataclass so callers (and V&V tests) can
# inspect the weighting parameters that were actually applied.
if isinstance(diag, dict):
self.diagnostics.update(diag)
return diag
[docs]
def build_foundation(
mode: str,
*,
spring_profile: Optional[str | Path] = None,
stiffness_matrix: Optional[str | Path | np.ndarray] = None,
ogx_dissipation: Optional[str | Path] = None,
ogx_capacity: Optional[str | Path] = None,
scour_depth: float = 0.0,
mode_d_alpha: float = 1.0,
mode_d_beta: float = 0.05,
diameter_m: float = 8.0,
skirt_length_m: Optional[float] = None,
skirt_thickness_m: float = 0.025,
physical: bool = False,
_suppress_deprecation_warning: bool = False,
) -> Foundation:
"""Construct a Foundation handle ready for the composer (LEGACY API).
**Deprecated (v1.1+):** use :mod:`op3.foundations.types` and
:mod:`op3.ssi` for new code. Example:
>>> from op3.foundations.types import Monopile
>>> from op3.ssi import Stiffness6x6
>>> mono = Monopile.from_oc3_spec(soil_profile=...)
>>> mono.with_ssi(Stiffness6x6(K=mono.head_stiffness_6x6()))
Parameters
----------
mode : str
One of 'fixed', 'stiffness_6x6', 'distributed_bnwf',
'dissipation_weighted', 'distributed_bnwf_nonlinear'.
Case-insensitive.
spring_profile : str or Path, optional
Path to a CSV with columns (depth_m, k_ini_kN_per_m,
p_ult_kN_per_m, spring_type). Required for Modes C and D.
stiffness_matrix : str, Path, or ndarray, optional
A 6x6 stiffness matrix, either a path to a CSV or a NumPy
array. Required for Mode B.
ogx_dissipation : str or Path, optional
Path to a CSV with columns (depth_m, w_z, D_total_kJ).
Required for Mode D.
ogx_capacity : str or Path, optional
Path to the OptumGX power-law capacity CSV with columns
(param_name, param_value). Used by Mode D for the ultimate
resistance scaling.
scour_depth : float, default 0.0
Scour depth in meters. Affects Modes B, C, D.
_suppress_deprecation_warning : bool
Internal flag used by ``op3.foundations.types`` adapters to
avoid spurious deprecation noise on the back-compat bridge.
"""
if not _suppress_deprecation_warning:
warnings.warn(
"op3.foundations.build_foundation (and FoundationMode) are "
"frozen at v1.0 and will be removed in v2.0. Use the new "
"type/SSI split: op3.foundations.types.{Monopile,Tripod,"
"Jacket,SuctionBucket} + op3.ssi.{Fixed,Stiffness6x6,"
"BNWFLumped,BNWFPhysical,CraigBampton}. See "
"op3/models/<name>/build.py for reference use.",
DeprecationWarning,
stacklevel=2,
)
try:
mode_enum = FoundationMode(mode.lower())
except ValueError:
raise ValueError(
f"Unknown foundation mode '{mode}'. "
f"Expected one of {[m.value for m in FoundationMode]}."
)
foundation = Foundation(
mode=mode_enum,
scour_depth=scour_depth,
mode_d_alpha=mode_d_alpha,
mode_d_beta=mode_d_beta,
diameter_m=diameter_m,
skirt_length_m=skirt_length_m,
skirt_thickness_m=skirt_thickness_m,
physical=physical,
)
if mode_enum == FoundationMode.FIXED:
foundation.source = "analytical (no data needed)"
return foundation
if mode_enum == FoundationMode.STIFFNESS_6X6:
if stiffness_matrix is None:
raise ValueError("Mode stiffness_6x6 requires stiffness_matrix argument")
if isinstance(stiffness_matrix, (str, Path)):
K = pd.read_csv(stiffness_matrix, header=None).values
foundation.source = f"CSV: {stiffness_matrix}"
else:
K = np.asarray(stiffness_matrix)
foundation.source = "ndarray (in-memory)"
if K.shape != (6, 6):
raise ValueError(f"stiffness_matrix must be 6x6, got {K.shape}")
foundation.stiffness_matrix = K
return foundation
if mode_enum == FoundationMode.DISTRIBUTED_BNWF:
if spring_profile is None:
raise ValueError("Mode distributed_bnwf requires spring_profile argument")
df = pd.read_csv(spring_profile)
foundation.spring_table = df
foundation.source = f"CSV: {spring_profile}"
return foundation
if mode_enum == FoundationMode.DISSIPATION_WEIGHTED:
if spring_profile is None or ogx_dissipation is None:
raise ValueError(
"Mode dissipation_weighted requires both spring_profile and "
"ogx_dissipation arguments"
)
df = pd.read_csv(spring_profile)
foundation.spring_table = df
foundation.dissipation_weights = pd.read_csv(ogx_dissipation)
foundation.source = f"spring: {spring_profile}, dissipation: {ogx_dissipation}"
return foundation
if mode_enum == FoundationMode.DISTRIBUTED_BNWF_NONLINEAR:
if spring_profile is None:
raise ValueError(
"Mode distributed_bnwf_nonlinear requires spring_profile argument"
)
df = pd.read_csv(spring_profile)
foundation.spring_table = df
# Physical is implicit for the nonlinear mode.
foundation.physical = True
foundation.source = f"CSV: {spring_profile} (PySimple1/TzSimple1 backbones)"
return foundation
raise RuntimeError(f"Unreachable — mode {mode_enum} not handled")
[docs]
def apply_scour_relief(spring_table: pd.DataFrame, scour_depth: float) -> pd.DataFrame:
"""Apply a stress-relief factor to a spring table for a given scour depth.
Rules:
- Nodes above the scoured mudline (z < scour_depth) have zero stiffness
and zero capacity (the soil is gone).
- Nodes below but near the scour front have a smoothly tapered factor
`relief(z) = sqrt((z - scour) / z)` to account for stress relief in
the remaining soil column.
- Nodes far below the scour front are unchanged.
This is the stress-correction procedure from Chapter 6 of the
dissertation and is mathematically identical to the factor documented
in Appendix A Section A.3.
"""
df = spring_table.copy()
z = df["depth_m"].values
relief = np.where(
z < scour_depth,
0.0,
np.sqrt(np.clip((z - scour_depth) / np.maximum(z, 1e-6), 0.0, 1.0)),
)
for col in ("k_ini_kN_per_m", "p_ult_kN_per_m"):
if col in df.columns:
df[col] = df[col].values * relief
return df
# ---------------------------------------------------------------------------
# PISA convenience: build a Mode B foundation directly from soil profile
# ---------------------------------------------------------------------------
[docs]
def foundation_from_pisa(
*,
diameter_m: float,
embed_length_m: float,
soil_profile: list,
n_segments: int = 50,
) -> Foundation:
"""
Construct a STIFFNESS_6X6 Foundation whose K matrix is derived from
the PISA framework (Burd 2020 / Byrne 2020). This is the canonical
Op^3 entry point for monopile foundations when site-specific p-y
curves are not available.
Parameters
----------
diameter_m, embed_length_m
Pile geometry.
soil_profile : list[op3.standards.pisa.SoilState]
Layered soil definition (depth, G, su or phi, soil_type).
n_segments
Vertical discretisation for the PISA integration (default 50).
"""
from op3.standards.pisa import pisa_pile_stiffness_6x6
K = pisa_pile_stiffness_6x6(
diameter_m=diameter_m,
embed_length_m=embed_length_m,
soil_profile=soil_profile,
n_segments=n_segments,
)
foundation = Foundation(mode=FoundationMode.STIFFNESS_6X6)
foundation.stiffness_matrix = K
foundation.source = (
f"PISA (Burd 2020 / Byrne 2020), D={diameter_m} m, "
f"L={embed_length_m} m, {len(soil_profile)} soil layers"
)
return foundation