"""
UFC 3-220-10, Chapter 8: Correlations for Soil and Rock

Equations 8-1 through 8-45 covering correlations for estimating soil
engineering properties from index tests, SPT, CPT, and dilatometer data.

Topics include:
    - Effective stress friction angle (coarse-grained and fine-grained soils)
    - Undrained shear strength
    - Consolidation parameters (compression index, constrained modulus)
    - Secondary compression
    - Elastic modulus conversions
    - California Bearing Ratio (CBR)
    - Hydraulic conductivity
    - Shear wave velocity

Reference:
    UFC 3-220-10, Soil Mechanics, 1 February 2022, Change 1, 11 March 2025
"""

import math
from typing import List, Tuple

from geotech_references._interpolation import _linterp

# ============================================================================
# SECTION 8-2.1: EFFECTIVE STRESS FRICTION ANGLE -- COARSE-GRAINED SOILS
# ============================================================================


def spt_n_correction_for_pore_pressure(n60: float) -> float:
    """Correct SPT N value for dynamic pore pressure effects (Equation 8-1).

    For saturated very fine or silty sand, the measured SPT N value should
    be corrected for dynamic pore pressure effects before using correlations
    based on Table 8-3.  If N60 <= 15 the blow count is returned unchanged;
    if N60 > 15 the correction of Terzaghi and Peck is applied.

    .. math::
        N' = \\begin{cases}
            N_{60}               & \\text{for } N_{60} \\le 15 \\\\
            15 + 0.5(N_{60}-15)  & \\text{for } N_{60} > 15
        \\end{cases}

    Parameters
    ----------
    n60 : float
        SPT blow count corrected for 60% energy (blows/ft).

    Returns
    -------
    float
        Blow count corrected for dynamic pore pressure effects, N'
        (blows/ft).

    Raises
    ------
    ValueError
        If *n60* is negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-1, p. 432.
    """
    if n60 < 0.0:
        raise ValueError("n60 must be non-negative.")
    if n60 <= 15.0:
        return n60
    return 15.0 + 0.5 * (n60 - 15.0)


def spt_friction_angle_roads(n60: float) -> float:
    """Effective stress friction angle from SPT N60 for roads and bridges (Equation 8-2).

    Correlation by Shioi and Fukui (1982) relating the effective stress
    friction angle of coarse-grained soils to the SPT N value corrected
    for 60% hammer energy.  This form is recommended for road and bridge
    applications.

    .. math::
        \\phi' = \\sqrt{15 \\cdot N_{60}} + 15

    Parameters
    ----------
    n60 : float
        SPT N value corrected for 60% energy (blows/ft).

    Returns
    -------
    float
        Effective stress friction angle, phi' (degrees).

    Raises
    ------
    ValueError
        If *n60* is negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-2, p. 433.
    """
    if n60 < 0.0:
        raise ValueError("n60 must be non-negative.")
    return math.sqrt(15.0 * n60) + 15.0


def spt_friction_angle_buildings(n60: float) -> float:
    """Effective stress friction angle from SPT N60 for buildings (Equation 8-3).

    Correlation by Shioi and Fukui (1982) relating the effective stress
    friction angle of coarse-grained soils to the SPT N value corrected
    for 60% hammer energy.  This form is recommended for building
    applications.

    .. math::
        \\phi' = 0.3 \\cdot N_{60} + 27

    Parameters
    ----------
    n60 : float
        SPT N value corrected for 60% energy (blows/ft).

    Returns
    -------
    float
        Effective stress friction angle, phi' (degrees).

    Raises
    ------
    ValueError
        If *n60* is negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-3, p. 433.
    """
    if n60 < 0.0:
        raise ValueError("n60 must be non-negative.")
    return 0.3 * n60 + 27.0


def spt_friction_angle_kulhawy_mayne(
    n60: float, sigma_v_eff: float, pa: float
) -> float:
    """Effective stress friction angle from SPT using Kulhawy and Mayne (Equation 8-4).

    Kulhawy and Mayne (1990) approximation of the Schmertmann (1975) and
    DeMello (1971) relationships between peak effective stress friction
    angle, overburden pressure, and SPT blow count for sands.

    .. math::
        \\tan(\\phi') = \\left[\\frac{N_{60}}{12.2 + 20.3
        \\left(\\frac{\\sigma'_v}{P_a}\\right)}\\right]^{0.34}

    Parameters
    ----------
    n60 : float
        SPT N value corrected for 60% energy (blows/ft).
    sigma_v_eff : float
        Vertical effective stress (stress units, e.g., kPa, psf).
    pa : float
        Atmospheric pressure in the same units as *sigma_v_eff*
        (e.g., 101.325 kPa or 2116.2 psf).

    Returns
    -------
    float
        Effective stress friction angle, phi' (degrees).

    Raises
    ------
    ValueError
        If *n60* is negative, *sigma_v_eff* is negative, or *pa* is not
        positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-4, p. 434.
    """
    if n60 < 0.0:
        raise ValueError("n60 must be non-negative.")
    if sigma_v_eff < 0.0:
        raise ValueError("sigma_v_eff must be non-negative.")
    if pa <= 0.0:
        raise ValueError("pa must be positive.")
    ratio = n60 / (12.2 + 20.3 * (sigma_v_eff / pa))
    if ratio < 0.0:
        raise ValueError("Computed ratio is negative; check inputs.")
    tan_phi = ratio ** 0.34
    return math.degrees(math.atan(tan_phi))


def spt_friction_angle_wolff(n1_60: float) -> float:
    """Effective stress friction angle from N1,60 using Wolff (Equation 8-5).

    Wolff (1989) approximation of the Peck et al. (1974) correlation
    between effective stress friction angle and SPT N value corrected for
    60% energy and overburden pressure (N1,60).

    .. math::
        \\phi' = 27.1 + 0.3 \\cdot N_{1,60} - 0.00054 \\cdot N_{1,60}^2

    Parameters
    ----------
    n1_60 : float
        SPT N value corrected for 60% energy and 1 atm overburden
        pressure (blows/ft).

    Returns
    -------
    float
        Effective stress friction angle, phi' (degrees).

    Raises
    ------
    ValueError
        If *n1_60* is negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-5, p. 435.
    """
    if n1_60 < 0.0:
        raise ValueError("n1_60 must be non-negative.")
    return 27.1 + 0.3 * n1_60 - 0.00054 * n1_60 ** 2


def spt_friction_angle_hatanaka_uchida(n1_60: float) -> float:
    """Effective stress friction angle from N1,60 using Hatanaka and Uchida (Equation 8-6).

    Hatanaka and Uchida (1996) correlation for the effective stress friction
    angle of sands based on N1,60, developed from triaxial tests on
    high-quality intact frozen samples of natural sands.  The original
    SPT hammer had an efficiency of 78%.

    .. math::
        \\phi' = \\sqrt{15.4 \\cdot N_{1,60}} + 20

    Parameters
    ----------
    n1_60 : float
        SPT N value corrected for 60% energy and 1 atm overburden
        pressure (blows/ft).

    Returns
    -------
    float
        Effective stress friction angle, phi' (degrees).

    Raises
    ------
    ValueError
        If *n1_60* is negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-6, p. 435.
    """
    if n1_60 < 0.0:
        raise ValueError("n1_60 must be non-negative.")
    return math.sqrt(15.4 * n1_60) + 20.0


# ============================================================================
# SECTION 8-2.1.3: CORRELATIONS WITH CONE PENETRATION TEST (CPT)
# ============================================================================


def cpt_friction_angle_mayne(
    qt: float, sigma_v_eff: float, pa: float
) -> float:
    """Effective stress friction angle from CPT using Mayne (Equation 8-7).

    Mayne (2007) correlation using calibration chamber tests to estimate
    the effective stress friction angle of coarse-grained soils from CPT
    corrected tip resistance.

    .. math::
        \\phi' = 17.6 + 11 \\cdot \\log_{10}\\!
        \\left(\\frac{q_t / P_a}{\\sigma'_v / P_a}\\right)

    Parameters
    ----------
    qt : float
        Corrected cone tip resistance, qt = qc + u*(1+a) (stress units).
    sigma_v_eff : float
        Effective vertical stress (same units as *qt*).
    pa : float
        Atmospheric pressure (same units as *qt* and *sigma_v_eff*).

    Returns
    -------
    float
        Effective stress friction angle, phi' (degrees).

    Raises
    ------
    ValueError
        If *qt* is not positive, *sigma_v_eff* is not positive, or *pa*
        is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-7, p. 436.
    """
    if qt <= 0.0:
        raise ValueError("qt must be positive.")
    if sigma_v_eff <= 0.0:
        raise ValueError("sigma_v_eff must be positive.")
    if pa <= 0.0:
        raise ValueError("pa must be positive.")
    return 17.6 + 11.0 * math.log10((qt / pa) / (sigma_v_eff / pa))


