Daugaard: probabilistic raw-material assessment with INTEGRATE

GEUS previously assessed raw-material (sand/gravel) potential at three target sub-areas in Daugaard (“Delområde 1/2/3”) using a sequential, deterministic workflow: invert the tTEM data to a single best-fit resistivity model, interpret lithology, and hand-estimate overburden and raw-material volumes per sub-area, constrained to what raw-material boreholes could document. Those results live in ReferenceProjects/Sdr Felding og Daugard til Integrate.pptx and the target-area outlines in ReferenceProjects/Fokusområder_Daugaard/.

This notebook is organised in three parts:

Part A – Resistivity only: probabilistic vs. deterministic. Invert the tTEM data with a generic resistivity-only prior (a plain layered-Earth model, no lithology classes) and compare the resulting harmonic-mean / std resistivity section to an externally computed deterministic WorkBench least-squares (LSQ) inversion. This part is purely a demonstration of how probabilistic resistivity-only inversion and deterministic inversion relate to each other – it is not used for the raw-material estimate.

Part B – The full INTEGRATE workflow. Build an informed prior from two geological scenarios (daugaard_standard.xlsx + daugaard_valley.xlsx, built with geoprior1d, merged 50/50), forward-model the prior tTEM data, load and add the borehole data, run the joint (tTEM + borehole) rejection inversion, demonstrate the ig.query tool, grow a data-driven raw-material region from the posterior probability map (B8), and turn it into a low / median / high volume estimate (B9), built from per-sounding thickness percentiles times the Voronoi-cell area around each sounding. Part B is purely about the probabilistic result and ends with a bar chart of the grown region’s raw-material volume.

Part C – Comparison to Mette’s original estimate. Everything involving Mette’s hand-drawn target polygons lives here: their own low/median/high volumes, and the region-vs-polygon comparison next to the single-number estimates from the old sequential/deterministic assessment.

One important caveat, from GEUS’s own notes on the old assessment (see ReferenceProjects/EmailFromMette.md): the deterministic raw-material estimate was capped at the depth documented by raw-material boreholes, even where the tTEM inversion suggested raw material could extend deeper. The probabilistic estimate here is not capped this way, so a meaningfully larger estimate is expected – a genuine methodological difference, not an error in either approach.

Run this notebook from within the examples/ directory (as with the other INTEGRATE examples) so that the case data downloads and the integrate_rawmaterial_utils helper module are found correctly.

try:
    get_*ipython()
    get_ipython().run_line_magic('load_ext', 'autoreload')
    get_ipython().run_line_magic('autoreload', '2')
except Exception:
    pass
import os

# Trim JAX/XLA compile time on the first run:
# - xla_gpu_autotune_level=1 skips exhaustive GEMM/conv autotuning
# - xla_backend_optimization_level=1 lowers XLA/LLVM optimisation effort
# Neither affects the ptxas (PTX -> SASS) step.  If compilation still takes
# many minutes with a `ptxas` process at 100 % CPU, XLA is using the wrong
# (system) ptxas -- see _ensure_bundled_ptxas in integrate_rejection_jax.py.
# NOTE: XLA_FLAGS is read when jax is first imported (via `import integrate`
# below), so in a notebook this cell must run before that -- restart the
# kernel after editing it.
os.environ["XLA_FLAGS"] = "--xla_gpu_autotune_level=1 --xla_backend_optimization_level=1"


import h5py
import numpy as np
import matplotlib.pyplot as plt
import time
import integrate as ig
import integrate_rawmaterial_utils as rmu

hardcopy = True

# Choose the forward model (the default is ga-aem)
os.environ["EM_FORWARD_METHOD"] = "anemone"
os.environ["EM_FORWARD_DEVICE"] = "cuda"
#os.environ["EM_FORWARD_DEVICE"] = "cpu"
#os.environ["EM_FORWARD_METHOD"] = "ga-aem"

t_start = time.time()

0. Settings and fixed file names

Every expensive step (prior sampling, forward modelling, rejection) writes to a fixed path defined below and is skipped with os.path.exists when that file already exists. This makes it possible to run any one section on its own: as long as the file-name variables in this cell are defined, a later section can just load f_post_h5 (etc.) without re-running the earlier ones – e.g. run Part C directly once f_post_h5 exists.

Every generated file name is prefixed with N<N>_ (and the working data file also encodes inflateNoise), so a different N writes to a separate set of files / figures. If you change inflateNoise, delete the affected N*_DAUGAARD_*.h5 files (or bump SUFFIX) so they are regenerated.

— run-size settings ————————————————-

N = 1_000_001   # production-scale
#N = 10_000      # demo-scale; increase for a production-quality run
N = 10_005
N = 100_006+1
# Prior size used everywhere: the generic prior (Part A) and each of the two
# geological-scenario priors merged into the informed prior (Part B, N // 2
# realizations each).

inflateNoise = 2        # multiply the tTEM data std by this (0 = use as-is)

SUFFIX = '_N%d_iN%d' % (N, inflateNoise)             # optional tag appended to every generated file name

# Prefix prepended to every generated file name (h5 + figures) so that a
# given choice of N writes to its own set of files, e.g. 'N12000_iN2_'.
PREFIX = ''

# --- fixed output file names -----------------------------------------
# working data file (noise-inflated copy; == downloaded file if inflateNoise == 0)
f_data_work_h5 = '%sDAUGAARD_AVG_gf%g%s.h5' % (PREFIX, inflateNoise, SUFFIX)

# Part A -- resistivity-only, generic prior
f_prior_generic_h5      = '%sDAUGAARD_PRIOR_GENERIC%s.h5'       % (PREFIX, SUFFIX)
f_prior_generic_data_h5 = '%sDAUGAARD_PRIOR_GENERIC_DATA%s.h5'  % (PREFIX, SUFFIX)
f_post_generic_h5       = '%sDAUGAARD_POSTERIOR_GENERIC%s.h5'   % (PREFIX, SUFFIX)

