6.3.4. Correlated geological priors#
Training a network on tiled 1-D models teaches it a per-station
relationship, not a spatial one: nothing in that training data ever
shows the network what a laterally continuous resistivity structure
looks like. pycsamt.ai.geology exists to generate complete
2-D sections and 3-D volumes directly, so response simulation and
training happen on a traceable geological prior instead of on independent columns
stitched together after the fact.
The package builds on a shared
GeologyGrid (canonical (z, x) or
(z, y, x) cell-centre grid) and composes several generators on
top of it: anisotropic correlated Gaussian fields
(CorrelatedField, via
generate_gaussian_field()), layered
geology with dipping interfaces
(generate_layered_geology()), ellipsoidal
lenses and bodies (insert_lenses()), and
topographic surfaces
(TopographicSurface). Every generator
takes an explicit random seed and exposes its generation provenance,
so a training dataset can always be traced back to exactly the
configuration that produced it.
The public objects are useful at different points in that construction.
GeologyGrid fixes coordinates and canonical array
order; GaussianCorrelation defines spatial
scales; CorrelatedField and
DirectionalVariogram carry sampled values and
their diagnostics. LayeredGeology then exposes
interfaces, named layer masks, resistivity, summaries, and persistence, while
LensGeology adds named body masks, overlap
diagnostics, and the chosen conflict policy. Finally,
TopographicSurface keeps terrain sampling and the
physics-facing earth mask aligned to the same grid. These are immutable result
objects: create a new composition instead of editing their arrays in place.
>>> import numpy as np
>>> from pycsamt.ai.geology import GeologyGrid, GaussianCorrelation
>>> from pycsamt.ai.geology import generate_gaussian_field
>>> grid = GeologyGrid.regular_2d(nx=32, nz=16, dx_m=100, dz_m=50)
>>> model = GaussianCorrelation(length_x_m=500, length_z_m=100)
>>> field = generate_gaussian_field(grid, model, seed=12)
>>> field.values.shape
(16, 32)
>>> round(float(np.mean(field.values)), 6)
0.0
>>> round(float(np.std(field.values)), 6)
1.0
The field values are dimensionless standard-normal scores, not resistivity.
They become geological parameters only after a caller assigns a distribution,
for example through ElectricalLayer. Keeping that
distinction explicit prevents a zero-centred Gaussian field from being passed
to a forward solver as if negative conductivity were meaningful.
The returned field is more than its values array. field_hash identifies
the values and provenance together, provenance() records the seed,
correlation, boundary policy and grid, and rescale returns another
immutable field with a requested sample mean and standard deviation:
>>> physical = field.rescale(mean=2.0, standard_deviation=0.25)
>>> round(float(physical.values.mean()), 6)
2.0
>>> round(float(physical.values.std()), 6)
0.25
>>> len(field.field_hash), field.provenance()["seed"]
(64, 12)
This rescaling still does not assign units. A mean of two could mean
log10(ohm m), porosity, or another parameter; that semantic choice belongs
in the dataset configuration and must travel with the array.
6.3.4.1. What a Gaussian correlation length means#
In two dimensions the requested stationary covariance is
Thus \(L_x\) and \(L_z\) are correlation length scales, not hard feature
widths: at a one-length separation the correlation is
\(e^{-1/2}\approx0.607\), and it approaches zero only asymptotically. The
ratio \(L_x/L_z\) is exposed as
anisotropy_xz. In 3-D,
length_y_m supplies the second horizontal principal scale and
azimuth_deg rotates the horizontal axes; azimuth has no role in 2-D.
The generator filters white noise in Fourier space with amplitude
so the power multiplier \(|A|^2\) is the spectrum corresponding to
equation (1). boundary="periodic"
filters directly on the requested grid and therefore joins opposite edges.
The default "reflect" filters a reflected doubled grid and crops its
centre, reducing that artificial wrap-around. Neither policy makes a small
domain representative: correlation lengths need several cells for numerical
resolution and the domain needs several correlation lengths for statistical
support. Extremely long lengths on a small grid are rejected when filtering
produces a numerically constant field.
Standardization enforces the sample mean and population standard deviation
of each realization to be exactly zero and one. This is convenient for
consistent scaling but suppresses between-realization fluctuations in those
two statistics. Set standardize=False when those fluctuations are part of
the intended prior, or call
rescale() when a precise sample mean
and spread are required.
6.3.4.2. Verify correlation statistically#
directional_variogram() computes the unbinned
empirical variogram at integer cell lags,
where \(P_m\) contains pairs separated by \(m\) cells along axis \(a\) and \(N_m=|P_m|\). For a standardized Gaussian field, the theoretical curve is \(\gamma(h)=1-C(h)\), with a sill near one. Pair counts fall with lag, so the far end of a single realization’s variogram is the least stable part. This is why correlation recovery belongs at the ensemble level:
>>> from pycsamt.ai.geology import directional_variogram
>>> variogram_x = directional_variogram(field, "x", max_lag_cells=5)
>>> variogram_x.lag_m.tolist()
[100.0, 200.0, 300.0, 400.0, 500.0]
>>> variogram_x.pair_count.tolist()
[496, 480, 464, 448, 432]
>>> np.all(np.diff(variogram_x.pair_count) < 0)
True
The upper-right panel below aggregates 32 independently seeded fields. Once lag is divided by its requested directional length, the horizontal and vertical medians nearly collapse onto the theoretical Gaussian curve. The 10th–90th percentile envelopes remain broad, especially beyond two correlation lengths: one field cannot be expected to reproduce the population covariance exactly. The upper-left realization also shows the intended \(L_x/L_z>1\) texture as horizontally elongated patches rather than independent columns.
6.3.4.3. Build stratigraphy before adding bodies#
ElectricalLayer defines a median resistivity and,
optionally, correlated within-unit variability. Before clipping to declared
bounds, the implemented model is
where \(\rho_{50}\) is resistivity_ohm_m, \(s_{10}\) is
log10_std, and \(g\) is a standardized correlated field. Therefore
resistivity_ohm_m is the lognormal median, not its arithmetic mean. A
positive log10_std requires a correlation model; this prevents accidental
cell-wise white-noise geology.
generate_layered_geology() generates correlated
interface relief independently from within-layer heterogeneity. Interfaces
are ordered from shallow to deep. minimum_thickness_m enforces separation;
interface_policy="raise" rejects a crossing realization, while the
default "project" moves infeasible surfaces into the allowed region and
records the adjusted fraction. A large adjustment fraction means the proposed
means, relief, and thickness are mutually inconsistent and should be revised,
not quietly accepted as the intended prior.
>>> from pycsamt.ai.geology import ElectricalLayer
>>> from pycsamt.ai.geology import generate_layered_geology
>>> units = [
... ElectricalLayer("cover", 20),
... ElectricalLayer("sediments", 100),
... ElectricalLayer("basement", 1000),
... ]
>>> layered = generate_layered_geology(
... GeologyGrid.regular_2d(nx=24, nz=16, dx_m=100, dz_m=50),
... units, [200, 500], seed=5,
... interface_relief_std_m=[30, 50],
... interface_correlation=GaussianCorrelation(600, 100),
... )
>>> layered.interface_depth_m.shape
(2, 24)
>>> summary = layered.summary()
>>> {name: round(value, 3) for name, value in summary["layer_fractions"].items()}
{'cover': 0.253, 'sediments': 0.37, 'basement': 0.378}
>>> round(summary["adjusted_interface_fraction"], 3)
0.0
The lower-left panel shows the result. White lines are continuous sampled interfaces; colour variation within the upper and lower units comes from separate correlated fields. Layer occupancy is worth recording across the whole dataset: a valid geometry can still be a poor training prior if a rare unit occupies too few cells or appears only at one depth.
6.3.4.4. Compose lenses with explicit overlap rules#
An EllipsoidalLens uses the normalized rotated
radius
in 2-D, with a corresponding \(y'\) term in 3-D. Cells with \(r\le1\)
belong to the envelope. A non-zero transition_fraction applies a cubic
smoothstep weight \(w\in[0,1]\) in the outer shell and blends in log
resistivity,
Log-space blending preserves positivity and gives multiplicative rather than
arithmetic transitions. When bodies overlap, declaration order is not allowed
to decide silently: error, first, last, most_conductive, or
most_resistive must state the rule.
>>> from pycsamt.ai.geology import EllipsoidalLens, insert_lenses
>>> lens = EllipsoidalLens(
... "conductor", 1200, 350, 350, 100, 5,
... transition_fraction=0.2,
... )
>>> composed = insert_lenses(layered, [lens], conflict_policy="error")
>>> lens_summary = composed.summary()
>>> round(lens_summary["assigned_fractions"]["conductor"], 4)
0.0521
>>> lens_summary["maximum_overlap"], lens_summary["conflict_policy"]
(1, 'error')
>>> round(float(np.min(composed.resistivity_ohm_m)), 3)
5.0
6.3.4.5. Topography is a mask, not a vertical stretch#
interpolate_topography() and
topography_from_sites() rasterize elevation onto the
grid’s horizontal cell centres. With reference elevation \(e_0\), terrain
depth and local cell depth are
Cells with \(d_{\mathrm{local}}<0\) form the air mask; geological arrays remain on the regular depth grid rather than being warped. The reference defaults to the maximum interpolated elevation, keeping surface depth non-negative. Elevation datum and projected horizontal coordinates must still be physically compatible–the class records their labels but cannot infer or repair a CRS or datum mismatch.
>>> from pycsamt.ai.geology import interpolate_topography
>>> x_sample = np.array([0, 600, 1200, 1800, 2400.0])
>>> elevation = np.array([100, 130, 115, 150, 125.0])
>>> surface = interpolate_topography(layered.grid, x_sample, elevation)
>>> terrain = surface.summary()
>>> round(terrain["relief_m"], 3), round(terrain["air_cell_fraction"], 4)
(45.417, 0.0234)
>>> round(terrain["maximum_slope_deg"], 3)
3.338
Direct construction with TopographicSurface is
appropriate when elevation has already been rasterized onto grid.x_m (or
onto (grid.y_m, grid.x_m) in 3-D). Use
interpolate_topography() when the inputs are sparse
projected samples, and topography_from_sites() when
they are site objects with station metadata. The latter rejects ambiguous
all-zero elevations by default and needs explicit projected (x, y)
coordinates for a 3-D grid.
The following complete example compares all three interpolation policies and
plots the principal TopographicSurface products.
It is the code used to produce the figure, so it can be copied and adapted by
changing the samples and grid rather than relying on hidden plotting logic.
>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> from pycsamt.ai.geology import GeologyGrid, interpolate_topography
>>>
>>> grid = GeologyGrid.regular_2d(nx=80, nz=36, dx_m=100, dz_m=35)
>>> sample_x = np.array(
... [0, 700, 1450, 2300, 3200, 4100, 5050, 6100, 7000, 8000]
... )
>>> sample_z = np.array(
... [418, 455, 438, 510, 487, 552, 515, 575, 548, 590.0]
... )
>>> surfaces = {
... method: interpolate_topography(
... grid, sample_x, sample_z, interpolation_method=method,
... source="surveyed benchmarks",
... station_names=tuple(f"T{i:02d}" for i in range(sample_x.size)),
... )
... for method in ("nearest", "linear", "cubic")
... }
>>> surface = surfaces["cubic"]
>>> type(surface).__name__
'TopographicSurface'
>>> report = surface.summary()
>>> round(report["relief_m"], 3), round(report["air_cell_fraction"], 3)
(154.636, 0.055)
>>> surface.local_depth_m().shape, surface.earth_mask().shape
((36, 80), (36, 80))
>>> np.array_equal(surface.air_mask(), ~surface.earth_mask())
True
>>>
>>> fig, axes = plt.subplots(2, 2, figsize=(12.4, 8.0))
>>> for method, item in surfaces.items():
... axes[0, 0].plot(grid.x_m / 1000, item.elevation_m, label=method)
>>> _ = axes[0, 0].scatter(
... sample_x / 1000, sample_z, c="black", marker="v",
... label="input samples",
... )
>>> _ = axes[0, 0].set(
... xlabel="Profile distance (km)", ylabel="Elevation (m)"
... )
>>> _ = axes[0, 0].legend()
>>> local_depth = surface.local_depth_m()
>>> image = axes[0, 1].imshow(
... local_depth, extent=(0, 8, 1.26, 0), aspect="auto",
... cmap="coolwarm", vmin=-160, vmax=1100,
... )
>>> _ = axes[0, 1].contour(
... grid.x_m / 1000, grid.z_m / 1000, local_depth,
... levels=[0], colors="black",
... )
>>> _ = axes[1, 0].imshow(
... surface.earth_mask(), extent=(0, 8, 1.26, 0),
... aspect="auto", cmap="Greys", vmin=0, vmax=1,
... )
>>> _ = axes[1, 1].plot(
... grid.x_m / 1000, surface.slope_degrees(), color="tab:red"
... )
>>> for ax in axes.ravel():
... ax.grid(alpha=0.2)
>>> fig.tight_layout()
>>> plt.show()
Executed terrain workflow from sparse elevation samples to the arrays used by a forward mesh.#
Nearest interpolation preserves sample plateaus but produces discontinuous
steps; linear interpolation is conservative between observations; cubic
interpolation is smoother but can introduce extrema not present at a sample.
Here the cubic raster spans 426.7–581.4 m even though the observations span
418–590 m because cell centres do not coincide with the endpoint samples.
The signed-depth panel
shows why surface_depth_m and local_depth_m must not be confused: the
former is one terrain value per horizontal cell, while the latter broadcasts
that surface across every depth cell. The binary mask is the geometry that a
terrain-capable forward adapter would need; the slope curve is a diagnostic of
the rasterization, not a replacement for inspecting the terrain itself. The
distinction matters in the current stack: MT2DAdapter, MT3DAdapter,
and the v1 ModEm3DAdapter all declare supports_topography=False and
supports_inactive_cells=False. They reject an inactive air mask rather
than silently solving terrain. Until a terrain-capable adapter is connected
and benchmarked, these masks support geological auditing and display, not a
claim of topographic Maxwell physics.
In the lower-right panel of the diagnostic, white cells lie above the black
terrain line and are excluded by earth_mask().
The conductive and resistive lenses retain smooth transition shells, while
the stratigraphic interfaces remain defined in the unwarped depth coordinate.
The exact executable used for all four panels is included here; it also shows
how the 32-realization variogram envelopes are assembled rather than hiding
that ensemble step behind the finished image.
</>View geology-prior diagnostic source codeClick to inspect and copy the complete code
1def make_geology_prior_diagnostic() -> None:
2 """Visualise correlation recovery and compositional geological priors."""
3 from pycsamt.ai.geology import (
4 ElectricalLayer,
5 EllipsoidalLens,
6 GaussianCorrelation,
7 GeologyGrid,
8 directional_variogram,
9 generate_gaussian_field,
10 generate_layered_geology,
11 insert_lenses,
12 interpolate_topography,
13 )
14
15 grid = GeologyGrid.regular_2d(nx=64, nz=32, dx_m=100, dz_m=50)
16 correlation = GaussianCorrelation(length_x_m=700, length_z_m=150)
17 example = generate_gaussian_field(grid, correlation, seed=12)
18
19 variograms_x = []
20 variograms_z = []
21 for seed in range(32):
22 realization = generate_gaussian_field(grid, correlation, seed=seed)
23 variograms_x.append(
24 directional_variogram(realization, "x", max_lag_cells=21).semivariance
25 )
26 variograms_z.append(
27 directional_variogram(realization, "z", max_lag_cells=15).semivariance
28 )
29 variograms_x = np.asarray(variograms_x)
30 variograms_z = np.asarray(variograms_z)
31 lag_x = np.arange(1, 22) * 100.0
32 lag_z = np.arange(1, 16) * 50.0
33
34 layers = (
35 ElectricalLayer(
36 "weathered cover", 30.0, log10_std=0.10,
37 heterogeneity=GaussianCorrelation(500, 120),
38 resistivity_bounds_ohm_m=(8.0, 120.0),
39 ),
40 ElectricalLayer("sedimentary unit", 180.0),
41 ElectricalLayer(
42 "basement", 1200.0, log10_std=0.12,
43 heterogeneity=GaussianCorrelation(900, 180),
44 resistivity_bounds_ohm_m=(300.0, 4000.0),
45 ),
46 )
47 layered = generate_layered_geology(
48 grid,
49 layers,
50 [350.0, 950.0],
51 seed=24,
52 interface_relief_std_m=[60.0, 110.0],
53 interface_correlation=GaussianCorrelation(850, 150),
54 minimum_thickness_m=100.0,
55 )
56 lenses = (
57 EllipsoidalLens(
58 "conductor", 2100.0, 650.0, 650.0, 170.0, 6.0,
59 dip_deg=12.0, transition_fraction=0.25,
60 ),
61 EllipsoidalLens(
62 "resistor", 4700.0, 1150.0, 520.0, 210.0, 3500.0,
63 dip_deg=-18.0, transition_fraction=0.20,
64 ),
65 )
66 composed = insert_lenses(layered, lenses, conflict_policy="error")
67 topo_x = np.linspace(0.0, 6400.0, 9)
68 topo_elevation = 420.0 + 70.0 * np.sin(topo_x / 900.0) + 25.0 * np.cos(
69 topo_x / 430.0
70 )
71 surface = interpolate_topography(
72 grid, topo_x, topo_elevation, source="synthetic survey",
73 interpolation_method="cubic",
74 )
75
76 fig, axes = plt.subplots(2, 2, figsize=(12.2, 8.4))
77 extent = [0.0, 6.4, 1.6, 0.0]
78 image = axes[0, 0].imshow(
79 example.values, extent=extent, aspect="auto", cmap="RdBu_r",
80 vmin=-2.5, vmax=2.5,
81 )
82 axes[0, 0].set(
83 title="One standardized anisotropic Gaussian field",
84 xlabel="Profile distance (km)", ylabel="Depth (km)",
85 )
86 fig.colorbar(image, ax=axes[0, 0], label="Standard deviations")
87
88 ax = axes[0, 1]
89 for lag, values, length, label, color in (
90 (lag_x, variograms_x, 700.0, "x direction", "#2563eb"),
91 (lag_z, variograms_z, 150.0, "z direction", "#dc2626"),
92 ):
93 normalized_lag = lag / length
94 median = np.median(values, axis=0)
95 lower, upper = np.percentile(values, [10, 90], axis=0)
96 ax.plot(normalized_lag, median, "o-", color=color, label=f"{label}: median")
97 ax.fill_between(normalized_lag, lower, upper, color=color, alpha=0.16)
98 theory_lag = np.linspace(0.0, 3.1, 180)
99 ax.plot(
100 theory_lag, 1.0 - np.exp(-0.5 * theory_lag**2),
101 "k--", lw=1.5, label="requested Gaussian model",
102 )
103 ax.set(
104 title="32-realization directional variogram audit",
105 xlabel="Lag / requested correlation length", ylabel="Semivariance",
106 xlim=(0, 3.1), ylim=(0, 1.65),
107 )
108 ax.grid(alpha=0.25)
109 ax.legend(fontsize=8)
110
111 for ax, values, title in (
112 (axes[1, 0], layered.resistivity_ohm_m, "Correlated interfaces + within-unit variability"),
113 (axes[1, 1], composed.resistivity_ohm_m, "Lenses + interpolated topographic mask"),
114 ):
115 display = np.log10(values)
116 if ax is axes[1, 1]:
117 display = np.where(surface.earth_mask(), display, np.nan)
118 image = ax.imshow(
119 display, extent=extent, aspect="auto", cmap="viridis_r",
120 vmin=0.6, vmax=3.7,
121 )
122 for interface in layered.interface_depth_m:
123 ax.plot(grid.x_m / 1000.0, interface / 1000.0,
124 color="white", lw=0.9, alpha=0.75)
125 if ax is axes[1, 1]:
126 ax.plot(
127 grid.x_m / 1000.0, surface.surface_depth_m / 1000.0,
128 color="#111827", lw=1.4, label="terrain",
129 )
130 ax.legend(fontsize=8, loc="lower left")
131 ax.set(title=title, xlabel="Profile distance (km)", ylabel="Depth (km)")
132 fig.colorbar(image, ax=ax, label=r"$\log_{10}\rho$ [$\Omega\cdot$m]")
133
134 fig.suptitle(
135 "A geological prior is audited statistically, then composed geometrically",
136 fontsize=13,
137 )
138 fig.tight_layout()
139 _save(fig, "geology_prior_diagnostic.png")
Statistical verification and geometric composition of a deterministic 2-D prior. Shaded variogram bands are the 10th–90th percentiles across 32 realizations, not uncertainty for one realization.#
6.3.4.6. Carry the same composition into 3-D#
A 3-D grid has canonical array shape (nz, ny, nx). Its
GaussianCorrelation must supply length_y_m;
azimuth_deg rotates the horizontal correlation axes. Likewise, a 3-D
EllipsoidalLens supplies center_y_m and
radius_y_m. Its azimuth rotates the horizontal major axis, while
dip_deg rotates that major axis toward depth. These angles describe the
prior geometry; they are not strike and dip estimates recovered from data.
This executable example builds correlated stratigraphy, inserts one dipping conductor, rasterizes a simple terrain plane from projected control points, and plots two vertical sections plus one horizontal slice. The named masks avoid relying on integer labels whose meaning may be forgotten later.
>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>> from pycsamt.ai.geology import (
... ElectricalLayer, EllipsoidalLens, GaussianCorrelation, GeologyGrid,
... generate_layered_geology, insert_lenses, interpolate_topography,
... )
>>> grid3 = GeologyGrid.regular_3d(
... nx=42, ny=30, nz=24, dx_m=150, dy_m=150, dz_m=60
... )
>>> correlation3 = GaussianCorrelation(
... length_x_m=900, length_y_m=600, length_z_m=240, azimuth_deg=30
... )
>>> units3 = (
... ElectricalLayer(
... "cover", 35, log10_std=0.08, heterogeneity=correlation3
... ),
... ElectricalLayer("host", 420),
... ElectricalLayer(
... "basement", 1800, log10_std=0.10, heterogeneity=correlation3
... ),
... )
>>> layered3 = generate_layered_geology(
... grid3, units3, [360, 900], seed=31,
... interface_relief_std_m=[70, 120],
... interface_correlation=correlation3,
... minimum_thickness_m=120,
... )
>>> body3 = EllipsoidalLens(
... "dipping conductor", center_x_m=3300, center_y_m=2250,
... center_z_m=690, radius_x_m=1150, radius_y_m=650,
... radius_z_m=260, resistivity_ohm_m=8, azimuth_deg=35,
... dip_deg=18, transition_fraction=0.22,
... )
>>> volume3 = insert_lenses(layered3, [body3], conflict_policy="error")
>>> grid3.shape, layered3.interface_depth_m.shape
((24, 30, 42), (2, 30, 42))
>>> int(volume3.lens_mask("dipping conductor").sum())
604
>>> round(volume3.summary()["assigned_fractions"]["dipping conductor"], 4)
0.02
>>> layered3.layer_mask("host").shape, layered3.interface(0).shape
((24, 30, 42), (30, 42))
>>>
>>> xy = np.array([
... [0, 0], [6300, 0], [0, 4500], [6300, 4500],
... [3150, 2250], [1575, 1125], [4725, 3375],
... ])
>>> elevation = 510 + 0.018 * xy[:, 0] - 0.012 * xy[:, 1]
>>> terrain3 = interpolate_topography(
... grid3, xy, elevation, source="projected control points"
... )
>>> log_rho = np.log10(volume3.resistivity_ohm_m)
>>> earth = terrain3.earth_mask()
>>> iy, ix = grid3.shape[1] // 2, grid3.shape[2] // 2
>>> iz = int(np.argmin(np.abs(grid3.z_m - body3.center_z_m)))
>>> views = (
... np.where(earth[:, iy, :], log_rho[:, iy, :], np.nan),
... np.where(earth[:, :, ix], log_rho[:, :, ix], np.nan),
... log_rho[iz],
... )
>>> fig, axes = plt.subplots(1, 3, figsize=(13.8, 4.8))
>>> extents = ([0, 6.3, 1.44, 0], [0, 4.5, 1.44, 0], [0, 6.3, 4.5, 0])
>>> for ax, values, extent in zip(axes, views, extents):
... image = ax.imshow(
... values, extent=extent, aspect="auto", cmap="viridis_r",
... vmin=0.7, vmax=3.4,
... )
>>> colorbar_axis = fig.add_axes([0.925, 0.19, 0.015, 0.60])
>>> _ = fig.colorbar(
... image, cax=colorbar_axis, label="log10 resistivity [ohm m]"
... )
>>> fig.subplots_adjust(
... left=0.06, right=0.90, bottom=0.13, top=0.82, wspace=0.32
... )
>>> plt.show()
Executed 3-D composition shown with one shared resistivity scale.#
The conductor appears with different widths and apparent inclinations in the
two vertical sections because each plane cuts the same rotated ellipsoid at a
different orientation. The horizontal slice exposes its azimuth more directly.
White cells in the vertical views are air from terrain3.earth_mask();
they are not missing geological samples. Notice also that the body occupies
only about 2% of the volume. That fraction should be checked across an
ensemble: a rare target can be scientifically plausible yet too uncommon for
a network to learn reliably.
6.3.4.7. Connecting a rich prior to 3-D Maxwell training#
The composition above is available to a custom forward workflow, but it is
not yet the distribution sampled by
pycsamt.ai.training.dataset3d.generate_3d_maxwell_dataset(). That
convenience generator currently draws one standardized Gaussian field and
maps it affinely into log10(resistivity). Increasing
n_realizations changes its random draws and correlation lengths; it does
not introduce the named layers, projected interfaces, lens masks, or overlap
policies demonstrated here. The 2-D Maxwell dataset generator has the same
Gaussian-field boundary.
For a layered/lens corpus, generate and persist each
LensGeology or
LayeredGeology realization first, convert
resistivity_ohm_m to conductivity, and explicitly transfer it from the
geological grid to a padded solver mesh. Solve the resulting
MaxwellProblem with the selected
adapter, then retain the original geological volume as the target or resample
it onto the declared output grid. In symbols,
The transfer operators, grid edges, model hash, station coordinates, forward problem hash, backend identity, and accepted or rejected solve status belong in the realization record. Nearest-cell extension into padding may be a reasonable explicit boundary convention; allowing array shapes to imply a mapping is not. Keep every corruption variant attached to the same parent model hash so variants cannot leak across dataset partitions.
Equation (8) also separates terrain display
from terrain physics. Applying earth_mask only to a plotted target does
not modify \(\boldsymbol{\sigma}_s\); a genuine terrain solve requires the
mask to enter a backend that supports inactive cells and has been validated
for that geometry.
6.3.4.8. Persist identity and audit the ensemble#
Generated fields, layered models, lens models, and topographic surfaces are
immutable and provide platform-stable hashes over their arrays and generation
provenance. Their to_npz/from_npz methods use pickle-free archives and
revalidate reconstructed geometry. A seed alone is insufficient: grid,
boundary policy, correlation definition, layer parameters, overlap policy,
and values all contribute to identity.
>>> from pathlib import Path
>>> from tempfile import TemporaryDirectory
>>> from pycsamt.ai.geology import (
... CorrelatedField, LayeredGeology, LensGeology, TopographicSurface,
... )
>>> with TemporaryDirectory() as directory:
... root = Path(directory)
... field_copy = CorrelatedField.from_npz(
... field.to_npz(root / "field.npz")
... )
... layer_copy = LayeredGeology.from_npz(
... layered.to_npz(root / "layers.npz")
... )
... lens_copy = LensGeology.from_npz(
... composed.to_npz(root / "lenses.npz")
... )
... surface_copy = TopographicSurface.from_npz(
... surface.to_npz(root / "terrain.npz")
... )
... print(field_copy.field_hash == field.field_hash)
... print(layer_copy.model_hash == layered.model_hash)
... print(lens_copy.model_hash == composed.model_hash)
... print(surface_copy.surface_hash == surface.surface_hash)
True
True
True
True
Before sending an ensemble to Solver-neutral Maxwell contracts, audit more than image quality: requested versus empirical variograms, resistivity histograms and bounds, layer and lens occupancy by depth, interface-adjustment fraction, overlap frequency, topographic air fraction and slope, and coverage of rare structural combinations. Split related corruptions or variants by shared realization lineage as explained in Canonical data contracts; otherwise nearly identical geology can leak across training and evaluation. These diagnostics test whether the sampler represents the intended prior. They do not prove that the intended prior represents the field area–that requires geological constraints and the domain-gap checks in Domain-gap and noise simulation.