def cpt_friction_angle_robertson_campanella(
    qc: float, sigma_v_eff: float
) -> float:
    """Effective stress friction angle from CPT using Robertson and Campanella (Equation 8-8).

    Robertson and Campanella (1983) correlation for the effective stress
    friction angle from electric cone CPT tip resistance tested in a
    calibration chamber on uncemented, unaged, moderately compressible
    quartz sands.  Approximated by Robertson and Cabal (2014).

    .. math::
        \\phi' = \\tan^{-1}\\!\\left[
        \\frac{1}{\\log_{10}\\!\\left(\\frac{q_c}{\\sigma'_v}\\right)
        + 0.29} \\cdot 2.68 \\right]^{-1}

    Note: The actual form from the text is:
    tan(phi') = 1 / [log10(qc/sigma'v) + 0.29] / 2.68
    which simplifies to phi' = arctan(log10(qc/sigma'v) + 0.29) ... after
    rearranging from the PDF equation presented as:
    phi' = arctan[1 / (log10(qc/sigma'v) + 0.29)] ... but the PDF shows:
    tan^{-1}[log(qc/sigma'v) + 0.29] ... Reading the equation carefully:

    .. math::
        \\phi' = \\arctan\\!\\left[
        \\frac{1}{\\log_{10}(q_c / \\sigma'_v) + 0.29}
        \\right] \\div 2.68

    Actually from the PDF text: 1 / [tan(phi') = log(qc/sv) + 0.29] / 2.68
    The standard Robertson & Campanella (1983) form is:

    .. math::
        \\phi' = \\arctan\\!\\left[0.1 + 0.38 \\cdot \\log_{10}(q_c/\\sigma'_v)\\right]

    From the PDF extracted text the equation reads:
    tan^{-1}[1 / (log(qc/sigma'v) + 0.29)] = phi' ... no, reading again:
    phi' = arctan[ 1 / ( log(qc/sigma'v) + 0.29 ) ] ... wait, let me
    re-read: "1 / tan(phi') = log(qc/sigma'v) + 0.29" ... rearranged as
    phi' = arctan(1 / (log10(qc/sigma'v) + 0.29))... but there is also
    the 2.68 factor.

    The standard published form (Robertson and Cabal 2014) is:
    phi' = arctan[1 / (2.68 * (log10(qc/sigma'v) + 0.29))]

    This is an inverse tangent equation.

    Parameters
    ----------
    qc : float
        Cone tip resistance (stress units).
    sigma_v_eff : float
        Effective vertical stress (same units as *qc*).

    Returns
    -------
    float
        Effective stress friction angle, phi' (degrees).

    Raises
    ------
    ValueError
        If *qc* is not positive or *sigma_v_eff* is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-8, p. 438.
    Robertson and Campanella (1983); Robertson and Cabal (2014).
    """
    if qc <= 0.0:
        raise ValueError("qc must be positive.")
    if sigma_v_eff <= 0.0:
        raise ValueError("sigma_v_eff must be positive.")
    log_ratio = math.log10(qc / sigma_v_eff)
    denominator = 2.68 * (log_ratio + 0.29)
    if denominator <= 0.0:
        raise ValueError(
            "Computed denominator is non-positive; qc/sigma_v_eff ratio "
            "may be too small for this correlation."
        )
    phi_rad = math.atan(1.0 / denominator)
    return math.degrees(phi_rad)


def cpt_oc_nc_resistance_ratio(ocr: float, beta: float) -> float:
    """Ratio of CPT tip resistance in OC vs NC sand (Equation 8-9).

    Schmertmann (1978) correction factor relating the CPT tip resistance
    in overconsolidated (OC) sand to that in normally consolidated (NC)
    sand via the overconsolidation ratio and an empirical exponent.

    .. math::
        R = \\frac{q_{c,OC}}{q_{c,NC}} = 1 + 0.75 (OCR^\\beta - 1)

    Parameters
    ----------
    ocr : float
        Overconsolidation ratio (dimensionless, >= 1.0).
    beta : float
        Empirical exponent (dimensionless).

    Returns
    -------
    float
        Ratio R = qc,OC / qc,NC (dimensionless).

    Raises
    ------
    ValueError
        If *ocr* is less than 1.0.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-9, p. 439.
    """
    if ocr < 1.0:
        raise ValueError("ocr must be >= 1.0.")
    return 1.0 + 0.75 * (ocr ** beta - 1.0)


# ============================================================================
# SECTION 8-2.1.4: CORRELATIONS WITH DILATOMETER (DMT)
# ============================================================================


def dmt_horizontal_stress_index(
    p0: float, u0: float, sigma_v_eff: float
) -> float:
    """Horizontal stress index from dilatometer test (Equation 8-10).

    Marchetti (1997) definition of the horizontal stress index (KD) used
    as the basis for dilatometer-based correlations for the effective stress
    friction angle.

    .. math::
        K_D = \\frac{p_0 - u_0}{\\sigma'_v}

    Parameters
    ----------
    p0 : float
        Corrected pressure required to initiate movement of the DMT
        membrane against the soil (stress units, e.g., kPa).
    u0 : float
        Hydrostatic (in situ) pore pressure (same stress units as *p0*).
    sigma_v_eff : float
        Effective vertical stress (same stress units as *p0*).

    Returns
    -------
    float
        Horizontal stress index, KD (dimensionless).

    Raises
    ------
    ValueError
        If *sigma_v_eff* is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-10, p. 442.
    """
    if sigma_v_eff <= 0.0:
        raise ValueError("sigma_v_eff must be positive.")
    return (p0 - u0) / sigma_v_eff


def dmt_friction_angle_upper_bound(kd: float) -> float:
    """Upper bound effective stress friction angle from DMT (Equation 8-11).

    Upper bound estimate of the effective stress friction angle for clean
    sands from the horizontal stress index (KD) of the dilatometer test,
    as proposed by Ricceri et al. (2002).

    .. math::
        \\phi' = 31 + \\frac{K_D}{0.236 + 0.066 \\cdot K_D}

    Parameters
    ----------
    kd : float
        Horizontal stress index from dilatometer test (dimensionless).

    Returns
    -------
    float
        Effective stress friction angle, phi' (degrees).

    Raises
    ------
    ValueError
        If the denominator (0.236 + 0.066 * kd) is zero or negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-11, p. 442.
    """
    denom = 0.236 + 0.066 * kd
    if denom <= 0.0:
        raise ValueError(
            "Denominator (0.236 + 0.066*KD) is non-positive; check KD value."
        )
    return 31.0 + kd / denom


def dmt_friction_angle_lower_bound(kd: float) -> float:
    """Lower bound effective stress friction angle from DMT (Equation 8-12).

    Lower bound estimate of the effective stress friction angle for clean
    sands from the horizontal stress index (KD) of the dilatometer test,
    as proposed by Marchetti (1997).  May underestimate the in situ friction
    angle by 2 to 4 degrees.

    .. math::
        \\phi' = 28 + 14.6 \\cdot \\log_{10}(K_D)
                 - 2.1 \\cdot [\\log_{10}(K_D)]^2

    Parameters
    ----------
    kd : float
        Horizontal stress index from dilatometer test (dimensionless,
        must be > 0).

    Returns
    -------
    float
        Effective stress friction angle, phi' (degrees).

    Raises
    ------
    ValueError
        If *kd* is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-12, p. 442.
    """
    if kd <= 0.0:
        raise ValueError("kd must be positive.")
    log_kd = math.log10(kd)
    return 28.0 + 14.6 * log_kd - 2.1 * log_kd ** 2


# ============================================================================
# SECTION 8-2.2: EFFECTIVE STRESS FRICTION ANGLE -- FINE-GRAINED SOILS
# ============================================================================


def fully_softened_friction_angle(pi: float) -> float:
    """Fully softened friction angle from plasticity index (Equation 8-13).

    Gibson (1953) relationship for the fully softened friction angle
    (phi'FS), approximated by Carter and Bentley (2016).  The fully
    softened friction angle is taken to be equal to the normally
    consolidated peak value.

    .. math::
        \\phi'_{FS} = -0.0058 \\cdot PI^{1.73} + 0.32 \\cdot PI + 36.2

    Parameters
    ----------
    pi : float
        Plasticity index (%, dimensionless number, e.g., 30 for PI = 30%).

    Returns
    -------
    float
        Fully softened friction angle, phi'FS (degrees).

    Raises
    ------
    ValueError
        If *pi* is negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-13, p. 443.
    """
    if pi < 0.0:
        raise ValueError("pi must be non-negative.")
    return -0.0058 * pi ** 1.73 + 0.32 * pi + 36.2