# Part B -- informed prior + boreholes
f_prior_scenario_h5_map = {
    'daugaard_standard': '%sDAUGAARD_PRIOR_STANDARD%s.h5' % (PREFIX, SUFFIX),
    'daugaard_valley':   '%sDAUGAARD_PRIOR_VALLEY%s.h5'   % (PREFIX, SUFFIX),
}
f_prior_merged_h5  = '%sDAUGAARD_PRIOR_MERGED%s.h5'          % (PREFIX, SUFFIX)
f_prior_data_h5    = '%sDAUGAARD_PRIOR_MERGED_DATA%s.h5'     % (PREFIX, SUFFIX)
f_prior_data_bh_h5 = '%sDAUGAARD_PRIOR_MERGED_DATA_BH%s.h5'  % (PREFIX, SUFFIX)
f_post_h5          = '%sDAUGAARD_POSTERIOR%s.h5'             % (PREFIX, SUFFIX)
f_post_jax_h5          = '%sDAUGAARD_POSTERIOR%s_jax.h5'             % (PREFIX, SUFFIX)

1. Load the data

1a. tTEM data and GEX system file

case = 'DAUGAARD'
files = ig.get_case_data(case=case, showInfo=1)
f_data_h5 = files[0]
file_gex = ig.get_gex_file_from_data(f_data_h5)
print("Using data file: %s" % f_data_h5)
print("Using GEX file: %s" % file_gex)

X, Y, LINE, ELEVATION = ig.get_geometry(f_data_h5)

ig.plot_geometry(f_data_h5, pl='LINE')
ig.plot_data(f_data_h5, hardcopy=hardcopy)
ig.plot_data_xy(f_data_h5, data_channel=15, cmap='jet')
if inflateNoise != 0:
    print("=" * 60)
    print("Using tTEM data with noise (std) inflated by a factor of %d" % inflateNoise)
    print("=" * 60)
    f_data_src_h5 = f_data_h5
    f_data_h5 = f_data_work_h5
    if not os.path.exists(f_data_h5):
        D = ig.load_data(f_data_src_h5)
        ig.copy_hdf5_file(f_data_src_h5, f_data_h5)
        ig.save_data_gaussian(D['d_obs'][0], D_std=D['d_std'][0] * inflateNoise,
                              f_data_h5=f_data_h5, file_gex=file_gex)

ig.plot_data(f_data_h5, useLog=0, hardcopy=hardcopy)
plt.show()

1b. Target-area polygons and the west-to-east profile line

The Daugaard focus-area shapefile (the areas Mette selected from the deterministic interpretation) has an almost-empty attribute table, so the 3 Delområder are matched to their names by polygon area, which is reported (and matches exactly) in the old-approach PowerPoint. The polygons are loaded here because the profile line used for the resistivity sections in Parts A and B runs through their centroids; the polygons themselves are used for the volume analysis in Part C.

DAUGAARD_AREA_ID_MAP = {
    308812.2: 'Delområde 1',   # DAU_02, DAU_06
    106727.7: 'Delområde 2',   # DAU_07
    196598.0: 'Delområde 3',   # DAU_05, DAU_03
}
f_shp_daugaard = os.path.join('ReferenceProjects', 'Fokusområder_Daugaard', 'Fokusområder_polygon.shp')
polygons = rmu.load_target_polygons(f_shp_daugaard, area_id_map=DAUGAARD_AREA_ID_MAP)

Build a profile line through the centroid of each target polygon, ordered from west (smallest X) to east (largest X), and find the sounding indices along that line with ig.find_points_along_line_segments – the same function used for profile selection in integrate_workflow.py and integrate_profiles.py. These indices (id_line) are reused in Parts A and B to plot resistivity/lithology sections along this single line.

centroids = {name: (geom.centroid.x, geom.centroid.y) for name, geom in polygons.items()}
centroids_sorted = sorted(centroids.items(), key=lambda kv: kv[1][0])  # west (small X) -> east (large X)
for name, (cx, cy) in centroids_sorted:
    print("  %-15s centroid = (%9.1f, %9.1f)" % (name, cx, cy))

Xl = np.array([cx for _, (cx, cy) in centroids_sorted])
Yl = np.array([cy for _, (cx, cy) in centroids_sorted])
Yl[0]=Yl[0]-200

buffer = 10.0

# Widen the profile at both ends so it also picks up every sounding
# strictly west of the westernmost Delområde center and strictly east of
# the easternmost one, not just soundings near the 3 centroids themselves:
# extend the line with one extra waypoint beyond each end of the data's
# X-range (at the Y of the nearest centroid), then re-search along the
# now-longer line.
pad = buffer
Xl_wide = np.concatenate(([X.min() - pad], Xl, [X.max() + pad]))
Yl_wide = np.concatenate(([Yl[0]], Yl, [Yl[-1]]))

indices, distances, segment_ids = ig.find_points_along_line_segments(X, Y, Xl_wide, Yl_wide, tolerance=buffer)
id_line = indices
print("Found %d soundings within %.0f m of the widened west-to-east center profile" % (len(id_line), buffer))

pl_data = [id_line[10], id_line[100]]

# Sanity check: are the tTEM soundings and the target polygons in the same
# coordinate frame? (Both are UTM32N, but this is worth confirming visually
# rather than assuming.) The selected profile line and its soundings are
# overlaid on the same plot.
rmu.plot_polygons_over_points(X, Y, polygons, title='Daugaard', hardcopy=hardcopy,
                              profile_xy=(Xl_wide, Yl_wide), profile_idx=id_line,
                              suffix=SUFFIX)
fig, ax, sc = ig.plot_xy(ELEVATION, X=X, Y=Y, cmap='jet', colorbar_label='Elevation [m]',
                         title='Data locations and selected profile', s=3)
ax.plot(X[id_line], Y[id_line], 'k.', label='profile soundings (id_line)')
j=0

