Skip to content

Note

Click here to download the full example code

A laterite profile: horizons, then grades

Weathering has turned an ultramafic bedrock into a blanket a few tens of meters thick: ferricrete at surface, limonite, saprolite, then fresh rock. 448 vertical holes, on a 50 m mesh with a 25 m infill in the middle, log the horizons and assay Ni and Co every meter. You model the geometry first, then the grades and the tonnes.

import sys
from pathlib import Path

HERE = Path(__file__).parent if "__file__" in globals() else Path.cwd()
sys.path.insert(0, str(HERE.parents[1]))
from itertools import pairwise

import boitata as bt
import matplotlib.pyplot as plt
import numpy as np
from common import ACCENT, GRAY, HIGHLIGHT, INK, LIGHT, map_axes, save

Horizon contacts

Each hole logs its horizons top down, and the top of each horizon is a contact; with vertical holes its elevation is the collar's less the depth. Where a hole has no ferricrete, the limonite starts at surface.

data = bt.datasets.nickel_laterite_profile()
collars, horizons = data["collars"], data["horizons"]
holes = collars["HOLE_ID"]
xy, surface = np.column_stack([collars["X"], collars["Y"]]), collars["Z"]
names = ["FERR", "LIM", "SAP", "BRK"]
logged, logged_hole = horizons["HORIZON"], horizons["HOLE_ID"]
row = {h: i for i, h in enumerate(holes)}
tops = {name: surface.copy() for name in names}
for name in names[1:]:
    keep = logged == name
    rows = [row[h] for h in logged_hole[keep]]
    tops[name][rows] = surface[rows] - horizons["FROM"][keep]
for name, below in pairwise(names):
    thickness = tops[name] - tops[below]
    print(f"{name:4}: {thickness.mean():4.1f} m on average, {thickness.min():.1f} to {thickness.max():.1f} m")
depth = surface - tops["BRK"]
print(f"bedrock: {depth.mean():.1f} m deep on average, {depth.min():.1f} to {depth.max():.1f} m")

i, j = np.round((xy[:, 0] - 30000) / 25), np.round((xy[:, 1] - 60000) / 25)
infill = (i % 2 == 1) | (j % 2 == 1)
print(f"{(~infill).sum()} holes on the 50 m mesh, {infill.sum()} infill")

fig, ax = plt.subplots(figsize=(7, 4.6), layout="constrained")
points = ax.scatter(*xy[~infill].T, c=depth[~infill], s=16, edgecolors=INK, linewidths=0.3)
ax.scatter(*xy[infill].T, s=10, facecolors="none", edgecolors=HIGHLIGHT, linewidths=0.8, label="25 m infill")
fig.colorbar(points, ax=ax, shrink=0.8, label="Depth to bedrock (m)")
ax.legend(loc="lower right", bbox_to_anchor=(1, 1), fontsize=8)
map_axes(ax, "Depth to bedrock on the 50 m mesh")
save(fig, "contacts")

Depth to bedrock on the 50 m mesh

Out:

FERR:  2.2 m on average, 0.0 to 5.3 m
LIM :  7.4 m on average, 3.5 to 16.2 m
SAP : 10.9 m on average, 2.6 to 49.6 m
bedrock: 20.5 m deep on average, 9.4 to 59.6 m
301 holes on the 50 m mesh, 147 infill

The saprolite varies most: 2.6 to 49.6 m. Bedrock lies 20.5 m deep on average, but 59.6 m in the deepest hole.

Is the 50 m mesh enough?

Krige the bedrock elevation from the mesh holes alone, then compare it with the contacts logged by the infill.

mesh_xy, mesh_z = xy[~infill], tops["BRK"][~infill]
experimental = bt.experimental_variogram(mesh_xy, mesh_z, 50.0, 500.0)
bedrock = bt.Variogram.fit(experimental, "spherical")
print(bedrock)
search = bt.Search(radius=250, max_samples=16, min_samples=4)
kriging = bt.OrdinaryKriging(bedrock, search).fit(mesh_xy, mesh_z)
error = bt.compare(kriging.predict(xy[infill]), tops["BRK"][infill])
naive = bt.compare(surface[infill] - depth[~infill].mean(), tops["BRK"][infill])
print(f"kriged at the infill: mean error {error['mean_error']:+.2f} m, RMSE {error['rmse']:.2f} m")
print(f"topography less the mean depth: RMSE {naive['rmse']:.2f} m")

