4.5. Map Overlays#

Overlay helpers add coordinate transforms, basemaps, contours, labels, profile lines, or terrain to figures assembled by the user. They do not create a second survey model: every visual layer should remain traceable to coordinates and values from the same MapData.

Use pycsamt.map.StationMap or pycsamt.map.MapView for standard maps. The lower-level helpers on this page are useful when a computed value, custom composition, or application callback needs direct control over individual traces.

4.5.1. Coordinates Before Decoration#

Web basemaps expect geographic longitude and latitude. Projected field coordinates must therefore be transformed explicitly rather than merely renamed:

>>> import numpy as np
>>> from pycsamt.map import CRSConfig, transform_xy

>>> east = np.array([500000.0, 500250.0])
>>> north = np.array([850000.0, 850200.0])
>>> lon, lat = transform_xy(
...     east,
...     north,
...     crs=CRSConfig(source=32630, target=4326),
... )
>>> np.round(lon, 6).tolist()
[-3.0, -2.997733]
>>> np.round(lat, 6).tolist()
[7.689755, 7.691564]

For station \(i\), the operation is

(1)#\[(\lambda_i,\phi_i) =T_{\mathrm{src}\rightarrow4326}(x_i,y_i),\]

where \(T\) includes the source datum and projection. With always_xy=True—the default—arguments remain x, y and WGS84 output remains longitude, latitude even when CRS metadata advertises a different axis order.

../../_images/map_overlays_crs_transform.png

The same four positions in UTM Zone 30N and WGS84. Their numerical scales change completely while their ordering and geometry remain consistent. Plotting the left-hand numbers directly on a geographic basemap would place the survey incorrectly.#

pycsamt.map.resolve_crs_info() provides auditable descriptions:

>>> from pycsamt.map import normalize_epsg, resolve_crs_info
>>> normalize_epsg(32630)
'EPSG:32630'
>>> print(resolve_crs_info("utm", zone=30, hemisphere="N"))
EPSG:32630 UTM Zone 30N (WGS 84)

4.5.2. Basemap Extent And Style#

pycsamt.map.build_basemap_layout() returns a pycsamt.map.BasemapConfig, not a complete figure:

>>> from pycsamt.map import build_basemap_layout
>>> basemap = build_basemap_layout(lon, lat, bearing=12.0)
>>> print(basemap.style, basemap.zoom, basemap.bearing)
open-street-map 14 12.0
>>> {key: round(value, 6) for key, value in basemap.center.items()}
{'lat': 7.690659, 'lon': -2.998867}

The center is the mean of finite coordinate pairs. Zoom is selected from their largest geographic span and constrained to a practical range. Missing coordinates produce a world view centered at zero. Native token-free styles include open-street-map, carto-positron, and carto-darkmatter; ESRI names such as esri-satellite resolve to a white-bg base plus a raster layer.

4.5.3. Measured Points And Interpolated Surfaces#

A contour overlay estimates values between scattered samples. For finite observations \((x_i,y_i,v_i)\) it constructs a regular grid and evaluates

(2)#\[\widehat v_{jk}=I(x'_j,y'_k\mid\{x_i,y_i,v_i\}_{i=1}^{n}).\]

The interpolation operator \(I\) is an assumption, not an additional measurement. SciPy supplies linear or requested scattered interpolation when available; the portable fallback assigns the nearest station value.

>>> from pycsamt.map import interpolate_overlay_grid
>>> xi, yi, grid = interpolate_overlay_grid(
...     lon, lat, np.array([100.0, 120.0]), grid_size=12
... )
Traceback (most recent call last):
...
ValueError: At least three finite points are required.

Three points are the mathematical minimum, but rarely provide enough support for geological interpretation.

../../_images/map_overlays_interpolation_comparison.png

Nearest, linear, and cubic interpolation of the same 28 measured L18 apparent-resistivity values at 102.4 Hz. White markers are the observations. Nearest interpolation is blocky, linear interpolation is piecewise planar, and cubic interpolation introduces smooth extrema; differences away from markers are produced by \(I\), not the EDI files.#

Build a Plotly contour trace when the interpolation is appropriate:

>>> from pycsamt.map import build_contour_overlay
>>> contour = build_contour_overlay(
...     np.array([2.0, 2.1, 2.2]),
...     np.array([1.0, 1.1, 1.0]),
...     np.array([100.0, 120.0, 80.0]),
...     levels=8,
...     mode="both",
... )
>>> print(contour.type, len(contour.x), len(contour.y))
contour 80 80

grid_size controls numerical resolution, not information content. Increasing it creates more pixels without creating more field samples.