for i in pl_data:
    j=j+1
    ax.plot(X[i], Y[i], 'wo', ms=16)
    ax.plot(X[i], Y[i], 'k*', ms=6+j*5, label='example data location %d (id=%d)' % (j,i))
    ax.text(X[i] + 25, Y[i] + 25, 'd%d (%d)' % (j, i), color='k', fontsize=18)
ax.legend(loc='best')
if hardcopy:
    fig.savefig(PREFIX + 'daugaard_data_locations_profile' + SUFFIX + '.png', dpi=200, bbox_inches='tight')
plt.show()

Part A – Resistivity only: probabilistic (generic prior) vs. deterministic

Invert the tTEM data with a generic resistivity-only prior (a plain layered-Earth model, lay_dist='chi2', no lithology classes, as in integrate_getting_started.py). Plot the posterior harmonic-mean and std resistivity along the west-to-east profile, then load the externally computed deterministic WorkBench LSQ inversion and plot it the same way. The point of this part is only to show how the probabilistic resistivity-only result and the deterministic result relate to each other.

t_generic_start = time.time()
if not os.path.exists(f_prior_generic_h5):
    ig.prior_model_layered(
        N=N, lay_dist='chi2', NLAY_deg=3, RHO_min=1, RHO_max=3000,
        f_prior_h5=f_prior_generic_h5, showInfo=1)
ig.plot_prior_stats(f_prior_generic_h5, hardcopy=hardcopy)
if not os.path.exists(f_prior_generic_data_h5):
    ig.prior_data_em(f_prior_generic_h5, file_gex, doMakePriorCopy=True,
                        f_prior_data_h5=f_prior_generic_data_h5)
if not os.path.exists(f_post_generic_h5):
    ig.integrate_rejection(
        f_prior_generic_data_h5, f_data_h5, f_post_h5=f_post_generic_h5,
        N_use=N, id_use=[1], autoT=1, T_base=1, showInfo=0,
        updatePostStat=True, backend='jax')

t_generic_end = time.time()


ig.plot_T_EV(f_post_generic_h5, pl='CHI2', hardcopy=hardcopy)

Probabilistic resistivity-only profile: harmonic mean and std (log space)

ig.plot_profile(f_post_generic_h5, ii=id_line, im=1, panels=['harmonicmean', 'std'],
                xaxis='x', gap_threshold=100, hardcopy=hardcopy,
                txt='probabilistic_generic')

alpha>0 fades the harmonic-mean panel by uncertainty, normalised in the same log10(std) space shown in the ‘std’ panel (default logstd_min=0.5, logstd_max=1.0): opaque at LogStd<=0.5, fully transparent at LogStd>=1.0.

ig.plot_profile(f_post_generic_h5, ii=id_line, im=1, panels=['harmonicmean', 'std'],
                xaxis='x', gap_threshold=100, hardcopy=hardcopy, alpha=.95,
                logstd_min=0.1, logstd_max=1.0,
                txt='probabilistic_generic_alpha')

Deterministic (WorkBench LSQ) inversion

Download the WorkBench LSQ result (smooth and sharp variants) and reproject it onto the same depth grid as the probabilistic posterior, following the pattern in integrate_paper_daugaard_supp.py, then plot it as a normal profile (mean + std, log space) along the same id_line.

import libaarhusxyz

ig.get_case_data(case=case, loadType='WB_smooth')
ig.get_case_data(case=case, loadType='WB_sharp')

f_xyz_list = {
    'smooth': 'SCI7_40_ml_Daugaard_I01_MOD_inv.xyz',
    'sharp':  'SCI7_40_ml_sharp2_I02_MOD_inv.xyz',
}

f_lsq_h5_list = {}
for label, f_xyz in f_xyz_list.items():
    f_lsq_h5 = os.path.splitext(f_xyz)[0] + '.h5'
    ig.copy_hdf5_file(f_post_generic_h5, f_lsq_h5)

    Xp, Yp, _, _ = ig.get_geometry(f_lsq_h5)
    with h5py.File(f_lsq_h5, 'r') as f:
        M1_median = f['/M1/Median'][:]
    with h5py.File(f_prior_generic_data_h5, 'r') as f:
        z = f['/M1'].attrs['x'][:].flatten()

    model = libaarhusxyz.XYZ(f_xyz)
    rho = model.layer_data['rho'].values
    rho_std = model.layer_data['rho_std'].values
    dep_top = model.layer_data['dep_top'].values
    utmx = model.flightlines['utmx'].values
    utmy = model.flightlines['utmy'].values

    M1_mean = np.full_like(M1_median, np.nan)
    M1_stdf = np.full_like(M1_median, np.nan)   # raw WorkBench rho_std (uncertainty factor, >= 1)
    for i in range(len(utmx)):
        idx = np.argmin((Xp - utmx[i]) ** 2 + (Yp - utmy[i]) ** 2)
        j_idx = np.searchsorted(dep_top[i], z, side='right') - 1
        valid = j_idx >= 0
        M1_mean[idx, valid] = rho[i, j_idx[valid]]
        M1_stdf[idx, valid] = rho_std[i, j_idx[valid]]

    # Aarhus Workbench RHO_STD is the *standard-deviation factor* (STDF): the
    # parameter is estimated in log space, so it is multiplicative --
    #   68% CI      = [rho / RHO_STD, rho * RHO_STD]
    #   std(log10 rho) = log10(RHO_STD)   <- this is what the std panel plots
    #   std(ln    rho) = ln(RHO_STD)
    # RHO_STD == 1 -> perfectly resolved; large -> unresolved (Workbench caps
    # it at 99). It is NOT additive and NOT a percentage, so (RHO_STD - 1) is
    # only valid when RHO_STD is close to 1 (see the smooth vs. sharp header:
    # /MODEL TYPE, /MODEL UNIT, /INVERSION DATA SPACE = Logarithmic).
    M1_logstd = np.log10(M1_stdf)           # std of log10(rho); values > 1 = unresolved
    M1_std    = M1_mean * np.log(M1_stdf)   # approx linear-space std [ohm-m] (rho * sigma_ln)

    # f_lsq_h5 was copied from the probabilistic posterior, so it still holds
    # that run's /M1 statistics. Overwrite EVERY statistic plot_profile might
    # read, or a stale probabilistic value shows through. In particular the
    # std panel plots /M1/LogStd. The LSQ result is a single model, so
    # mean = median = harmonic mean = the model itself.
    with h5py.File(f_lsq_h5, 'a') as f:
        for key in ['/M1/Mean', '/M1/LogMean', '/M1/Median', '/M1/HarmonicMean',
                    '/M1/Std', '/M1/LogStd']:
            if key in f:
                del f[key]
        f['/M1/Mean'] = M1_mean
        f['/M1/Median'] = M1_mean
        f['/M1/HarmonicMean'] = M1_mean
        f['/M1/LogMean'] = np.log10(M1_mean)
        f['/M1/Std'] = M1_std           # linear-space std [ohm-m]
        f['/M1/LogStd'] = M1_logstd     # std of log10(rho) -- what the std panel plots
    f_lsq_h5_list[label] = f_lsq_h5

