Skip to content

ge.lab — API reference

Particle size

sieve_ana

Python
sieve_ana(opening: List[Union[str, float]], mass_retained: List[float], standard: str = 'ASTM', total_mass: Optional[float] = None) -> Dict[str, np.ndarray]

Perform sieve analysis and return percent finer.

Args: opening: List of sieve designations (e.g., "#4") or nominal openings (mm). mass_retained: Mass of soil retained on each sieve (g). standard: 'ASTM', 'BS', or 'IS'. total_mass: If None, calculated as sum(mass_retained).

Returns: Dict containing arrays for opening, mass_retained, percent_retained, cumulative_retained, and percent_finer.

Source code in geoeq/soil/sieve.py
Python
def sieve_ana(
    opening: List[Union[str, float]], 
    mass_retained: List[float], 
    standard: str = "ASTM",
    total_mass: Optional[float] = None
) -> Dict[str, np.ndarray]:
    """
    Perform sieve analysis and return percent finer.

    Args:
        opening: List of sieve designations (e.g., "#4") or nominal openings (mm).
        mass_retained: Mass of soil retained on each sieve (g).
        standard: 'ASTM', 'BS', or 'IS'.
        total_mass: If None, calculated as sum(mass_retained).

    Returns:
        Dict containing arrays for opening, mass_retained, percent_retained, 
        cumulative_retained, and percent_finer.
    """
    if len(opening) != len(mass_retained):
        raise ValueError("Opening and mass_retained must have the same length.")

    # Convert designations to mm
    mm_openings = np.array([get_opening(o, standard) for o in opening])
    m_ret = np.array(mass_retained)

    # Sort by opening (descending)
    idx = np.argsort(mm_openings)[::-1]
    mm_openings = mm_openings[idx]
    m_ret = m_ret[idx]

    if total_mass is None:
        total_mass = np.sum(m_ret)

    check_positive(total_mass, "total_mass")

    pct_ret = (m_ret / total_mass) * 100.0
    cum_ret = np.cumsum(pct_ret)
    pct_finer = 100.0 - cum_ret

    # Ensure no negative values (numerical precision)
    pct_finer = np.maximum(pct_finer, 0.0)

    return {
        "diameter": mm_openings,
        "opening": mm_openings,
        "mass_retained": m_ret,
        "percent_retained": pct_ret,
        "cumulative_retained": cum_ret,
        "percent_finer": pct_finer,
        "total_mass": total_mass
    }

hydro_ana

Python
hydro_ana(reading: List[float], time: List[float], T: Union[float, List[float]], Gs: float = 2.65, Ws: float = 50.0, Cz: float = 0.0, model: str = '152H', units: str = 'SI') -> Dict[str, np.ndarray]

Perform hydrometer analysis and return (diameter, percent_finer).

Args: reading: List of raw hydrometer readings (top of meniscus). time: Elapsed time from start (minutes). T: Temperature of suspension (Celsius). Gs: Specific gravity of soil solids. Ws: Initial dry mass of soil used (g). Cz: Zero correction (dispersant correction). model: '152H' is default.

Returns: Dict with 'diameter' (mm) and 'percent_finer' (%) arrays.

Source code in geoeq/soil/hydrometer.py
Python
def hydro_ana(
    reading: List[float], 
    time: List[float], 
    T: Union[float, List[float]], 
    Gs: float = 2.65,
    Ws: float = 50.0,
    Cz: float = 0.0,
    model: str = "152H",
    units: str = "SI"
) -> Dict[str, np.ndarray]:
    """
    Perform hydrometer analysis and return (diameter, percent_finer).

    Args:
        reading: List of raw hydrometer readings (top of meniscus).
        time: Elapsed time from start (minutes).
        T: Temperature of suspension (Celsius).
        Gs: Specific gravity of soil solids.
        Ws: Initial dry mass of soil used (g).
        Cz: Zero correction (dispersant correction).
        model: '152H' is default.

    Returns:
        Dict with 'diameter' (mm) and 'percent_finer' (%) arrays.
    """
    r = np.array(reading)
    t = np.array(time)
    temp = np.array(T) if isinstance(T, list) else np.full_like(r, T)

    if len(r) != len(t):
        raise ValueError("reading and time must have same length.")

    # 1. Corrected reading for Percent Finer (Rc)
    # Rc = R_actual - Cz + CT (temperature correction)
    # CT is approx 0.0 at 20C, and adds ~0.2 per degree C above 20.
    CT = (temp - 20.0) * 0.2 
    Rc = r - Cz + CT

    # 2. Correction factor 'a' for Gs != 2.65
    a = (1.65 / (Gs - 1.0)) * (Gs / 2.65)

    # 3. Percent Finer P (%)
    # P = (Rc * a / Ws) * 100
    P = (Rc * a / Ws) * 100.0

    # 4. Corrected reading for Effective Depth (for Diameter calculation)
    # For diameter, we often use the Meniscus reading directly 
    # but the ASTM says use R corrected for meniscus if needed.
    # L depends on the ACTUAL position of the hydrometer.
    L = np.array([effective_depth(val, model) for val in r])

    # 5. Particle Diameter D (mm)
    # D = K * sqrt(L/t), where K = sqrt(30 * eta / (Gs - Gw) * g)
    # g = 981 cm/s^2, eta = viscosity.
    # A common way is to use tables or formula:
    # K factor depends on Gs and Temperature.
    # Simplified K lookup/calc:
    viscosity = 0.01002 * (10**( (1.3272*(20-temp)-0.001053*(temp-20)**2)/(temp+105) )) # Poise (g/cm-s)
    Gw = 0.9982 # Approx density of water at 20C
    K = np.sqrt((30.0 * viscosity) / ( (Gs - Gw) * 980.7 ))

    D = K * np.sqrt(L / t)

    # Clean results
    P = np.maximum(0.0, np.minimum(100.0, P))

    # Sort by diameter descending
    idx = np.argsort(D)[::-1]

    return {
        "diameter": D[idx],
        "percent_finer": P[idx],
        "L": L[idx],
        "Rc": Rc[idx]
    }

grain_d10

Python
grain_d10(data: Dict[str, ndarray]) -> float

Get D10 (effective size).

Source code in geoeq/soil/grain_size.py
Python
def grain_d10(data: Dict[str, np.ndarray]) -> float:
    """ Get D10 (effective size). """
    return grain_interpolate(data["diameter"], data["percent_finer"], 10.0)

grain_d30

Python
grain_d30(data: Dict[str, ndarray]) -> float

Get D30.

Source code in geoeq/soil/grain_size.py
Python
def grain_d30(data: Dict[str, np.ndarray]) -> float:
    """ Get D30. """
    return grain_interpolate(data["diameter"], data["percent_finer"], 30.0)

grain_d60

Python
grain_d60(data: Dict[str, ndarray]) -> float

Get D60.

Source code in geoeq/soil/grain_size.py
Python
def grain_d60(data: Dict[str, np.ndarray]) -> float:
    """ Get D60. """
    return grain_interpolate(data["diameter"], data["percent_finer"], 60.0)

grain_Cu

Python
grain_Cu(data: Dict[str, ndarray]) -> float

Get Uniformity Coefficient Cu = D60/D10.

Source code in geoeq/soil/grain_size.py
Python
def grain_Cu(data: Dict[str, np.ndarray]) -> float:
    """ Get Uniformity Coefficient Cu = D60/D10. """
    d60 = grain_d60(data)
    d10 = grain_d10(data)
    if d10 > 0 and not np.isnan(d60) and not np.isnan(d10):
        return d60 / d10
    return np.nan

grain_Cc

Python
grain_Cc(data: Dict[str, ndarray]) -> float

Get Coefficient of Curvature Cc = (D30^2)/(D60 * D10).

Source code in geoeq/soil/grain_size.py
Python
def grain_Cc(data: Dict[str, np.ndarray]) -> float:
    """ Get Coefficient of Curvature Cc = (D30^2)/(D60 * D10). """
    d60 = grain_d60(data)
    d30 = grain_d30(data)
    d10 = grain_d10(data)
    if d60 > 0 and d10 > 0 and not np.isnan(d60) and not np.isnan(d30) and not np.isnan(d10):
        return (d30**2) / (d60 * d10)
    return np.nan

Shear strength

direct_shear

Python
direct_shear(normal_stress: Union[List[float], ndarray], shear_stress: Union[List[float], ndarray]) -> Dict[str, float]

Process direct shear test results to obtain shear strength parameters.

