Note
Click here to download the full example code
compositing¶
the stacked sulphide lenses: 16 995 assays, mostly 1 m or 2 m, taken only in and around the mineralized zones, and 1726 lithology intervals. compositing brings the assays to one support. it must not average across a contact, read unsampled core as zero, or create or lose metal.
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]))
import boitata as bt
import matplotlib.pyplot as plt
import numpy as np
from common import ACCENT, GRAY, LIGHT, save
assays and lithology come in separate interval tables. merge_intervals splits both at each boundary so each piece
carries its grades and its lithology. pieces outside the assayed zones have no grades.
data = bt.datasets.stacked_sulphide_lenses()
collar, survey, assay, lithology = data["collars"], data["surveys"], data["assays"], data["lithology"]
GRADES = ["ZN_PCT", "PB_PCT", "CU_PCT", "AG_GPT", "AU_GPT"]
intervals = bt.merge_intervals(assay, lithology)
dh = bt.Drillholes(collar, survey, intervals)
print(f"{assay.num_rows} assays + {lithology.num_rows} lithology intervals -> {intervals.num_rows} merged")
print(dh)
Out:
16995 assays + 1726 lithology intervals -> 17848 merged
Drillholes(289 holes, 17848 intervals)
HOLE_ID: Utf8
FROM: Float64
TO: Float64
ZN_PCT: Float64
PB_PCT: Float64
CU_PCT: Float64
AG_GPT: Float64
AU_GPT: Float64
DENSITY: Float64
LITH: Utf8
compositing to 2 m by LITH cuts intervals at each 2 m mark and at each contact, so no composite averages across
one. a grade is the mean over the length that carries a value, which keeps unsampled core from counting as zero.
each grade returns that length as <grade>_length, next to length, which also counts unsampled ground.
composites without assays are dropped. here the assayed zones start and end at lithology contacts, so inside a
lithology Zn is sampled everywhere. the RC holes have no Au assays.
composites = dh.composite(2.0, GRADES, domain="LITH")
partial = composites["ZN_PCT_length"] < composites["length"] - 1e-9
no_au = np.isnan(composites["AU_GPT"])
print(
f"{len(composites)} composites; {partial.sum()} partly unsampled for Zn; {no_au.sum()} without Au (RC holes)"
)
Out:
13602 composites; 0 partly unsampled for Zn; 2247 without Au (RC holes)
the assays are mostly 1 m or 2 m and the composites 2 m, with shorter tails where a run of one lithology ends.
raw_len = assay["TO"] - assay["FROM"]
fig, ax = plt.subplots(figsize=(6, 3.4), layout="constrained")
bins = np.arange(0, 3.0, 0.125)
ax.hist(raw_len, bins, color=LIGHT, edgecolor=GRAY, lw=0.5, label="assays")
ax.hist(composites["length"], bins, histtype="step", color=ACCENT, lw=1.6, label="composites")
ax.set(title="Interval lengths", xlabel="Length (m)", ylabel="Count")
ax.legend(loc="upper left")
save(fig, "compositing")

other supports. length=None gives one composite per run of a lithology. intervals= composites to given
intervals instead, here 10 m benches: the depths where each desurveyed path crosses a bench elevation. residual=
decides what happens to a run's tail shorter than min_fraction of the length: kept, merged into the previous
composite or dropped. without domain, composites cross contacts and categories= gives the lithology covering
most of each.
BENCH = 10.0
paths = dh.paths()
hole, depth, z = paths["HOLE_ID"], paths["depth"], paths["z"]
cuts = {h: [0.0, depth[hole == h].max()] for h in np.unique(hole)}
for i in np.flatnonzero(hole[1:] == hole[:-1]):
lo, hi = sorted((z[i], z[i + 1]))
for level in np.arange(np.ceil(lo / BENCH) * BENCH, hi, BENCH):
cuts[hole[i]].append(depth[i] + (level - z[i]) / (z[i + 1] - z[i]) * (depth[i + 1] - depth[i]))
benches = {"HOLE_ID": [], "FROM": [], "TO": []}
for h, c in cuts.items():
c = np.unique(c)
benches["HOLE_ID"] += [h] * (len(c) - 1)
benches["FROM"] += list(c[:-1])
benches["TO"] += list(c[1:])
modes = {
"2 m": composites,
"runs": dh.composite(None, GRADES, domain="LITH"),
"10 m benches": dh.composite(None, GRADES, domain="LITH", intervals=benches),
"2 m, merge < 1 m": dh.composite(2.0, GRADES, domain="LITH", residual="merge"),
"2 m, drop < 1 m": dh.composite(2.0, GRADES, domain="LITH", residual="drop"),
"2 m, majority LITH": dh.composite(2.0, GRADES, categories=["LITH"]),
}
for name, c in modes.items():
print(f"{name:>18}: {len(c):6} composites, median length {np.median(c['length']):.1f} m")
Out:
2 m: 13602 composites, median length 2.0 m
runs: 873 composites, median length 12.2 m
10 m benches: 3176 composites, median length 10.8 m
2 m, merge < 1 m: 13301 composites, median length 2.0 m
2 m, drop < 1 m: 13284 composites, median length 2.0 m
2 m, majority LITH: 13390 composites, median length 2.0 m
metal balance: Σ grade × <grade>_length over the composites reproduces Σ grade × interval length over the assays,
for each grade and each mode except drop, which leaves its short tails out. weighting by length instead
counts unsampled ground at the composite grade and inflates metal, here where majority-LITH composites cross a
contact into unassayed rock.
def metal(grade, length):
return np.nansum(grade * length)
assayed = {g: metal(assay[g], raw_len) for g in GRADES}
print(f"{'':>18} {'Zn metal':>10} {'error':>8} {'by length':>9}")
print(f"{'assays':>18} {assayed['ZN_PCT']:10.1f}")
for name, c in modes.items():
zn_metal = metal(c["ZN_PCT"], c["ZN_PCT_length"])
error, naive = zn_metal / assayed["ZN_PCT"] - 1, metal(c["ZN_PCT"], c["length"]) / assayed["ZN_PCT"] - 1
print(f"{name:>18} {zn_metal:10.1f} {error:+8.1e} {naive:+9.1%}")
if name != "2 m, drop < 1 m":
for g in GRADES:
assert abs(metal(c[g], c[f"{g}_length"]) / assayed[g] - 1) < 1e-9, (name, g)
print("metal balanced to 1e-9 for", ", ".join(GRADES))
Out:
Zn metal error by length
assays 15710.3
2 m 15710.3 -1.1e-16 -0.0%
runs 15710.3 +0.0e+00 +0.0%
10 m benches 15710.3 -2.2e-16 -0.0%
2 m, merge < 1 m 15710.3 -1.1e-16 -0.0%
2 m, drop < 1 m 15388.3 -2.0e-02 -2.0%
2 m, majority LITH 15710.3 -1.1e-16 +0.1%
metal balanced to 1e-9 for ZN_PCT, PB_PCT, CU_PCT, AG_GPT, AU_GPT
Total running time of the script: ( 0 minutes 0.386 seconds)
Download Python source code: example_02_03.py