Same west-to-east profile (id_line) as the probabilistic plot above.

for label, f_lsq_h5 in f_lsq_h5_list.items():
    ig.plot_profile(f_lsq_h5, ii=id_line, im=1, panels=['mean', 'std'],
                    xaxis='x', gap_threshold=100, hardcopy=hardcopy,
                    txt='WB_%s' % label)
ig.plot_profile(f_post_generic_h5, ii=id_line, im=1, panels=['harmonicmean', 'std'],
                xaxis='x', gap_threshold=100, hardcopy=hardcopy,
                txt='probabilistic_generic')

Takeaway: the probabilistic resistivity-only inversion recovers a section broadly consistent with the deterministic WorkBench LSQ result, but additionally quantifies the uncertainty (std) at every point rather than delivering only a single smooth/sharp model. This resistivity-only comparison says nothing about raw-material volumes – that needs the informed lithology prior in Part B.

Part B – The full INTEGRATE workflow (informed prior + boreholes)

t_full_start = time.time()

B1. Informed prior from two geological scenarios

The prior for the real workflow is built with geoprior1d (https://github.com/GEUSjesper/geoprior1d) from Excel specifications of the expected layer sequences, thicknesses, and resistivities for the Daugaard geology – this is the step GEUS owns for a new target area.

Two scenarios are available: daugaard_standard.xlsx (background/regional geology) and daugaard_valley.xlsx (geology inside a buried meltwater valley). Since it is not known in advance whether a given sounding sits inside or outside a buried valley, N // 2 realizations are generated independently from each Excel specification and combined into a single prior with ig.merge_prior (50% from each scenario), letting the data indicate, location by location, which scenario it is more consistent with – the same construction used in integrate_workflow.py and integrate_daugaard_multi_prior.py.

t_full_start_prior = time.time()

from geoprior1d import geoprior1d

ig.get_case_data(case=case, filelist=['daugaard_standard.xlsx', 'daugaard_valley.xlsx'])

# N_prior_each realizations per geological scenario; the merged prior has 2x this.
N_prior_each = N // 2
f_xlsx_files = ['daugaard_standard.xlsx', 'daugaard_valley.xlsx']

f_prior_h5_list = []
for file_xlsx in f_xlsx_files:
    fname = file_xlsx.split('.')[0]
    f_scen_h5 = f_prior_scenario_h5_map[fname]
    if not os.path.exists(f_scen_h5):
        geoprior1d(file_xlsx, Nreals=N_prior_each, dz=1, dmax=90, output_file=f_scen_h5)
    else:
        print("Using existing prior realizations: %s" % f_scen_h5)
    f_prior_h5_list.append(f_scen_h5)

if not os.path.exists(f_prior_merged_h5):
    ig.merge_prior(f_prior_h5_list, f_prior_merged_h5=f_prior_merged_h5)
else:
    print("Using existing merged prior: %s" % f_prior_merged_h5)
f_prior_h5 = f_prior_merged_h5

t_full_end_prior = time.time()

ig.plot_prior_stats(f_prior_h5, hardcopy=hardcopy)
ig.prior_describe(f_prior_h5)

B2. Boreholes

%%

BHOLES = ig.read_borehole('daugaard_12boreholes.json', showInfo=1)
ig.plot_boreholes(BHOLES, f_prior_h5, fontsize=17, hardcopy=hardcopy);

B3. Prior tTEM data

Forward-model the merged prior once (this is the expensive step; the result is reused for the borehole prior data below).

t_full_start_prior_data_em = time.time()
if not os.path.exists(f_prior_data_h5):
    ig.prior_data_em(f_prior_h5, file_gex, doMakePriorCopy=True,
                        f_prior_data_h5=f_prior_data_h5)
t_full_end_prior_data_em = time.time()

B4. Borehole prior data

Add the boreholes as extra, jointly inverted data types onto a fresh copy of the tTEM prior-data file. ig.save_borehole_data takes the whole BHOLES list in one call (no Python loop) and returns one prior/data /D index per borehole, exactly as in integrate_workflow.py. Boreholes are appended right after the tTEM data (/D1), so on the cached path the data IDs are the deterministic 2 .. 1+len(BHOLES). Skipped entirely if the posterior already exists.

t_full_start_prior_data_bd = time.time()

im_prior = 2
id_borehole_list = list(range(2, 2 + len(BHOLES)))   # deterministic /D indices (cached path)

if not os.path.exists(f_post_h5) and not os.path.exists(f_prior_data_bh_h5):
    ig.copy_hdf5_file(f_prior_data_h5, f_prior_data_bh_h5)
    id_prior_list, id_borehole_list = ig.save_borehole_data(
        f_prior_data_bh_h5, f_data_h5, BHOLES,
        im_prior=im_prior,
        range_xyz=100,
        range_data=1, range_data_i_use=[17,18,19,20],
        doPlot=True, showInfo=0)
else:
    print("Skipping borehole prior-data build (posterior or %s already exists)."
          % f_prior_data_bh_h5)

t_full_end_prior_data_bd = time.time()

ig.plot_discrete_data_entropy(f_data_h5, id_list=[2,3],plotPoints=True, ) Entropy map over ALL multinomial (borehole) datasets — no id_list needed

fig, ax, sc = ig.plot_discrete_data_entropy(f_data_h5, cmap = 'hot',
                                            plotPoints=True, plotPoints_size=1, plotPoints_color='k',
                                            plotPoints_alpha=0.1,
                                            hardcopy=False)

# Overlay the borehole collar locations from the BHOLES list
bx = [bh['X'] for bh in BHOLES]
by = [bh['Y'] for bh in BHOLES]
ax.plot(bx, by, 'rx', ms=9, mew=1.5, label='boreholes')
for bh in BHOLES:
    ax.annotate(bh['name'], (bh['X'], bh['Y']),
                xytext=(4, 4), textcoords='offset points', fontsize=7)
ax.legend(loc='best')

fig.savefig('DAUGAARD_entropy_with_boreholes_N%d.png' % (N), dpi=150, bbox_inches='tight')

B5. Joint (tTEM + borehole) rejection inversion

t_full_start_rejection = time.time()

N_use = N   # subset of the merged prior used in the rejection sampler

if not os.path.exists(f_post_h5):
    id_use = [1] + id_borehole_list   # tTEM (id 1) jointly with all borehole logs
    ig.integrate_rejection(
        f_prior_data_bh_h5, f_data_h5, f_post_h5=f_post_h5,
        N_use=N_use, id_use=id_use, nr=1000, T_N_above=50, T_P_acc_level=0.2,
        autoT=1, showInfo=1, updatePostStat=True,
        backend ='numpy')

    id_use = [1]
    ig.integrate_rejection(
        f_prior_data_bh_h5, f_data_h5, f_post_h5=f_post_jax_h5,
        N_use=N_use, id_use=id_use, nr=1000, T_N_above=50, T_P_acc_level=0.2,
        autoT=1, showInfo=1, updatePostStat=True,
        backend ='jax')

else:
    print("Using existing posterior: %s" % f_post_h5)

t_full_end_rejection = time.time()


ig.plot_T_EV(f_post_h5, pl='CHI2', hardcopy=hardcopy)

B6. Inspect the posterior

Resistivity (harmonic mean, im=1) and mode lithology (im=2) along the west-to-east profile, then depth-slice maps.

ig.plot_profile(f_post_h5, im=1, ii=id_line, key='HarmonicMean', gap_threshold=100,
                xaxis='x', hardcopy=hardcopy)

ig.plot_profile(f_post_h5, ii=id_line, im=1,
                xaxis='x', gap_threshold=100, hardcopy=hardcopy, alpha=.95,
                logstd_min=0.1, logstd_max=1.0,
                txt='_alpha'
                )
ig.plot_profile(f_post_h5, im=2, ii=id_line, gap_threshold=100,
                xaxis='x',
                hardcopy=hardcopy)

ig.plot_profile(f_post_h5, im=2, ii=id_line, gap_threshold=100,
                xaxis='x',
                alpha=0.95, entropy_min=0.1, entropy_max=1.0,
                txt='_alpha',
                hardcopy=hardcopy)
for i_plot in pl_data:
    ig.plot_post_stats(f_post_h5, i_plot=i_plot, im=1, hardcopy=hardcopy)
    ig.plot_post_stats(f_post_h5, i_plot=i_plot, im=2, hardcopy=hardcopy)
for elevation in [40, 30, 20, 10]:
    ig.plot_feature_2d(f_post_h5, im=1, key='HarmonicMean', elevation=elevation,
                       uselog=1, s=2, hardcopy=hardcopy)
    plt.show()

for elevation in [40, 30, 20, 10]:
    ig.plot_feature_2d(f_post_h5, im=2, key='Mode', elevation=elevation,
                       s=2, hardcopy=hardcopy)
    plt.show()

Prior (wheat/gray), posterior (black) and observed (red) tTEM data at a couple of specific (X, Y) locations, using ig.plot_data_prior_post, which takes a sounding index (i_plot) – so each location is first snapped to its nearest sounding via argmin on the same (X, Y) loaded in section 1a (the same pattern used for the WorkBench reprojection in Part A and the region-growing seed in B8).

for i_plot in pl_data:
    #print("Location (%.1f, %.1f) -> nearest sounding #%d at (%.1f, %.1f)"
    #      % (X_loc, Y_loc, i_plot, X[i_plot], Y[i_plot]))
    ig.plot_data_prior_post(f_post_h5, i_plot=i_plot, hardcopy=hardcopy)
ig.plot_data_prior(f_post_h5, f_data_h5, hardcopy=hardcopy)

B7. The ig.query tool

We need to find WHERE we potentially have raw material worth producing!

%%

useLLM = False
if 'ANTHROPIC_API_KEY' in os.environ:
    useLLM = True
if useLLM:
    #os.environ['ANTHROPIC_API_KEY']='sk-ant-XXXXX'
    query_raw, interp_raw, prompt_raw = ig.query_from_text(
        'What is the probbability that the cumulative thickness af raw materials (any sand and gravel) is greater than 10 m within the top 30 m, and where the oberburden (the top layer of non-raw material) is no more than 3 meters thick?',
        f_prior_h5=f_prior_h5,
        api_key=os.environ.get('ANTHROPIC_API_KEY'))
else:
    query_raw = {'constraints': [{'im': 2,
       'classes': [2, 5, 6],
       'thickness_mode': 'cumulative',
       'thickness_comparison': '>',
       'thickness_threshold': 10.0,
       'depth_min': 0.0,
       'depth_max': 30.0,
       'negate': False},
      {'im': 2,
       'classes': [1, 3, 4, 7, 8],
       'thickness_mode': 'first_occurrence',
       'thickness_comparison': '<=',
       'thickness_threshold': 3.0,
       'depth_min': 0.0,
       'depth_max': 30.0,
       'negate': False}]}

perform the que

raw_classes = query_raw['constraints'][0]['classes']    # sand + gravel: coarse raw material
fine_classes = query_raw['constraints'][1]['classes']    # everything else: fine, non-raw material (overburden)

t_query1_start = time.time()

P_raw, meta_raw = ig.query(f_post_h5, query_raw)



ig.query_plot(P_raw, meta_raw,
              query_text="P(raw material)",
              text_panel=True,
              hardcopy=PREFIX + 'daugaard_P_raw' + SUFFIX if hardcopy else False)

t_query1_end = time.time()

B8. Automatic region search: grow an area of potential raw material

Delineate an area of potential raw material by letting a region grow itself outward from the most promising spot: start at the sounding with the highest raw-material probability P_raw, then repeatedly add the neighbouring sounding with the next-highest P_raw – as long as it is still >= P_MIN. The region simply keeps spreading into adjacent high-probability ground and stops on its own when the surrounding soundings are no longer likely enough.

P_MIN is the main knob (the polygon “width”/area): “include every connected sounding where the probability of raw material is at least this”. P_MIN = 0.5 -> the region is the contiguous patch (around the seed) where raw material is more likely than not. Lower it to grow the region, raise it to shrink it. MAX_AREA_M2 is an optional hard cap (None = no cap).

Neighbours come from a Voronoi tessellation of the sounding locations (two soundings are neighbours iff their Voronoi cells share an edge); each cell is clipped to a concave hull of the soundings so its area is meaningful. The region area is then the sum of its soundings’ cell areas, and its raw-material volume is sum(cell_area * thickness_percentile) (see B9).

Before growing, edge-affected soundings are dropped (flag_edge_cells): a sounding on the sparse rim of the survey has a huge, badly constrained Voronoi cell that would otherwise let the region balloon into empty ground.

Organization. This block first defines the reusable region-analysis functions (below), then B9 runs the clean end-to-end pipeline (query -> pick point(s) -> grow -> percentile -> volume).

Two entry points:

* ``find_coherent_area(X, Y, P, p_min, ...)`` -- the full pipeline in one
  call: builds the Voronoi adjacency + per-sounding cells, applies the
  edge filter, grows a connected region from a seed, and returns the
  region indices, its boundary polygon, AND the Voronoi scaffold (graph /
  cells / areas / outline / keep-mask) for reuse. These now live in the
  ``integrate`` package (integrate.integrate_query), called as ``ig.<name>``.

* ``grow_connected_region(P, neighbors, cell_area, p_min, ...)`` -- the
  pure graph-grow step on a pre-built scaffold (no Voronoi/shapely).
  ``find_coherent_area`` uses it internally.

The region-search functions (voronoi_graph, voronoi_cells_ordered, cells_to_polygon, flag_edge_cells, grow_connected_region, find_coherent_area) are part of the integrate package – call them as ig.<name>(…). See the B8 markdown above for what each does.

POS_CENTER = []
POS_CENTER.append((543039.3,6175596.0))   # west end of the profile
POS_CENTER.append((544500.0,6175800.0))   # west end of the profile

AREA_LIST = []

for icenter in range(len(POS_CENTER)):

    # --- central nodel
    X_center = POS_CENTER[icenter][0]
    Y_center = POS_CENTER[icenter][1]

    # --- region-growing knobs (the polygon "width"/area is set by these two) ---
    P_MIN       = 0.2      # keep growing while the next sounding has P_raw >= this
    MAX_AREA_M2 = None     # optional hard cap on the region area [m^2]; None = no cap

    # --- edge-affected-cell filter (soundings on the sparse rim of the survey) ---
    HULL_RATIO  = 0.10    # concave-hull tightness for the survey outline (0 = tight, 1 = convex)
    EDGE_BUFFER = None    # m; a sounding this close to the outline is "near the edge".
                        # None -> auto = 2 * sqrt(interior-median cell area)
    CELL_AREA_K = 6.0     # a cell is "oversized" if larger than K x the interior-median cell area
    ELONG_MAX   = 4.0     # drop near-edge cells more elongated than this
                        # (perimeter^2 / (4 pi area); 1 = disc); None to skip this test

    CELL_SIZE   = 5.0     # m; raster resolution for the polygon-clipped cell areas used in B9


    # Grow the region at the single highest-P_raw sounding. `AREA` (idx / mask /
    # polygon / vor / neighbors / cells / cell_area / boundary / good / ...) is
    # used directly everywhere below, including in B9.
    AREA = ig.find_coherent_area(X, Y, P_raw, P_MIN, max_area_m2=MAX_AREA_M2,
                                X_center = X_center, Y_center = Y_center,
                                hull_ratio=HULL_RATIO, edge_buffer=EDGE_BUFFER,
                                cell_area_k=CELL_AREA_K, elong_max=ELONG_MAX)

    edge_affected = ~AREA['good']
    P_raw_eff = np.where(AREA['good'], P_raw, -np.inf)   # edge-affected soundings are unreachable
    print("Edge filter dropped %d / %d soundings; default region seed %d at (%.0f, %.0f), P_raw=%.2f"
        % (int(edge_affected.sum()), len(X), AREA['seed'],
            X[AREA['seed']], Y[AREA['seed']], P_raw[AREA['seed']]))

    plt.figure(figsize=(8, 8))
    plt.plot(X, Y,'.', markersize=.1, color='lightgray', label='all soundings')
    plt.scatter(X, Y, c=P_raw, s=2, cmap='hot_r', vmin=0, vmax=1, label='kept soundings')
    #plt.plot(X[AREA['idx']], Y[AREA['idx']], 'k.', markersize=1, label='region')
    plt.plot(*AREA['polygon'].exterior.xy, 'k-', lw=2, label='polygon')
    plt.plot(AREA['X_center'], AREA['Y_center'], 'ko', ms=22, label='CENTER')
    plt.legend()
    plt.xlabel('X')
    plt.ylabel('Y')
    plt.title('P_raw with edge-affected soundings dropped')
    plt.axis('equal')
    plt.show()

    AREA_LIST.append(AREA)

B9. Volume: query -> percentile -> volume

One ig.query percentile query per quantity – raw material (raw_classes: sand+gravel) and overburden (fine_classes: every other, non-raw class) – run once over ALL soundings, exactly like the percentile-query examples in integrate_query.py. Volume at each percentile = sum(percentile_thickness * cell_area) over the grown region’s soundings (AREA['mask'], from B8), via ig.region_volumes(pct, area).

—- A. one percentile query per quantity (run ONCE, shared by every AREA) -

PCT = [5, 50, 95]      # -> low / median / high

query_overburden   = {"im": 2, "classes": fine_classes, "thickness_mode": "cumulative", "depth_min": 0.0}
query_raw_material = {"im": 2, "classes": raw_classes,  "thickness_mode": "cumulative", "depth_min": 0.0}

t_query2_start = time.time()

pct_overburden, _   = ig.query(f_post_h5, {"metric": query_overburden, "percentiles": PCT})
pct_raw_material, _ = ig.query(f_post_h5, {"metric": query_raw_material, "percentiles": PCT})
# each: (N_sounding, len(PCT)) posterior thickness percentiles [m], one row per sounding

t_query2_end = time.time()

# ---- B. volume per grown area (one entry per AREA in AREA_LIST) -----------
AREA_NAMES         = ['Area %d' % i for i in range(len(AREA_LIST))]
V_overburden_list   = []
V_raw_material_list  = []

for iarea, AREA in enumerate(AREA_LIST):

    V_overburden   = ig.region_volumes(pct_overburden, AREA)
    V_raw_material = ig.region_volumes(pct_raw_material, AREA)
    V_overburden_list.append(V_overburden)
    V_raw_material_list.append(V_raw_material)
    print("%-8s overburden   PCT%s = %s m^3" % (AREA_NAMES[iarea], PCT, np.round(V_overburden).astype(int)))
    print("%-8s raw material PCT%s = %s m^3" % (AREA_NAMES[iarea], PCT, np.round(V_raw_material).astype(int)))

    # Voronoi cells coloured by P_raw; edge-affected cells hatched grey; this
    # area's grown region outlined.
    fig, ax, mappable = ig.plot_voronoi_cells(AREA, P=P_raw, vmin=0, vmax=1)
    ax.plot(*AREA['polygon'].exterior.xy, color='k', lw=2.5, label='grown region')
    ax.plot(AREA['X_center'], AREA['Y_center'], 'ko', ms=25, label='center location')
    ax.set_xlabel('UTM X (m)')
    ax.set_ylabel('UTM Y (m)')
    ax.set_title('%s -- Voronoi cells coloured by P_raw  (grey hatch = edge-affected, dropped)' % AREA_NAMES[iarea])
    fig.colorbar(mappable, ax=ax, label='P_raw')
    ax.legend(fontsize=8)
    if hardcopy:
        fig.savefig('%sdaugaard_voronoi_cells_area%d%s.png' % (PREFIX, iarea, SUFFIX), dpi=200, bbox_inches='tight')
    plt.show()

# ---- C. raw-material volume, one bar per grown area ----------------------------
# Purely probabilistic -- comparison to Mette's polygons is in Part C.
Vg = np.vstack(V_raw_material_list)
xg = np.arange(len(AREA_LIST))
fig, ax = plt.subplots(figsize=(3 + 1.5 * len(AREA_LIST), 5))
ax.bar(xg, Vg[:, 1], yerr=[Vg[:, 1] - Vg[:, 0], Vg[:, 2] - Vg[:, 1]], capsize=5, color='C0')
ax.set_xticks(xg)
ax.set_xticklabels(AREA_NAMES)
ax.set_ylabel('Raw-material volume (m$^3$)')
ax.set_title('Raw-material volume per grown area  (bar = P50, whiskers = P5-P95)')
ax.grid(True, axis='y', ls='--', alpha=0.4)
if hardcopy:
    fig.savefig(PREFIX + 'daugaard_rawmat_volume_B' + SUFFIX + '.png', dpi=200, bbox_inches='tight')
plt.show()

Part C – Comparison to Mette’s original (deterministic) estimate

Everything that involves Mette’s hand-drawn target polygons lives here – Part B is about the probabilistic result only. Per-polygon low / median / high volumes reuse the same ig.region_volumes(pct, area) helper and the pct_* arrays queried in B9, on an AREA-like dict built from the per-sounding area inside each of Mette’s polygons (rasterized at CELL_SIZE resolution via rmu.compute_point_footprint_area). Reference numbers are transcribed from ReferenceProjects/Sdr Felding og Daugard til Integrate.pptx (cross-checked against the shapefile polygon areas).

The grown region is no longer a single area: every area in AREA_LIST (grown from its own centre in B8) is carried through the comparison, so C2’s bar chart shows one bar per grown area alongside one per Mette polygon, and C3 overlays both sets on the Voronoi tessellation.

C1. Low / median / high volumes for each of Mette’s target polygons.

prob_results = {}
for name, polygon in polygons.items():
    footprint_area = rmu.compute_point_footprint_area(X, Y, polygon, cell_size=CELL_SIZE)
    poly_area = {'mask': footprint_area > 0, 'cell_area': footprint_area}
    prob_results[name] = {'overburden':   ig.region_volumes(pct_overburden, poly_area),
                          'raw_material': ig.region_volumes(pct_raw_material, poly_area)}
    print("%-15s overburden   PCT%s = %s m^3" % (name, PCT, np.round(prob_results[name]['overburden']).astype(int)))
    print("%-15s raw material PCT%s = %s m^3" % (name, PCT, np.round(prob_results[name]['raw_material']).astype(int)))

C2. Raw-material volume, every grown area vs. Mette’s polygons (bar = P50, whiskers = P5-P95), then the side-by-side tables from rmu.compare_to_reference.

names = AREA_NAMES + list(prob_results)
V = np.vstack(V_raw_material_list + [prob_results[n]['raw_material'] for n in prob_results])
x = np.arange(len(names))
fig, ax = plt.subplots(figsize=(5 + 1.2 * len(names), 5))
ax.bar(x, V[:, 1], yerr=[V[:, 1] - V[:, 0], V[:, 2] - V[:, 1]],
       capsize=5, color=['C0'] * len(AREA_NAMES) + ['0.7'] * len(prob_results))
ax.set_xticks(x)
ax.set_xticklabels(names, rotation=30, ha='right')
ax.set_ylabel('Raw-material volume (m$^3$)')
ax.set_title('Raw-material volume  (bar = P50, whiskers = P5-P95)')
ax.grid(True, axis='y', ls='--', alpha=0.4)
if hardcopy:
    fig.savefig(PREFIX + 'daugaard_rawmat_volume_C' + SUFFIX + '.png', dpi=200, bbox_inches='tight')
plt.show()

C3. Voronoi overview: cells coloured by P_raw, with Mette’s target polygons (shades of blue, thin solid lines) and the grown areas (black->light-grey, thicker solid lines) drawn on top. The Voronoi scaffold (cells / edge filter / survey outline) is identical for every AREA, so AREA_LIST[0] is the backdrop.

fig, ax, mappable = ig.plot_voronoi_cells(AREA_LIST[0], P=P_raw, vmin=0, vmax=1)

def _grey_shades(n, lo=0.0, hi=0.75):
    """n greyscale levels from black (`lo`) to light grey (`hi`)."""
    return [str(lo + (hi - lo) * (i / max(n - 1, 1))) for i in range(n)]

area_greys = _grey_shades(len(AREA_LIST))
for i, AREA in enumerate(AREA_LIST):
    shade = area_greys[i]
    ax.plot(*AREA['polygon'].exterior.xy, color=shade, lw=3.0, zorder=3,
            label=AREA_NAMES[i])
    ax.plot(AREA['X_center'], AREA['Y_center'], marker='o', color=shade,
            ms=12, mec='k', zorder=5)

mette_blues = [plt.cm.Blues(0.45 + 0.5 * (i / max(len(polygons) - 1, 1)))
               for i in range(len(polygons))]
for i, (name, polygon) in enumerate(polygons.items()):
    ax.plot(*polygon.exterior.xy, color=mette_blues[i], lw=1.0, zorder=4,
            label="Mette: %s" % name)

ax.set_xlabel('UTM X (m)')
ax.set_ylabel('UTM Y (m)')
ax.set_title("Voronoi cells (P_raw) -- Mette's polygons vs. the grown areas")
fig.colorbar(mappable, ax=ax, label='P_raw')
ax.legend(fontsize=8, loc='best')
if hardcopy:
    fig.savefig(PREFIX + 'daugaard_voronoi_areas_vs_mette' + SUFFIX + '.png', dpi=200, bbox_inches='tight')
plt.show()

DAUGAARD_REFERENCE = {
    # 'coarser' (gravel-only) is kept for documentation but not compared below --
    # this workflow only distinguishes raw material (sand+gravel) vs. overburden
    # (everything else), not a further gravel-only split.
    'Delområde 1': {'overburden': 865_000,   'raw_material': 4_500_000, 'coarser': 1_700_000},
    'Delområde 2': {'overburden': 566_000,   'raw_material': 1_700_000, 'coarser': 1_000_000},
    'Delområde 3': {'overburden': 452_000,   'raw_material': 3_300_000, 'coarser': 2_200_000},
}

rmu.compare_to_reference(prob_results, DAUGAARD_REFERENCE, quantities=('overburden', 'raw_material'),
                         hardcopy=hardcopy, f_name=PREFIX + 'daugaard_rawmaterial_comparison' + SUFFIX)

Discussion

The probabilistic estimates come with an explicit min/median/max range instead of a single number, which is the key practical addition of this workflow: a decision-maker can see not just a central raw-material volume estimate, but how confident that estimate is, sub-area by sub-area.

If the probabilistic median raw-material volume is noticeably larger than the old deterministic estimate, recall the caveat from GEUS’s notes on the old assessment (ReferenceProjects/EmailFromMette.md): the deterministic estimate was capped at the depth documented by raw-material boreholes, even where the tTEM inversion suggested raw material could extend deeper. The probabilistic model is not capped this way, so part of any gap reflects that methodological difference rather than a disagreement about the shallow geology. Whether deep/older units in raw_classes (e.g. Miocene sand) should count as producible raw material at all is a geological judgement call that should be reviewed before treating this as a strict apples-to-apples comparison (see Part B’s printed class list).

t_end = time.time()
t_full_end = time.time()

total running time

print("Total running time: %.1f s" % (t_end - t_start))
print(32*"*")
# generic
print("Generic posterior: %.1f s" % (t_generic_end - t_generic_start))
print(32*"*")
print("Total running time (full workflow): %.1f s" % (t_full_end - t_full_start))
# prior data (tTEM)
print("Prior data (tTEM): %.1f s" % (t_full_end_prior - t_full_start_prior))
# prior data (boreholes)
print("Prior data (boreholes): %.1f s" % (t_full_end_prior_data_bd - t_full_start_prior_data_bd))
# rejection inversion
print("Rejection inversion: %.1f s" % (t_full_end_rejection - t_full_start_rejection))
# query
print("Query: %.1f s" % (t_query1_end - t_query1_start))
# percentile query
print("Percentile query: %.1f s" % (t_query2_end - t_query2_start))

Gallery generated by Sphinx-Gallery