fig, (a, b) = plt.subplots(1, 2, figsize=(9.2, 3.4), layout="constrained")
bt.plot.variogram(experimental, variogram=bedrock, ax=a, color=ACCENT)
a.set(xlabel="Lag distance (m)", ylabel="γ(h) (m²)", title="Bedrock elevation, 50 m mesh")
for keep, lag, color, label in (
    (~infill, 50.0, ACCENT, "50 m mesh"),
    (infill, 12.5, HIGHLIGHT, "25 m infill"),
):
    exp = bt.experimental_variogram(xy[keep], depth[keep], lag, 300.0)
    b.plot(exp.lags, exp.gammas, "o-", color=color, ms=3, label=label)
b.set(xlim=(0, 300), ylim=(0, None), xlabel="Lag distance (m)", ylabel="γ(h) (m²)", title="Depth to bedrock")
b.legend(loc="lower right")
save(fig, "bedrock")

Bedrock elevation, 50 m mesh, Depth to bedrock

Out:

Variogram(nugget=0, structures=[Structure("spherical", sill=344.4516832057495, range=905.6018592151071)], rotation=(0.0, 0.0, 0.0), ratios=(1.0, 1.0))
kriged at the infill: mean error -0.77 m, RMSE 5.25 m
topography less the mean depth: RMSE 5.55 m

The elevation variogram keeps climbing over the whole area: the bedrock follows the topography. Kriged from the mesh, it misses the infill contacts by 5.25 m RMSE, little better than the topography less the mean depth, 5.55 m. The depth to bedrock shows why: on the 50 m mesh its variogram is flat from the first lag, pure nugget, while the infill resolves a structure shorter than 50 m. The pockets and ridges of fresh rock are narrower than the mesh; only the infill sees them.

A layered block model

With all holes, topography and bedrock elevations are kriged on a 25 m grid, and so are the ferricrete and limonite thicknesses; hung below the topography, they give the other two contacts. Each surface becomes a mesh with grid_surface, and from_meshes labels each sub-cell by the first surface it lies below, in the order bedrock, saprolite, limonite, topography; above the topography nothing matches and no block is made. Blocks of 25 × 25 × 5 m split into 0.5 m sub-cells along z follow the contacts; the bedrock blocks are then dropped.

grid = bt.BlockModel((29987.5, 59987.5, 0), (25, 25, 1), (41, 29, 1))
surfaces = {
    "topography": surface,
    "FERR": tops["FERR"] - tops["LIM"],
    "LIM": tops["LIM"] - tops["SAP"],
    "BRK": tops["BRK"],
}
kriged = {}
for name, values in surfaces.items():
    variogram = bt.Variogram.fit(bt.experimental_variogram(xy, values, 25.0, 400.0), "spherical")
    kriged[name] = bt.OrdinaryKriging(variogram, search).fit(xy, values).predict(grid.coords[:, :2])
lim = kriged["topography"] - kriged["FERR"].clip(0)
sap = lim - kriged["LIM"].clip(0)
print(f"saprolite at least {(sap - kriged['BRK']).min():.1f} m thick")
grid = grid.with_columns({"topography": kriged["topography"], "LIM": lim, "SAP": sap, "BRK": kriged["BRK"]})
contacts = {name: bt.grid_surface(grid, name) for name in ["BRK", "SAP", "LIM", "topography"]}
bottom = np.floor(kriged["BRK"].min() / 5) * 5
count = (40, 28, int(np.ceil((kriged["topography"].max() - bottom) / 5)))
rules = [(mesh, "below", name) for mesh, name in zip(contacts.values(), ["BRK", "SAP", "LIM", "FERR"])]
blocks = bt.BlockModel.from_meshes(
    (30000, 60000, bottom), (25, 25, 5), count, rules, (1, 1, 10), column="HORIZON"
)
blocks = blocks.filter(blocks["HORIZON"] != "BRK")
horizon = blocks["HORIZON"]
for name in names[:3]:
    print(f"{name}: {blocks.volumes[horizon == name].sum() / 1e6:.2f} Mm³")
print(f"{len(blocks)} blocks and sub-blocks")

Out:

saprolite at least 1.9 m thick
FERR: 1.49 Mm³
LIM: 5.47 Mm³
SAP: 7.64 Mm³
27213 blocks and sub-blocks

The kriged saprolite never thins below 1.9 m, so the bedrock, kriged on its own, never cuts into the limonite. The volumes match the logged thicknesses over the 0.7 km² of the model: 1.49 Mm³ of ferricrete, 5.47 of limonite and 7.64 of saprolite.

Ni and Co by horizon

The assays are composited to 1 m within each horizon. Each composite's height above bedrock is its elevation less the bedrock contact of its own hole.