def secant_friction_angle_shear_strength(
    sigma_ff_eff: float, phi_sec_deg: float
) -> float:
    """Effective stress shear strength using secant friction angle (Equation 8-14).

    Computes the shear strength on a nonlinear failure envelope using
    a stress-dependent secant friction angle.

    .. math::
        s = \\sigma'_{ff} \\cdot \\tan(\\phi'_{sec})

    Parameters
    ----------
    sigma_ff_eff : float
        Effective normal stress on the failure plane (stress units).
    phi_sec_deg : float
        Stress-dependent secant friction angle (degrees).

    Returns
    -------
    float
        Effective stress shear strength, s (same stress units as
        *sigma_ff_eff*).

    Raises
    ------
    ValueError
        If *sigma_ff_eff* is negative or *phi_sec_deg* is outside
        [0, 90).

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-14, p. 447.
    """
    if sigma_ff_eff < 0.0:
        raise ValueError("sigma_ff_eff must be non-negative.")
    if phi_sec_deg < 0.0 or phi_sec_deg >= 90.0:
        raise ValueError("phi_sec_deg must be in [0, 90) degrees.")
    return sigma_ff_eff * math.tan(math.radians(phi_sec_deg))


def power_function_shear_strength(
    a: float, sigma_ff_eff: float, pa: float, b: float
) -> float:
    """Nonlinear shear strength using two-parameter power function (Equation 8-15).

    Describes shear strength nonlinearly using empirical parameters *a*
    (steepness) and *b* (curvature).  Commonly used for fully softened
    and residual shear strengths.

    .. math::
        s = a \\cdot P_a \\left(\\frac{\\sigma'_{ff}}{P_a}\\right)^b

    Parameters
    ----------
    a : float
        Empirical coefficient related to steepness (dimensionless).
    sigma_ff_eff : float
        Effective normal stress on the failure plane (stress units).
    pa : float
        Atmospheric pressure (same stress units as *sigma_ff_eff*).
    b : float
        Empirical coefficient related to curvature (dimensionless).

    Returns
    -------
    float
        Effective stress shear strength, s (same stress units as
        *sigma_ff_eff*).

    Raises
    ------
    ValueError
        If *sigma_ff_eff* is negative or *pa* is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-15, p. 447.
    """
    if sigma_ff_eff < 0.0:
        raise ValueError("sigma_ff_eff must be non-negative.")
    if pa <= 0.0:
        raise ValueError("pa must be positive.")
    return a * pa * (sigma_ff_eff / pa) ** b


def residual_friction_angle_gibson(pi: float) -> float:
    """Residual friction angle from plasticity index (Equation 8-16).

    Gibson (1953) relationship for the residual friction angle (phi'r),
    approximated by Carter and Bentley (2016).  The polynomial fit agrees
    with the original curve to within 5%.

    .. math::
        \\phi'_r = 0.084 \\cdot PI^{1.4} - 0.75 \\cdot PI + 31.9

    Parameters
    ----------
    pi : float
        Plasticity index (%).

    Returns
    -------
    float
        Residual friction angle, phi'r (degrees).

    Raises
    ------
    ValueError
        If *pi* is negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-16, p. 449.
    """
    if pi < 0.0:
        raise ValueError("pi must be non-negative.")
    return 0.084 * pi ** 1.4 - 0.75 * pi + 31.9


def residual_friction_angle_stark_hussain(
    ll: float, c0: float, c1: float, c2: float, c3: float
) -> float:
    """Residual friction angle from liquid limit using Stark and Hussain (Equation 8-17).

    Stark and Hussain (2013) correlation providing stress-dependent
    residual secant friction angles.  The coefficients C0 through C3
    depend on the clay fraction, liquid limit range, and effective
    normal stress; they are tabulated in Table 8-6 of the UFC.

    .. math::
        \\phi'_r = C_0 + C_1 \\cdot LL + C_2 \\cdot LL^2 + C_3 \\cdot LL^3

    Parameters
    ----------
    ll : float
        Liquid limit (%).
    c0 : float
        Empirical intercept coefficient (degrees).
    c1 : float
        Empirical first-order coefficient (degrees / %).
    c2 : float
        Empirical second-order coefficient (degrees / %^2).
    c3 : float
        Empirical third-order coefficient (degrees / %^3).

    Returns
    -------
    float
        Residual friction angle, phi'r (degrees).

    Raises
    ------
    ValueError
        If *ll* is negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-17, Table 8-6, p. 453.
    """
    if ll < 0.0:
        raise ValueError("ll must be non-negative.")
    return c0 + c1 * ll + c2 * ll ** 2 + c3 * ll ** 3


# ============================================================================
# SECTION 8-3: UNDRAINED SHEAR STRENGTH
# ============================================================================


def undrained_strength_ratio_skempton(pi: float) -> float:
    """Undrained shear strength ratio from plasticity index (Equation 8-18).

    Skempton (1957) correlation for the undrained shear strength ratio
    (su/sigma'v) of normally consolidated clays based on the plasticity
    index.  Based primarily on field vane tests (possibly uncorrected).

    .. math::
        \\frac{s_u}{\\sigma'_v} = 0.11 + 0.0037 \\cdot PI

    Parameters
    ----------
    pi : float
        Plasticity index (%).

    Returns
    -------
    float
        Undrained shear strength ratio, su/sigma'v (dimensionless).

    Raises
    ------
    ValueError
        If *pi* is negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-18, p. 455.
    """
    if pi < 0.0:
        raise ValueError("pi must be non-negative.")
    return 0.11 + 0.0037 * pi


def undrained_strength_ratio_hansbo(ll: float) -> float:
    """Undrained shear strength ratio from liquid limit (Equation 8-19).

    Hansbo (1957) correlation for the undrained shear strength ratio
    (su/sigma'v) of normally consolidated clays using the liquid limit.
    Supported by data from Scandinavian clays collected by Larson (1980).

    .. math::
        \\left(\\frac{s_u}{\\sigma'_v}\\right)_{NC} = 0.0045 \\cdot LL

    Parameters
    ----------
    ll : float
        Liquid limit (%).

    Returns
    -------
    float
        Undrained shear strength ratio for NC clay, su/sigma'v
        (dimensionless).

    Raises
    ------
    ValueError
        If *ll* is negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-19, p. 456.
    """
    if ll < 0.0:
        raise ValueError("ll must be non-negative.")
    return 0.0045 * ll


def undrained_strength_ratio_oc(
    su_sigma_v_nc: float, ocr: float, m: float
) -> float:
    """Undrained shear strength ratio for OC clays (Equation 8-20).

    Jamiolkowski et al. (1985) and Ladd and DeGroot (2004) relationship
    accounting for the effect of stress history on the undrained shear
    strength ratio.  The normally consolidated ratio is scaled by OCR
    raised to the power *m*.

    .. math::
        \\left(\\frac{s_u}{\\sigma'_v}\\right)_{OC} =
        \\left(\\frac{s_u}{\\sigma'_v}\\right)_{NC} \\cdot OCR^m

    Parameters
    ----------
    su_sigma_v_nc : float
        Undrained shear strength ratio for normally consolidated clay,
        (su/sigma'v)_NC (dimensionless).
    ocr : float
        Overconsolidation ratio (dimensionless, >= 1.0).
    m : float
        Semi-empirical fitting parameter (dimensionless).  Typical value
        is approximately 0.8 based on DSS tests; see Table 8-9 for other
        values.

    Returns
    -------
    float
        Undrained shear strength ratio for OC clay, (su/sigma'v)_OC
        (dimensionless).

    Raises
    ------
    ValueError
        If *su_sigma_v_nc* is negative or *ocr* is less than 1.0.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-20, p. 460.
    """
    if su_sigma_v_nc < 0.0:
        raise ValueError("su_sigma_v_nc must be non-negative.")
    if ocr < 1.0:
        raise ValueError("ocr must be >= 1.0.")
    return su_sigma_v_nc * ocr ** m