4.5.4. Lines, Labels, And Response Values#

Line and label helpers return either Cartesian Scatter or geographic Scattermap traces according to geo:

>>> from pycsamt.map import (
...     build_profile_line_overlay,
...     build_station_label_overlay,
... )
>>> line = build_profile_line_overlay(lon, lat, geo=True, name="L18")
>>> labels = build_station_label_overlay(
...     lon, lat, ["S00", "S01"], geo=True
... )
>>> print(line.type, labels.type, tuple(labels.text))
scattermap scattermap ('S00', 'S01')

The line follows normalized station order. It communicates acquisition geometry; it does not interpolate the electromagnetic value along the polyline.

../../_images/map_overlays_contour_profile_labels.png

L18 apparent resistivity at the selected 102.4 Hz sample. Measured markers remain visible over the linear contour surface, the blue line shows station order, and only every fourth station is labelled to keep the dense northern turn legible. The large triangular regions near the turn have weak spatial support and should be read cautiously.#

4.5.5. Multiple Lines Without Visual Crowding#

For a multi-line survey, small multiples often communicate geometry more clearly than 128 labels on one map:

>>> from pycsamt.map import load_lines
>>> data = load_lines("data/AMT/WILLY_DATA", detect="folder")
>>> print(data.lines)
('L18PLT', 'L22PLT', 'L26PLT', 'L30PLT', 'L34PLT')
>>> sum(2 for _profile in data.profiles)
10

Two traces per profile—one line and one label trace—give ten traces. In a combined interactive map, labels can instead be limited to endpoints or exposed through hover text.

../../_images/map_overlays_multiline_grid.png

Five profile overlays on identical coordinate limits. Endpoint labels identify acquisition direction without covering every marker, while the grid makes differences in line position and shape directly comparable.#

4.5.6. Topography Is Geometry, Not Response#

pycsamt.map.build_topography_overlay() returns Mesh3d for scattered elevations and Surface for a two-dimensional grid:

>>> from pycsamt.map import build_topography_overlay
>>> mesh = build_topography_overlay(
...     np.array([0.0, 1.0, 0.0]),
...     np.array([0.0, 0.0, 1.0]),
...     np.array([100.0, 120.0, 110.0]),
...     opacity=0.55,
... )
>>> print(mesh.type, mesh.opacity)
mesh3d 0.55

For scattered stations, vertices are \((x_i,y_i,h_i)\). A mesh connects those supports but does not improve elevation accuracy between them.

../../_images/map_overlays_topography_surface.png

Observed station elevations draped above the final bundled ModEM inversion. Black tracks and points retain the five acquisition lines; the terrain colors show the station-interpolated elevation surface. Below it, cyan marks the 30-ohm-metre conductive boundary and translucent red marks the 1000-ohm-metre resistive boundary. Their relation to relief is now visible without making the terrain opaque or enclosing the model in solid panes.#

The terrain is still an overlay rather than an inversion response. Its interpolation is constrained only where stations provide elevations, whereas the two subsurface shells come from the final ModEM resistivity mesh. A spatial coincidence between relief and a shell is therefore an observation to test, not evidence that topography caused or validates the anomaly.

View topography-and-ModEM overlay source codeClick to inspect and copy the complete code
 1def make_modem_topography_overlay() -> None:
 2    """Drape observed elevations above bodies from the final ModEM model."""
 3    result, rho, x, y, z = _modem_model()
 4    edi = load_lines(EDI, detect="folder", recursive=True)
 5    elevation_by_id = {
 6        station.id: station.elevation
 7        for station in edi.stations
 8        if station.elevation is not None
 9    }