Fits the Mohr–Coulomb failure criterion by least-squares linear regression through the (σ', τ_f) data:

.. math::

Text Only
\tau_f = c' + \sigma' \tan\phi'  \qquad \text{[Das Eq.\,8.3]}
PARAMETER DESCRIPTION
normal_stress

Effective normal stress on the failure plane for each specimen (kPa). Minimum 3 values required.

TYPE: array_like

shear_stress

Peak (or residual) shear stress at failure for each specimen (kPa).

TYPE: array_like

RETURNS DESCRIPTION
dict

'c' : float — cohesion intercept (kPa). 'phi' : float — friction angle (degrees). 'r_squared' : float — coefficient of determination.

Examples:

Python Console Session
>>> from geoeq.lab.shear import direct_shear
>>> res = direct_shear([50, 100, 150], [38, 62, 86])
>>> round(res['phi'], 1)
25.6
>>> round(res['c'], 1)
13.3
Source code in geoeq/lab/shear.py
Python
def direct_shear(
    normal_stress: Union[List[float], np.ndarray],
    shear_stress: Union[List[float], np.ndarray],
) -> Dict[str, float]:
    r"""
    Process direct shear test results to obtain shear strength parameters.

    Fits the Mohr–Coulomb failure criterion by least-squares linear
    regression through the (σ', τ_f) data:

    .. math::

        \tau_f = c' + \sigma' \tan\phi'  \qquad \text{[Das Eq.\,8.3]}

    Parameters
    ----------
    normal_stress : array_like
        Effective normal stress on the failure plane for each specimen (kPa).
        Minimum 3 values required.
    shear_stress : array_like
        Peak (or residual) shear stress at failure for each specimen (kPa).

    Returns
    -------
    dict
        ``'c'`` : float — cohesion intercept (kPa).
        ``'phi'`` : float — friction angle (degrees).
        ``'r_squared'`` : float — coefficient of determination.

    Examples
    --------
    >>> from geoeq.lab.shear import direct_shear
    >>> res = direct_shear([50, 100, 150], [38, 62, 86])
    >>> round(res['phi'], 1)
    25.6
    >>> round(res['c'], 1)
    13.3
    """
    sigma = np.asarray(normal_stress, dtype=float)
    tau = np.asarray(shear_stress, dtype=float)

    if len(sigma) < 3:
        raise ValueError("Need at least 3 data points for direct shear test.")
    if len(sigma) != len(tau):
        raise ValueError("normal_stress and shear_stress must have the same length.")
    for s in sigma:
        check_non_negative(s, "normal_stress")
    for t in tau:
        check_non_negative(t, "shear_stress")

    coeffs = np.polyfit(sigma, tau, 1)
    tan_phi = coeffs[0]
    c = coeffs[1]

    phi_deg = float(np.degrees(np.arctan(tan_phi)))

    ss_res = np.sum((tau - np.polyval(coeffs, sigma)) ** 2)
    ss_tot = np.sum((tau - np.mean(tau)) ** 2)
    r_sq = 1.0 - ss_res / ss_tot if ss_tot > 0 else 1.0

    return {
        "c": float(max(c, 0.0)),
        "phi": phi_deg,
        "r_squared": float(r_sq),
    }

triaxial

Python
triaxial(sigma3: Union[List[float], ndarray], delta_sigma: Union[List[float], ndarray], kind: str = 'CD') -> Dict[str, float]

Process triaxial compression test data to obtain shear strength parameters.

From each specimen the major principal stress at failure is:

.. math::

Text Only
\sigma_1 = \sigma_3 + \Delta\sigma_f

A Mohr–Coulomb envelope tangent to the circles yields c and φ.

For UU tests (kind = "UU"), undrained shear strength is returned as :math:S_u = \Delta\sigma_f / 2.

PARAMETER DESCRIPTION
sigma3

Cell (confining) pressure for each specimen (kPa). Minimum 2 for UU, 3 for CD/CU.

TYPE: array_like

delta_sigma

Deviator stress at failure Δσ_f = σ₁ − σ₃ for each specimen (kPa).

TYPE: array_like

kind

Test type: - 'UU' — Unconsolidated-undrained (total stress, φ = 0). - 'CU' — Consolidated-undrained (total stress parameters). - 'CD' — Consolidated-drained (effective stress parameters).

TYPE: (UU, CU, CD) DEFAULT: 'UU'

RETURNS DESCRIPTION
dict

'c' : float — cohesion (kPa). 'phi' : float — friction angle (degrees). 'sigma1' : ndarray — major principal stress at failure (kPa). 'sigma3' : ndarray — confining pressures (kPa). For UU: also 'Su' — average undrained shear strength (kPa).

References

Das (2021), Ch. 8, Sections 8.5–8.9.

Examples:

Python Console Session
>>> from geoeq.lab.shear import triaxial
>>> res = triaxial([100, 200, 300], [220, 380, 540], kind='CD')
>>> round(res['phi'], 1)
26.6
Source code in geoeq/lab/shear.py
Python
def triaxial(
    sigma3: Union[List[float], np.ndarray],
    delta_sigma: Union[List[float], np.ndarray],
    kind: str = "CD",
) -> Dict[str, float]:
    r"""
    Process triaxial compression test data to obtain shear strength parameters.

    From each specimen the major principal stress at failure is:

    .. math::

        \sigma_1 = \sigma_3 + \Delta\sigma_f

    A Mohr–Coulomb envelope tangent to the circles yields *c* and *φ*.

    For UU tests (*kind* = ``"UU"``), undrained shear strength is returned
    as :math:`S_u = \Delta\sigma_f / 2`.

    Parameters
    ----------
    sigma3 : array_like
        Cell (confining) pressure for each specimen (kPa). Minimum 2 for
        UU, 3 for CD/CU.
    delta_sigma : array_like
        Deviator stress at failure Δσ_f = σ₁ − σ₃ for each specimen (kPa).
    kind : {'UU', 'CU', 'CD'}, default ``'CD'``
        Test type:
        - ``'UU'`` — Unconsolidated-undrained (total stress, φ = 0).
        - ``'CU'`` — Consolidated-undrained (total stress parameters).
        - ``'CD'`` — Consolidated-drained (effective stress parameters).

    Returns
    -------
    dict
        ``'c'`` : float — cohesion (kPa).
        ``'phi'`` : float — friction angle (degrees).
        ``'sigma1'`` : ndarray — major principal stress at failure (kPa).
        ``'sigma3'`` : ndarray — confining pressures (kPa).
        For UU: also ``'Su'`` — average undrained shear strength (kPa).

    References
    ----------
    Das (2021), Ch. 8, Sections 8.5–8.9.

    Examples
    --------
    >>> from geoeq.lab.shear import triaxial
    >>> res = triaxial([100, 200, 300], [220, 380, 540], kind='CD')
    >>> round(res['phi'], 1)
    26.6
    """
    s3 = np.asarray(sigma3, dtype=float)
    ds = np.asarray(delta_sigma, dtype=float)
    kind = kind.upper()

    if kind not in ("UU", "CU", "CD"):
        raise ValueError(f"kind must be 'UU', 'CU', or 'CD', got '{kind}'.")
    if len(s3) != len(ds):
        raise ValueError("sigma3 and delta_sigma must have the same length.")

    min_pts = 2 if kind == "UU" else 3
    if len(s3) < min_pts:
        raise ValueError(f"Need at least {min_pts} specimens for {kind} test.")

    for v in s3:
        check_non_negative(v, "sigma3")
    for v in ds:
        check_positive(v, "delta_sigma")

    s1 = s3 + ds

    if kind == "UU":
        Su = float(np.mean(ds / 2.0))
        return {
            "c": Su,
            "phi": 0.0,
            "Su": Su,
            "sigma1": s1,
            "sigma3": s3,
        }

    # For CD/CU: fit Mohr–Coulomb from p-q space
    # p = (σ₁ + σ₃)/2, q = (σ₁ - σ₃)/2
    p = (s1 + s3) / 2.0
    q = (s1 - s3) / 2.0

    # q = a + p * tan(alpha), where sin(phi) = tan(alpha), c = a / cos(phi)
    coeffs = np.polyfit(p, q, 1)
    tan_alpha = coeffs[0]
    a = coeffs[1]

    sin_phi = tan_alpha
    sin_phi = np.clip(sin_phi, -1.0, 1.0)
    phi_rad = np.arcsin(sin_phi)
    phi_deg = float(np.degrees(phi_rad))
    cos_phi = np.cos(phi_rad)
    c = float(a / cos_phi) if cos_phi > 1e-10 else float(a)

    return {
        "c": max(c, 0.0),
        "phi": phi_deg,
        "sigma1": s1,
        "sigma3": s3,
    }

unconfined

Python
unconfined(qu: Union[float, List[float], ndarray]) -> Dict[str, Union[float, np.ndarray]]

Process unconfined compression test — compute undrained shear strength.

.. math::

Text Only
S_u = \frac{q_u}{2}  \qquad \text{[Das Eq.\,8.9]}
PARAMETER DESCRIPTION
qu

Unconfined compressive strength (kPa).

TYPE: float or array_like

RETURNS DESCRIPTION
dict

'qu' : float or ndarray — unconfined compressive strength (kPa). 'Su' : float or ndarray — undrained shear strength (kPa). 'consistency' : str — consistency description.

References

Das (2021), Table 8.4; ASTM D2166.

Examples:

Python Console Session
>>> from geoeq.lab.shear import unconfined
>>> res = unconfined(120)
>>> res['Su']
60.0
>>> res['consistency']
'Stiff'
Source code in geoeq/lab/shear.py
Python
def unconfined(
    qu: Union[float, List[float], np.ndarray],
) -> Dict[str, Union[float, np.ndarray]]:
    r"""
    Process unconfined compression test — compute undrained shear strength.

    .. math::

        S_u = \frac{q_u}{2}  \qquad \text{[Das Eq.\,8.9]}

    Parameters
    ----------
    qu : float or array_like
        Unconfined compressive strength (kPa).

    Returns
    -------
    dict
        ``'qu'`` : float or ndarray — unconfined compressive strength (kPa).
        ``'Su'`` : float or ndarray — undrained shear strength (kPa).
        ``'consistency'`` : str — consistency description.

    References
    ----------
    Das (2021), Table 8.4; ASTM D2166.

    Examples
    --------
    >>> from geoeq.lab.shear import unconfined
    >>> res = unconfined(120)
    >>> res['Su']
    60.0
    >>> res['consistency']
    'Stiff'
    """
    qu_arr = np.asarray(qu, dtype=float)
    check_positive(qu_arr, "qu")

    Su = qu_arr / 2.0

    def _classify(val):
        if val < 12.5:
            return "Very soft"
        elif val < 25:
            return "Soft"
        elif val < 50:
            return "Medium"
        elif val < 100:
            return "Stiff"
        elif val < 200:
            return "Very stiff"
        else:
            return "Hard"

    if np.ndim(qu) == 0:
        consistency = _classify(float(Su))
    else:
        consistency = [_classify(float(v)) for v in np.atleast_1d(Su)]

    return {
        "qu": _scalar_or_array(qu_arr, qu),
        "Su": _scalar_or_array(Su, qu),
        "consistency": consistency,
    }

mohr_circle

Python
mohr_circle(sigma1: Union[float, List[float], ndarray], sigma3: Union[float, List[float], ndarray], ax=None, save_as: Optional[str] = None) -> Dict

Draw Mohr circles and compute the failure envelope.

For each pair (σ₁, σ₃), draws a Mohr circle centred at :math:\bigl(\tfrac{\sigma_1+\sigma_3}{2},\;0\bigr) with radius :math:\tfrac{\sigma_1-\sigma_3}{2}.

If ≥ 3 circles are given, a best-fit Mohr–Coulomb envelope is drawn.

PARAMETER DESCRIPTION
sigma1

Major principal stress at failure (kPa).

TYPE: float or array_like

sigma3

Minor principal stress (confining pressure) at failure (kPa).

TYPE: float or array_like

ax

Existing axes to plot on.

TYPE: Axes DEFAULT: None

save_as

File path to save figure (e.g. 'mohr.png').

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
dict

'c' : float — cohesion intercept (kPa), or None if < 3 circles. 'phi' : float — friction angle (degrees), or None. 'ax' : matplotlib.axes.Axes.

References

Das (2021), Ch. 8, Fig. 8.8.

Examples:

Python Console Session
>>> from geoeq.lab.shear import mohr_circle
>>> res = mohr_circle([320, 580, 840], [100, 200, 300])
>>> round(res['phi'], 1)
26.6
Source code in geoeq/lab/shear.py
Python
def mohr_circle(
    sigma1: Union[float, List[float], np.ndarray],
    sigma3: Union[float, List[float], np.ndarray],
    ax=None,
    save_as: Optional[str] = None,
) -> Dict:
    r"""
    Draw Mohr circles and compute the failure envelope.

    For each pair (σ₁, σ₃), draws a Mohr circle centred at
    :math:`\bigl(\tfrac{\sigma_1+\sigma_3}{2},\;0\bigr)` with radius
    :math:`\tfrac{\sigma_1-\sigma_3}{2}`.

    If ≥ 3 circles are given, a best-fit Mohr–Coulomb envelope is drawn.

    Parameters
    ----------
    sigma1 : float or array_like
        Major principal stress at failure (kPa).
    sigma3 : float or array_like
        Minor principal stress (confining pressure) at failure (kPa).
    ax : matplotlib.axes.Axes, optional
        Existing axes to plot on.
    save_as : str, optional
        File path to save figure (e.g. ``'mohr.png'``).

    Returns
    -------
    dict
        ``'c'`` : float — cohesion intercept (kPa), or ``None`` if < 3 circles.
        ``'phi'`` : float — friction angle (degrees), or ``None``.
        ``'ax'`` : matplotlib.axes.Axes.

    References
    ----------
    Das (2021), Ch. 8, Fig. 8.8.

    Examples
    --------
    >>> from geoeq.lab.shear import mohr_circle
    >>> res = mohr_circle([320, 580, 840], [100, 200, 300])
    >>> round(res['phi'], 1)
    26.6
    """
    import matplotlib.pyplot as plt

    s1 = np.atleast_1d(np.asarray(sigma1, dtype=float))
    s3 = np.atleast_1d(np.asarray(sigma3, dtype=float))

    if len(s1) != len(s3):
        raise ValueError("sigma1 and sigma3 must have the same length.")

    for v in s3:
        check_non_negative(v, "sigma3")
    for v in s1:
        check_positive(v, "sigma1")

    if ax is None:
        fig, ax = plt.subplots(figsize=(10, 6))
    else:
        fig = ax.get_figure()

    colors = plt.cm.tab10(np.linspace(0, 1, max(len(s1), 10)))

    for i, (sig1, sig3) in enumerate(zip(s1, s3)):
        center = (sig1 + sig3) / 2.0
        radius = (sig1 - sig3) / 2.0
        theta = np.linspace(0, np.pi, 200)
        x = center + radius * np.cos(theta)
        y = radius * np.sin(theta)
        ax.plot(x, y, color=colors[i], linewidth=1.5,
                label=f"σ₃={sig3:.0f}, σ₁={sig1:.0f} kPa")

    c_val, phi_val = None, None
    if len(s1) >= 3:
        p = (s1 + s3) / 2.0
        q = (s1 - s3) / 2.0
        coeffs = np.polyfit(p, q, 1)
        sin_phi = np.clip(coeffs[0], -1.0, 1.0)
        phi_rad = np.arcsin(sin_phi)
        phi_val = float(np.degrees(phi_rad))
        cos_phi = np.cos(phi_rad)
        c_val = float(coeffs[1] / cos_phi) if cos_phi > 1e-10 else float(coeffs[1])
        c_val = max(c_val, 0.0)

        sigma_env = np.linspace(0, float(np.max(s1)) * 1.1, 200)
        tau_env = c_val + sigma_env * np.tan(phi_rad)
        ax.plot(sigma_env, tau_env, 'k--', linewidth=2,
                label=f"Envelope: c={c_val:.1f} kPa, φ={phi_val:.1f}°")

    ax.set_xlabel("Normal Stress σ (kPa)", fontweight="bold")
    ax.set_ylabel("Shear Stress τ (kPa)", fontweight="bold")
    ax.set_title("Mohr Circles & Failure Envelope", fontweight="bold")
    ax.set_aspect("equal", adjustable="datalim")
    ax.set_ylim(bottom=0)
    ax.set_xlim(left=0)
    ax.grid(True, alpha=0.3)
    ax.legend(fontsize=8, loc="upper left")
    ax.axhline(0, color="black", linewidth=0.5)

    if save_as:
        plt.savefig(save_as, bbox_inches="tight", dpi=300)

    return {
        "c": c_val,
        "phi": phi_val,
        "ax": ax,
    }

Consolidation

oedometer

Python
oedometer(stress: Union[List[float], ndarray], void_ratio: Union[List[float], ndarray]) -> Dict

Process oedometer (1-D consolidation) test data.

Computes the compression index :math:C_c, recompression index :math:C_r, and identifies the virgin compression line (VCL).

.. math::

Text Only
C_c = \frac{e_1 - e_2}{\log_{10}(\sigma'_2 / \sigma'_1)}
\qquad \text{[Das Eq.\,7.9]}
PARAMETER DESCRIPTION
stress

Effective vertical stress for each load step (kPa). Must be > 0.

TYPE: array_like

void_ratio

Void ratio at end of each load step.

TYPE: array_like

RETURNS DESCRIPTION
dict

'stress' : ndarray — sorted stresses (kPa). 'void_ratio' : ndarray — corresponding void ratios. 'Cc' : float — compression index (slope of VCL on e–log p). 'Cr' : float — recompression index (slope of recompression portion, estimated from first two points).

References

Das (2021), Ch. 7, Eq. 7.9.

Examples:

Python Console Session
>>> from geoeq.lab.consolidation import oedometer
>>> stress = [25, 50, 100, 200, 400, 800]
>>> e = [0.88, 0.86, 0.82, 0.74, 0.62, 0.48]
>>> res = oedometer(stress, e)
>>> round(res['Cc'], 2)
0.46
Source code in geoeq/lab/consolidation.py
Python
def oedometer(
    stress: Union[List[float], np.ndarray],
    void_ratio: Union[List[float], np.ndarray],
) -> Dict:
    r"""
    Process oedometer (1-D consolidation) test data.

    Computes the compression index :math:`C_c`, recompression index
    :math:`C_r`, and identifies the virgin compression line (VCL).

    .. math::

        C_c = \frac{e_1 - e_2}{\log_{10}(\sigma'_2 / \sigma'_1)}
        \qquad \text{[Das Eq.\,7.9]}

    Parameters
    ----------
    stress : array_like
        Effective vertical stress for each load step (kPa). Must be > 0.
    void_ratio : array_like
        Void ratio at end of each load step.

    Returns
    -------
    dict
        ``'stress'`` : ndarray — sorted stresses (kPa).
        ``'void_ratio'`` : ndarray — corresponding void ratios.
        ``'Cc'`` : float — compression index (slope of VCL on e–log p).
        ``'Cr'`` : float — recompression index (slope of recompression
        portion, estimated from first two points).

    References
    ----------
    Das (2021), Ch. 7, Eq. 7.9.

    Examples
    --------
    >>> from geoeq.lab.consolidation import oedometer
    >>> stress = [25, 50, 100, 200, 400, 800]
    >>> e = [0.88, 0.86, 0.82, 0.74, 0.62, 0.48]
    >>> res = oedometer(stress, e)
    >>> round(res['Cc'], 2)
    0.46
    """
    s = np.asarray(stress, dtype=float)
    e = np.asarray(void_ratio, dtype=float)

    if len(s) != len(e):
        raise ValueError("stress and void_ratio must have the same length.")
    if len(s) < 3:
        raise ValueError("Need at least 3 load steps.")
    for v in s:
        check_positive(v, "stress")
    for v in e:
        check_non_negative(v, "void_ratio")

    idx = np.argsort(s)
    s = s[idx]
    e = e[idx]

    log_s = np.log10(s)

    # Cc: slope of steepest segment on e vs log(p) (virgin compression)
    slopes = []
    for i in range(len(s) - 1):
        dlog = log_s[i + 1] - log_s[i]
        if dlog > 0:
            slopes.append(-(e[i + 1] - e[i]) / dlog)
        else:
            slopes.append(0.0)

    Cc = float(max(slopes)) if slopes else 0.0

    # Cr: slope of first two points (recompression)
    dlog0 = log_s[1] - log_s[0]
    Cr = float(-(e[1] - e[0]) / dlog0) if dlog0 > 0 else 0.0

    return {
        "stress": s,
        "void_ratio": e,
        "Cc": Cc,
        "Cr": Cr,
    }

preconsolidation

Python
preconsolidation(stress: Union[List[float], ndarray], void_ratio: Union[List[float], ndarray], method: str = 'casagrande') -> Dict[str, float]

Determine preconsolidation pressure from e–log(p) data.

Casagrande method (default):

  1. Find the point of maximum curvature on the e–log p curve.
  2. Draw a horizontal line and a tangent at that point.
  3. Bisect the angle between them.
  4. The intersection of the bisector with the virgin compression line (VCL) gives :math:p_c.
PARAMETER DESCRIPTION
stress

Effective vertical stress (kPa), > 0.

TYPE: array_like

void_ratio

Void ratio at each load step.

TYPE: array_like

method

Extraction method.

TYPE: casagrande DEFAULT: 'casagrande'

RETURNS DESCRIPTION
dict

'pc' : float — preconsolidation pressure (kPa). 'method' : str.

References

Casagrande (1936); Das (2021), Section 7.4.

Examples:

Python Console Session
>>> from geoeq.lab.consolidation import preconsolidation
>>> stress = [25, 50, 100, 200, 400, 800]
>>> e = [0.88, 0.86, 0.82, 0.74, 0.62, 0.48]
>>> res = preconsolidation(stress, e)
>>> 50 < res['pc'] < 200
True
Source code in geoeq/lab/consolidation.py
Python
def preconsolidation(
    stress: Union[List[float], np.ndarray],
    void_ratio: Union[List[float], np.ndarray],
    method: str = "casagrande",
) -> Dict[str, float]:
    r"""
    Determine preconsolidation pressure from e–log(p) data.

    **Casagrande method** (default):

    1. Find the point of maximum curvature on the e–log p curve.
    2. Draw a horizontal line and a tangent at that point.
    3. Bisect the angle between them.
    4. The intersection of the bisector with the virgin compression
       line (VCL) gives :math:`p_c`.

    Parameters
    ----------
    stress : array_like
        Effective vertical stress (kPa), > 0.
    void_ratio : array_like
        Void ratio at each load step.
    method : {'casagrande'}, default ``'casagrande'``
        Extraction method.

    Returns
    -------
    dict
        ``'pc'`` : float — preconsolidation pressure (kPa).
        ``'method'`` : str.

    References
    ----------
    Casagrande (1936); Das (2021), Section 7.4.

    Examples
    --------
    >>> from geoeq.lab.consolidation import preconsolidation
    >>> stress = [25, 50, 100, 200, 400, 800]
    >>> e = [0.88, 0.86, 0.82, 0.74, 0.62, 0.48]
    >>> res = preconsolidation(stress, e)
    >>> 50 < res['pc'] < 200
    True
    """
    method = method.lower()
    if method != "casagrande":
        raise ValueError(f"Method '{method}' not supported. Use 'casagrande'.")

    s = np.asarray(stress, dtype=float)
    e = np.asarray(void_ratio, dtype=float)
    if len(s) < 4:
        raise ValueError("Need at least 4 data points for Casagrande method.")

    idx = np.argsort(s)
    s = s[idx]
    e = e[idx]
    log_s = np.log10(s)

    from scipy.interpolate import PchipInterpolator
    interp = PchipInterpolator(log_s, e)
    log_s_fine = np.linspace(log_s[0], log_s[-1], 500)
    e_fine = interp(log_s_fine)

    e_1 = interp(log_s_fine, 1)
    e_2 = interp(log_s_fine, 2)
    curvature = np.abs(e_2) / (1 + e_1 ** 2) ** 1.5
    i_max = np.argmax(curvature)

    tangent_slope = float(e_1[i_max])
    e_mc = float(e_fine[i_max])
    log_mc = float(log_s_fine[i_max])

    # VCL: last two points (steepest part)
    vcl_slope = (e[-1] - e[-2]) / (log_s[-1] - log_s[-2])
    vcl_intercept = e[-1] - vcl_slope * log_s[-1]

    # Bisector slope = average of horizontal (0) and tangent slope
    bisector_slope = tangent_slope / 2.0
    bisector_intercept = e_mc - bisector_slope * log_mc

    # Intersection of bisector with VCL
    if abs(vcl_slope - bisector_slope) < 1e-12:
        pc = 10 ** log_mc
    else:
        log_pc = (bisector_intercept - vcl_intercept) / (vcl_slope - bisector_slope)
        pc = 10 ** log_pc

    return {
        "pc": float(pc),
        "method": method,
    }

compression_index

Python
compression_index(method: str = 'terzaghi', LL: Optional[float] = None, e0: Optional[float] = None, wn: Optional[float] = None, Gs: Optional[float] = None) -> float

Estimate compression index :math:C_c from empirical correlations.

PARAMETER DESCRIPTION
method

Correlation to use:

  • 'terzaghi' : :math:C_c = 0.009 \,(LL - 10) — Terzaghi & Peck (1967).
  • 'skempton' : :math:C_c = 0.007 \,(LL - 7) — remolded clays.
  • 'rendon' : :math:C_c = 0.01 \, w_n — Rendon-Herrero (1983).
  • 'nishida' : :math:C_c = 1.15 \,(e_0 - 0.35) — all clays.
  • 'nagaraj' : :math:C_c = 0.2343 \,e_0 \, G_s — Nagaraj & Murthy (1986).
  • 'hough' : :math:C_c = 0.29 \,(e_0 - 0.27) — inorganic silty clays.

TYPE: str DEFAULT: ``'terzaghi'``

LL

Liquid limit (%). Required for terzaghi, skempton.

TYPE: float DEFAULT: None

e0

Initial void ratio. Required for nishida, nagaraj, hough.

TYPE: float DEFAULT: None

wn

Natural water content (%). Required for rendon.

TYPE: float DEFAULT: None

Gs

Specific gravity. Required for nagaraj.

TYPE: float DEFAULT: None

RETURNS DESCRIPTION
float

Estimated compression index :math:C_c.

References

Das (2021), Table 7.3.

Examples:

Python Console Session
>>> from geoeq.lab.consolidation import compression_index
>>> round(compression_index(method='terzaghi', LL=50), 3)
0.36
Source code in geoeq/lab/consolidation.py
Python
def compression_index(
    method: str = "terzaghi",
    LL: Optional[float] = None,
    e0: Optional[float] = None,
    wn: Optional[float] = None,
    Gs: Optional[float] = None,
) -> float:
    r"""
    Estimate compression index :math:`C_c` from empirical correlations.

    Parameters
    ----------
    method : str, default ``'terzaghi'``
        Correlation to use:

        - ``'terzaghi'`` : :math:`C_c = 0.009 \,(LL - 10)` — Terzaghi & Peck (1967).
        - ``'skempton'`` : :math:`C_c = 0.007 \,(LL - 7)` — remolded clays.
        - ``'rendon'`` : :math:`C_c = 0.01 \, w_n` — Rendon-Herrero (1983).
        - ``'nishida'`` : :math:`C_c = 1.15 \,(e_0 - 0.35)` — all clays.
        - ``'nagaraj'`` : :math:`C_c = 0.2343 \,e_0 \, G_s` — Nagaraj & Murthy (1986).
        - ``'hough'`` : :math:`C_c = 0.29 \,(e_0 - 0.27)` — inorganic silty clays.
    LL : float, optional
        Liquid limit (%). Required for ``terzaghi``, ``skempton``.
    e0 : float, optional
        Initial void ratio. Required for ``nishida``, ``nagaraj``, ``hough``.
    wn : float, optional
        Natural water content (%). Required for ``rendon``.
    Gs : float, optional
        Specific gravity. Required for ``nagaraj``.

    Returns
    -------
    float
        Estimated compression index :math:`C_c`.

    References
    ----------
    Das (2021), Table 7.3.

    Examples
    --------
    >>> from geoeq.lab.consolidation import compression_index
    >>> round(compression_index(method='terzaghi', LL=50), 3)
    0.36
    """
    method = method.lower()

    if method == "terzaghi":
        if LL is None:
            raise ValueError("LL is required for Terzaghi correlation.")
        return 0.009 * (LL - 10)
    elif method == "skempton":
        if LL is None:
            raise ValueError("LL is required for Skempton correlation.")
        return 0.007 * (LL - 7)
    elif method == "rendon":
        if wn is None:
            raise ValueError("wn is required for Rendon-Herrero correlation.")
        return 0.01 * wn
    elif method == "nishida":
        if e0 is None:
            raise ValueError("e0 is required for Nishida correlation.")
        return 1.15 * (e0 - 0.35)
    elif method == "nagaraj":
        if e0 is None or Gs is None:
            raise ValueError("e0 and Gs are required for Nagaraj correlation.")
        return 0.2343 * e0 * Gs
    elif method == "hough":
        if e0 is None:
            raise ValueError("e0 is required for Hough correlation.")
        return 0.29 * (e0 - 0.27)
    else:
        raise ValueError(
            f"Unknown method '{method}'. Choose from: terzaghi, skempton, "
            "rendon, nishida, nagaraj, hough."
        )

cv

Python
cv(time: Union[List[float], ndarray], deformation: Union[List[float], ndarray], method: str = 'log', H_dr: float = 1.0) -> Dict[str, float]

Compute the coefficient of consolidation :math:c_v from time–deformation data.

Log-time method (Casagrande):

.. math::

Text Only
c_v = \frac{T_{50} \cdot H_{dr}^2}{t_{50}}
\qquad T_{50} = 0.197

Root-time method (Taylor):

.. math::

Text Only
c_v = \frac{T_{90} \cdot H_{dr}^2}{t_{90}}
\qquad T_{90} = 0.848
PARAMETER DESCRIPTION
time

Elapsed time (minutes).

TYPE: array_like

deformation

Dial reading or settlement (mm). Increasing = more compression.

TYPE: array_like

method

'log' — Casagrande log-time; 'root' — Taylor root-time.

TYPE: (log, root) DEFAULT: 'log'

H_dr

Drainage path length (cm). Half the specimen height for double drainage.

TYPE: float DEFAULT: 1.0

RETURNS DESCRIPTION
dict

'cv' : float — coefficient of consolidation (cm²/min). 't50' or 't90' : float — time for 50% or 90% consolidation. 'method' : str.

References

Das (2021), Section 7.7, Eqs. 7.22, 7.23.

Examples:

Python Console Session
>>> from geoeq.lab.consolidation import cv
>>> t = [0.1, 0.25, 0.5, 1, 2, 4, 8, 15, 30, 60, 120, 240, 480, 1440]
>>> d = [0.0, 0.05, 0.12, 0.22, 0.35, 0.50, 0.65, 0.78, 0.88, 0.95,
...      0.99, 1.02, 1.04, 1.06]
>>> res = cv(t, d, method='log', H_dr=1.0)
>>> res['cv'] > 0
True
Source code in geoeq/lab/consolidation.py
Python
def cv(
    time: Union[List[float], np.ndarray],
    deformation: Union[List[float], np.ndarray],
    method: str = "log",
    H_dr: float = 1.0,
) -> Dict[str, float]:
    r"""
    Compute the coefficient of consolidation :math:`c_v` from
    time–deformation data.

    **Log-time method** (Casagrande):

    .. math::

        c_v = \frac{T_{50} \cdot H_{dr}^2}{t_{50}}
        \qquad T_{50} = 0.197

    **Root-time method** (Taylor):

    .. math::

        c_v = \frac{T_{90} \cdot H_{dr}^2}{t_{90}}
        \qquad T_{90} = 0.848

    Parameters
    ----------
    time : array_like
        Elapsed time (minutes).
    deformation : array_like
        Dial reading or settlement (mm). Increasing = more compression.
    method : {'log', 'root'}, default ``'log'``
        ``'log'`` — Casagrande log-time; ``'root'`` — Taylor root-time.
    H_dr : float, default 1.0
        Drainage path length (cm). Half the specimen height for
        double drainage.

    Returns
    -------
    dict
        ``'cv'`` : float — coefficient of consolidation (cm²/min).
        ``'t50'`` or ``'t90'`` : float — time for 50% or 90% consolidation.
        ``'method'`` : str.

    References
    ----------
    Das (2021), Section 7.7, Eqs. 7.22, 7.23.

    Examples
    --------
    >>> from geoeq.lab.consolidation import cv
    >>> t = [0.1, 0.25, 0.5, 1, 2, 4, 8, 15, 30, 60, 120, 240, 480, 1440]
    >>> d = [0.0, 0.05, 0.12, 0.22, 0.35, 0.50, 0.65, 0.78, 0.88, 0.95,
    ...      0.99, 1.02, 1.04, 1.06]
    >>> res = cv(t, d, method='log', H_dr=1.0)
    >>> res['cv'] > 0
    True
    """
    t = np.asarray(time, dtype=float)
    d = np.asarray(deformation, dtype=float)
    method = method.lower()

    if len(t) != len(d):
        raise ValueError("time and deformation must have the same length.")
    if len(t) < 5:
        raise ValueError("Need at least 5 data points.")
    check_positive(H_dr, "H_dr")

    idx = np.argsort(t)
    t = t[idx]
    d = d[idx]

    if method == "log":
        d_max = np.max(d)
        d_min = np.min(d)
        # d100 = point where secondary compression starts (inflection)
        # Approximate: last point where slope changes significantly
        d_100 = d_max
        # d0 = initial reading
        d_0 = d_min
        # d50 = halfway
        d_50 = (d_0 + d_100) / 2.0

        # Interpolate to find t50
        from scipy.interpolate import interp1d
        try:
            f = interp1d(d, t, kind="linear", fill_value="extrapolate")
            t_50 = float(f(d_50))
        except Exception:
            t_50 = float(t[len(t) // 2])

        T_50 = 0.197
        cv_val = T_50 * H_dr ** 2 / t_50

        return {
            "cv": float(cv_val),
            "t50": float(t_50),
            "method": "log",
        }

    elif method == "root":
        d_max = np.max(d)
        d_min = np.min(d)
        d_90 = d_min + 0.9 * (d_max - d_min)

        from scipy.interpolate import interp1d
        try:
            f = interp1d(d, t, kind="linear", fill_value="extrapolate")
            t_90 = float(f(d_90))
        except Exception:
            t_90 = float(t[int(0.9 * len(t))])

        T_90 = 0.848
        cv_val = T_90 * H_dr ** 2 / t_90

        return {
            "cv": float(cv_val),
            "t90": float(t_90),
            "method": "root",
        }

    else:
        raise ValueError(f"method must be 'log' or 'root', got '{method}'.")

Compaction

proctor

Python
proctor(water_content: Union[List[float], ndarray], dry_density: Union[List[float], ndarray]) -> Dict[str, float]

Process Proctor compaction test data to find optimum moisture content and maximum dry unit weight.

Fits a second-degree polynomial to the (w, γ_d) data and locates the peak.

PARAMETER DESCRIPTION
water_content

Water content for each compaction point (%).

TYPE: array_like

dry_density

Dry unit weight for each compaction point (kN/m³).

TYPE: array_like

RETURNS DESCRIPTION
dict

'w_opt' : float — optimum moisture content (%). 'gamma_d_max' : float — maximum dry unit weight (kN/m³). 'coeffs' : ndarray — polynomial coefficients [a, b, c].

References

Das (2021), Section 5.6; ASTM D698 / D1557.

Examples:

Python Console Session
>>> from geoeq.lab.compaction import proctor
>>> w = [8, 10, 12, 14, 16, 18]
>>> gd = [17.5, 18.2, 18.8, 19.0, 18.7, 18.1]
>>> res = proctor(w, gd)
>>> 13 < res['w_opt'] < 15
True
>>> 18.5 < res['gamma_d_max'] < 19.5
True
Source code in geoeq/lab/compaction.py
Python
def proctor(
    water_content: Union[List[float], np.ndarray],
    dry_density: Union[List[float], np.ndarray],
) -> Dict[str, float]:
    r"""
    Process Proctor compaction test data to find optimum moisture content
    and maximum dry unit weight.

    Fits a second-degree polynomial to the (w, γ_d) data and locates
    the peak.

    Parameters
    ----------
    water_content : array_like
        Water content for each compaction point (%).
    dry_density : array_like
        Dry unit weight for each compaction point (kN/m³).

    Returns
    -------
    dict
        ``'w_opt'`` : float — optimum moisture content (%).
        ``'gamma_d_max'`` : float — maximum dry unit weight (kN/m³).
        ``'coeffs'`` : ndarray — polynomial coefficients [a, b, c].

    References
    ----------
    Das (2021), Section 5.6; ASTM D698 / D1557.

    Examples
    --------
    >>> from geoeq.lab.compaction import proctor
    >>> w = [8, 10, 12, 14, 16, 18]
    >>> gd = [17.5, 18.2, 18.8, 19.0, 18.7, 18.1]
    >>> res = proctor(w, gd)
    >>> 13 < res['w_opt'] < 15
    True
    >>> 18.5 < res['gamma_d_max'] < 19.5
    True
    """
    w = np.asarray(water_content, dtype=float)
    gd = np.asarray(dry_density, dtype=float)

    if len(w) != len(gd):
        raise ValueError("water_content and dry_density must have the same length.")
    if len(w) < 3:
        raise ValueError("Need at least 3 data points for Proctor test.")
    for v in w:
        check_non_negative(v, "water_content")
    for v in gd:
        check_positive(v, "dry_density")

    coeffs = np.polyfit(w, gd, 2)
    a, b, c = coeffs

    if a >= 0:
        # Parabola opens upward — no maximum; return the highest measured point
        i_max = np.argmax(gd)
        return {
            "w_opt": float(w[i_max]),
            "gamma_d_max": float(gd[i_max]),
            "coeffs": coeffs,
        }

    w_opt = -b / (2.0 * a)
    gamma_d_max = float(np.polyval(coeffs, w_opt))

    return {
        "w_opt": float(w_opt),
        "gamma_d_max": gamma_d_max,
        "coeffs": coeffs,
    }

zav_line

Python
zav_line(Gs: float, w_range: Union[List[float], ndarray, None] = None, gamma_w: float = GAMMA_WATER) -> Dict[str, np.ndarray]

Compute the Zero Air Voids (ZAV) line — the theoretical maximum dry unit weight at S = 100 %.

.. math::

Text Only
\gamma_{d,\text{zav}} = \frac{G_s \, \gamma_w}{1 + w \, G_s}
\qquad \text{[Das Eq.\,5.12]}
PARAMETER DESCRIPTION
Gs

Specific gravity of soil solids (typically 2.60–2.80).

TYPE: float

w_range

Water content values (%) at which to compute γ_d. Default [4, 6, 8, …, 30].

TYPE: array_like DEFAULT: None

gamma_w

Unit weight of water (kN/m³).

TYPE: float DEFAULT: 9.81

RETURNS DESCRIPTION
dict

'water_content' : ndarray — w values (%). 'dry_density' : ndarray — γ_d at S = 100 % (kN/m³).

References

Das (2021), Eq. 5.12.

Examples:

Python Console Session
>>> from geoeq.lab.compaction import zav_line
>>> res = zav_line(Gs=2.70)
>>> res['dry_density'][0] > res['dry_density'][-1]
True
Source code in geoeq/lab/compaction.py
Python
def zav_line(
    Gs: float,
    w_range: Union[List[float], np.ndarray, None] = None,
    gamma_w: float = GAMMA_WATER,
) -> Dict[str, np.ndarray]:
    r"""
    Compute the Zero Air Voids (ZAV) line — the theoretical maximum dry
    unit weight at S = 100 %.

    .. math::

        \gamma_{d,\text{zav}} = \frac{G_s \, \gamma_w}{1 + w \, G_s}
        \qquad \text{[Das Eq.\,5.12]}

    Parameters
    ----------
    Gs : float
        Specific gravity of soil solids (typically 2.60–2.80).
    w_range : array_like, optional
        Water content values (%) at which to compute γ_d. Default
        ``[4, 6, 8, …, 30]``.
    gamma_w : float, default 9.81
        Unit weight of water (kN/m³).

    Returns
    -------
    dict
        ``'water_content'`` : ndarray — w values (%).
        ``'dry_density'`` : ndarray — γ_d at S = 100 % (kN/m³).

    References
    ----------
    Das (2021), Eq. 5.12.

    Examples
    --------
    >>> from geoeq.lab.compaction import zav_line
    >>> res = zav_line(Gs=2.70)
    >>> res['dry_density'][0] > res['dry_density'][-1]
    True
    """
    check_positive(Gs, "Gs")
    if w_range is None:
        w_range = np.arange(4, 32, 2, dtype=float)
    else:
        w_range = np.asarray(w_range, dtype=float)

    w_dec = w_range / 100.0
    gd = (Gs * gamma_w) / (1.0 + w_dec * Gs)

    return {
        "water_content": w_range,
        "dry_density": gd,
    }

saturation_line

Python
saturation_line(Gs: float, S: float, w_range: Union[List[float], ndarray, None] = None, gamma_w: float = GAMMA_WATER) -> Dict[str, np.ndarray]

Compute a constant-saturation line on the compaction curve.

.. math::

Text Only
\gamma_d = \frac{G_s \, \gamma_w}{1 + \frac{w \, G_s}{S}}

where S is a fraction (0–1).

PARAMETER DESCRIPTION
Gs

Specific gravity.

TYPE: float

S

Degree of saturation as a fraction (e.g. 0.8 for 80 %).

TYPE: float

w_range

Water content values (%). Default [4, 6, …, 30].

TYPE: array_like DEFAULT: None

gamma_w

TYPE: float DEFAULT: 9.81

RETURNS DESCRIPTION
dict

'water_content' : ndarray (%). 'dry_density' : ndarray (kN/m³).

Examples:

Python Console Session
>>> from geoeq.lab.compaction import saturation_line
>>> res = saturation_line(Gs=2.70, S=0.8)
>>> all(res['dry_density'] > 0)
True
Source code in geoeq/lab/compaction.py
Python
def saturation_line(
    Gs: float,
    S: float,
    w_range: Union[List[float], np.ndarray, None] = None,
    gamma_w: float = GAMMA_WATER,
) -> Dict[str, np.ndarray]:
    r"""
    Compute a constant-saturation line on the compaction curve.

    .. math::

        \gamma_d = \frac{G_s \, \gamma_w}{1 + \frac{w \, G_s}{S}}

    where *S* is a fraction (0–1).

    Parameters
    ----------
    Gs : float
        Specific gravity.
    S : float
        Degree of saturation as a fraction (e.g. 0.8 for 80 %).
    w_range : array_like, optional
        Water content values (%). Default ``[4, 6, …, 30]``.
    gamma_w : float, default 9.81

    Returns
    -------
    dict
        ``'water_content'`` : ndarray (%).
        ``'dry_density'`` : ndarray (kN/m³).

    Examples
    --------
    >>> from geoeq.lab.compaction import saturation_line
    >>> res = saturation_line(Gs=2.70, S=0.8)
    >>> all(res['dry_density'] > 0)
    True
    """
    check_positive(Gs, "Gs")
    check_positive(S, "S")
    if S > 1.0:
        raise ValueError(f"S must be ≤ 1.0, got {S}.")

    if w_range is None:
        w_range = np.arange(4, 32, 2, dtype=float)
    else:
        w_range = np.asarray(w_range, dtype=float)

    w_dec = w_range / 100.0
    gd = (Gs * gamma_w) / (1.0 + w_dec * Gs / S)

    return {
        "water_content": w_range,
        "dry_density": gd,
    }

relative_compaction

Python
relative_compaction(gamma_d: Union[float, ndarray], gamma_d_max: float) -> Union[float, np.ndarray]

Compute relative compaction.

.. math::

Text Only
RC\,(\%) = \frac{\gamma_d}{\gamma_{d,\max}} \times 100
PARAMETER DESCRIPTION
gamma_d

Field dry unit weight (kN/m³).

TYPE: float or array_like

gamma_d_max

Maximum dry unit weight from Proctor test (kN/m³).

TYPE: float

RETURNS DESCRIPTION
float or ndarray

Relative compaction (%).

Examples:

Python Console Session
>>> from geoeq.lab.compaction import relative_compaction
>>> relative_compaction(17.5, 19.0)
92.1...
Source code in geoeq/lab/compaction.py
Python
def relative_compaction(
    gamma_d: Union[float, np.ndarray],
    gamma_d_max: float,
) -> Union[float, np.ndarray]:
    r"""
    Compute relative compaction.

    .. math::

        RC\,(\%) = \frac{\gamma_d}{\gamma_{d,\max}} \times 100

    Parameters
    ----------
    gamma_d : float or array_like
        Field dry unit weight (kN/m³).
    gamma_d_max : float
        Maximum dry unit weight from Proctor test (kN/m³).

    Returns
    -------
    float or ndarray
        Relative compaction (%).

    Examples
    --------
    >>> from geoeq.lab.compaction import relative_compaction
    >>> relative_compaction(17.5, 19.0)
    92.1...
    """
    gd = np.asarray(gamma_d, dtype=float)
    check_positive(gamma_d_max, "gamma_d_max")
    check_positive(gd, "gamma_d")

    rc = (gd / gamma_d_max) * 100.0

    if np.ndim(gamma_d) == 0:
        return float(rc)
    return rc

Permeability

constant_head

Python
constant_head(Q: Union[float, ndarray], L: float, A: float, h: float, t: float = 1.0) -> Union[float, np.ndarray]

Compute hydraulic conductivity from a constant-head permeability test.

.. math::

Text Only
k = \frac{Q \, L}{A \, h \, t}
\qquad \text{[Das Eq.\,5.11]}
PARAMETER DESCRIPTION
Q

Volume of water collected (cm³).

TYPE: float or array_like

L

Length of soil specimen (cm).

TYPE: float

A

Cross-sectional area of specimen (cm²).

TYPE: float

h

Constant head difference (cm).

TYPE: float

t

Duration of flow collection (s).

TYPE: float DEFAULT: 1.0

RETURNS DESCRIPTION
float or ndarray

Hydraulic conductivity k (cm/s).

References

Das (2021), Eq. 5.11; ASTM D2434.

Examples:

Python Console Session
>>> from geoeq.lab.permeability import constant_head
>>> round(constant_head(Q=500, L=15, A=30, h=50, t=120), 6)
0.041667
Source code in geoeq/lab/permeability.py
Python
def constant_head(
    Q: Union[float, np.ndarray],
    L: float,
    A: float,
    h: float,
    t: float = 1.0,
) -> Union[float, np.ndarray]:
    r"""
    Compute hydraulic conductivity from a constant-head permeability test.

    .. math::

        k = \frac{Q \, L}{A \, h \, t}
        \qquad \text{[Das Eq.\,5.11]}

    Parameters
    ----------
    Q : float or array_like
        Volume of water collected (cm³).
    L : float
        Length of soil specimen (cm).
    A : float
        Cross-sectional area of specimen (cm²).
    h : float
        Constant head difference (cm).
    t : float, default 1.0
        Duration of flow collection (s).

    Returns
    -------
    float or ndarray
        Hydraulic conductivity *k* (cm/s).

    References
    ----------
    Das (2021), Eq. 5.11; ASTM D2434.

    Examples
    --------
    >>> from geoeq.lab.permeability import constant_head
    >>> round(constant_head(Q=500, L=15, A=30, h=50, t=120), 6)
    0.041667
    """
    Q_arr = np.asarray(Q, dtype=float)
    check_positive(L, "L")
    check_positive(A, "A")
    check_positive(h, "h")
    check_positive(t, "t")
    check_positive(Q_arr, "Q")

    k = (Q_arr * L) / (A * h * t)

    if np.ndim(Q) == 0:
        return float(k)
    return np.asarray(k)

falling_head

Python
falling_head(a: float, L: float, A: float, h1: float, h2: float, t: float) -> float

Compute hydraulic conductivity from a falling-head permeability test.

.. math::

Text Only
k = \frac{a \, L}{A \, t} \ln\!\left(\frac{h_1}{h_2}\right)
\qquad \text{[Das Eq.\,5.13]}
PARAMETER DESCRIPTION
a

Cross-sectional area of the standpipe (cm²).

TYPE: float

L

Length of soil specimen (cm).

TYPE: float

A

Cross-sectional area of specimen (cm²).

TYPE: float

h1

Initial head in the standpipe (cm).

TYPE: float

h2

Final head in the standpipe (cm), must be < h1.

TYPE: float

t

Elapsed time (s).

TYPE: float

RETURNS DESCRIPTION
float

Hydraulic conductivity k (cm/s).

References

Das (2021), Eq. 5.13; ASTM D5084.

Examples:

Python Console Session
>>> from geoeq.lab.permeability import falling_head
>>> k = falling_head(a=1.0, L=15, A=30, h1=100, h2=50, t=600)
>>> round(k, 7)
5.78e-04
Source code in geoeq/lab/permeability.py
Python
def falling_head(
    a: float,
    L: float,
    A: float,
    h1: float,
    h2: float,
    t: float,
) -> float:
    r"""
    Compute hydraulic conductivity from a falling-head permeability test.

    .. math::

        k = \frac{a \, L}{A \, t} \ln\!\left(\frac{h_1}{h_2}\right)
        \qquad \text{[Das Eq.\,5.13]}

    Parameters
    ----------
    a : float
        Cross-sectional area of the standpipe (cm²).
    L : float
        Length of soil specimen (cm).
    A : float
        Cross-sectional area of specimen (cm²).
    h1 : float
        Initial head in the standpipe (cm).
    h2 : float
        Final head in the standpipe (cm), must be < h1.
    t : float
        Elapsed time (s).

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

    References
    ----------
    Das (2021), Eq. 5.13; ASTM D5084.

    Examples
    --------
    >>> from geoeq.lab.permeability import falling_head
    >>> k = falling_head(a=1.0, L=15, A=30, h1=100, h2=50, t=600)
    >>> round(k, 7)
    5.78e-04
    """
    check_positive(a, "a")
    check_positive(L, "L")
    check_positive(A, "A")
    check_positive(h1, "h1")
    check_positive(h2, "h2")
    check_positive(t, "t")
    if h2 >= h1:
        raise ValueError(f"h2 must be less than h1, got h1={h1}, h2={h2}.")

    k = (a * L) / (A * t) * np.log(h1 / h2)
    return float(k)

Atterberg test procedure

liquid_limit_test

Python
liquid_limit_test(blow_count: Union[List[float], ndarray], water_content: Union[List[float], ndarray]) -> Dict[str, float]

Determine liquid limit from Casagrande cup test data.

The liquid limit is the water content at 25 blows on the semi-logarithmic flow curve:

.. math::

Text Only
w = a \cdot N^b

where N is the blow count and a, b are fitted constants. LL is evaluated at N = 25.

PARAMETER DESCRIPTION
blow_count

Number of blows for each trial (typically 3–5 trials spanning 15–35 blows).

TYPE: array_like

water_content

Water content for each trial (%).

TYPE: array_like

RETURNS DESCRIPTION
dict

'LL' : float — liquid limit (%). 'slope' : float — flow index I_f (slope of flow curve). 'r_squared' : float — goodness of fit.

References

Das (2021), Section 4.4; ASTM D4318.

Examples:

Python Console Session
>>> from geoeq.lab.atterberg_test import liquid_limit_test
>>> res = liquid_limit_test([15, 20, 28, 34], [42.1, 40.8, 38.5, 36.9])
>>> 38 < res['LL'] < 40
True
Source code in geoeq/lab/atterberg_test.py
Python
def liquid_limit_test(
    blow_count: Union[List[float], np.ndarray],
    water_content: Union[List[float], np.ndarray],
) -> Dict[str, float]:
    r"""
    Determine liquid limit from Casagrande cup test data.

    The liquid limit is the water content at 25 blows on the
    semi-logarithmic flow curve:

    .. math::

        w = a \cdot N^b

    where *N* is the blow count and *a*, *b* are fitted constants.
    LL is evaluated at N = 25.

    Parameters
    ----------
    blow_count : array_like
        Number of blows for each trial (typically 3–5 trials spanning
        15–35 blows).
    water_content : array_like
        Water content for each trial (%).

    Returns
    -------
    dict
        ``'LL'`` : float — liquid limit (%).
        ``'slope'`` : float — flow index I_f (slope of flow curve).
        ``'r_squared'`` : float — goodness of fit.

    References
    ----------
    Das (2021), Section 4.4; ASTM D4318.

    Examples
    --------
    >>> from geoeq.lab.atterberg_test import liquid_limit_test
    >>> res = liquid_limit_test([15, 20, 28, 34], [42.1, 40.8, 38.5, 36.9])
    >>> 38 < res['LL'] < 40
    True
    """
    N = np.asarray(blow_count, dtype=float)
    w = np.asarray(water_content, dtype=float)

    if len(N) < 2:
        raise ValueError("Need at least 2 data points.")
    if len(N) != len(w):
        raise ValueError("blow_count and water_content must have the same length.")
    for v in N:
        check_positive(v, "blow_count")
    for v in w:
        check_positive(v, "water_content")

    # Semi-log fit: w vs log10(N)
    log_N = np.log10(N)
    coeffs = np.polyfit(log_N, w, 1)
    slope = coeffs[0]  # flow index
    intercept = coeffs[1]

    LL = float(np.polyval(coeffs, np.log10(25.0)))

    # R²
    w_pred = np.polyval(coeffs, log_N)
    ss_res = np.sum((w - w_pred) ** 2)
    ss_tot = np.sum((w - np.mean(w)) ** 2)
    r_sq = 1.0 - ss_res / ss_tot if ss_tot > 0 else 1.0

    return {
        "LL": LL,
        "slope": float(slope),
        "r_squared": float(r_sq),
    }

CBR

cbr_test

Python
cbr_test(penetration: Union[List[float], ndarray], load: Union[List[float], ndarray], area: float = 19.35) -> Dict[str, float]

Process CBR test data — compute the California Bearing Ratio.

.. math::

Text Only
CBR\,(\%) = \frac{P_{\text{test}}}{P_{\text{standard}}} \times 100

where :math:P_{\text{test}} is the load at 2.54 mm or 5.08 mm penetration and :math:P_{\text{standard}} is the standard load for crushed rock at the same penetration.

The CBR is reported as the larger of the values at 2.54 mm and 5.08 mm penetration.

PARAMETER DESCRIPTION
penetration

Piston penetration values (mm).

TYPE: array_like

load

Corresponding load values (kN).

TYPE: array_like

area

Piston area (cm²). Standard CBR piston diameter = 49.63 mm.

TYPE: float DEFAULT: 19.35

RETURNS DESCRIPTION
dict

'CBR' : float — California Bearing Ratio (%). 'CBR_2_54' : float — CBR at 2.54 mm penetration (%). 'CBR_5_08' : float — CBR at 5.08 mm penetration (%). 'load_2_54' : float — test load at 2.54 mm (kN). 'load_5_08' : float — test load at 5.08 mm (kN).

References

Das (2021), Section 5.9; ASTM D1883.

Examples:

Python Console Session
>>> from geoeq.lab.cbr import cbr_test
>>> pen = [0, 0.64, 1.27, 2.54, 3.81, 5.08, 7.62, 10.16, 12.70]
>>> load = [0, 0.8, 1.9, 4.2, 6.1, 7.8, 10.5, 12.1, 13.2]
>>> res = cbr_test(pen, load)
>>> res['CBR'] > 0
True
Source code in geoeq/lab/cbr.py
Python
def cbr_test(
    penetration: Union[List[float], np.ndarray],
    load: Union[List[float], np.ndarray],
    area: float = 19.35,
) -> Dict[str, float]:
    r"""
    Process CBR test data — compute the California Bearing Ratio.

    .. math::

        CBR\,(\%) = \frac{P_{\text{test}}}{P_{\text{standard}}} \times 100

    where :math:`P_{\text{test}}` is the load at 2.54 mm or 5.08 mm
    penetration and :math:`P_{\text{standard}}` is the standard load
    for crushed rock at the same penetration.

    The CBR is reported as the **larger** of the values at 2.54 mm and
    5.08 mm penetration.

    Parameters
    ----------
    penetration : array_like
        Piston penetration values (mm).
    load : array_like
        Corresponding load values (kN).
    area : float, default 19.35
        Piston area (cm²). Standard CBR piston diameter = 49.63 mm.

    Returns
    -------
    dict
        ``'CBR'`` : float — California Bearing Ratio (%).
        ``'CBR_2_54'`` : float — CBR at 2.54 mm penetration (%).
        ``'CBR_5_08'`` : float — CBR at 5.08 mm penetration (%).
        ``'load_2_54'`` : float — test load at 2.54 mm (kN).
        ``'load_5_08'`` : float — test load at 5.08 mm (kN).

    References
    ----------
    Das (2021), Section 5.9; ASTM D1883.

    Examples
    --------
    >>> from geoeq.lab.cbr import cbr_test
    >>> pen = [0, 0.64, 1.27, 2.54, 3.81, 5.08, 7.62, 10.16, 12.70]
    >>> load = [0, 0.8, 1.9, 4.2, 6.1, 7.8, 10.5, 12.1, 13.2]
    >>> res = cbr_test(pen, load)
    >>> res['CBR'] > 0
    True
    """
    p = np.asarray(penetration, dtype=float)
    f = np.asarray(load, dtype=float)

    if len(p) != len(f):
        raise ValueError("penetration and load must have the same length.")
    if len(p) < 3:
        raise ValueError("Need at least 3 data points.")

    idx = np.argsort(p)
    p = p[idx]
    f = f[idx]

    from scipy.interpolate import interp1d
    interp = interp1d(p, f, kind="linear", fill_value="extrapolate")

    load_2_54 = float(interp(2.54))
    load_5_08 = float(interp(5.08))

    cbr_2_54 = (load_2_54 / _STANDARD_LOAD_2_54) * 100.0
    cbr_5_08 = (load_5_08 / _STANDARD_LOAD_5_08) * 100.0

    return {
        "CBR": max(cbr_2_54, cbr_5_08),
        "CBR_2_54": cbr_2_54,
        "CBR_5_08": cbr_5_08,
        "load_2_54": load_2_54,
        "load_5_08": load_5_08,
    }

Plot helpers

grain_size_plot

Python
grain_size_plot(data: Union[Dict[str, ndarray], Dict[str, Dict[str, ndarray]]], smooth: bool = False, annotation: bool = False, D_para: bool = True, Cu_para: bool = True, Cc_para: bool = True, param_pos: Union[str, Tuple[float, float]] = 'top right', save_as: Optional[str] = None, ax: Optional[Axes] = None, **kwargs) -> plt.Figure

Professional grain size distribution plot with advanced smoothing.

Args: data: Dict with 'diameter' and 'percent_finer', or Dict of Dicts for multi-source. smooth: If True, uses high-resolution PCHIP interpolation. annotation: If True, acknowledge Sieve and Hydrometer parts separately. D_para: Show markers/projection for D10, D30, D60. Cu_para: Display Cu on plot. Cc_para: Display Cc on plot. param_pos: Position of parameter box. E.g., 'top right', 'top left', or (x, y). save_as: Filename to save. ax: matplotlib axes. kwargs: matplotlib line properties.

Source code in geoeq/viz/grain_size.py
Python
def grain_size_plot(
    data: Union[Dict[str, np.ndarray], Dict[str, Dict[str, np.ndarray]]], 
    smooth: bool = False,
    annotation: bool = False,
    D_para: bool = True, 
    Cu_para: bool = True, 
    Cc_para: bool = True,
    param_pos: Union[str, Tuple[float, float]] = "top right",
    save_as: Optional[str] = None,
    ax: Optional[plt.Axes] = None,
    **kwargs
) -> plt.Figure:
    """
    Professional grain size distribution plot with advanced smoothing.

    Args:
        data: Dict with 'diameter' and 'percent_finer', or Dict of Dicts for multi-source.
        smooth: If True, uses high-resolution PCHIP interpolation.
        annotation: If True, acknowledge Sieve and Hydrometer parts separately.
        D_para: Show markers/projection for D10, D30, D60.
        Cu_para: Display Cu on plot.
        Cc_para: Display Cc on plot.
        param_pos: Position of parameter box. E.g., 'top right', 'top left', or (x, y).
        save_as: Filename to save.
        ax: matplotlib axes.
        kwargs: matplotlib line properties.
    """
    if ax is None:
        fig, ax = plt.subplots(figsize=(10, 7))
    else:
        fig = ax.get_figure()

    # Marker cycle for multi-datasets
    marker_cycle = ['s', '*', '^', 'D', 'o', 'v']

    # Handle single vs multi-dataset
    datasets = {}
    if "diameter" in data:
        datasets["Combined"] = data
        combined_data = data
    else:
        datasets = data
        all_d = np.concatenate([ds["diameter"] for ds in datasets.values()])
        all_p = np.concatenate([ds["percent_finer"] for ds in datasets.values()])
        combined_data = {"diameter": all_d, "percent_finer": all_p}

    # 1. Plot the continuous smooth line
    if smooth:
        d_all = combined_data["diameter"]
        p_all = combined_data["percent_finer"]
        idx_all = np.argsort(d_all)
        d_s_all = d_all[idx_all]
        p_s_all = p_all[idx_all]

        if len(d_s_all) > 2:
            log_d_all = np.log10(np.maximum(d_s_all, 1e-6))
            interp = PchipInterpolator(log_d_all, p_s_all)
            d_smooth = np.logspace(np.log10(d_s_all[0]), np.log10(d_s_all[-1]), 1000)
            p_smooth = interp(np.log10(d_smooth))

            line_kwargs = kwargs.copy()
            line_kwargs.pop('marker', None)
            ax.plot(d_smooth, p_smooth, **line_kwargs)

    # 2. Plot Markers and Legends for individual datasets
    for i, (label, dset) in enumerate(datasets.items()):
        d = dset["diameter"]
        p = dset["percent_finer"]
        idx = np.argsort(d)

        # Determine marker for this dataset
        m = kwargs.get('marker', marker_cycle[i % len(marker_cycle)])

        if not smooth:
            ax.plot(d[idx], p[idx], label=label if annotation else None, marker=m, **kwargs)
        else:
            # If smoothing, plot only markers for each part
            marker_kwargs = kwargs.copy()
            marker_kwargs['linestyle'] = 'None'
            marker_kwargs['marker'] = m
            ax.plot(d[idx], p[idx], label=label if annotation else None, **marker_kwargs)

    # Global Plot Styling
    ax.invert_xaxis()
    ax.set_xscale('log')
    ax.set_xlabel("Particle Diameter (mm)", fontweight="bold")
    ax.set_ylabel("Percent Passing (%)", fontweight="bold")
    ax.set_ylim(-2, 108)
    ax.set_xlim(100, 0.001)

    # Grid and Shading
    ax.grid(True, which="major", linestyle="-", alpha=0.4, color='gray')
    ax.grid(True, which="minor", linestyle="--", alpha=0.2, color='gray')
    ax.axvspan(100, 4.75, color='#e0e0e0', alpha=0.2)  # Gravel
    ax.axvspan(4.75, 0.075, color='#fdf5e6', alpha=0.2) # Sand
    ax.axvspan(0.075, 0.001, color='#e6f3ff', alpha=0.2) # Fines
    ax.axvline(4.75, color='black', alpha=0.3, linewidth=1, linestyle='-')
    ax.axvline(0.075, color='black', alpha=0.3, linewidth=1, linestyle='-')

    # Dx Parameters (Red Dotted / Professional)
    if D_para:
        d10 = grain_d10(combined_data)
        d30 = grain_d30(combined_data)
        d60 = grain_d60(combined_data)
        for val, target in zip([d60, d30, d10], [60, 30, 10]):
            if not np.isnan(val):
                ax.hlines(target, xmin=105.0, xmax=val, colors='red', linestyles=':', linewidth=1.1)
                ax.vlines(val, ymin=-2.0, ymax=target, colors='red', linestyles=':', linewidth=1.1)
                ax.plot(val, target, marker='o', markersize=5, color='red', alpha=0.8)

    # Text Box Positioning
    text_parts = []
    if D_para:
        text_parts.append(f"$D_{{60}}$ : {grain_d60(combined_data):.3f} mm")
        text_parts.append(f"$D_{{30}}$ : {grain_d30(combined_data):.3f} mm")
        text_parts.append(f"$D_{{10}}$ : {grain_d10(combined_data):.3f} mm")
    if Cu_para:
        cu = grain_Cu(combined_data)
        text_parts.append(f"$C_u$  : {cu:.2f}")
    if Cc_para:
        cc = grain_Cc(combined_data)
        text_parts.append(f"$C_c$  : {cc:.2f}")

    if text_parts:
        # Default positioning logic
        tx, ty, tha, tva = 0.97, 0.95, 'right', 'top' # top right
        if isinstance(param_pos, str):
            pos_map = {
                "top right": (0.97, 0.95, 'right', 'top'),
                "top left": (0.03, 0.95, 'left', 'top'),
                "bottom right": (0.97, 0.05, 'right', 'bottom'),
                "bottom left": (0.03, 0.05, 'left', 'bottom'),
            }
            if param_pos in pos_map:
                tx, ty, tha, tva = pos_map[param_pos]
        elif isinstance(param_pos, (tuple, list)) and len(param_pos) == 2:
            tx, ty = param_pos

        ax.text(tx, ty, "\n".join(text_parts), transform=ax.transAxes, 
                fontsize=9, verticalalignment=tva, horizontalalignment=tha,
                bbox=dict(boxstyle='round', facecolor='white', alpha=0.9, edgecolor='gray'))

    # Class labels (Moved slightly to avoid box overlap)
    ax.text(25, 104, "GRAVEL", ha="center", va="center", fontsize=9, fontweight="bold", color="#666666")
    ax.text(0.6, 104, "SAND", ha="center", va="center", fontsize=9, fontweight="bold", color="#666666")
    ax.text(0.012, 104, "FINES", ha="left", va="center", fontsize=9, fontweight="bold", color="#666666")

    if annotation:
        ax.legend(loc="lower left", fontsize=8, framealpha=0.9, edgecolor='gray')

    if save_as:
        plt.savefig(save_as, bbox_inches='tight', dpi=300)

    return fig

flow_curve_plot

Python
flow_curve_plot(blow_count: Union[List[float], ndarray], water_content: Union[List[float], ndarray], ax=None, save_as: Optional[str] = None) -> Dict

Plot the flow curve (water content vs blow count on semi-log scale).

PARAMETER DESCRIPTION
blow_count

Number of blows.

TYPE: array_like

water_content

Water content (%).

TYPE: array_like

ax

TYPE: Axes DEFAULT: None

save_as

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
dict

'LL', 'slope', 'r_squared', 'ax'.

Examples:

Python Console Session
>>> from geoeq.lab.atterberg_test import flow_curve_plot
>>> res = flow_curve_plot([15, 20, 28, 34], [42.1, 40.8, 38.5, 36.9])
Source code in geoeq/lab/atterberg_test.py
Python
def flow_curve_plot(
    blow_count: Union[List[float], np.ndarray],
    water_content: Union[List[float], np.ndarray],
    ax=None,
    save_as: Optional[str] = None,
) -> Dict:
    r"""
    Plot the flow curve (water content vs blow count on semi-log scale).

    Parameters
    ----------
    blow_count : array_like
        Number of blows.
    water_content : array_like
        Water content (%).
    ax : matplotlib.axes.Axes, optional
    save_as : str, optional

    Returns
    -------
    dict
        ``'LL'``, ``'slope'``, ``'r_squared'``, ``'ax'``.

    Examples
    --------
    >>> from geoeq.lab.atterberg_test import flow_curve_plot
    >>> res = flow_curve_plot([15, 20, 28, 34], [42.1, 40.8, 38.5, 36.9])
    """
    import matplotlib.pyplot as plt

    N = np.asarray(blow_count, dtype=float)
    w = np.asarray(water_content, dtype=float)

    result = liquid_limit_test(N, w)

    if ax is None:
        fig, ax = plt.subplots(figsize=(8, 6))
    else:
        fig = ax.get_figure()

    ax.scatter(N, w, s=80, color="steelblue", edgecolors="navy", zorder=5,
               label="Test data")

    N_fit = np.linspace(10, 40, 100)
    log_N_fit = np.log10(N_fit)
    w_fit = result["slope"] * log_N_fit + (result["LL"] - result["slope"] * np.log10(25.0))
    ax.plot(N_fit, w_fit, "r-", linewidth=2, label="Flow curve")

    ax.axvline(25, color="green", linestyle="--", alpha=0.7, label="N = 25")
    ax.plot(25, result["LL"], "r*", markersize=15,
            label=f"LL = {result['LL']:.1f}%")

    ax.set_xscale("log")
    ax.set_xlabel("Number of Blows N", fontweight="bold")
    ax.set_ylabel("Water Content w (%)", fontweight="bold")
    ax.set_title("Flow Curve — Liquid Limit Determination", fontweight="bold")
    ax.set_xlim(10, 100)
    ax.grid(True, which="both", alpha=0.3)
    ax.legend(fontsize=9)

    ax.text(0.97, 0.97,
            f"LL = {result['LL']:.1f}%\n$I_f$ = {result['slope']:.2f}\n$R^2$ = {result['r_squared']:.4f}",
            transform=ax.transAxes, fontsize=10, va="top", ha="right",
            bbox=dict(boxstyle="round", facecolor="white", alpha=0.9, edgecolor="gray"))

    if save_as:
        plt.savefig(save_as, bbox_inches="tight", dpi=300)

    result["ax"] = ax
    return result

proctor_plot

Python
proctor_plot(water_content: Union[List[float], ndarray], dry_density: Union[List[float], ndarray], Gs: float = 2.65, show_zav: bool = True, show_sat_lines: bool = True, ax=None, save_as: Optional[str] = None) -> Dict

Plot Proctor compaction curve with ZAV line and saturation contours.

PARAMETER DESCRIPTION
water_content

Water content (%).

TYPE: array_like

dry_density

Dry unit weight (kN/m³).

TYPE: array_like

Gs

Specific gravity for ZAV and saturation lines.

TYPE: float DEFAULT: 2.65

show_zav

Plot the zero air voids line.

TYPE: bool DEFAULT: True

show_sat_lines

Plot 60%, 80% saturation contours.

TYPE: bool DEFAULT: True

ax

TYPE: Axes DEFAULT: None

save_as

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
dict

'w_opt', 'gamma_d_max', 'ax'.

Examples:

Python Console Session
>>> from geoeq.lab.compaction import proctor_plot
>>> w = [8, 10, 12, 14, 16, 18]
>>> gd = [17.5, 18.2, 18.8, 19.0, 18.7, 18.1]
>>> res = proctor_plot(w, gd, Gs=2.70)
Source code in geoeq/lab/compaction.py
Python
def proctor_plot(
    water_content: Union[List[float], np.ndarray],
    dry_density: Union[List[float], np.ndarray],
    Gs: float = 2.65,
    show_zav: bool = True,
    show_sat_lines: bool = True,
    ax=None,
    save_as: Optional[str] = None,
) -> Dict:
    r"""
    Plot Proctor compaction curve with ZAV line and saturation contours.

    Parameters
    ----------
    water_content : array_like
        Water content (%).
    dry_density : array_like
        Dry unit weight (kN/m³).
    Gs : float, default 2.65
        Specific gravity for ZAV and saturation lines.
    show_zav : bool, default True
        Plot the zero air voids line.
    show_sat_lines : bool, default True
        Plot 60%, 80% saturation contours.
    ax : matplotlib.axes.Axes, optional
    save_as : str, optional

    Returns
    -------
    dict
        ``'w_opt'``, ``'gamma_d_max'``, ``'ax'``.

    Examples
    --------
    >>> from geoeq.lab.compaction import proctor_plot
    >>> w = [8, 10, 12, 14, 16, 18]
    >>> gd = [17.5, 18.2, 18.8, 19.0, 18.7, 18.1]
    >>> res = proctor_plot(w, gd, Gs=2.70)
    """
    import matplotlib.pyplot as plt

    w = np.asarray(water_content, dtype=float)
    gd = np.asarray(dry_density, dtype=float)

    result = proctor(w, gd)

    if ax is None:
        fig, ax = plt.subplots(figsize=(9, 6))
    else:
        fig = ax.get_figure()

    # Plot data points
    ax.scatter(w, gd, s=80, color="steelblue", edgecolors="navy", zorder=5,
               label="Test data")

    # Fit curve
    w_fine = np.linspace(float(w.min()) - 1, float(w.max()) + 1, 200)
    gd_fine = np.polyval(result["coeffs"], w_fine)
    ax.plot(w_fine, gd_fine, "b-", linewidth=2, label="Best-fit curve")

    # Optimum point
    ax.axvline(result["w_opt"], color="red", linestyle="--", alpha=0.5)
    ax.axhline(result["gamma_d_max"], color="red", linestyle="--", alpha=0.5)
    ax.plot(result["w_opt"], result["gamma_d_max"], "r*", markersize=15,
            label=f"Optimum: w={result['w_opt']:.1f}%, γ_d={result['gamma_d_max']:.2f}")

    # ZAV line
    w_zav = np.linspace(float(w.min()) - 2, float(w.max()) + 4, 100)
    if show_zav:
        zav = zav_line(Gs, w_zav)
        ax.plot(zav["water_content"], zav["dry_density"], "k-", linewidth=2,
                label=f"ZAV (Gs={Gs})")

    # Saturation lines
    if show_sat_lines:
        for S_pct in [60, 80]:
            sat = saturation_line(Gs, S_pct / 100.0, w_zav)
            ax.plot(sat["water_content"], sat["dry_density"], "--",
                    color="gray", linewidth=1, alpha=0.7)
            # Label at the end
            ax.annotate(f"S={S_pct}%",
                        xy=(float(sat["water_content"][-1]),
                            float(sat["dry_density"][-1])),
                        fontsize=8, color="gray")

    ax.set_xlabel("Water Content w (%)", fontweight="bold")
    ax.set_ylabel("Dry Unit Weight γ_d (kN/m³)", fontweight="bold")
    ax.set_title("Proctor Compaction Test", fontweight="bold")
    ax.grid(True, alpha=0.3)
    ax.legend(fontsize=8, loc="lower left")

    # Reasonable y-limits
    margin = 0.5
    ax.set_ylim(float(gd.min()) - margin, float(max(gd.max(), result["gamma_d_max"])) + margin)

    if save_as:
        plt.savefig(save_as, bbox_inches="tight", dpi=300)

    result["ax"] = ax
    return result

oedometer_plot

Python
oedometer_plot(stress: Union[List[float], ndarray], void_ratio: Union[List[float], ndarray], show_pc: bool = True, ax=None, save_as: Optional[str] = None) -> Dict

Plot e–log(σ') curve from oedometer test data.

PARAMETER DESCRIPTION
stress

Effective vertical stress (kPa).

TYPE: array_like

void_ratio

Void ratio at each load step.

TYPE: array_like

show_pc

If True, estimate and mark the preconsolidation pressure.

TYPE: bool DEFAULT: True

ax

Existing axes.

TYPE: Axes DEFAULT: None

save_as

File path to save figure.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
dict

'Cc', 'Cr', 'pc' (if show_pc), 'ax'.

Examples:

Python Console Session
>>> from geoeq.lab.consolidation import oedometer_plot
>>> res = oedometer_plot([25, 50, 100, 200, 400, 800],
...                     [0.88, 0.86, 0.82, 0.74, 0.62, 0.48])
Source code in geoeq/lab/consolidation.py
Python
def oedometer_plot(
    stress: Union[List[float], np.ndarray],
    void_ratio: Union[List[float], np.ndarray],
    show_pc: bool = True,
    ax=None,
    save_as: Optional[str] = None,
) -> Dict:
    r"""
    Plot e–log(σ') curve from oedometer test data.

    Parameters
    ----------
    stress : array_like
        Effective vertical stress (kPa).
    void_ratio : array_like
        Void ratio at each load step.
    show_pc : bool, default True
        If True, estimate and mark the preconsolidation pressure.
    ax : matplotlib.axes.Axes, optional
        Existing axes.
    save_as : str, optional
        File path to save figure.

    Returns
    -------
    dict
        ``'Cc'``, ``'Cr'``, ``'pc'`` (if show_pc), ``'ax'``.

    Examples
    --------
    >>> from geoeq.lab.consolidation import oedometer_plot
    >>> res = oedometer_plot([25, 50, 100, 200, 400, 800],
    ...                     [0.88, 0.86, 0.82, 0.74, 0.62, 0.48])
    """
    import matplotlib.pyplot as plt

    s = np.asarray(stress, dtype=float)
    e = np.asarray(void_ratio, dtype=float)

    oed = oedometer(s, e)
    s_sorted = oed["stress"]
    e_sorted = oed["void_ratio"]

    if ax is None:
        fig, ax = plt.subplots(figsize=(9, 6))
    else:
        fig = ax.get_figure()

    ax.plot(s_sorted, e_sorted, "bo-", markersize=6, linewidth=1.5, label="Test data")
    ax.set_xscale("log")
    ax.set_xlabel("Effective Stress σ' (kPa)", fontweight="bold")
    ax.set_ylabel("Void Ratio e", fontweight="bold")
    ax.set_title("Oedometer Test — e vs log(σ')", fontweight="bold")
    ax.grid(True, which="both", alpha=0.3)
    ax.invert_yaxis()

    info_lines = [
        f"$C_c$ = {oed['Cc']:.3f}",
        f"$C_r$ = {oed['Cr']:.3f}",
    ]

    result = {
        "Cc": oed["Cc"],
        "Cr": oed["Cr"],
        "ax": ax,
    }

    if show_pc and len(s) >= 4:
        try:
            pc_res = preconsolidation(s, e)
            pc = pc_res["pc"]
            ax.axvline(pc, color="red", linestyle="--", alpha=0.7,
                       label=f"$p_c$ = {pc:.0f} kPa")
            info_lines.append(f"$p_c$ = {pc:.0f} kPa")
            result["pc"] = pc
        except Exception:
            pass

    ax.text(0.97, 0.97, "\n".join(info_lines), transform=ax.transAxes,
            fontsize=10, va="top", ha="right",
            bbox=dict(boxstyle="round", facecolor="white", alpha=0.9, edgecolor="gray"))

    ax.legend(fontsize=9)

    if save_as:
        plt.savefig(save_as, bbox_inches="tight", dpi=300)

    return result

cv_plot

Python
cv_plot(time: Union[List[float], ndarray], deformation: Union[List[float], ndarray], method: str = 'log', H_dr: float = 1.0, ax=None, save_as: Optional[str] = None) -> Dict

Plot time–deformation data and determine :math:c_v.

For method='log', x-axis is log(time); for method='root', x-axis is √time.

PARAMETER DESCRIPTION
time

Elapsed time (minutes).

TYPE: array_like

deformation

Dial reading / settlement (mm).

TYPE: array_like

method

TYPE: (log, root) DEFAULT: 'log'

H_dr

Drainage path (cm).

TYPE: float DEFAULT: 1.0

ax

TYPE: Axes DEFAULT: None

save_as

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
dict

'cv', 'ax', and 't50' or 't90'.

Examples:

Python Console Session
>>> from geoeq.lab.consolidation import cv_plot
>>> t = [0.1, 0.25, 0.5, 1, 2, 4, 8, 15, 30, 60, 120, 240, 480, 1440]
>>> d = [0.0, 0.05, 0.12, 0.22, 0.35, 0.50, 0.65, 0.78, 0.88, 0.95,
...      0.99, 1.02, 1.04, 1.06]
>>> res = cv_plot(t, d, method='log', H_dr=1.0)
Source code in geoeq/lab/consolidation.py
Python
def cv_plot(
    time: Union[List[float], np.ndarray],
    deformation: Union[List[float], np.ndarray],
    method: str = "log",
    H_dr: float = 1.0,
    ax=None,
    save_as: Optional[str] = None,
) -> Dict:
    r"""
    Plot time–deformation data and determine :math:`c_v`.

    For ``method='log'``, x-axis is log(time); for ``method='root'``,
    x-axis is √time.

    Parameters
    ----------
    time : array_like
        Elapsed time (minutes).
    deformation : array_like
        Dial reading / settlement (mm).
    method : {'log', 'root'}, default ``'log'``
    H_dr : float, default 1.0
        Drainage path (cm).
    ax : matplotlib.axes.Axes, optional
    save_as : str, optional

    Returns
    -------
    dict
        ``'cv'``, ``'ax'``, and ``'t50'`` or ``'t90'``.

    Examples
    --------
    >>> from geoeq.lab.consolidation import cv_plot
    >>> t = [0.1, 0.25, 0.5, 1, 2, 4, 8, 15, 30, 60, 120, 240, 480, 1440]
    >>> d = [0.0, 0.05, 0.12, 0.22, 0.35, 0.50, 0.65, 0.78, 0.88, 0.95,
    ...      0.99, 1.02, 1.04, 1.06]
    >>> res = cv_plot(t, d, method='log', H_dr=1.0)
    """
    import matplotlib.pyplot as plt

    t = np.asarray(time, dtype=float)
    d = np.asarray(deformation, dtype=float)
    result = cv(t, d, method=method, H_dr=H_dr)

    if ax is None:
        fig, ax = plt.subplots(figsize=(9, 6))
    else:
        fig = ax.get_figure()

    idx = np.argsort(t)
    t_sorted = t[idx]
    d_sorted = d[idx]

    if method == "log":
        ax.plot(t_sorted, d_sorted, "bo-", markersize=5, linewidth=1.2)
        ax.set_xscale("log")
        ax.set_xlabel("Time (min) — log scale", fontweight="bold")
        t_mark = result["t50"]
        ax.axvline(t_mark, color="red", linestyle="--", alpha=0.7,
                   label=f"$t_{{50}}$ = {t_mark:.2f} min")
    else:
        ax.plot(np.sqrt(t_sorted), d_sorted, "bo-", markersize=5, linewidth=1.2)
        ax.set_xlabel("√Time (√min)", fontweight="bold")
        t_mark = result["t90"]
        ax.axvline(np.sqrt(t_mark), color="red", linestyle="--", alpha=0.7,
                   label=f"$t_{{90}}$ = {t_mark:.2f} min")

    ax.set_ylabel("Deformation (mm)", fontweight="bold")
    ax.set_title(f"Consolidation — {'Log-Time' if method == 'log' else 'Root-Time'} Method",
                 fontweight="bold")
    ax.grid(True, alpha=0.3)
    ax.legend()

    ax.text(0.97, 0.03, f"$c_v$ = {result['cv']:.4f} cm²/min",
            transform=ax.transAxes, fontsize=10, va="bottom", ha="right",
            bbox=dict(boxstyle="round", facecolor="white", alpha=0.9, edgecolor="gray"))

    if save_as:
        plt.savefig(save_as, bbox_inches="tight", dpi=300)

    result["ax"] = ax
    return result

direct_shear_plot

Python
direct_shear_plot(normal_stress: Union[List[float], ndarray], shear_stress: Union[List[float], ndarray], ax=None, save_as: Optional[str] = None) -> Dict

Plot direct shear test results with failure envelope.

Plots the (σ', τ_f) data and the best-fit Mohr–Coulomb line.

PARAMETER DESCRIPTION
normal_stress

Effective normal stress on the failure plane (kPa).

TYPE: array_like

shear_stress

Shear stress at failure (kPa).

TYPE: array_like

ax

Existing axes.

TYPE: Axes DEFAULT: None

save_as

File path to save.

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
dict

'c', 'phi', 'r_squared', 'ax'.

Examples:

Python Console Session
>>> from geoeq.lab.shear import direct_shear_plot
>>> res = direct_shear_plot([50, 100, 150], [38, 62, 86])
Source code in geoeq/lab/shear.py
Python
def direct_shear_plot(
    normal_stress: Union[List[float], np.ndarray],
    shear_stress: Union[List[float], np.ndarray],
    ax=None,
    save_as: Optional[str] = None,
) -> Dict:
    r"""
    Plot direct shear test results with failure envelope.

    Plots the (σ', τ_f) data and the best-fit Mohr–Coulomb line.

    Parameters
    ----------
    normal_stress : array_like
        Effective normal stress on the failure plane (kPa).
    shear_stress : array_like
        Shear stress at failure (kPa).
    ax : matplotlib.axes.Axes, optional
        Existing axes.
    save_as : str, optional
        File path to save.

    Returns
    -------
    dict
        ``'c'``, ``'phi'``, ``'r_squared'``, ``'ax'``.

    Examples
    --------
    >>> from geoeq.lab.shear import direct_shear_plot
    >>> res = direct_shear_plot([50, 100, 150], [38, 62, 86])
    """
    import matplotlib.pyplot as plt

    sigma = np.asarray(normal_stress, dtype=float)
    tau = np.asarray(shear_stress, dtype=float)

    result = direct_shear(sigma, tau)

    if ax is None:
        fig, ax = plt.subplots(figsize=(8, 6))
    else:
        fig = ax.get_figure()

    ax.scatter(sigma, tau, s=80, color="steelblue", edgecolors="navy",
               zorder=5, label="Test data")

    sigma_fit = np.linspace(0, float(np.max(sigma)) * 1.3, 200)
    tau_fit = result["c"] + sigma_fit * np.tan(np.radians(result["phi"]))
    ax.plot(sigma_fit, tau_fit, "r-", linewidth=2,
            label=f"c = {result['c']:.1f} kPa, φ = {result['phi']:.1f}°")

    ax.set_xlabel("Normal Stress σ' (kPa)", fontweight="bold")
    ax.set_ylabel("Shear Stress τ (kPa)", fontweight="bold")
    ax.set_title("Direct Shear Test — Failure Envelope", fontweight="bold")
    ax.set_xlim(left=0)
    ax.set_ylim(bottom=0)
    ax.grid(True, alpha=0.3)
    ax.legend()

    if save_as:
        plt.savefig(save_as, bbox_inches="tight", dpi=300)

    result["ax"] = ax
    return result

permeability_plot

Python
permeability_plot(Q_values: Union[List[float], ndarray, None] = None, head_gradient: Union[List[float], ndarray, None] = None, time_values: Union[List[float], ndarray, None] = None, head_values: Union[List[float], ndarray, None] = None, test_type: str = 'constant', ax=None, save_as: Optional[str] = None) -> Dict

Plot permeability test data.

For constant-head: plots Q vs hydraulic gradient i. For falling-head: plots ln(h) vs time.

PARAMETER DESCRIPTION
Q_values

Flow volumes (cm³) — for constant-head.

TYPE: array_like DEFAULT: None

head_gradient

Hydraulic gradients — for constant-head.

TYPE: array_like DEFAULT: None

time_values

Time readings (s) — for falling-head.

TYPE: array_like DEFAULT: None

head_values

Head readings (cm) — for falling-head.

TYPE: array_like DEFAULT: None

test_type

TYPE: (constant, falling) DEFAULT: 'constant'

ax

TYPE: Axes DEFAULT: None

save_as

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
dict

'ax' : Axes, 'k' : float (slope-derived k estimate).

Examples:

Python Console Session
>>> from geoeq.lab.permeability import permeability_plot
>>> res = permeability_plot(Q_values=[10, 20, 30],
...     head_gradient=[1.0, 2.0, 3.0], test_type='constant')
Source code in geoeq/lab/permeability.py
Python
def permeability_plot(
    Q_values: Union[List[float], np.ndarray, None] = None,
    head_gradient: Union[List[float], np.ndarray, None] = None,
    time_values: Union[List[float], np.ndarray, None] = None,
    head_values: Union[List[float], np.ndarray, None] = None,
    test_type: str = "constant",
    ax=None,
    save_as: Optional[str] = None,
) -> Dict:
    r"""
    Plot permeability test data.

    For constant-head: plots Q vs hydraulic gradient i.
    For falling-head: plots ln(h) vs time.

    Parameters
    ----------
    Q_values : array_like, optional
        Flow volumes (cm³) — for constant-head.
    head_gradient : array_like, optional
        Hydraulic gradients — for constant-head.
    time_values : array_like, optional
        Time readings (s) — for falling-head.
    head_values : array_like, optional
        Head readings (cm) — for falling-head.
    test_type : {'constant', 'falling'}, default ``'constant'``
    ax : matplotlib.axes.Axes, optional
    save_as : str, optional

    Returns
    -------
    dict
        ``'ax'`` : Axes, ``'k'`` : float (slope-derived k estimate).

    Examples
    --------
    >>> from geoeq.lab.permeability import permeability_plot
    >>> res = permeability_plot(Q_values=[10, 20, 30],
    ...     head_gradient=[1.0, 2.0, 3.0], test_type='constant')
    """
    import matplotlib.pyplot as plt

    if ax is None:
        fig, ax = plt.subplots(figsize=(8, 6))
    else:
        fig = ax.get_figure()

    k_est = None

    if test_type == "constant":
        if Q_values is None or head_gradient is None:
            raise ValueError("Q_values and head_gradient required for constant-head plot.")
        Q = np.asarray(Q_values, dtype=float)
        i = np.asarray(head_gradient, dtype=float)
        ax.scatter(i, Q, s=80, color="steelblue", edgecolors="navy", zorder=5)

        coeffs = np.polyfit(i, Q, 1)
        i_fit = np.linspace(0, float(i.max()) * 1.2, 100)
        ax.plot(i_fit, np.polyval(coeffs, i_fit), "r-", linewidth=2)

        k_est = float(coeffs[0])
        ax.set_xlabel("Hydraulic Gradient i", fontweight="bold")
        ax.set_ylabel("Flow Rate Q (cm³/s)", fontweight="bold")
        ax.set_title("Constant-Head Permeability Test", fontweight="bold")

    elif test_type == "falling":
        if time_values is None or head_values is None:
            raise ValueError("time_values and head_values required for falling-head plot.")
        t = np.asarray(time_values, dtype=float)
        h = np.asarray(head_values, dtype=float)
        ln_h = np.log(h)

        ax.scatter(t, ln_h, s=80, color="steelblue", edgecolors="navy", zorder=5)
        coeffs = np.polyfit(t, ln_h, 1)
        t_fit = np.linspace(0, float(t.max()) * 1.1, 100)
        ax.plot(t_fit, np.polyval(coeffs, t_fit), "r-", linewidth=2)

        ax.set_xlabel("Time (s)", fontweight="bold")
        ax.set_ylabel("ln(h)", fontweight="bold")
        ax.set_title("Falling-Head Permeability Test", fontweight="bold")
        k_est = float(-coeffs[0])

    else:
        raise ValueError(f"test_type must be 'constant' or 'falling', got '{test_type}'.")

    ax.grid(True, alpha=0.3)

    if save_as:
        plt.savefig(save_as, bbox_inches="tight", dpi=300)

    return {"ax": ax, "k": k_est}

cbr_plot

Python
cbr_plot(penetration: Union[List[float], ndarray], load: Union[List[float], ndarray], area: float = 19.35, ax=None, save_as: Optional[str] = None) -> Dict

Plot CBR load–penetration curve with standard reference points.

PARAMETER DESCRIPTION
penetration

Penetration values (mm).

TYPE: array_like

load

Load values (kN).

TYPE: array_like

area

Piston area (cm²).

TYPE: float DEFAULT: 19.35

ax

TYPE: Axes DEFAULT: None

save_as

TYPE: str DEFAULT: None

RETURNS DESCRIPTION
dict

'CBR', 'ax', etc.

Examples:

Python Console Session
>>> from geoeq.lab.cbr import cbr_plot
>>> pen = [0, 0.64, 1.27, 2.54, 3.81, 5.08, 7.62, 10.16, 12.70]
>>> load = [0, 0.8, 1.9, 4.2, 6.1, 7.8, 10.5, 12.1, 13.2]
>>> res = cbr_plot(pen, load)
Source code in geoeq/lab/cbr.py
Python
def cbr_plot(
    penetration: Union[List[float], np.ndarray],
    load: Union[List[float], np.ndarray],
    area: float = 19.35,
    ax=None,
    save_as: Optional[str] = None,
) -> Dict:
    r"""
    Plot CBR load–penetration curve with standard reference points.

    Parameters
    ----------
    penetration : array_like
        Penetration values (mm).
    load : array_like
        Load values (kN).
    area : float, default 19.35
        Piston area (cm²).
    ax : matplotlib.axes.Axes, optional
    save_as : str, optional

    Returns
    -------
    dict
        ``'CBR'``, ``'ax'``, etc.

    Examples
    --------
    >>> from geoeq.lab.cbr import cbr_plot
    >>> pen = [0, 0.64, 1.27, 2.54, 3.81, 5.08, 7.62, 10.16, 12.70]
    >>> load = [0, 0.8, 1.9, 4.2, 6.1, 7.8, 10.5, 12.1, 13.2]
    >>> res = cbr_plot(pen, load)
    """
    import matplotlib.pyplot as plt

    p = np.asarray(penetration, dtype=float)
    f = np.asarray(load, dtype=float)

    result = cbr_test(p, f, area)

    if ax is None:
        fig, ax = plt.subplots(figsize=(9, 6))
    else:
        fig = ax.get_figure()

    idx = np.argsort(p)
    ax.plot(p[idx], f[idx], "bo-", markersize=6, linewidth=1.5, label="Test data")

    # Reference lines at 2.54 and 5.08 mm
    for pen_val, load_val, cbr_val, label in [
        (2.54, result["load_2_54"], result["CBR_2_54"], "2.54 mm"),
        (5.08, result["load_5_08"], result["CBR_5_08"], "5.08 mm"),
    ]:
        ax.axvline(pen_val, color="red", linestyle="--", alpha=0.5)
        ax.plot(pen_val, load_val, "r*", markersize=12)
        ax.annotate(f"CBR={cbr_val:.1f}%",
                    xy=(pen_val, load_val),
                    xytext=(pen_val + 0.5, load_val + 0.5),
                    fontsize=9, color="red",
                    arrowprops=dict(arrowstyle="->", color="red", alpha=0.5))

    # Standard reference loads
    ax.plot(2.54, _STANDARD_LOAD_2_54, "gs", markersize=8, label="Standard (2.54 mm)")
    ax.plot(5.08, _STANDARD_LOAD_5_08, "g^", markersize=8, label="Standard (5.08 mm)")

    ax.set_xlabel("Penetration (mm)", fontweight="bold")
    ax.set_ylabel("Load (kN)", fontweight="bold")
    ax.set_title(f"CBR Test — CBR = {result['CBR']:.1f}%", fontweight="bold")
    ax.set_xlim(left=0)
    ax.set_ylim(bottom=0)
    ax.grid(True, alpha=0.3)
    ax.legend(fontsize=8)

    if save_as:
        plt.savefig(save_as, bbox_inches="tight", dpi=300)

    result["ax"] = ax
    return result