def undrained_strength_from_preconsolidation(sigma_p_eff: float) -> float:
    """Undrained shear strength from preconsolidation pressure (Equation 8-21).

    Sabatini et al. (2002) approximation for very soft clays with
    overconsolidation ratios less than 2.  Assumes *m* equals 1 in the
    SHANSEP framework (Equation 8-20).

    .. math::
        s_u \\approx 0.21 \\cdot \\sigma'_p

    Parameters
    ----------
    sigma_p_eff : float
        Preconsolidation pressure (stress units, e.g., kPa, psf).

    Returns
    -------
    float
        Undrained shear strength, su (same stress units as
        *sigma_p_eff*).

    Raises
    ------
    ValueError
        If *sigma_p_eff* is negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-21, p. 461.
    """
    if sigma_p_eff < 0.0:
        raise ValueError("sigma_p_eff must be non-negative.")
    return 0.21 * sigma_p_eff


def undrained_strength_ratio_acu_from_icu(su_sigma_v_icu: float) -> float:
    """CK0U strength ratio from ICU strength ratio (Equation 8-22).

    Kulhawy and Mayne (1990) relationship to convert the normally
    consolidated undrained strength ratio from isotropically consolidated
    undrained (ICU) triaxial compression tests to anisotropically (K0)
    consolidated undrained (CK0U, or ACU) triaxial compression tests.

    .. math::
        \\left(\\frac{s_u}{\\sigma'_v}\\right)_{ACU} =
        0.15 + 0.49 \\left(\\frac{s_u}{\\sigma'_v}\\right)_{ICU}

    Parameters
    ----------
    su_sigma_v_icu : float
        Undrained strength ratio from ICU triaxial compression tests
        (dimensionless).

    Returns
    -------
    float
        Undrained strength ratio from CK0U triaxial compression tests,
        (su/sigma'v)_ACU (dimensionless).

    Raises
    ------
    ValueError
        If *su_sigma_v_icu* is negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-22, p. 462.
    """
    if su_sigma_v_icu < 0.0:
        raise ValueError("su_sigma_v_icu must be non-negative.")
    return 0.15 + 0.49 * su_sigma_v_icu


def cpt_undrained_strength_nc(qc: float, nc: float) -> float:
    """Undrained shear strength from CPT using Nc method (Equation 8-23).

    Simplest CPT-based method relating the cone tip resistance directly
    to undrained shear strength through an empirical bearing capacity
    factor.  May be less accurate at depths > 15 m because overburden
    pressure is not considered.

    .. math::
        s_u = \\frac{q_c}{N_c}

    Parameters
    ----------
    qc : float
        Cone tip resistance (stress units, e.g., kPa, tsf).
    nc : float
        Empirical bearing capacity factor (dimensionless).  Typically
        17 to 23 for NC and slightly OC clays.

    Returns
    -------
    float
        Undrained shear strength, su (same stress units as *qc*).

    Raises
    ------
    ValueError
        If *qc* is negative or *nc* is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-23, p. 462.
    """
    if qc < 0.0:
        raise ValueError("qc must be non-negative.")
    if nc <= 0.0:
        raise ValueError("nc must be positive.")
    return qc / nc


def cpt_undrained_strength_nk(
    qc: float, sigma_v: float, nk: float
) -> float:
    """Undrained shear strength from CPT using Nk method (Equation 8-24).

    Considers the total overburden pressure at the measurement depth.
    The empirical bearing capacity factor Nk should be calibrated on
    a site- or region-specific basis.

    .. math::
        s_u = \\frac{q_c - \\sigma_v}{N_k}

    Parameters
    ----------
    qc : float
        Cone tip resistance (stress units).
    sigma_v : float
        Total vertical stress at the depth of measurement (same stress
        units as *qc*).
    nk : float
        Empirical bearing capacity factor (dimensionless).  Typical
        range 10 to 19, average about 15.

    Returns
    -------
    float
        Undrained shear strength, su (same stress units as *qc*).

    Raises
    ------
    ValueError
        If *nk* is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-24, p. 463.
    """
    if nk <= 0.0:
        raise ValueError("nk must be positive.")
    return (qc - sigma_v) / nk


def cpt_undrained_strength_nkt(
    qt: float, sigma_v: float, nkt: float
) -> float:
    """Undrained shear strength from CPT using Nkt method (Equation 8-25).

    Modification of the Nk method that uses the corrected tip resistance
    (qt) which accounts for pore pressure acting at the cone tip.
    Values of Nkt are often in the range of 14 to 16.

    .. math::
        s_u = \\frac{q_t - \\sigma_v}{N_{kt}}

    Parameters
    ----------
    qt : float
        Corrected cone tip resistance, qt = qc + u*(1+a) (stress units).
    sigma_v : float
        Total vertical stress at the depth of measurement (same stress
        units as *qt*).
    nkt : float
        Empirical bearing capacity factor (dimensionless).  Typical
        range 14 to 16.

    Returns
    -------
    float
        Undrained shear strength, su (same stress units as *qt*).

    Raises
    ------
    ValueError
        If *nkt* is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-25, p. 463.
    """
    if nkt <= 0.0:
        raise ValueError("nkt must be positive.")
    return (qt - sigma_v) / nkt


def spt_undrained_strength_stroud_butler(n: float, pi: float) -> float:
    """Undrained shear strength from SPT for OC clays (Equation 8-26).

    Carter and Bentley (2016) approximation of the Stroud and Butler
    (1975) trendline relating undrained shear strength of
    overconsolidated clays to SPT N and plasticity index.  The
    relationship exhibits significant scatter.

    .. math::
        s_u = \\frac{N}{4.36 + \\frac{8910}{PI^3}}

    Parameters
    ----------
    n : float
        SPT N value (blows/ft).
    pi : float
        Plasticity index (%).  Must be > 0.

    Returns
    -------
    float
        Undrained shear strength, su (kPa).

    Raises
    ------
    ValueError
        If *n* is negative or *pi* is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-26, p. 464.
    """
    if n < 0.0:
        raise ValueError("n must be non-negative.")
    if pi <= 0.0:
        raise ValueError("pi must be positive.")
    return n / (4.36 + 8910.0 / pi ** 3)


# ============================================================================
# SECTION 8-4: CONSOLIDATION PARAMETERS
# ============================================================================


def intrinsic_compression_index(e_star_100: float, e_star_1000: float) -> float:
    """Intrinsic compression index from void ratios (Equation 8-27).

    Burland (1990) definition of the intrinsic compression index based
    on the void ratios at 100 and 1000 kPa for reconstituted clays.

    .. math::
        C^*_c = e^*_{100} - e^*_{1000}

    Parameters
    ----------
    e_star_100 : float
        Intrinsic void ratio at 100 kPa (dimensionless).
    e_star_1000 : float
        Intrinsic void ratio at 1000 kPa (dimensionless).

    Returns
    -------
    float
        Intrinsic compression index, C*c (dimensionless).

    Raises
    ------
    ValueError
        If *e_star_100* <= *e_star_1000* (result would be non-positive).

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-27, p. 475.
    """
    result = e_star_100 - e_star_1000
    if result <= 0.0:
        raise ValueError(
            "e_star_100 must be greater than e_star_1000 for a positive "
            "compression index."
        )
    return result


def void_index(e: float, e_star_100: float, c_star_c: float) -> float:
    """Void index from void ratio (Equation 8-28).

    Burland (1990) normalization of the current void ratio with respect
    to the intrinsic void ratio at 100 kPa and the intrinsic compression
    index.

    .. math::
        I_v = \\frac{e - e^*_{100}}{C^*_c}

    Parameters
    ----------
    e : float
        Current void ratio (dimensionless).
    e_star_100 : float
        Intrinsic void ratio at 100 kPa (dimensionless).
    c_star_c : float
        Intrinsic compression index (dimensionless).  Must be > 0.

    Returns
    -------
    float
        Void index, Iv (dimensionless).

    Raises
    ------
    ValueError
        If *c_star_c* is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-28, p. 475.
    """
    if c_star_c <= 0.0:
        raise ValueError("c_star_c must be positive.")
    return (e - e_star_100) / c_star_c


def intrinsic_void_ratio_at_100kpa(e_l: float) -> float:
    """Intrinsic void ratio at 100 kPa from void ratio at liquid limit (Equation 8-29).

    Burland (1990) correlation to estimate the intrinsic void ratio at
    100 kPa from the void ratio at the liquid limit (eL).

    .. math::
        e^*_{100} = 0.109 + 0.679 \\cdot e_L - 0.089 \\cdot e_L^2
                    + 0.016 \\cdot e_L^3

    Parameters
    ----------
    e_l : float
        Void ratio at water content equal to the liquid limit
        (dimensionless).

    Returns
    -------
    float
        Intrinsic void ratio at 100 kPa, e*100 (dimensionless).

    Raises
    ------
    ValueError
        If *e_l* is negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-29, p. 475.
    """
    if e_l < 0.0:
        raise ValueError("e_l must be non-negative.")
    return 0.109 + 0.679 * e_l - 0.089 * e_l ** 2 + 0.016 * e_l ** 3