10    modem_data = result.data_obs
11    samples = []
12    for name, (east, north, _height) in modem_data.site_coords.items():
13        short_name = name.split("-", 1)[-1]
14        elevation = elevation_by_id.get(short_name)
15        if elevation is not None:
16            samples.append((name, east / 1000.0, north / 1000.0,
17                            elevation / 1000.0))
18    names, sx, sy, sh = zip(*samples)
19    sx, sy, sh = map(np.asarray, (sx, sy, sh))
20
21    gx = np.linspace(sx.min(), sx.max(), 100)
22    gy = np.linspace(sy.min(), sy.max(), 90)
23    GX, GY = np.meshgrid(gx, gy)
24    GH = griddata((sx, sy), sh, (GX, GY), method="linear")
25    nearest = griddata((sx, sy), sh, (GX, GY), method="nearest")
26    GH = np.where(np.isfinite(GH), GH, nearest)
27
28    # ModEM nodes are stored from zero; station offsets are centred on the
29    # model origin. Shift cell centres into that same coordinate frame.
30    mx = (x - 0.5 * (x[0] + x[-1])) / 1000.0
31    my = (y - 0.5 * (y[0] + y[-1])) / 1000.0
32    xi = np.flatnonzero((mx >= sx.min() - 0.25) & (mx <= sx.max() + 0.25))
33    yi = np.flatnonzero((my >= sy.min() - 0.25) & (my <= sy.max() + 0.25))
34    zi = np.arange(min(31, len(z)))
35    sub = np.log10(rho[np.ix_(zi, yi, xi)])
36    spacing = (
37        float(np.median(np.diff(z[zi]))) / 1000.0,
38        float(np.median(np.diff(my[yi]))),
39        float(np.median(np.diff(mx[xi]))),
40    )
41
42    fig = plt.figure(figsize=(12.2, 8.2), constrained_layout=True)
43    ax = fig.add_subplot(111, projection="3d")
44    terrain = ax.plot_surface(
45        GX, GY, GH, cmap="terrain", alpha=0.58,
46        linewidth=0, antialiased=True, shade=True,
47    )
48    surface_level = float(np.nanmedian(sh))
49    for level, color, alpha in (
50        (np.log10(30.0), "#06b6d4", 0.34),
51        (np.log10(1000.0), "#dc2626", 0.18),
52    ):
53        verts, faces, _, _ = marching_cubes(sub, level=level, spacing=spacing)
54        ax.plot_trisurf(
55            verts[:, 2] + mx[xi[0]],
56            verts[:, 1] + my[yi[0]],
57            surface_level - verts[:, 0],
58            triangles=faces, color=color, alpha=alpha,
59            linewidth=0.04, edgecolor=color,
60        )
61
62    line_names = np.array([name.split("-")[1] for name in names])
63    for line in sorted(set(line_names)):
64        keep = line_names == line
65        order = np.argsort(sx[keep])
66        lx, ly, lh = sx[keep][order], sy[keep][order], sh[keep][order]
67        ax.plot(lx, ly, lh + 0.006, color="#111827", linewidth=1.25,
68                marker="o", markersize=2.3, zorder=20)
69        ax.text(lx[-1], ly[-1], lh[-1] + 0.018, f"L{line}",
70                fontsize=8, weight="bold")
71
72    ax.set_title(
73        "Observed topography over the final ModEM inversion\n"
74        "cyan: $\\rho\leq30$ $\\Omega$ m; red: $\\rho\geq1000$ $\\Omega$ m",
75        weight="bold",
76    )
77    ax.set_xlabel("Relative east (km)")
78    ax.set_ylabel("Relative north (km)")
79    ax.set_zlabel("Elevation relative to datum (km)")
80    ax.view_init(elev=27, azim=-57)
81    ax.grid(True, linestyle=":", linewidth=0.6, alpha=0.3)
82    for axis in (ax.xaxis, ax.yaxis, ax.zaxis):
83        axis.pane.set_alpha(0.0)
84        axis._axinfo["grid"].update(
85            color=(0.39, 0.45, 0.55, 0.32), linestyle=":", linewidth=0.6,
86        )
87    cbar = fig.colorbar(terrain, ax=ax, shrink=0.62, pad=0.08)
88    cbar.set_label("Station-interpolated elevation (km)")
89    fig.savefig(IMAGES / "map_overlays_topography_surface.png", dpi=190)
90    plt.close(fig)

4.5.7. Composing A Custom Figure#

Overlay helpers return ordinary Plotly objects:

>>> import plotly.graph_objects as go
>>> fig = go.Figure()
>>> fig.add_trace(contour)
Figure({
...
})
>>> fig.update_layout(
...     map=dict(
...         style=basemap.style,
...         center=basemap.center,
...         zoom=basemap.zoom,
...         bearing=basemap.bearing,
...     )
... )
Figure({
...
})

Do not mix Cartesian contour traces with geographic map traces in the same axes. For a geographic filled contour, use the station-map contour_image path described in Station Maps.

4.5.8. Troubleshooting#

ImportError during CRS conversion

Install pyproj through the geographic or full dependency extra.

ValueError: At least three finite points are required.

Remove incomplete rows and confirm at least three coordinate/value triples remain. Prefer markers when spatial support is weak.

Contour artifacts cross empty areas

Interpolation operates inside a geometric footprint, not geological boundaries. Retain measured markers, compare interpolation methods, or mask unsupported regions.

Labels overlap

Label endpoints or a regular subset, use hover text for the remainder, or use small multiples as above.

Basemap opens at world scale

No finite longitude/latitude pairs reached the layout helper. Inspect data.has_geo and verify the source CRS.