intervals = bt.merge_intervals(data["assays"], horizons)
drillholes = bt.Drillholes(collars, data["surveys"], intervals)
composites = drillholes.composite(1.0, ["NI_PCT", "CO_PCT"], domain="HORIZON", residual="merge")
unit, hole = composites["HORIZON"], composites["HOLE_ID"]
height = composites.z - tops["BRK"][[row[h] for h in hole]]
ni, co = composites["NI_PCT"], composites["CO_PCT"]
print(f"{'':5}{'n':>6}{'Ni %':>7}{'CV':>6}{'Co %':>7}{'CV':>6}")
for name in names:
    keep = unit == name
    print(
        f"{name:5}{keep.sum():6d}{ni[keep].mean():7.2f}{ni[keep].std() / ni[keep].mean():6.2f}"
        f"{co[keep].mean():7.3f}{co[keep].std() / co[keep].mean():6.2f}"
    )

scheme = bt.Categories(names, colors=[HIGHLIGHT, ACCENT, GRAY, LIGHT])
fig, axes = plt.subplots(1, 3, figsize=(11, 3.4), layout="constrained")
bt.plot.boxplot(ni, scheme.encode(list(unit)), scheme=scheme, ax=axes[0])
axes[0].set(title="Ni by horizon", ylabel="Ni (%)")
bt.plot.boxplot(co, scheme.encode(list(unit)), scheme=scheme, ax=axes[1])
axes[1].set(title="Co by horizon", ylabel="Co (%)")
bins = np.arange(-5, 41, 2.5)
bt.plot.conditional(height, ni, bins=bins, ax=axes[2])
axes[2].axvline(0, color=INK, lw=1)
axes[2].set(xlim=(-5, 40), xlabel="Height above bedrock (m)", ylabel="Ni (%)", title="Ni against height")
save(fig, "grades")

Ni by horizon, Co by horizon, Ni against height

Out:

          n   Ni %    CV   Co %    CV
FERR   1019   0.36  0.44  0.026  0.69
LIM    3358   1.08  0.44  0.117  0.82
SAP    4921   1.81  0.48  0.042  0.79
BRK    1609   0.29  0.48  0.016  0.82

Each horizon has its own grades. Ni rises down the profile, from 0.36 % in the ferricrete to 1.08 % in the limonite and 1.81 % in the saprolite, then falls to 0.29 % in fresh rock. Co peaks in the limonite, at 0.117 %, three times the saprolite's. Against the height above bedrock, Ni is richest in the first meters above fresh rock and fades upwards: the grades follow position in the profile more than elevation.

Grades into the blocks