def intrinsic_compression_index_from_el(e_l: float) -> float:
    """Intrinsic compression index from void ratio at liquid limit (Equation 8-30).

    Burland (1990) correlation to estimate the intrinsic compression
    index from the void ratio at the liquid limit (eL).

    .. math::
        C^*_c = 0.256 \\cdot e_L - 0.04

    Parameters
    ----------
    e_l : float
        Void ratio at water content equal to the liquid limit
        (dimensionless).

    Returns
    -------
    float
        Intrinsic compression index, C*c (dimensionless).

    Raises
    ------
    ValueError
        If *e_l* is negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-30, p. 475.
    """
    if e_l < 0.0:
        raise ValueError("e_l must be non-negative.")
    return 0.256 * e_l - 0.04


# ============================================================================
# SECTION 8-4.3: CONSTRAINED MODULUS
# ============================================================================


def constrained_modulus_linear(m: float, sigma_v_eff: float) -> float:
    """Constrained modulus -- linear form (Equation 8-31).

    Janbu (1963) linear relationship between the secant drained
    constrained modulus and the vertical effective stress for normally
    consolidated clays, silts, and sands.

    .. math::
        M_{ds} = m \\cdot \\sigma'_v

    Parameters
    ----------
    m : float
        Modulus number (dimensionless).
    sigma_v_eff : float
        Vertical effective stress (stress units, e.g., kPa).

    Returns
    -------
    float
        Constrained modulus, Mds (same stress units as *sigma_v_eff*).

    Raises
    ------
    ValueError
        If *m* is negative or *sigma_v_eff* is negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-31, p. 478.
    """
    if m < 0.0:
        raise ValueError("m must be non-negative.")
    if sigma_v_eff < 0.0:
        raise ValueError("sigma_v_eff must be non-negative.")
    return m * sigma_v_eff


def constrained_modulus_nonlinear(
    m: float, sigma_v_eff: float, pa: float
) -> float:
    """Constrained modulus -- nonlinear form (Equation 8-32).

    Janbu (1963) nonlinear (square-root) relationship between the
    secant drained constrained modulus and the vertical effective stress
    for normally consolidated clays, silts, and sands.

    .. math::
        M_{ds} = m \\cdot P_a \\cdot
        \\left(\\frac{\\sigma'_v}{P_a}\\right)^{0.5}

    Parameters
    ----------
    m : float
        Modulus number (dimensionless).
    sigma_v_eff : float
        Vertical effective stress (stress units).
    pa : float
        Atmospheric pressure (same stress units as *sigma_v_eff*).

    Returns
    -------
    float
        Constrained modulus, Mds (same stress units as *sigma_v_eff*).

    Raises
    ------
    ValueError
        If *m* is negative, *sigma_v_eff* is negative, or *pa* is not
        positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-32, p. 478.
    """
    if m < 0.0:
        raise ValueError("m must be non-negative.")
    if sigma_v_eff < 0.0:
        raise ValueError("sigma_v_eff must be non-negative.")
    if pa <= 0.0:
        raise ValueError("pa must be positive.")
    return m * pa * (sigma_v_eff / pa) ** 0.5


def constrained_modulus_spt(f: float, n: float, pa: float) -> float:
    """Constrained modulus from SPT using Stroud (Equation 8-33).

    Stroud (1974) correlation for the constrained modulus of clays from
    SPT N value.  The empirical coefficient *f* depends on the plasticity
    index (see Figure 8-46 of the UFC).  According to Kulhawy and Mayne
    (1990), this correlation is not very reliable.

    .. math::
        M_{ds} = f \\cdot N \\cdot P_a

    Parameters
    ----------
    f : float
        Empirical coefficient related to plasticity index (dimensionless,
        from Figure 8-46).
    n : float
        SPT blow count (blows/ft).
    pa : float
        Atmospheric pressure (stress units, e.g., kPa).

    Returns
    -------
    float
        Constrained modulus, Mds (same stress units as *pa*).

    Raises
    ------
    ValueError
        If *f* is negative, *n* is negative, or *pa* is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-33, p. 478.
    """
    if f < 0.0:
        raise ValueError("f must be non-negative.")
    if n < 0.0:
        raise ValueError("n must be non-negative.")
    if pa <= 0.0:
        raise ValueError("pa must be positive.")
    return f * n * pa


def constrained_modulus_cpt(alpha: float, qc: float) -> float:
    """Constrained modulus from CPT tip resistance (Equation 8-34).

    General correlation relating the constrained modulus to the CPT tip
    resistance via an empirical coefficient alpha, as compiled by
    Mitchell and Gardner (1975).  Alpha can range from 0.4 to 8, but is
    most commonly between 1 and 3.

    .. math::
        M_{ds} = \\alpha \\cdot q_c

    Parameters
    ----------
    alpha : float
        Empirical coefficient (dimensionless).  Typically 1 to 3.
    qc : float
        Cone tip resistance (stress units).

    Returns
    -------
    float
        Constrained modulus, Mds (same stress units as *qc*).

    Raises
    ------
    ValueError
        If *alpha* is negative or *qc* is negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-34, p. 481.
    """
    if alpha < 0.0:
        raise ValueError("alpha must be non-negative.")
    if qc < 0.0:
        raise ValueError("qc must be non-negative.")
    return alpha * qc


# ============================================================================
# SECTION 8-4.4: COEFFICIENT OF SECONDARY COMPRESSION
# ============================================================================


def secondary_compression_ratio(wn: float) -> float:
    """Modified secondary compression index from natural water content (Equation 8-35).

    Mesri (1973) correlation to estimate the modified coefficient of
    secondary compression (Cepsilon-alpha) for normally consolidated
    clays based on the natural water content.

    .. math::
        C_{\\varepsilon\\alpha} = 0.0001 \\cdot w_n

    Parameters
    ----------
    wn : float
        Natural water content (%, e.g., 40 for w = 40%).

    Returns
    -------
    float
        Modified secondary compression ratio, C_epsilon_alpha
        (dimensionless strain / log cycle of time).

    Raises
    ------
    ValueError
        If *wn* is negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-35, p. 482.
    """
    if wn < 0.0:
        raise ValueError("wn must be non-negative.")
    return 0.0001 * wn


# ============================================================================
# SECTION 8-5: ELASTIC MODULUS
# ============================================================================


def drained_youngs_modulus_from_undrained(
    eu: float, nu: float
) -> float:
    """Drained Young's modulus from undrained Young's modulus (Equation 8-36).

    Converts the undrained Young's modulus (obtained from field testing
    of fine-grained soils) to the drained Young's modulus using the
    drained Poisson's ratio.  Assumes an undrained Poisson's ratio of
    0.5.

    .. math::
        E = \\frac{2(1+\\nu)}{3} \\cdot E_u

    Parameters
    ----------
    eu : float
        Young's modulus for undrained conditions (stress units, e.g.,
        kPa, MPa).
    nu : float
        Poisson's ratio for drained conditions (dimensionless).
        Must be in the range (-1, 0.5).

    Returns
    -------
    float
        Young's modulus for drained conditions, E (same stress units
        as *eu*).

    Raises
    ------
    ValueError
        If *eu* is negative or *nu* is outside (-1, 0.5).

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-36, p. 485.
    """
    if eu < 0.0:
        raise ValueError("eu must be non-negative.")
    if nu <= -1.0 or nu >= 0.5:
        raise ValueError("nu must be in the range (-1, 0.5).")
    return 2.0 * (1.0 + nu) / 3.0 * eu


# ============================================================================
# SECTION 8-6: CALIFORNIA BEARING RATIO (CBR)
# ============================================================================


def cbr_from_dcp_power(a_coeff: float, dcp: float, x: float) -> float:
    """CBR from dynamic cone penetration index -- power form (Equation 8-37).

    General power-law correlation used by many researchers to estimate
    the California Bearing Ratio from DCP index.  Values of *a_coeff*
    and *x* from various sources are tabulated in Table 8-24.

    .. math::
        CBR = A \\cdot DCP^x

    Parameters
    ----------
    a_coeff : float
        Empirical coefficient, A (dimensionless).
    dcp : float
        Dynamic cone penetration index (mm/blow).  Must be > 0.
    x : float
        Empirical exponent (dimensionless, typically negative).

    Returns
    -------
    float
        California Bearing Ratio, CBR (%).

    Raises
    ------
    ValueError
        If *a_coeff* is negative or *dcp* is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-37, Table 8-24, p. 492.
    """
    if a_coeff < 0.0:
        raise ValueError("a_coeff must be non-negative.")
    if dcp <= 0.0:
        raise ValueError("dcp must be positive.")
    return a_coeff * dcp ** x


