Skip to content

ge.profile — Layered ground model

The ge.profile module is the connective tissue between lab tests, site investigation, and engineering design. A Soil dataclass holds one homogeneous layer's properties; a SoilProfile stacks them with an optional water table and integrates the unit weights downward to produce total, pore, and effective stress at any depth.

Quick example

Python
import geoeq as ge

clay = ge.Soil("Soft Clay", gamma=17, gamma_sat=18.5,
               phi=0, c=25, e=0.9, Cc=0.27)
sand = ge.Soil("Dense Sand", gamma=19, gamma_sat=20.5, phi=35)

p = ge.SoilProfile([
    (0, 2,  ge.Soil("Fill", gamma=18)),
    (2, 8,  clay),
    (8, 20, sand),
], water_table=1.5)

p.stress_at(10)
# {'sigma': 188.0, 'u': 78.48, 'sigma_eff': 109.52}

p.layer_at(5).name              # 'Soft Clay'
p.total_stress([2, 5, 10])      # vectorised
p.plot(dz=0.1)                  # publication-quality figure

Profile stress

Total (σ), pore (u), and effective (σ') vertical stress vs depth for a three-layer profile (Fill / Soft Clay / Dense Sand) with the water table at 1.5 m. Generated by SoilProfile.plot().

Stress conventions (Das 2010 Ch. 5)

For a depth \(z\) in a layered profile with the water table at \(z_w\):

\[ \begin{aligned} \sigma_v(z) &= \sum_i \gamma_i \, \Delta z_i \\ u(z) &= \gammaw \, \max(0,\, z - z_w) \\ \sigma'_v(z) &= \sigma_v(z) - u(z) \end{aligned} \]

GeoEq uses \(\gamma_i = \gamma_{\text{bulk}}\) above the water table and \(\gamma_i = \gamma_{\text{sat}}\) below it. Effective stress is Terzaghi's principle.

The Soil dataclass

A Soil carries the engineering properties of one layer. Only name is required; everything else has sensible defaults:

Field Default Meaning
gamma 18.0 Bulk unit weight above WT (kN/m³)
gamma_sat gamma + 1.5 Saturated unit weight below WT
phi 30 Effective friction angle (°)
c 0 Effective cohesion (kPa)
e None Void ratio
Gs 2.70 Specific gravity of solids
Cc, Cr, OCR None, None, 1.0 Consolidation parameters
Su None Undrained shear strength
Es, mu None, 0.30 Elastic modulus and Poisson ratio
k None Hydraulic conductivity (m/s)

A helper method returns the effective unit weight:

Python
clay.gamma_effective()              # gamma_sat - gamma_w

Common operations

Python
# Build a calculation grid at 0.5 m spacing
grid = ge.mesh(p, dz=0.5)

# Export to a pandas DataFrame
df = p.to_dataframe()

# Iterate over layers
for top, bot, soil in p:
    print(f"{soil.name}: {top}{bot} m, gamma={soil.gamma}")

# Multi-borehole log plot
ge.log_plot({"BH1": p, "BH2": other_profile})

API reference

Soil dataclass

Python
Soil(name: str = 'Soil', gamma: float = 18.0, gamma_sat: Optional[float] = None, phi: float = 30.0, c: float = 0.0, e: Optional[float] = None, Gs: float = 2.7, k: Optional[float] = None, Es: Optional[float] = None, mu: float = 0.3, Cc: Optional[float] = None, Cr: Optional[float] = None, OCR: float = 1.0, Su: Optional[float] = None, description: str = '')

A single soil layer's engineering properties.

Examples:

Python Console Session
>>> from geoeq.profile import Soil
>>> clay = Soil("Soft Clay", gamma=17, gamma_sat=18.5, phi=0, c=25, e=0.9)
>>> clay.name
'Soft Clay'

gamma_effective

Python
gamma_effective() -> float

Submerged (buoyant) unit weight gamma' = gamma_sat - gamma_w.

Source code in geoeq/profile/soil.py
Python
def gamma_effective(self) -> float:
    """Submerged (buoyant) unit weight gamma' = gamma_sat - gamma_w."""
    from geoeq.core.constants import GAMMA_WATER
    return float(self.gamma_sat) - GAMMA_WATER

SoilProfile

Python
SoilProfile(layers: Sequence[LayerInput], water_table: Optional[float] = None)

Layered soil profile with stress computations and plotting.

PARAMETER DESCRIPTION
layers

Layers in order of increasing depth. Layers must be contiguous and non-overlapping. Tops and bottoms in metres.

TYPE: sequence of (top, bot, Soil)

water_table

Depth of the phreatic surface (m, positive downward). None or np.inf means no water table (dry profile).

TYPE: float DEFAULT: None

Examples:

Python Console Session
>>> from geoeq.profile import Soil, SoilProfile
>>> p = SoilProfile([
...     (0, 2, Soil("Fill",       gamma=18)),
...     (2, 8, Soil("Soft Clay",  gamma=17, gamma_sat=18.5)),
...     (8, 20, Soil("Dense Sand", gamma=19, gamma_sat=20.5)),
... ], water_table=2.0)
>>> round(p.effective_stress(10), 1)
133.7
Source code in geoeq/profile/profile.py
Python
def __init__(
    self,
    layers: Sequence[LayerInput],
    water_table: Optional[float] = None,
):
    if not layers:
        raise ValueError("SoilProfile needs at least one layer.")
    self._layers: List[_Layer] = []
    prev_bot = layers[0][0]
    for i, (top, bot, soil) in enumerate(layers):
        if bot <= top:
            raise ValueError(
                f"Layer {i}: bottom ({bot}) must be > top ({top}).")
        if not np.isclose(top, prev_bot):
            raise ValueError(
                f"Layer {i} starts at {top} m but previous layer ended "
                f"at {prev_bot} m -- profile must be contiguous.")
        if not isinstance(soil, Soil):
            raise TypeError(
                f"Layer {i}: third element must be a Soil instance.")
        self._layers.append(_Layer(float(top), float(bot), soil))
        prev_bot = bot
    self.water_table = (
        float("inf") if water_table is None else float(water_table))

layers

Python
layers() -> List[Tuple[float, float, Soil]]

Return layers as a list of (top, bot, Soil) tuples.

Source code in geoeq/profile/profile.py
Python
def layers(self) -> List[Tuple[float, float, Soil]]:
    """Return layers as a list of ``(top, bot, Soil)`` tuples."""
    return [(L.top, L.bot, L.soil) for L in self._layers]

layer_at

Python
layer_at(z: float) -> Soil

Return the Soil instance at depth z (m).

Source code in geoeq/profile/profile.py
Python
def layer_at(self, z: float) -> Soil:
    """Return the ``Soil`` instance at depth z (m)."""
    z = float(z)
    if z < self.top or z > self.bottom:
        raise ValueError(
            f"Depth {z} m is outside the profile "
            f"({self.top}..{self.bottom}).")
    for L in self._layers:
        if L.top <= z <= L.bot:
            return L.soil
    raise ValueError(f"Depth {z} m not found in any layer.")

add_layer

Python
add_layer(top: float, bot: float, soil: Soil) -> None

Append a layer at the bottom of the profile.

Source code in geoeq/profile/profile.py
Python
def add_layer(self, top: float, bot: float, soil: Soil) -> None:
    """Append a layer at the bottom of the profile."""
    if not np.isclose(top, self.bottom):
        raise ValueError(
            f"New layer must start at current bottom "
            f"({self.bottom} m), got {top} m.")
    if bot <= top:
        raise ValueError(f"bot ({bot}) must be > top ({top}).")
    self._layers.append(_Layer(float(top), float(bot), soil))

total_stress

Python
total_stress(z: Union[float, Iterable[float]]) -> Union[float, np.ndarray]

Total vertical stress sigma_v at depth z (kPa).

Notes

Integrates gamma above water table and gamma_sat below. If the water table is above the ground surface, hydrostatic pressure of the standing water is added.

Source code in geoeq/profile/profile.py
Python
def total_stress(self, z: Union[float, Iterable[float]]) -> Union[float, np.ndarray]:
    """Total vertical stress sigma_v at depth z (kPa).

    Notes
    -----
    Integrates ``gamma`` above water table and ``gamma_sat`` below.
    If the water table is above the ground surface, hydrostatic
    pressure of the standing water is added.
    """
    z_arr = np.atleast_1d(np.asarray(z, dtype=float))
    out = np.zeros_like(z_arr)
    for i, zi in enumerate(z_arr):
        out[i] = self._sigma_at(float(zi))
    return float(out[0]) if np.isscalar(z) else out

pore_pressure

Python
pore_pressure(z: Union[float, Iterable[float]]) -> Union[float, np.ndarray]

Hydrostatic pore water pressure u at depth z (kPa).

Source code in geoeq/profile/profile.py
Python
def pore_pressure(
    self, z: Union[float, Iterable[float]]
) -> Union[float, np.ndarray]:
    """Hydrostatic pore water pressure u at depth z (kPa)."""
    z_arr = np.atleast_1d(np.asarray(z, dtype=float))
    u = GAMMA_WATER * np.maximum(0.0, z_arr - self.water_table)
    return float(u[0]) if np.isscalar(z) else u

effective_stress

Python
effective_stress(z: Union[float, Iterable[float]]) -> Union[float, np.ndarray]

Effective vertical stress sigma'_v = sigma_v - u (kPa).

Source code in geoeq/profile/profile.py
Python
def effective_stress(
    self, z: Union[float, Iterable[float]]
) -> Union[float, np.ndarray]:
    """Effective vertical stress sigma'_v = sigma_v - u (kPa)."""
    return self.total_stress(z) - self.pore_pressure(z)

stress_at

Python
stress_at(z: float) -> dict

Return {'sigma': ..., 'u': ..., 'sigma_eff': ...} at depth z.

Source code in geoeq/profile/profile.py
Python
def stress_at(self, z: float) -> dict:
    """Return ``{'sigma': ..., 'u': ..., 'sigma_eff': ...}`` at depth z."""
    return {
        "sigma": float(self.total_stress(z)),
        "u": float(self.pore_pressure(z)),
        "sigma_eff": float(self.effective_stress(z)),
    }

to_dataframe

Python
to_dataframe()

Export layer data to a pandas DataFrame (if pandas is installed).

Source code in geoeq/profile/profile.py
Python
def to_dataframe(self):
    """Export layer data to a pandas DataFrame (if pandas is installed)."""
    try:
        import pandas as pd
    except ImportError as e:  # pragma: no cover -- soft dep
        raise ImportError("pandas is required for to_dataframe()") from e
    rows = []
    for L in self._layers:
        rows.append({
            "name": L.soil.name,
            "top": L.top,
            "bot": L.bot,
            "thickness": L.thickness,
            "gamma": L.soil.gamma,
            "gamma_sat": L.soil.gamma_sat,
            "phi": L.soil.phi,
            "c": L.soil.c,
        })
    return pd.DataFrame(rows)

plot

Python
plot(dz: float = 0.1, ax=None, show: bool = False, save_as=None)

Plot sigma, u, sigma' vs depth.

Returns the Matplotlib figure for further customization.

Source code in geoeq/profile/profile.py
Python
def plot(self, dz: float = 0.1, ax=None, show: bool = False, save_as=None):
    """Plot sigma, u, sigma' vs depth.

    Returns the Matplotlib figure for further customization.
    """
    import matplotlib.pyplot as plt
    depths = np.arange(self.top, self.bottom + dz / 2, dz)
    sigma = self.total_stress(depths)
    u = self.pore_pressure(depths)
    sigma_eff = self.effective_stress(depths)

    if ax is None:
        fig, ax = plt.subplots(figsize=(6, 8))
    else:
        fig = ax.figure
    ax.plot(sigma, depths, label=r"$\sigma$ (total)",
            color="#1f3a93", linewidth=1.8)
    ax.plot(u, depths, label=r"$u$ (pore water)",
            color="#2980b9", linewidth=1.8, linestyle="--")
    ax.plot(sigma_eff, depths, label=r"$\sigma'$ (effective)",
            color="#c0392b", linewidth=1.8)

    # Water table line.
    if np.isfinite(self.water_table):
        ax.axhline(self.water_table, color="#3498db",
                   linewidth=1.0, linestyle=":")
        ax.text(0.02, self.water_table, "  WT",
                transform=ax.get_yaxis_transform(),
                va="center", color="#2980b9", fontsize=9)
    # Layer boundaries.
    for L in self._layers[1:]:
        ax.axhline(L.top, color="0.7", linewidth=0.6)

    ax.invert_yaxis()
    ax.set_xlabel("Stress (kPa)")
    ax.set_ylabel("Depth (m)")
    ax.set_title("Stress profile")
    ax.grid(True, alpha=0.3)
    ax.legend(loc="lower right")

    if save_as:
        fig.savefig(save_as, dpi=300, bbox_inches="tight")
    if show:  # pragma: no cover -- interactive
        plt.show()
    return fig

mesh

Python
mesh(profile: SoilProfile, dz: float = 0.5) -> np.ndarray

Calculation grid of depths from profile top to bottom at spacing dz.

Source code in geoeq/profile/profile.py
Python
def mesh(profile: SoilProfile, dz: float = 0.5) -> np.ndarray:
    """Calculation grid of depths from profile top to bottom at spacing dz."""
    if dz <= 0:
        raise ValueError("dz must be positive.")
    return np.arange(profile.top, profile.bottom + dz / 2, dz)

log_plot

Python
log_plot(boreholes, save_as=None)

Multi-borehole log plot.

PARAMETER DESCRIPTION
boreholes

Mapping of borehole label to profile.

TYPE: dict[str, SoilProfile]

RETURNS DESCRIPTION
Figure
Source code in geoeq/profile/profile.py
Python
def log_plot(boreholes, save_as=None):  # pragma: no cover -- light stub
    """Multi-borehole log plot.

    Parameters
    ----------
    boreholes : dict[str, SoilProfile]
        Mapping of borehole label to profile.

    Returns
    -------
    matplotlib.figure.Figure
    """
    import matplotlib.pyplot as plt
    fig, axes = plt.subplots(1, len(boreholes), figsize=(3 * len(boreholes), 8),
                             sharey=True)
    if len(boreholes) == 1:
        axes = [axes]
    for ax, (name, profile) in zip(axes, boreholes.items()):
        for top, bot, soil in profile:
            ax.fill_betweenx([top, bot], 0, 1, alpha=0.4,
                             label=soil.name)
            ax.text(0.5, (top + bot) / 2, soil.name,
                    ha="center", va="center", fontsize=8)
        ax.set_xlim(0, 1)
        ax.set_xticks([])
        ax.set_title(name)
        ax.invert_yaxis()
    axes[0].set_ylabel("Depth (m)")
    if save_as:
        fig.savefig(save_as, dpi=300, bbox_inches="tight")
    return fig