Krige each horizon in flattened coordinates: easting, northing and height above the bedrock surface, for composites and blocks alike (vertical_distance gives the blocks' heights). Every horizon and element gets its own variogram, fitted along the east–west drill rows and down the holes at once; composites of one horizon inform only its blocks.

flat = np.column_stack([composites.coords[:, :2], height])
targets = np.column_stack([blocks.coords[:, :2], contacts["BRK"].vertical_distance(blocks)])
grades = {"NI_PCT": np.full(len(blocks), np.nan), "CO_PCT": np.full(len(blocks), np.nan)}
for name in names[:3]:
    keep, into = unit == name, horizon == name
    for element, values in grades.items():
        v = composites[element][keep]
        across = bt.experimental_variogram(flat[keep], v, 25.0, 300.0, azimuth=90, tolerance=20, bandwidth=5)
        down = bt.experimental_variogram(flat[keep], v, 1.0, 10.0, holes=hole[keep])
        model = bt.Variogram.fit_directional(
            [across, down],
            [(90, 0), (0, 90)],
            rotation=[90, 0, 0],
            ratios=[(1, 1), (0.01, 0.5)],
            ranges=[(25, 300)],
        )
        s = model.structures[0]
        print(
            f"{name:4} {element[:2]}: nugget {model.nugget / model.sill:.0%}, "
            f"{s.range:.0f} m across, {s.range * model.ratios[1]:.1f} m down"
        )
        search = bt.Search(
            radius=300, ratios=model.ratios, rotation=model.rotation, max_samples=24, max_per_hole=6
        )
        values[into] = (
            bt.OrdinaryKriging(model, search).fit(flat[keep], v, holes=hole[keep]).predict(targets[into])
        )
blocks = blocks.with_columns(grades)
print(f"{sum(np.isnan(g).sum() for g in grades.values())} values left unestimated")

Out:

FERR NI: nugget 28%, 70 m across, 4.9 m down
FERR CO: nugget 43%, 167 m across, 83.3 m down
LIM  NI: nugget 28%, 25 m across, 5.7 m down
LIM  CO: nugget 55%, 213 m across, 10.4 m down
SAP  NI: nugget 25%, 25 m across, 7.7 m down
SAP  CO: nugget 46%, 25 m across, 7.5 m down
0 values left unestimated

A quarter to a half of each variance is nugget. Down the profile Ni stays continuous for 5 to 8 m; across it, the Ni of the limonite and saprolite loses its continuity within 25 m, the shortest range allowed and the infill spacing. Between holes the blocks lean on the mean of their horizon at their height. The ferricrete, 2 m thick, has too few pairs down the holes to fit its Co anisotropy, which ends on its bound. A section across the infill, at true scale, shows the horizons and the kriged Ni, with the contacts logged by the holes within 12.5 m.

plane = ((30500, 60351, 340), 90, 90)
near = np.abs(xy[:, 1] - 60351) < 12.5
profile = bt.Categories(names[:3], colors=[HIGHLIGHT, ACCENT, GRAY])
fig, axes = plt.subplots(2, 1, figsize=(10, 3.8), layout="constrained", sharex=True)
bt.plot.section(
    blocks, profile.encode(list(horizon)), plane=plane, scheme=profile, colorbar=False, ax=axes[0]
)
bt.plot.section(blocks, "NI_PCT", plane=plane, colorbar=False, vmin=0, vmax=3, ax=axes[1])
fig.colorbar(axes[1].collections[0], ax=axes[1], shrink=0.9, label="Ni (%)")
bt.plot.category_legend(profile, axes[0], loc="lower left", bbox_to_anchor=(1, 0), fontsize=8)
for ax, title in zip(axes, ("Horizons", "Kriged Ni"), strict=True):
    for name in names[1:]:
        ax.scatter(xy[near, 0], tops[name][near], s=30, marker="_", color=INK, lw=1)
    ax.set(title=title, xlim=(30300, 30700), ylim=(315, 375))
axes[0].set_xlabel("")
save(fig, "section")

Horizons, Kriged Ni

Tonnes and grade by horizon

The dataset measures no density, so assume typical dry densities of laterite: 2.2 t/m³ for the ferricrete, 1.5 for the limonite and 1.6 for the saprolite. Tonnes are block volume × density; each horizon is reported whole and above 1.5 % Ni.

density = {"FERR": 2.2, "LIM": 1.5, "SAP": 1.6}
tonnes = blocks.volumes * np.array([density[h] for h in horizon])
ni, co = blocks["NI_PCT"], blocks["CO_PCT"]
print(f"{'':16}{'Mt':>6}{'Ni %':>7}{'Co %':>7}{'kt Ni':>8}{'kt Co':>7}")
for name in names[:3]:
    for label, keep in (("all", horizon == name), ("≥ 1.5 % Ni", (horizon == name) & (ni >= 1.5))):
        t = tonnes[keep].sum()
        if t == 0:
            continue
        ni_t, co_t = (tonnes[keep] * ni[keep]).sum() / 100, (tonnes[keep] * co[keep]).sum() / 100
        print(
            f"{name:4} {label:11}{t / 1e6:6.2f}{100 * ni_t / t:7.2f}{100 * co_t / t:7.3f}"
            f"{ni_t / 1e3:8.1f}{co_t / 1e3:7.2f}"
        )

Out:

                    Mt   Ni %   Co %   kt Ni  kt Co
FERR all          3.28   0.37  0.027    12.0   0.88
LIM  all          8.21   1.09  0.120    89.4   9.84
LIM  ≥ 1.5 % Ni   0.43   1.63  0.157     7.0   0.67
SAP  all         12.22   1.81  0.041   220.8   5.06
SAP  ≥ 1.5 % Ni   9.42   1.94  0.044   182.9   4.11

The block grades of each horizon reproduce its composites: 0.37 against 0.36 % Ni in the ferricrete, 1.09 against 1.08 in the limonite, 1.81 in the saprolite. The saprolite holds 12.2 Mt at 1.81 % Ni, 221 kt of nickel, and 9.42 Mt of it lie above 1.5 % Ni. The limonite, 8.21 Mt at 1.09 % Ni, is poorer in nickel, but at 0.120 % Co it holds 9.84 of the 15.8 kt of cobalt. The ferricrete, 3.28 Mt at 0.37 % Ni, is overburden. Every tonne scales with the assumed densities: a saprolite at 1.4 instead of 1.6 t/m³ would lose an eighth of its tonnes and metal.

Total running time of the script: ( 0 minutes 0.901 seconds)

Download Python source code: example_12_04.py

Download Jupyter notebook: example_12_04.ipynb

Gallery generated by mkdocs-gallery