def cbr_from_dcp_nazzal(dcp: float) -> float:
    """CBR from DCP using Nazzal correlation (Equation 8-38).

    Nazzal (2003) correlation for soils with DCP values between 6.3
    and 67 mm/blow.

    .. math::
        CBR = \\frac{2559}{DCP^{1.84}} + \\frac{1}{7.35} - 1

    Note: Reading the PDF carefully, the equation is:
    CBR = 2559 / (DCP^1.84 + 7.35) - 1
    Actually, re-reading: "CBR = 2559 / (DCP^1.84) + 1/7.35 - 1" ...
    The extracted text reads: "2559 / (1 + 7.35)^1.84 ..." No. Let me
    re-read the raw text:

    1.84
    2559
    1
    7.35
    CBR
    DCP
    =
    +
    -

    This is: CBR = 2559 / (DCP^1.84 + 7.35) - 1 ... No. Looking at the
    layout more carefully: the fraction has 2559 on top and DCP^1.84 on
    the bottom, then + 1/7.35 ... Actually from layout analysis:

    CBR = 2559 / DCP^1.84 + 1/7.35

    But that yields large CBR values. Checking Nazzal (2003), the
    published form is: CBR = 1 / (0.002871 * DCP) ^ 2
    or more commonly: log(CBR) = ... Let me use what the PDF says
    most literally. The stacked fractions suggest:

    CBR = 2559 / (DCP^1.84) - 1 + 1/7.35

    The most standard Nazzal (2003) form found in literature is:
    CBR = (292 / DCP)^1.12
    But the UFC presents a different specific form. Reading the stacked
    notation carefully: 2559/(DCP^1.84 + 7.35) - 1

    After careful analysis of the PDF layout:

    .. math::
        CBR = \\frac{2559}{DCP^{1.84} + 7.35} - 1

    Parameters
    ----------
    dcp : float
        Dynamic cone penetration index (mm/blow).  Valid range is
        6.3 to 67 mm/blow.

    Returns
    -------
    float
        California Bearing Ratio, CBR (%).

    Raises
    ------
    ValueError
        If *dcp* is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-38, p. 492.
    """
    if dcp <= 0.0:
        raise ValueError("dcp must be positive.")
    return 2559.0 / (dcp ** 1.84 + 7.35) - 1.0


def cbr_from_spt(n: float) -> float:
    """CBR from SPT N value using Livneh (Equation 8-39).

    Livneh (1989) correlation to estimate the California Bearing Ratio
    from SPT blow count.

    .. math::
        \\log(CBR) = -5.13 + 6.55 \\cdot \\log
        \\left(\\frac{300}{N^{0.26}}\\right)

    Rearranged:

    .. math::
        CBR = 10^{\\left[-5.13 + 6.55 \\cdot
        \\log_{10}\\!\\left(\\frac{300}{N^{0.26}}\\right)\\right]}

    Parameters
    ----------
    n : float
        SPT blow count (blows/ft).  Use N60 for modern hammers.
        Must be > 0.

    Returns
    -------
    float
        California Bearing Ratio, CBR (%).

    Raises
    ------
    ValueError
        If *n* is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-39, p. 493.
    """
    if n <= 0.0:
        raise ValueError("n must be positive.")
    log_cbr = -5.13 + 6.55 * math.log10(300.0 / n ** 0.26)
    return 10.0 ** log_cbr


# ============================================================================
# SECTION 8-7: HYDRAULIC CONDUCTIVITY
# ============================================================================


def hydraulic_conductivity_hazen(d10: float, c: float = 1.0) -> float:
    """Hydraulic conductivity from Hazen equation (Equation 8-40).

    Hazen (1911) correlation for saturated clean sands with fines
    content less than 5% and D10 values from 0.1 to 3 mm.

    .. math::
        k = C \\cdot D_{10}^2

    Parameters
    ----------
    d10 : float
        Grain size corresponding to 10% passing (mm).  Valid range
        is 0.1 to 3 mm.
    c : float, optional
        Empirical coefficient, usually taken to be 1.0 (cm/s/mm^2).
        Default is 1.0.

    Returns
    -------
    float
        Hydraulic conductivity, k (cm/s).

    Raises
    ------
    ValueError
        If *d10* is not positive or *c* is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-40, p. 494.
    """
    if d10 <= 0.0:
        raise ValueError("d10 must be positive.")
    if c <= 0.0:
        raise ValueError("c must be positive.")
    return c * d10 ** 2


def hydraulic_conductivity_kozeny_carman(
    fractions: List[Tuple[float, float, float]],
    e: float,
    s: float = 6.0,
) -> float:
    """Hydraulic conductivity from modified Kozeny-Carman equation (Equation 8-41).

    Carrier (2003) modified version of the Kozeny-Carman equation
    (Kozeny 1927; Carman 1938, 1956) using the full grain-size
    distribution.

    .. math::
        k = 1.99 \\times 10^4 \\cdot
        \\frac{1}{S^2} \\cdot
        \\frac{e^3}{1+e} \\cdot
        \\left[\\sum \\frac{f_i}{0.404 \\cdot D_{li} + 0.596 \\cdot D_{si}}
        \\right]^{-2} \\cdot
        \\frac{\\mathrm{cm}}{\\mathrm{s}} \\cdot \\frac{100\\%}{\\mathrm{mm}^2}

    The result is in cm/s when D is in mm.

    Parameters
    ----------
    fractions : list of (fi, d_li, d_si) tuples
        Each tuple contains:
        - fi : float -- fraction of particles (by mass) between two
          adjacent sieve sizes (as a decimal, e.g., 0.15 for 15%).
        - d_li : float -- particle size of the coarser sieve (mm).
        - d_si : float -- particle size of the finer sieve (mm).
    e : float
        Void ratio (dimensionless).
    s : float, optional
        Surface area factor (dimensionless).  6.0 for spheres, up to
        8.5 for angular particles.  Default is 6.0.

    Returns
    -------
    float
        Hydraulic conductivity, k (cm/s).

    Raises
    ------
    ValueError
        If *e* is negative, *s* is not positive, or any sieve size is
        not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-41, p. 498.
    """
    if e < 0.0:
        raise ValueError("e must be non-negative.")
    if s <= 0.0:
        raise ValueError("s must be positive.")

    summation = 0.0
    for fi, d_li, d_si in fractions:
        if d_li <= 0.0 or d_si <= 0.0:
            raise ValueError(
                "All sieve sizes (d_li, d_si) must be positive."
            )
        avg_d = 0.404 * d_li + 0.596 * d_si
        summation += fi / avg_d

    if summation == 0.0:
        raise ValueError("Sum of fi/D_avg is zero; check input fractions.")

    k = 1.99e4 * (1.0 / s ** 2) * (e ** 3 / (1.0 + e)) * (1.0 / summation ** 2)
    return k


def hydraulic_conductivity_carrier_beckman(
    e: float, pl: float, pi: float
) -> float:
    """Hydraulic conductivity of fine-grained soils from index properties (Equation 8-42).

    Carrier and Beckman (1984) correlation relating the hydraulic
    conductivity of fine-grained soils to the void ratio, plastic limit,
    and plasticity index.

    .. math::
        k = 0.0174 \\cdot
        \\frac{[e - 0.027 \\cdot (PL - 0.242 \\cdot PI)]^{4.29}}
        {(1+e) \\cdot PI}

    Parameters
    ----------
    e : float
        Void ratio (dimensionless).
    pl : float
        Plastic limit (%).
    pi : float
        Plasticity index (%).  Must be > 0.

    Returns
    -------
    float
        Hydraulic conductivity, k (m/s).

    Raises
    ------
    ValueError
        If *e* is negative or *pi* is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-42, p. 498.
    """
    if e < 0.0:
        raise ValueError("e must be non-negative.")
    if pi <= 0.0:
        raise ValueError("pi must be positive.")
    numerator = (e - 0.027 * (pl - 0.242 * pi)) ** 4.29
    denominator = (1.0 + e) * pi
    return 0.0174 * numerator / denominator


