Survey-line triage at a target frequency#

This example uses pycsamt.site for a task that is different from the previous gallery pages:

rank and visualize survey lines before choosing where to do heavier processing.

When a project contains several lines, it is often useful to answer practical questions before running static-shift correction, phase tensor analysis, or inversion:

  • Which lines have complete station coverage?

  • Are the station coordinates usable for map/profile plots?

  • At a target frequency, which lines show stronger apparent-resistivity contrasts?

  • Do phase slopes look smooth enough for a first-pass interpretation?

  • Is a quick strike proxy consistent along the line, or does it flip?

This page uses the bundled WILLY survey because it contains five AMT lines with consistent station-name prefixes: 18-*, 22-*, 26-*, 30-*, and 34-*.

1. Imports and data loading#

import os
import sys
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np


def repo_root():
    root = os.environ.get("PYCSAMT_DOCS_REPO_ROOT")
    return Path(root) if root else Path(__file__).resolve().parents[3]


ROOT = repo_root()
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))

from pycsamt.emtools import ensure_sites
from pycsamt.site import (
    SitesReport,
    keep_finite_z,
    phase_slope,
    res_at_freq,
    strike_estimate,
)

willy_dir = ROOT / "data" / "AMT" / "WILLY_DATA"
target_frequency_hz = 100.0
phase_band_hz = (10.0, 1_000.0)

all_sites = ensure_sites(willy_dir, recursive=True, verbose=0)
all_sites = keep_finite_z(all_sites)

print(f"Loaded WILLY survey stations: {len(all_sites)}")
Loaded WILLY survey stations: 53

2. Build one joined analysis table#

The site API gives us several small, composable tables:

Joining them by station name creates a lightweight triage table.

report = SitesReport(all_sites).to_dataframe(api=False)
rho100 = res_at_freq(all_sites, target_frequency_hz, how="nearest", api=False)
slopes = phase_slope(all_sites, phase_band_hz, api=False)
strike = strike_estimate(all_sites, method="phase_diff", api=False)

table = report.merge(rho100, on="station", how="left")
table = table.merge(slopes, on="station", how="left")
table = table.merge(
    strike[["station", "theta_deg"]], on="station", how="left"
)

table["line"] = table["station"].str.split("-", n=1).str[0]
table["station_number"] = (
    table["station"].str.extract(r"-(\d+)", expand=False).astype(float)
)
table["rho_gmean"] = np.sqrt(table["res_xy"] * table["res_yx"])
table["rho_anisotropy"] = np.log10(table["res_xy"] / table["res_yx"]).abs()
table["phase_slope_abs"] = table[["slope_xy", "slope_yx"]].abs().mean(axis=1)

print(
    table[
        [
            "station",
            "line",
            "nfreq",
            "f_used",
            "rho_gmean",
            "rho_anisotropy",
            "phase_slope_abs",
            "theta_deg",
        ]
    ]
    .head(10)
    .to_string(index=False)
)
 station line  nfreq  f_used    rho_gmean  rho_anisotropy  phase_slope_abs  theta_deg
  22-2VF   22     53   102.4 4.755009e+07        0.359369         2.357386        0.0
 22-24BF   22     53   102.4 1.130083e+08        0.355853        68.662910        0.0
  22-20A   22     53   102.4 9.469967e+07        0.345438        31.681287        0.0
  22-11A   22     53   102.4 1.072935e+09        0.923089         8.965401       90.0
   22-4U   22     53   102.4 3.249677e+08        0.887110         4.441426       90.0
  22-17U   22     53   102.4 1.221331e+09        0.148340         6.171060       90.0
  22-16A   22     53   102.4 8.497952e+08        0.306616        11.950603       90.0
22-025AF   22     53   102.4 8.266051e+07        1.126366        79.830635        0.0
 22-19VF   22     53   102.4 9.647970e+08        0.427565         8.505171        0.0
 22-14BF   22     53   102.4 4.830317e+08        0.552841        15.248471       90.0

rho_gmean is the geometric mean of rho_xy and rho_yx at the target frequency. rho_anisotropy is a simple log-ratio between the two off-diagonal components. These are not final interpretation products; they are compact screening variables.

3. Line-level triage scores#

A line summary helps decide where to look next. Here we rank lines by station count, median target-frequency resistivity, median anisotropy, and median phase-slope magnitude.

line_summary = (
    table.groupby("line")
    .agg(
        n_station=("station", "count"),
        median_rho=("rho_gmean", "median"),
        p90_rho=("rho_gmean", lambda x: np.nanpercentile(x, 90)),
        median_anisotropy=("rho_anisotropy", "median"),
        median_phase_slope=("phase_slope_abs", "median"),
        strike_0_fraction=("theta_deg", lambda x: float(np.mean(x == 0.0))),
    )
    .reset_index()
)

print("Line-level triage summary:")
print(line_summary.to_string(index=False))
Line-level triage summary:
line  n_station   median_rho      p90_rho  median_anisotropy  median_phase_slope  strike_0_fraction
  18         28 1.688095e+08 4.514963e+08           0.466932           18.696307           0.535714
  22         25 2.020950e+08 1.029680e+09           0.355853            8.505171           0.400000

4. Map view: all stations coloured by target-frequency resistivity#

The WILLY EDI headers contain coordinates, so a first map can be drawn from the site report alone. The colour is the target-frequency apparent resistivity proxy.

