9.4. Controlled-Source Edge QC (CSAMT / CSEM)#
Natural-source AMT/MT diagnostics (AMT/CSAMT Edge Diagnostics)
assume a plane-wave field with no operator-controlled
transmitter. Controlled-source methods, especially CSAMT
and CSEM, add a grounded dipole transmitter. That changes
the field questions at the edge: is the receiver far enough from the
source for a plane-wave interpretation, is there resolvable energy at
every transmitted line, and is the source current steady enough
to trust the sounding? The pycsamt.iot.edge_csamt and
pycsamt.iot.edge_csem modules answer these questions with
numpy-only checks on short edge windows, reusing the same spectral
helpers as pycsamt.iot.edge_amt.
A transmitter device role and a source telemetry packet
let a transmitter node report its state alongside the receivers. The
examples below use synthetic data so the same outputs can be reproduced
without an EDI file or a field logger export.
9.4.1. Synthetic Controlled-Source Window#
This first block creates one CSAMT-like receiver window and matching transmitter telemetry. The expected transmitter frequency comb is 8, 32, 128, and 512 Hz, but the synthetic signal only contains the first three lines — that deliberate missing line is what makes the frequency-comb check below worth running. The CSEM amplitude curve also contains a small bump at 6000 m so the monotonic-decay check has something real to flag.
>>> import numpy as np
>>> rng = np.random.default_rng(51)
>>> sample_rate = 2048.0
>>> n_samples = 8192
>>> t = np.arange(n_samples) / sample_rate
>>> tx_frequencies = np.array([8.0, 32.0, 128.0, 512.0])
>>> window = (
... 0.18 * np.sin(2 * np.pi * 8.0 * t)
... + 0.32 * np.sin(2 * np.pi * 32.0 * t)
... + 0.24 * np.sin(2 * np.pi * 128.0 * t)
... + 0.035 * rng.standard_normal(n_samples)
... )
>>> tx_current = (
... 9.8 + 0.08 * np.sin(2 * np.pi * 0.6 * t)
... + 0.03 * rng.standard_normal(n_samples)
... )
>>> tx_voltage = 248.0 + 1.2 * rng.standard_normal(n_samples)
>>> offsets_m = np.array([1000, 2000, 4000, 6000, 8000, 10000], dtype=float)
>>> amplitudes = np.array([8.0e-12, 4.1e-12, 2.1e-12, 2.7e-12, 7.0e-13, 2.0e-13])
>>> phases = np.array([-8.0, -16.0, -29.0, -44.0, -61.0, -79.0])
9.4.2. Skin-Depth Field Zones#
The controlling CSAMT quantity is not offset alone, but the transmitter-receiver offset expressed in skin depth units. For resistivity \(\rho\) in \(\Omega\,m\) and frequency \(f\) in Hz, pyCSAMT uses
Here \(r\) is the transmitter-receiver separation and \(q\) is
the offset ratio. A frequency is labelled near when \(q \le 1\),
far when \(q \ge 3\), and transition between those limits.
High frequencies have smaller skin depth, so they usually reach the far
field first — which is exactly why the sweep below runs from 4096 Hz
down to 1 Hz rather than the other way around.
>>> from pycsamt.iot import classify_field_zones
>>> freqs = np.array([4096.0, 1024.0, 256.0, 64.0, 16.0, 4.0, 1.0])
>>> cov = classify_field_zones(freqs, resistivity=100.0, offset_m=5000.0)
>>> for f, d, ratio, zone in zip(cov.freq_hz, cov.skin_depth_m, cov.offset_ratio, cov.zones):
... print(f"{f:7.1f} Hz skin={d:8.1f} m r/delta={ratio:5.2f} zone={zone}")
4096.0 Hz skin= 78.6 m r/delta=63.58 zone=far
1024.0 Hz skin= 157.3 m r/delta=31.79 zone=far
256.0 Hz skin= 314.6 m r/delta=15.90 zone=far
64.0 Hz skin= 629.1 m r/delta= 7.95 zone=far
16.0 Hz skin= 1258.2 m r/delta= 3.97 zone=far
4.0 Hz skin= 2516.5 m r/delta= 1.99 zone=transition
1.0 Hz skin= 5032.9 m r/delta= 0.99 zone=near
>>> print(f"all_far_field: {cov.all_far_field}")
all_far_field: False
>>> print(f"correction_recommended: {cov.correction_recommended}")
correction_recommended: True
>>> print(f"first_far_field_hz: {cov.first_far_field_hz():.1f}")
first_far_field_hz: 16.0
Five of the seven rows clear the far-field ratio of 3 comfortably, some by
more than an order of magnitude. The two low-frequency rows are the ones
to treat carefully: 4 Hz lands at r/delta = 1.99, inside the
transition band, and 1 Hz at 0.99 is just barely inside the near
field. A near-field correction is recommended the moment either
row appears, which is why correction_recommended is True here
even though most of the sweep is safely far field.
9.4.3. Transmitter Frequency Comb#
CSAMT transmits a discrete frequency comb rather than the continuous natural spectrum used by passive AMT/MT. For each expected line \(f_k\), pyCSAMT compares the peak power spectral density inside a narrow band around \(f_k\) with the median positive PSD floor \(P_{50}\):
A line is detected when that SNR reaches the configured threshold and the line is below Nyquist.
>>> from pycsamt.iot import detect_transmitter_frequencies
>>> comb = detect_transmitter_frequencies(
... window, sample_rate=sample_rate, tx_frequencies=tx_frequencies,
... snr_threshold_db=8.0,
... )
>>> print(f"detected lines: {comb.n_detected} of {comb.n_expected}")
detected lines: 3 of 4
>>> print(f"missing lines: {comb.missing()}")
missing lines: [512.0]
>>> for line in comb.lines:
... print(f"{line.frequency_hz:6.1f} Hz snr={line.snr_db:6.2f} dB detected={line.detected}")
8.0 Hz snr= 25.89 dB detected=True
32.0 Hz snr= 35.50 dB detected=True
128.0 Hz snr= 32.98 dB detected=True
512.0 Hz snr= 0.87 dB detected=False
The three lines that were actually built into window all clear the
8 dB threshold by more than 25 dB — the comb detector is not marginal
about them. The 512 Hz line, never added to the synthetic signal in the
first place, reports 0.87 dB: indistinguishable from the noise floor,
exactly as a genuinely silent transmitter line should look. This is the
one deliberately planted failure on the page, and the detector finds it
without being told which line was missing.
9.4.4. Source-Signal Stability#
The transmitter current sets the signal level of every sounding, so its steadiness bounds the receiver-side data quality. The source-stability check first identifies the on-state samples as those above a fraction of the peak absolute current. On those samples it reports the mean current \(\bar I\), the coefficient of variation
and a simple drift amplitude, estimated as the absolute slope of
on-state current versus sample index multiplied by the number of on-state
samples. A source is unstable when the current CV exceeds max_cv or
when no finite current is present.
>>> from pycsamt.iot import assess_source_stability
>>> status = assess_source_stability(tx_current, tx_voltage=tx_voltage)
>>> print(f"stable: {status.stable}")
stable: True
>>> print(f"current_mean_a: {status.current_mean_a:.3f}")
current_mean_a: 9.810
>>> print(f"current_cv: {status.current_cv:.4f}")
current_cv: 0.0066
>>> print(f"current_drift_a: {status.current_drift_a:.3f}")
current_drift_a: 0.005
>>> print(f"on_fraction: {status.on_fraction:.2f}")
on_fraction: 1.00
>>> print(f"voltage_mean_v: {status.voltage_mean_v:.2f}")
voltage_mean_v: 247.98
A CV of 0.0066 is well inside the default max_cv = 0.05, and
on_fraction = 1.00 says the synthetic transmitter never keyed off
during this window, so every sample counts toward the on-state
statistics. This particular source is easy to trust; a transmitter with a
failing power supply or a loose ground connection would instead show a
current CV rising toward, then past, max_cv.
9.4.5. CSEM Offset Response#
CSEM records a dipole source with a receiver array, and
its signature edge product is the response as a function of offset at
each frequency. field_vs_offset() builds the
magnitude-versus-offset and phase-versus-offset curve,
finds the detectability limit, and checks that detectable
amplitude decays monotonically with offset.
For detectable amplitudes \(A_i\), the dynamic range is
The monotonic check allows a small tolerance \(\tau\) and flags a reading when \(A_{i+1} > A_i(1+\tau)\). A bump can mean a bad receiver, a geometry error, or real 3-D structure worth revisiting.
>>> from pycsamt.iot import field_vs_offset
>>> resp = field_vs_offset(
... offsets_m=offsets_m, amplitudes=amplitudes, phases_deg=phases,
... noise_floor=1e-13, frequency_hz=1.0,
... )
>>> print(f"n_detectable: {resp.n_detectable} of {resp.n_offsets}")
n_detectable: 6 of 6
>>> print(f"max_detectable_offset_m: {resp.max_detectable_offset_m:.0f}")
max_detectable_offset_m: 10000
>>> print(f"monotonic_decay: {resp.monotonic_decay}")
monotonic_decay: False
>>> print(f"dynamic_range_db: {resp.dynamic_range_db:.2f}")
dynamic_range_db: 32.04
>>> print(f"above_noise: {resp.above_noise}")
above_noise: [True, True, True, True, True, True]
Every offset stays above the \(10^{-13}\) noise floor, so
n_detectable is a clean 6 of 6 and the reported dynamic range spans
the full curve. monotonic_decay is False for exactly the reason
the synthetic data was built that way: the amplitude at 4000 m
(\(2.1\times10^{-12}\)) rises to \(2.7\times10^{-12}\) at 6000 m,
a 29% jump that clears the default 5% monotonic_tol easily, before
the curve resumes its decay out to 10000 m. A real bump of that shape at
one offset, surrounded by an otherwise clean decay, is a much stronger
signal of a local problem — a bad receiver, a geometry error — than a
gently increasing trend would be.
9.4.6. Transmitter Telemetry#
A transmitter node reports its state as a source packet, parsed by the
SourcePayload schema with the same tolerant alias folding and range
validation as the other payloads. In the example below, current,
tx_voltage, frequency, ab_m, and tx_rx_offset are all
normalised to canonical payload fields.
>>> from pycsamt.iot import DeviceConfig, FieldSession, parse_payload
>>> tx = DeviceConfig("tx-1", role="transmitter")
>>> payload = parse_payload("source", {
... "tx_id": "TX1", "current": 9.8, "tx_voltage": 250.0,
... "frequency": 32.0, "ab_m": 100.0, "tx_rx_offset": 5000.0,
... })
>>> session = FieldSession("CS1", method="csamt", devices=[tx])
>>> _ = session.add_packet({
... "device_id": "tx-1", "timestamp": 10.0, "topic": tx.topic("source"),
... "kind": "source", "payload": payload.as_dict(),
... })
>>> print(
... "payload current/frequency/offset: "
... f"{payload.tx_current_a:.1f} A, {payload.tx_frequency_hz:.1f} Hz, {payload.offset_m:.0f} m"
... )
payload current/frequency/offset: 9.8 A, 32.0 Hz, 5000 m
>>> print(f"session packets: {len(session.packets)}")
session packets: 1
The raw payload keys (current, tx_voltage, frequency,
tx_rx_offset) never appear again after parse_payload runs — every
downstream read goes through the canonical tx_current_a,
tx_voltage_a, tx_frequency_hz, and offset_m attributes, so a
transmitter reporting under a slightly different vendor-specific key name
would parse into exactly the same shape.
9.4.7. Static Shift Estimate#
A galvanic static shift multiplies apparent resistivity
by a frequency-independent factor while leaving phase nearly
unchanged. pyCSAMT estimates the per-period log split between the xy
and yx modes,
The result is a static-shift candidate when the median split is large enough, the standard deviation of \(s_i\) is small enough, and the mean phase difference stays below the phase threshold.
>>> periods = np.logspace(-3, 1, 12)
>>> res_yx = 80.0 * (1 + 0.08 * np.sin(np.linspace(0, np.pi, periods.size)))
>>> res_xy = 3.0 * res_yx
>>> phi_yx = 42.0 + 1.5 * np.sin(np.linspace(0, 2 * np.pi, periods.size))
>>> phi_xy = phi_yx + 1.2
>>> from pycsamt.iot import estimate_static_shift
>>> ss = estimate_static_shift(res_xy, res_yx, phase_xy=phi_xy, phase_yx=phi_yx)
>>> print(f"static_shift: {ss.static_shift}")
static_shift: True
>>> print(f"shift_factor: {ss.shift_factor:.2f}")
shift_factor: 3.00
>>> print(f"split_decades: {ss.split_decades:.3f}")
split_decades: 0.477
>>> print(f"consistency_std: {ss.consistency_std:.3f}")
consistency_std: 0.000
>>> print(f"phase_diff_deg: {ss.phase_diff_deg:.2f}")
phase_diff_deg: 1.20
res_xy was built as exactly three times res_yx at every period, so
the recovered shift_factor of 3.00 is not a coincidence — it is the
constant this synthetic data was designed to hide, and
consistency_std: 0.000 confirms the split is perfectly
frequency-independent because both curves share the same oscillation
before the multiplication. The 1.2-degree constant offset added to
phi_xy stays far under the default 10-degree phase threshold, which
is what a true galvanic distortion should look like: parallel resistivity
curves on a log scale with matching phase.
9.4.8. Transmitter Timing Lock#
A CSAMT/CSEM receiver can also report its transmitter timing lock
alongside normal clock synchronisation, using the tx_locked,
tx_sync_offset_ms, and tx_id fields of the sync payload.
>>> sync = parse_payload("sync", {
... "offset_ms": 0.4, "transmitter_locked": True,
... "tx_offset_ms": 0.2, "tx_id": "TX1",
... })
>>> print(f"tx_locked: {sync.tx_locked}")
tx_locked: True
>>> print(f"tx_sync_offset_ms: {sync.tx_sync_offset_ms:.1f}")
tx_sync_offset_ms: 0.2
Note that tx_sync_offset_ms (0.2 ms, how far the receiver’s clock sits
from the transmitter’s own timing) is a different number from the
offset_ms field (0.4 ms) this same payload also carries for ordinary
GPS/network synchronisation — a receiver can be well locked to a GPS
reference and still drift slightly from the transmitter, or vice versa,
so the two offsets are tracked independently rather than collapsed into
one.
9.4.9. The Controlled-Source Figure#
The same synthetic objects can be rendered as a compact four-panel field
summary: field-zone ratio, transmitter-comb SNR, source-current
stability, and the CSEM offset response. None of these four views has a
dedicated plot_* helper, so the figure is written directly against
matplotlib from the values already computed above.
>>> from pathlib import Path
>>> import matplotlib.pyplot as plt
>>> out_dir = Path("docs/source/images/user_guide/iot")
>>> out_dir.mkdir(parents=True, exist_ok=True)
>>> fig, axes = plt.subplots(2, 2, figsize=(10.8, 7.2), constrained_layout=True)
>>> zone_colors = {"near": "tab:red", "transition": "tab:orange", "far": "tab:green"}
>>> _ = axes[0, 0].scatter(cov.freq_hz, cov.offset_ratio, c=[zone_colors[z] for z in cov.zones], s=56)
>>> _ = axes[0, 0].axhline(1.0, color="0.35", ls=":", lw=1.0)
>>> _ = axes[0, 0].axhline(3.0, color="0.35", ls="--", lw=1.0)
>>> axes[0, 0].set_xscale("log")
>>> axes[0, 0].invert_xaxis()
>>> _ = axes[0, 0].set_xlabel("Frequency (Hz)")
>>> _ = axes[0, 0].set_ylabel("r / skin depth")
>>> _ = axes[0, 0].set_title("CSAMT field zones")
>>> axes[0, 0].grid(alpha=0.25, which="both")
>>> _ = axes[0, 1].bar(
... [line.frequency_hz for line in comb.lines],
... [line.snr_db for line in comb.lines],
... width=[line.frequency_hz * 0.18 for line in comb.lines],
... color=["tab:blue" if line.detected else "tab:red" for line in comb.lines],
... )
>>> _ = axes[0, 1].axhline(8.0, color="0.25", ls="--", lw=1.0)
>>> axes[0, 1].set_xscale("log")
>>> _ = axes[0, 1].set_xlabel("Expected line (Hz)")
>>> _ = axes[0, 1].set_ylabel("SNR (dB)")
>>> _ = axes[0, 1].set_title("Transmitter comb")
>>> axes[0, 1].grid(axis="y", alpha=0.25)
>>> _ = axes[1, 0].plot(t[:1200], tx_current[:1200], color="tab:purple", lw=1.0)
>>> _ = axes[1, 0].axhline(status.current_mean_a, color="0.25", ls="--", lw=1.0)
>>> _ = axes[1, 0].set_xlabel("Time (s)")
>>> _ = axes[1, 0].set_ylabel("Current (A)")
>>> _ = axes[1, 0].set_title("Source-current stability")
>>> axes[1, 0].grid(alpha=0.25)
>>> _ = axes[1, 1].semilogy(offsets_m, amplitudes, marker="o", color="tab:brown")
>>> _ = axes[1, 1].axhline(1e-13, color="0.25", ls="--", lw=1.0)
>>> _ = axes[1, 1].set_xlabel("Offset (m)")
>>> _ = axes[1, 1].set_ylabel("Amplitude")
>>> _ = axes[1, 1].set_title("CSEM MVO with suspect bump")
>>> axes[1, 1].grid(alpha=0.25, which="both")
>>> fig.savefig(out_dir / "user-guide-iot-controlled-source-01.png", dpi=180)
The top-left panel plots the field-zone scatter on inverted log axes, so
frequency decreases left to right, matching the direction skin depth
grows. Five green points sit comfortably above the dashed far-field line
at 3, the orange point sits between the two threshold lines in the
transition band, and the red point sits at the bottom near a ratio of 1
— the same near/transition/far split already printed above, now visible
as a shape rather than a table. The top-right panel’s three tall blue
bars for 8, 32, and 128 Hz all clear the 8 dB dashed threshold with room
to spare, while the missing 512 Hz line is a bar too short to see,
coloured red. The bottom-left panel shows the source current oscillating
around its mean (the dashed line at 9.81 A) with a visible slow upward
drift over the first tenth of a second before settling — small enough
that the CV stayed at 0.0066. The bottom-right panel is the clearest
single picture on the page: an overall decay from \(10^{-11}\) to
below \(10^{-13}\) amplitude over four orders of magnitude in offset,
broken by one unmistakable uptick at 6000 m before the decay resumes —
exactly the bump monotonic_decay: False flagged numerically.
9.4.10. Report Aggregation#
csamt_edge_report() and
csem_edge_report() collate the per-channel
diagnostics shown above into one dictionary each, and
csamt_edge_table() /
csem_edge_table() flatten one or more of
those reports into pyCSAMT tables for multi-channel or multi-frequency
reporting — the same table-building pattern
amt_edge_table() uses on the natural-source
side. In practice, keep the source packet, comb result, field-zone
coverage, and CSEM offset response together in the field manifest so a
later reviewer can see both the measured response and the source state
that produced it. Method-Aware QC picks this up next, showing how
a declared acquisition method gates which of these controlled-source
checks even apply.