18.19. Build A Two-Line Occam2D Survey For Interpretation#

Every tutorial in this documentation set that reaches a real inversion result stops there – the result is the deliverable. Interpret A Two-Line Occam2D Survey With interp And geology picks up one step later and asks what pycsamt.interp and pycsamt.geology can do with one: calibrate it against boreholes, classify it into lithology, and record independent structural evidence such as a fault trace. That page needs a real, defensible inversion result to work with first, which is what this page builds – two lines, each crossing the same kind of geological structure (a dipping fault offsetting a resistive basement) at a different angle, each carried through a real compiled Occam2D inversion.

Both lines are synthetic: a known “true” earth model, forward-modelled with pyCSAMT’s own validated 2-D solver, then inverted for real with the same compiled Occam2D binary Stratagem Field Data To Occam2D Inversion uses on real Dafang field data. Synthetic rather than field data here for one specific reason – Interpret A Two-Line Occam2D Survey With interp And geology needs to check a calibrated, classified model against known ground truth at a handful of borehole locations, which only a synthetic experiment with a known true model can provide honestly. Everything downstream of the true model – the forward physics, the inversion, the coordinate handling – is exactly as real as it would be for a field survey; only the earth model being imaged is invented.

Every function this page relies on beyond that invented model lives in the public API, not in a documentation-only script – layered_with_fault(), sites_from_response(), and synthetic_elevation_profile() are all real, independently tested pycsamt functions, usable in any project, not copied here from a script that would break the moment it left this repository.

18.19.1. A true earth model, independent of the inversion mesh#

The true model is a three-layer stack – overburden, a weathered zone, a resistive basement – offset by one dipping fault, positive-down:

>>> from pycsamt.forward.synthetic import LayeredModel
>>> from pycsamt.forward.grid2d import Grid2D

>>> true_layers = LayeredModel(
...     resistivity=[80.0, 300.0, 3000.0], thickness=[60.0, 260.0],
... )
>>> grid = Grid2D.layered_with_fault(
...     true_layers,
...     fault_x_m=1200.0, apparent_dip_deg=65.0,
...     throw_m=700.0, downthrown_side="right",
...     nx=60, nz=50, x_max=2400.0, z_max=1600.0, n_stations=25,
...     name="Line A",
... )
>>> grid.resistivity.shape
(60, 80)
>>> grid.n_stations
25
>>> grid.x_stations[0] - grid.core_x_offset, grid.x_stations[-1] - grid.core_x_offset
(0.0, 2400.0000000000005)

25 stations, 100 m apart, over a 2.4 km line – core_x_offset is where layered_with_fault()’s padding ends and the station-carrying core begins, converting each station’s position in the solver’s padded grid back to along-profile chainage, 0 to 2400 m, the coordinate system everything downstream (Occam2D, boreholes, pycsamt.geology) will actually use. value_at() answers the same “what is the true resistivity here” question directly, for any position, in either coordinate frame:

>>> grid.value_at(100.0, 30.0, chainage=True)   # footwall, shallow
80.0
>>> grid.value_at(2000.0, 900.0, chainage=True)  # downthrown, deep
300.0

Line B crosses the same fault system, just at a different angle to its strike: 65 degrees is what a profile running perpendicular to strike would see; 47 degrees is what a profile crossing it 30 degrees more obliquely would see. That is not two different faults with different dips – Structural measurements explains the apparent-dip relationship in full, once FaultTrace is built for each line in Interpret A Two-Line Occam2D Survey With interp And geology.

Every section on this page, starting here, is draped over a synthetic display topography – explained in full further down, in Drape synthetic topography for display – purely so the figures read like a real field section; nothing about the forward solve, Occam2D, or the values above is affected by it:

View the true-model figure source codeClick to inspect and copy the complete code
 1def make_true_models_figure():
 2    """Write the two-panel true-model figure (both lines' Grid2D), draped
 3    over the same real terrain the inversion-result and structural
 4    figures use, via :func:`pycsamt.topo.plot_topo_section`'s raw
 5    ``(x_centers, z_centers, rho_2d)`` array-triple input -- Grid2D is
 6    not a ResistivityModel, so this is the generic entry point rather
 7    than the ResistivityModel-specific one."""
 8    fig, axes = plt.subplots(2, 1, figsize=(9, 8))
 9    for ax, line in zip(axes, (LINE_A, LINE_B)):