def hydraulic_conductivity_benson_landfill(
    cf: float,
    gc: float,
    w_compactor: float,
    pi: float,
    w: float,
    gamma_w: float,
    gamma_d: float,
    gs: float,
) -> float:
    """Hydraulic conductivity of compacted clay liners (Equation 8-43).

    Benson et al. (1994) correlation based on intact test specimens from
    compacted clay liners at 67 landfills in North America.

    .. math::
        \\ln(k) = -18.35 + 0.08 \\cdot PI + 2.87 \\cdot GC
                  - 0.32 \\cdot CF - 0.02 \\cdot W
                  + \\frac{894}{1 + \\frac{w \\cdot \\gamma_w}{\\gamma_d \\cdot G_s}
                  \\cdot ... }

    Re-reading the equation from the PDF more carefully:

    .. math::
        \\ln(k) = -18.35 + 0.08 \\cdot PI + 2.87 \\cdot GC
                  - 0.32 \\cdot CF - 0.02 \\cdot W
                  + \\frac{894}{1 + \\frac{w \\cdot \\gamma_w}
                  {\\gamma_d} \\cdot G_s}

    Actually, the PDF text layout is:
    ln(k) = -18.35 + 0.08*PI + 2.87*GC - 0.32*CF - 0.02*W + 894/(1 + w*gamma_w/(gamma_d*Gs))

    Wait -- looking again, the fraction at the end with gamma_w/gamma_d/Gs
    is computing the degree of saturation: S = w*Gs*gamma_w/gamma_d...
    Actually the standard form for degree of saturation is S = w*Gs/e
    where e = Gs*gamma_w/gamma_d - 1. The fraction structure in the PDF
    is: 894 / (1 + (w*gamma_w)/(gamma_d) * Gs) which is not standard.

    Re-reading the stacked text from the PDF extraction:
    894
    ln
    18.35
    0.08
    2.87
    0.32
    0.02
    1
    w
    d
    s
    w
    k
    PI
    GC
    CF
    W
    G
    gamma
    gamma

    The correct form from the PDF layout (fraction under 894) is:
    894 * [w * gamma_w / (gamma_d)] * Gs ... no that doesn't work either.

    Based on the Benson et al. (1994) paper, the actual equation is:
    ln(k) = -18.35 + 0.08*PI + 2.87*GC - 0.32*CF - 0.02*W + 894*S
    where S = degree of saturation = w * Gs / e, and
    e = Gs * gamma_w / gamma_d - 1.

    However, the PDF text renders as:
    894 / [1 + w*gamma_w/(gamma_d*Gs)]

    After reconciling, the most faithful reading of the PDF is:
    ln(k) = -18.35 + 0.08*PI + 2.87*GC - 0.32*CF - 0.02*W
            + 894 / [1 / (w * gamma_w / (gamma_d * Gs))]

    Actually, simplifying the stacked fraction in the PDF, the last
    term is: 894 * w * gamma_w / (gamma_d * Gs * (1 + ...)) ...

    Given the ambiguity, I will implement the form most consistent with
    the Benson et al. (1994) original paper, where the degree of
    saturation S = w * Gs * gamma_w / gamma_d is the key variable:

    Actually the standard soil mechanics relation is:
    S * e = w * Gs, and e = Gs * gamma_w / gamma_d - 1

    So: S = w * Gs / e = w * Gs / (Gs * gamma_w / gamma_d - 1)

    The PDF layout suggests the final term is 894 * S where
    S is expressed as w/(1/Gs - gamma_d/(gamma_w*Gs)) ... Looking
    at the equation structure one more time, it appears to be:

    ln(k) = -18.35 + 0.08*PI + 2.87*GC - 0.32*CF - 0.02*W
            + 894 / (something involving w, gamma_w, gamma_d, Gs)

    I will implement this using the standard Benson et al. (1994) form.

    Parameters
    ----------
    cf : float
        Clay-sized fraction (% by mass smaller than 0.002 mm).
    gc : float
        Gravel content (% retained between 75 mm and No. 4 sieve).
    w_compactor : float
        Weight of field compactor (kN).
    pi : float
        Plasticity index (%).
    w : float
        Molding water content (decimal, e.g., 0.20 for 20%).
    gamma_w : float
        Unit weight of water (kN/m^3, e.g., 9.81).
    gamma_d : float
        Dry unit weight (kN/m^3).
    gs : float
        Specific gravity of the solids (dimensionless).

    Returns
    -------
    float
        Hydraulic conductivity, k (m/s).

    Raises
    ------
    ValueError
        If *gamma_d* is not positive, *gamma_w* is not positive, or
        *gs* is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-43, p. 499.
    Benson et al. (1994).
    """
    if gamma_d <= 0.0:
        raise ValueError("gamma_d must be positive.")
    if gamma_w <= 0.0:
        raise ValueError("gamma_w must be positive.")
    if gs <= 0.0:
        raise ValueError("gs must be positive.")

    # Degree of saturation
    e = gs * gamma_w / gamma_d - 1.0
    if e <= 0.0:
        raise ValueError(
            "Computed void ratio is non-positive; check gamma_d, gamma_w, gs."
        )
    s_degree = w * gs / e

    ln_k = (-18.35 + 0.08 * pi + 2.87 * gc - 0.32 * cf
            - 0.02 * w_compactor + 894.0 * s_degree)
    return math.exp(ln_k)


def hydraulic_conductivity_benson_trast(
    pi: float,
    cf: float,
    e_effort: float,
    w: float,
    gamma_w: float,
    gamma_d: float,
    gs: float,
) -> float:
    """Hydraulic conductivity of compacted clays from Benson and Trast (Equation 8-44).

    Benson and Trast (1995) correlation from hydraulic conductivity tests
    on 13 compacted clays used for compacted clay liners.

    .. math::
        \\ln(k) = -15 - 0.087 \\cdot PI - 0.054 \\cdot CF
                  + 0.022 \\cdot E + 0.91 \\cdot S

    where S = degree of saturation computed from the molding water
    content, dry unit weight, specific gravity, and unit weight of water.
    The term involving gamma_w, gamma_d, w, and Gs in the PDF computes
    the degree of saturation.

    Parameters
    ----------
    pi : float
        Plasticity index (%).
    cf : float
        Clay-sized fraction (% by mass smaller than 0.002 mm).
    e_effort : float
        Compactive effort index: -1 for modified Proctor, 0 for standard
        Proctor, 1 for reduced Proctor.
    w : float
        Molding water content (decimal, e.g., 0.20 for 20%).
    gamma_w : float
        Unit weight of water (kN/m^3, e.g., 9.81).
    gamma_d : float
        Dry unit weight (kN/m^3).
    gs : float
        Specific gravity of the solids (dimensionless).

    Returns
    -------
    float
        Hydraulic conductivity, k (m/s).

    Raises
    ------
    ValueError
        If *gamma_d* is not positive, *gamma_w* is not positive, or
        *gs* is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-44, p. 499.
    Benson and Trast (1995).
    """
    if gamma_d <= 0.0:
        raise ValueError("gamma_d must be positive.")
    if gamma_w <= 0.0:
        raise ValueError("gamma_w must be positive.")
    if gs <= 0.0:
        raise ValueError("gs must be positive.")

    # Degree of saturation
    e = gs * gamma_w / gamma_d - 1.0
    if e <= 0.0:
        raise ValueError(
            "Computed void ratio is non-positive; check gamma_d, gamma_w, gs."
        )
    s_degree = w * gs / e

    ln_k = (-15.0 - 0.087 * pi - 0.054 * cf
            + 0.022 * e_effort + 0.91 * s_degree)
    return math.exp(ln_k)


# ============================================================================
# SECTION 8-8: SHEAR WAVE VELOCITY
# ============================================================================


def shear_wave_velocity_spt(
    b: float, n: float, x: float, z: float = 1.0, y: float = 0.0
) -> float:
    """Shear wave velocity from SPT N value (Equation 8-45).

    General correlation form used by many researchers to estimate the
    shear wave velocity from SPT N values.  Specific coefficients are
    tabulated in Tables 8-26 and 8-27.

    .. math::
        V_s = B \\cdot N^x \\cdot z^y

    Parameters
    ----------
    b : float
        Empirical coefficient, B.
    n : float
        SPT blow count (blows/ft).  Use N60 for modern hammers.
    x : float
        Empirical exponent on N.
    z : float, optional
        Depth to the soil layer (m).  Default is 1.0 (no depth effect).
    y : float, optional
        Empirical exponent on depth (dimensionless).  Default is 0.0
        (no depth effect).

    Returns
    -------
    float
        Shear wave velocity, Vs (m/s).

    Raises
    ------
    ValueError
        If *b* is negative, *n* is negative, or *z* is not positive.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Equation 8-45, Tables 8-26 and 8-27, p. 500.
    """
    if b < 0.0:
        raise ValueError("b must be non-negative.")
    if n < 0.0:
        raise ValueError("n must be non-negative.")
    if z <= 0.0:
        raise ValueError("z must be positive.")
    return b * n ** x * z ** y


# ===========================================================================
# TABLE 8-6: Stark & Hussain (2013) Coefficients
# ===========================================================================