fig, ax = plt.subplots(figsize=(7.4, 5.6))
scatter = ax.scatter(
    table["lon"],
    table["lat"],
    c=np.log10(table["rho_gmean"]),
    s=48,
    cmap="viridis",
    edgecolor="black",
    linewidth=0.4,
)
for _, row in line_summary.iterrows():
    sub = table[table["line"] == row["line"]]
    ax.text(
        sub["lon"].mean(), sub["lat"].mean(), f"L{row['line']}", weight="bold"
    )

cb = fig.colorbar(scatter, ax=ax)
cb.set_label(f"log10 apparent resistivity at {target_frequency_hz:g} Hz")
ax.set_xlabel("Longitude")
ax.set_ylabel("Latitude")
ax.set_title("WILLY survey lines: target-frequency resistivity screen")
ax.grid(alpha=0.25)
fig.tight_layout()
WILLY survey lines: target-frequency resistivity screen

This map is intentionally a triage plot. It helps spot spatially coherent contrasts and coordinate problems before detailed geologic interpretation.

5. Line profiles: apparent resistivity along station order#

The station numbers embedded in WILLY names provide a stable along-line coordinate. Plotting all five lines together shows which lines are smooth and which have sharper station-to-station changes.

fig, ax = plt.subplots(figsize=(9.5, 4.5))
for line, sub in table.sort_values("station_number").groupby("line"):
    ax.semilogy(
        sub["station_number"],
        sub["rho_gmean"],
        marker="o",
        lw=1.4,
        label=f"L{line}",
    )

ax.set_xlabel("Station number within line")
ax.set_ylabel(
    f"Geometric mean apparent resistivity at {target_frequency_hz:g} Hz"
)
ax.set_title("Target-frequency line profiles")
ax.grid(True, which="both", alpha=0.25)
ax.legend(ncol=5, fontsize=8)
fig.tight_layout()
Target-frequency line profiles

6. Anisotropy and phase-slope triage#

rho_anisotropy highlights disagreement between the two off-diagonal apparent resistivities. phase_slope_abs is a compact proxy for how rapidly phase changes through the selected frequency band.

Stations in the upper-right of this scatter deserve attention: they combine strong component disagreement with rapidly varying phase.

fig, ax = plt.subplots(figsize=(7.5, 5.2))
for line, sub in table.groupby("line"):
    ax.scatter(
        sub["rho_anisotropy"],
        sub["phase_slope_abs"],
        s=55,
        label=f"L{line}",
        alpha=0.85,
        edgecolor="black",
        linewidth=0.3,
    )

ax.set_xlabel("log10(rho_xy / rho_yx), absolute")
ax.set_ylabel("Mean absolute phase slope (deg/decade)")
ax.set_title("Stations to inspect before heavier processing")
ax.grid(alpha=0.25)
ax.legend(ncol=3, fontsize=8)
fig.tight_layout()
Stations to inspect before heavier processing

7. Heatmap-style matrix by line and station number#

A heatmap can be easier to scan than five overlapping curves. Missing station numbers remain blank, which makes line coverage obvious.

pivot = table.pivot_table(
    index="line",
    columns="station_number",
    values="rho_gmean",
    aggfunc="median",
)

fig, ax = plt.subplots(figsize=(10, 3.6))
im = ax.imshow(np.log10(pivot.to_numpy()), aspect="auto", cmap="magma")
ax.set_yticks(np.arange(len(pivot.index)))
ax.set_yticklabels([f"L{line}" for line in pivot.index])
ax.set_xticks(np.arange(len(pivot.columns))[::3])
ax.set_xticklabels([int(x) for x in pivot.columns[::3]], rotation=0)
ax.set_xlabel("Station number")
ax.set_title(f"Line/station matrix: log10 rho at {target_frequency_hz:g} Hz")
cb = fig.colorbar(im, ax=ax)
cb.set_label("log10 apparent resistivity")
fig.tight_layout()
Line/station matrix: log10 rho at 100 Hz

8. Pick a line for follow-up#

The most useful line is not always the most resistive. For demonstration, choose the line with the largest 90th-percentile target-frequency resistivity, then print its highest-contrast stations.

followup_line = line_summary.sort_values("p90_rho", ascending=False).iloc[0][
    "line"
]
followup = table[table["line"] == followup_line].copy()
followup = followup.sort_values("rho_gmean", ascending=False)

print(f"Suggested follow-up line by p90 rho: L{followup_line}")
print(
    followup[
        [
            "station",
            "rho_gmean",
            "rho_anisotropy",
            "phase_slope_abs",
            "theta_deg",
        ]
    ]
    .head(8)
    .to_string(index=False)
)
Suggested follow-up line by p90 rho: L22
 station    rho_gmean  rho_anisotropy  phase_slope_abs  theta_deg
  22-18A 1.753364e+09        0.171168         5.096616        0.0
  22-17U 1.221331e+09        0.148340         6.171060       90.0
  22-11A 1.072935e+09        0.923089         8.965401       90.0
 22-19VF 9.647970e+08        0.427565         8.505171        0.0
  22-16A 8.497952e+08        0.306616        11.950603       90.0
  22-12U 8.232311e+08        0.869214        10.510065       90.0
  22-15U 8.042945e+08        0.464120        11.870561       90.0
22-013VF 5.359454e+08        0.205618        17.784491       90.0

9. What this site workflow contributes#

This page is deliberately not a replacement for QC, static-shift analysis, or inversion. It is a pre-processing triage layer.

The value of using pycsamt.site here is that the workflow combines metadata, station grouping, frequency-aware impedance summaries, phase behavior, and quick orientation proxies without editing the survey. The output tells the next developer which lines and stations deserve careful follow-up, and why.

Total running time of the script: (0 minutes 0.718 seconds)

Gallery generated by Sphinx-Gallery