10        grid = build_true_grid(line)
11        names = [f"L{i:02d}" for i in range(grid.n_stations)]
12        station_x = grid.x_stations - grid.core_x_offset
13        elevation = synthetic_elevation_profile(station_x, **LINE_TOPO_KWARGS[line["label"]])
14        plot_topo_section(
15            (grid.x_centers - grid.core_x_offset, grid.z_centers, np.log10(grid.resistivity)),
16            ax=ax,
17            elevation=elevation,
18            station_x=station_x,
19            station_names=names,
20            depth_max=1600.0,
21            cmap="turbo_r",
22            vmin=1.5,
23            vmax=3.6,
24            title=f"{line['label']} -- true model",
25        )
26        # Grid2D.layered_with_fault pads its mesh far past the station-
27        # carrying core (matching Occam2D's own padding convention), and
28        # plot_topo_section's auto xlim spans that full padded mesh.
29        # ResistivityModel.clip_to_stations() crops this for the real
30        # inversion figures; Grid2D has no such method, so it is cropped
31        # here for display instead, purely visually -- no data is dropped.
32        ax.set_xlim(-0.1, 2.5)
33    fig.tight_layout()
34    fig.savefig(IMAGES / "true_models.png", dpi=170, bbox_inches="tight")
35    plt.close(fig)
True resistivity models for Line A and Line B, each a 3-layer stack offset by a dipping fault, draped over synthetic display topography with station markers.

Both true models, before any inversion, draped over the same synthetic display topography built below via plot_topo_section(). The fault is sharp here – Occam2D’s own smoothness regularization will not reproduce that sharpness later, which is exactly what this page’s real inversion result demonstrates.#

This grid is deliberately not the mesh Occam2D will later invert on. Solving the forward problem on the same discretisation an inversion uses to recover it is a well-known way to flatter that inversion’s accuracy – an inverse crime. Keeping the two meshes independent, as they are here, means any structure the inversion recovers below reflects real resolving power, not an artefact of matching grids.

18.19.2. Forward-model real physics#

MT2DForward solves both TE and TM modes on this grid – the same validated finite-difference solver behind MT2DAdapter, checked in this project’s own test suite against analytic half-space and layered-earth benchmarks, including a regression guard for a real TM-mode sign bug found and fixed earlier in this package’s history.

>>> import numpy as np
>>> from pycsamt.forward.em2d import MT2DForward

>>> freqs_hz = np.array([
...     2000.0, 1000.0, 500.0, 200.0, 100.0, 50.0, 20.0, 10.0, 5.0, 2.0,
...     1.0, 0.5, 0.25,
... ])
>>> resp = MT2DForward(freqs_hz, grid, verbose=False).run()
>>> resp.rho_a_te.shape
(13, 25)
>>> np.round(resp.rho_a_te[:, 0], 1)   # station 0 -- footwall side
array([ 130.4,  136.4,  166.8,  272.3,  386.5,  511.7,  767.7, 1052.8,
       1377.1, 1795.6, 2069.6, 2294.4, 2470.7])
>>> np.round(resp.rho_a_te[:, -1], 1)  # station 24 -- downthrown side
array([ 110.2,  100.3,   93.9,   88.3,   75.3,   67.5,   93.9,  151. ,
        250.6,  465.8,  696.6,  974.3, 1273.8])

At the highest frequency (2000 Hz, a shallow skin depth) both stations read a similar apparent resistivity – both sit under the same shallow overburden. By the lowest frequency (0.25 Hz) they have diverged by roughly a factor of two: station 0’s sounding keeps climbing toward the true, undisturbed basement at 320 m, while station 24 sits on the downthrown block, where that same basement has been dropped to 1020 m and is not yet within reach of even this line’s lowest-frequency skin depth. That is the real, physical signal a fault-crossing profile is expected to show – and, as the recovered section further down shows, a smooth Occam2D inversion does not reproduce it as a sharp step.

18.19.3. Package sites for Occam2D#

InputBuilder reads through OccamData.from_edi, whose actual contract – read directly off its source rather than assumed – accepts any object exposing name, coords, freq, rho, phase, rho_err, and phase_err, not only a real pycsamt.site.Site. Site itself has no array-based constructor (only Site(edi: EDIFile), wrapping a real or in-memory-synthesized EDI file), so building one for pure array data would mean populating an EDIFile’s HEAD/Z sections by hand for no benefit here – this synthetic case has no EDI file to round-trip. sites_from_response() builds the minimal object that contract actually needs, directly from a real ForwardResponse2D:

>>> from pycsamt.models.occam2d import sites_from_response
>>> names = [f"L{i:02d}" for i in range(grid.n_stations)]
>>> sites = sites_from_response(resp, grid.x_stations, names)
>>> len(sites), sites[0].name
(25, 'L00')
>>> np.round(sites[0].rho[:, 0, 1][:3], 2)   # TE rho_a, first 3 frequencies
array([130.37, 136.44, 166.84])

TM phase is stored raw, not pre-shifted – OccamData.from_edi applies the conventional +180 degree normalisation into the first quadrant itself (rho_code == 5 in its own source); shifting it again would double-apply the correction, which is exactly why sites_from_response does not.

18.19.4. Build native Occam2D input files#

>>> from pathlib import Path
>>> from pycsamt.models.occam2d import InputBuilder, OccamConfig

>>> cfg = OccamConfig(
...     modes=["TE"], n_layers=32, n_airlayers=0,
...     cell_size_horizontal=60.0, cell_size_vertical_top=15.0,
...     depth_scale=1.16, initial_rho=200.0,
...     error_floor_rho=0.10, error_floor_phase=2.0,
...     target_misfit=1.5, max_iterations=80,
... )
>>> workdir = Path("runs/line_a_occam2d")
>>> builder = InputBuilder(sites, workdir=workdir, config=cfg, verbose=0)
>>> _ = builder.build(title="Synthetic Line A")
>>> print(builder.summary())
InputBuilder summary
  workdir   : runs\line_a_occam2d
  sites     : 25
  freqs     : 13
  data pts  : 650
  mesh      : 62 x 32 cells
  params    : 832
  modes     : ['TE']

modes=['TE'] and looser-than-default error floors (10% resistivity, 2 degrees phase) are both deliberate, empirically-found choices, not defaults left untouched – see the callout after the real run below for why. n_airlayers=0 matches every other real compiled run in this documentation set: the vendored Fortran solver manages air layers internally and a nonzero count here desynchronizes the mesh file, the same bug Stratagem Field Data To Occam2D Inversion documents and fixes. workdir is a dedicated, previously-empty directory – deliberately, not incidentally: Occam2D’s own iteration-file discovery scans the whole directory, so leftover files from an earlier, different run in the same place can silently contaminate which iteration a later InversionResult picks as “best”. Reuse a workdir across repeated experiments only after clearing it, exactly as Prepare an Occam2D Inversion already recommends (“keep each experiment in its own directory… much easier to audit than repeatedly overwriting run01”).

18.19.5. Run the real compiled solver#

>>> from pycsamt.models.occam2d import OccamRunner, InversionResult

>>> binary = Path("pycsamt/models/occam2d/_source/Occam2D.exe").resolve()
>>> runner = OccamRunner(workdir=workdir, binary_path=binary, verbose=0)
>>> exit_code = runner.run(
...     max_iter=cfg.max_iterations, target_misfit=cfg.target_misfit,
...     auto_compile=False,
... )
>>> exit_code
0

This is a real, genuinely solved compiled Occam2D run – the same Occam2D.exe Stratagem Field Data To Occam2D Inversion compiles from the bundled Fortran source, invoked here against a purely synthetic data file. Loading the result:

>>> result_a = InversionResult(workdir)
>>> result_a.final_rms
2.858345
>>> [round(float(v), 2) for v in result_a.log.rms]
[4.03, 3.09, 2.89, 2.89, 2.87, 2.86, 2.86]

Seven iterations, RMS falling from 4.03 to a stable plateau at 2.86 – not the target misfit of 1.5, and reported as such rather than adjusted to look cleaner. The same sequence for Line B, built identically – a different fault position and apparent dip, a fresh dedicated workdir, otherwise the same config:

>>> grid_b = Grid2D.layered_with_fault(
...     true_layers,
...     fault_x_m=1400.0, apparent_dip_deg=47.0,
...     throw_m=700.0, downthrown_side="left",
...     nx=60, nz=50, x_max=2400.0, z_max=1600.0, n_stations=25,
...     name="Line B",
... )
>>> resp_b = MT2DForward(freqs_hz, grid_b, verbose=False).run()
>>> sites_b = sites_from_response(resp_b, grid_b.x_stations, names)
>>> workdir_b = Path("runs/line_b_occam2d")
>>> builder_b = InputBuilder(sites_b, workdir=workdir_b, config=cfg, verbose=0)
>>> _ = builder_b.build(title="Synthetic Line B")
>>> runner_b = OccamRunner(workdir=workdir_b, binary_path=binary, verbose=0)
>>> runner_b.run(
...     max_iter=cfg.max_iterations, target_misfit=cfg.target_misfit,
...     auto_compile=False,
... )
0
>>> result_b = InversionResult(workdir_b)
>>> result_b.final_rms
2.840193
>>> len(result_b.log.rms)
8

Reaching this pair of clean, stable runs took real iteration on the config: an initial attempt with joint TE+TM modes and tighter (3%/1 degree) error floors converged nicely for Line B but drove Line A’s roughness to nan after only 4-5 iterations, a real numerical fragility of the joint-mode, tightly-floored optimisation landscape for that particular data – not a fluke, since retrying the identical config reproduced the same early failure. TE-only with looser floors, used above, converges cleanly for both.

18.19.6. A real coordinate-alignment bug, found while bridging into interp#

The first version of this page’s recovered-resistivity figure showed nothing but flat horizontal layering for both lines – no trace of either true fault, even though the forward-modelled data above clearly carries one. The true model was not the problem: from_occam2d() was silently building x_centers in the wrong coordinate frame.

OccamMesh.from_data builds its mesh with x=0 at the outer edge of its own geometrically-expanding horizontal padding, not at the first station – padding that always exists, seven cells deep on each side, whenever InputBuilder builds a mesh:

>>> mesh = result_a.mesh
>>> x_c_raw = (mesh.x_nodes[:-1] + mesh.x_nodes[1:]) / 2.0
>>> np.round(x_c_raw[:5], 1)
array([ 3840.,  9600., 12480., 13920., 14640.])
>>> sta_x = np.asarray(result_a.data.offsets, dtype=float)
>>> sta_x[:5]
array([  0., 100., 200., 300., 400.])

x_centers starts at 3840 m; station_x (from result.data.offsets, real chainage) starts at 0. Left uncorrected, every station’s nearest column – exactly what column_nearest()/station_column() compute – collapses onto the same, meaningless, far-left padding cell:

>>> naive_idx = [int(np.argmin(np.abs(x_c_raw - s))) for s in sta_x]
>>> set(naive_idx)
{0}

Every one of 25 stations resolving to column 0. That silently breaks ModelCalibrator, per-station StratigraphicLog classification, and any other code that looks up a station’s own column – for any real Occam2D result built through InputBuilder, not something specific to this tutorial’s data, and it had no test coverage catching it. Padding is built identically on both sides of the mesh (OccamMesh.from_data’s left_pad/right_pad share one list, just mirrored), so the station-carrying core sits exactly centred in the full mesh width – which is what lets the correct shift be recovered purely from x_nodes and data.offsets, without hard-coding the padding-cell count:

>>> from pycsamt.interp import ResistivityModel
>>> model_a = ResistivityModel.from_occam2d(result_a)
>>> fixed_idx = [int(np.argmin(np.abs(model_a.x_centers - s))) for s in model_a.station_x]
>>> len(set(fixed_idx))
25
>>> model_a.x_centers[7], model_a.station_x[0]
(25.0, 0.0)

25 distinct columns for 25 stations, each within half a core-cell width (25 m here) of its own real position. Fixed directly in pycsamt/interp/_base.py, with a regression test (test_from_occam2d_aligns_x_centers_to_station_offsets) built on a small hand-built mesh precisely so it does not depend on a slow, real compiled solver run to keep failing if this regresses.

clip_to_stations() handles the other half of the same padding issue – dropping those wide boundary columns entirely for display, rather than merely locating the right ones:

>>> clipped_a = model_a.clip_to_stations()
>>> model_a.n_x, clipped_a.n_x
(62, 50)
View the recovered-resistivity figure source codeClick to inspect and copy the complete code
 1def make_inversion_results_figure(result_a, result_b):
 2    """Write the two-panel real recovered-resistivity figure, draped over
 3    the synthetic display topography via :func:`pycsamt.topo.plot_topo_section`."""
 4    fig, axes = plt.subplots(2, 1, figsize=(9, 8))
 5    for ax, result, line in zip(axes, (result_a, result_b), (LINE_A, LINE_B)):
 6        model = ResistivityModel.from_occam2d(result).clip_to_stations()
 7        elevation = synthetic_elevation_profile(model.station_x, **LINE_TOPO_KWARGS[line["label"]])
 8        plot_topo_section(
 9            model,
10            ax=ax,
11            elevation=elevation,
12            chainage=model.station_x / 1000.0,
13            topo_source="array",
14            depth_max=1600.0,
15            cmap="turbo_r",
16            vmin=1.5,
17            vmax=4.0,
18            title=f"{line['label']} -- real Occam2D (TE), RMS={model.rms:.2f}",
19        )
20    fig.tight_layout()
21    fig.savefig(IMAGES / "inversion_results.png", dpi=170, bbox_inches="tight")
22    plt.close(fig)
Real Occam2D resistivity sections for Line A and Line B, draped over synthetic topography, showing a smooth lateral resistivity gradient consistent with each line's fault.

Both real, coordinate-aligned, clipped-to-core recovered sections, draped over the synthetic display topography built below. Line A tilts resistive (footwall) toward the left and conductive (downthrown) toward the right; Line B tilts the opposite way, matching its opposite downthrown_side. Neither shows the true model’s sharp fault – smoothness-regularized Occam2D is not expected to reproduce a discrete offset as a step, only as this kind of broad tilt. Recovering the fault’s actual position needs the borehole and structural evidence Interpret A Two-Line Occam2D Survey With interp And geology brings in next.#

18.19.7. Drape synthetic topography for display#

Neither the forward solve above (MT2DForward’s receivers are fixed at z=0) nor pyCSAMT’s Occam2D file writers (result.data.offsets never reads a third, elevation coordinate) know anything about topography – confirmed directly from their source, not assumed. Real terrain is a display-only layer here, added after the fact with pycsamt.topo.plot_topo_section(), exactly as it would be for a real survey whose inversion also ran on a flat datum:

>>> from pycsamt.topo import synthetic_elevation_profile
>>> elev_a = synthetic_elevation_profile(
...     clipped_a.station_x, base_m=120.0, amplitude_m=35.0,
... )
>>> round(float(elev_a.min()), 1), round(float(elev_a.max()), 1)
(122.3, 168.1)

A modest 45 m of relief across the line – enough to be worth draping, not enough to dominate the 1.2-1.6 km section beneath it. Line B uses a related but distinct profile (a different phase_m shift, in the same call), so the figure above shows two genuinely different lines rather than two copies of one:

View the topography figure source codeClick to inspect and copy the complete code
 1def make_topography_figure():
 2    """Write the synthetic display-topography figure for both lines."""
 3    fig, ax = plt.subplots(figsize=(9, 3.2))
 4    chainage = np.linspace(0, 2400, 200)
 5    for line, color in zip((LINE_A, LINE_B), ("#B9770E", "#1F618D")):
 6        elev = synthetic_elevation_profile(chainage, **LINE_TOPO_KWARGS[line["label"]])
 7        ax.plot(chainage, elev, color=color, label=line["label"])
 8    ax.set_xlabel("Profile position x (m)")
 9    ax.set_ylabel("Elevation (m)")
10    ax.set_title("Synthetic display topography (not used by the forward solve or Occam2D)")
11    ax.legend(loc="upper right", fontsize=9)
12    ax.grid(alpha=0.25)
13    fig.tight_layout()
14    fig.savefig(IMAGES / "topography.png", dpi=170, bbox_inches="tight")
15    plt.close(fig)
Synthetic display topography profiles for Line A and Line B.

Each line’s own synthetic elevation profile, used only for display (in the figure above) and, in Interpret A Two-Line Occam2D Survey With interp And geology, for placing borehole collars sensibly relative to the surface.#

18.19.8. What carries forward to Part 2#

Interpret A Two-Line Occam2D Survey With interp And geology picks up from exactly the two real, loaded InversionResult objects built above – InversionResult(Path("runs/line_a_occam2d")) and InversionResult(Path("runs/line_b_occam2d")) – and from the same Grid2D true models built above (via layered_with_fault()), reusing value_at() directly to sample honest ground truth for both calibration and held-out validation boreholes at chosen profile positions.