_TABLE_8_6_STARK_HUSSAIN = {
    ("lt_20", "50_kpa"):  {"c0": 35.2, "c1": -0.1440, "c2": 0.000564, "c3": -0.00000116},
    ("lt_20", "100_kpa"): {"c0": 33.4, "c1": -0.1330, "c2": 0.000495, "c3": -0.00000092},
    ("lt_20", "400_kpa"): {"c0": 35.3, "c1": -0.1460, "c2": 0.000567, "c3": -0.00000115},
    ("20_to_50", "50_kpa"):  {"c0": 42.8, "c1": -0.2980, "c2": 0.001390, "c3": -0.00000251},
    ("20_to_50", "100_kpa"): {"c0": 38.5, "c1": -0.2220, "c2": 0.000880, "c3": -0.00000137},
    ("20_to_50", "400_kpa"): {"c0": 38.8, "c1": -0.2330, "c2": 0.001010, "c3": -0.00000168},
    ("gt_50", "50_kpa"):  {"c0": 53.5, "c1": -0.6100, "c2": 0.004060, "c3": -0.00000925},
    ("gt_50", "100_kpa"): {"c0": 49.2, "c1": -0.4820, "c2": 0.002870, "c3": -0.00000600},
    ("gt_50", "400_kpa"): {"c0": 44.1, "c1": -0.3400, "c2": 0.001780, "c3": -0.00000345},
}


def table_8_6_stark_hussain(cf_range: str, sigma_n_range: str) -> dict:
    """Stark & Hussain (2013) polynomial coefficients for residual
    friction angle (Table 8-6).

    Returns the four coefficients (C0, C1, C2, C3) for use with
    ``residual_friction_angle_stark_hussain`` (Equation 8-17).

    Parameters
    ----------
    cf_range : str
        Clay fraction range: ``"lt_20"`` (< 20 %), ``"20_to_50"``
        (20-50 %), or ``"gt_50"`` (> 50 %).
    sigma_n_range : str
        Effective normal stress level: ``"50_kPa"``, ``"100_kPa"``,
        or ``"400_kPa"``.

    Returns
    -------
    dict
        ``{"c0": float, "c1": float, "c2": float, "c3": float}``.

    Raises
    ------
    ValueError
        If the combination is not recognised.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Table 8-6, p. 453.
    Stark & Hussain (2013).
    """
    key = (cf_range.lower().strip(), sigma_n_range.lower().strip())
    if key not in _TABLE_8_6_STARK_HUSSAIN:
        raise ValueError(
            f"Unknown combination cf_range='{cf_range}', "
            f"sigma_n_range='{sigma_n_range}'."
        )
    return dict(_TABLE_8_6_STARK_HUSSAIN[key])


# ===========================================================================
# TABLE 8-9: SHANSEP Exponent m
# ===========================================================================

_TABLE_8_9_SHANSEP_M = {
    "ciuc": 0.85,
    "ck0uc": 0.80,
    "dss": 0.80,
    "ck0ue": 0.70,
    "field_vane": 0.85,
}


def table_8_9_shansep_m(test_type: str) -> float:
    """SHANSEP exponent m for undrained strength ratio (Table 8-9).

    Returns the semi-empirical exponent *m* for use with
    ``undrained_strength_ratio_oc`` (Equation 8-20).

    Parameters
    ----------
    test_type : str
        One of ``"CIUC"``, ``"CK0UC"``, ``"DSS"``, ``"CK0UE"``,
        ``"field_vane"``.

    Returns
    -------
    float
        Exponent m (dimensionless).

    Raises
    ------
    ValueError
        If *test_type* is not recognised.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Table 8-9, p. 460.
    """
    key = test_type.lower().strip()
    if key not in _TABLE_8_9_SHANSEP_M:
        raise ValueError(
            f"Unknown test_type '{test_type}'. "
            f"Choose from: {list(_TABLE_8_9_SHANSEP_M.keys())}"
        )
    return _TABLE_8_9_SHANSEP_M[key]


# ===========================================================================
# TABLE 8-24: CBR-DCP Correlation Coefficients
# ===========================================================================

_TABLE_8_24_CBR_DCP = {
    "webster_1992":  {"a": 292.0, "x": -1.12},
    "ese_2006":      {"a": 17.6, "x": -0.64},
    "livneh_2000":   {"a": 327.0, "x": -0.78},
    "harrison_1987": {"a": 1620.0, "x": -2.10},
    "chua_1988":     {"a": 3370.0, "x": -1.51},
    "pen_2002":      {"a": 146.0, "x": -0.70},
}


def table_8_24_cbr_dcp(source: str) -> dict:
    """CBR-DCP power-law correlation coefficients (Table 8-24).

    Returns the empirical coefficient *a* and exponent *x* for use
    with ``cbr_from_dcp_power`` (Equation 8-37).

    Parameters
    ----------
    source : str
        Correlation source name: ``"webster_1992"``, ``"ese_2006"``,
        ``"livneh_2000"``, ``"harrison_1987"``, ``"chua_1988"``,
        or ``"pen_2002"``.

    Returns
    -------
    dict
        ``{"a": float, "x": float}``.

    Raises
    ------
    ValueError
        If *source* is not recognised.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Table 8-24, p. 492.
    """
    key = source.lower().strip()
    if key not in _TABLE_8_24_CBR_DCP:
        raise ValueError(
            f"Unknown source '{source}'. "
            f"Choose from: {list(_TABLE_8_24_CBR_DCP.keys())}"
        )
    return dict(_TABLE_8_24_CBR_DCP[key])


# ===========================================================================
# FIGURE 8-46: Stroud (1974) f-coefficient for Constrained Modulus
# ===========================================================================

# Digitised from Figure 8-46 (f vs PI)
_FIG_8_46_PI = [10.0, 15.0, 20.0, 25.0, 30.0, 40.0, 50.0, 60.0, 80.0]
_FIG_8_46_F  = [500.0, 450.0, 400.0, 350.0, 300.0, 250.0, 225.0, 200.0, 175.0]


def figure_8_46_f(pi: float) -> float:
    """Stroud (1974) f-coefficient from plasticity index (Figure 8-46).

    Returns the empirical coefficient *f* for use with
    ``constrained_modulus_spt`` (Equation 8-30).

    Parameters
    ----------
    pi : float
        Plasticity index (%).  Typically 10 to 80.

    Returns
    -------
    float
        Coefficient f (dimensionless).

    Raises
    ------
    ValueError
        If *pi* is negative.

    References
    ----------
    UFC 3-220-10, Soil Mechanics, 1 Feb 2022, Change 1, 11 Mar 2025,
    Chapter 8, Figure 8-46, p. 477.
    Stroud, M.A. (1974).
    """
    if pi < 0.0:
        raise ValueError("pi must be non-negative.")
    return _linterp(pi, _FIG_8_46_PI, _FIG_8_46_F)


def plot_figure_8_46(PI=None, ax=None, show=True, **kwargs):
    """Reproduce UFC Figure 8-46: Stroud f-coefficient vs Plasticity Index.

    Parameters
    ----------
    PI : float, optional
        Plasticity index query point.  If provided, highlights the
        interpolated f value.
    ax : matplotlib.axes.Axes, optional
        Axes to plot on.
    show : bool, optional
        If True, calls plt.show().

    Returns
    -------
    matplotlib.axes.Axes
    """
    from geotech_references._plotting import get_pyplot, setup_engineering_plot
    plt = get_pyplot()
    if ax is None:
        fig, ax = plt.subplots(figsize=(8, 5))

    # Smooth interpolated curve
    pi_smooth = [i * 1.0 for i in range(5, 86)]
    f_smooth = [figure_8_46_f(p) for p in pi_smooth]
    ax.plot(pi_smooth, f_smooth, 'b-', linewidth=1.5, label='Stroud (1974)')

    # Digitised data points
    ax.plot(_FIG_8_46_PI, _FIG_8_46_F, 'ko', markersize=6,
            label='Digitised points')

    # Query point
    if PI is not None:
        f_q = figure_8_46_f(PI)
        ax.plot(PI, f_q, 's', color='red', markersize=10, zorder=5,
                label=f'PI={PI:.0f} → f={f_q:.0f}')
        ax.axhline(f_q, color='red', linestyle=':', alpha=0.4)
        ax.axvline(PI, color='red', linestyle=':', alpha=0.4)

    ax.legend(fontsize=8)
    setup_engineering_plot(
        ax, 'Figure 8-46: Stroud f-Coefficient',
        'Plasticity Index, PI (%)', 'f (dimensionless)')
    if show:
        plt.tight_layout()
        plt.show()
    return ax
