12.3. The Airborne Site View#

AirborneSite and AirborneSites give a flat, Sites-shaped view over AirborneEMLine/ AirborneEMDataset records – one wrapped AirborneEMRecord per sample, read directly from its EMTF document. Unlike Site, whose numeric accessors all route through a materialized EDI, an AirborneSite has no EDI bridge and no impedance requirement, so it can hold a tipper, an interstation transfer function, a scalar tilt reading, or a MobileMT admittance tensor equally, and read whichever one its EMTF document actually carries. This page works entirely from the four sample surveys committed under data/AFMAG/, data/ZTEM/, and data/mobileMT/ – see Airborne Data Model Overview for what each represents – rather than the small hand-built lines Flight Lines and Datasets constructs from scratch.

12.3.1. Reading Sample Surveys#

ensure_asites() is the single entry point every airborne-aware emtools function coerces its input through, and it reads a directory of EMTF-XML files directly into an AirborneSites:

>>> from pycsamt.airborne.site import ensure_asites
>>> ztem = ensure_asites("data/ZTEM/gold_springs_nv")
>>> len(ztem), ztem.technologies
(105, ('ztem',))
>>> afmag_o = ensure_asites("data/AFMAG/abitibi_on")
>>> len(afmag_o), afmag_o.technologies
(13, ('afmag_original',))
>>> afmag_a = ensure_asites("data/AFMAG/yulong_belt_cn")
>>> len(afmag_a), afmag_a.technologies
(11, ('afmag_airmt',))
>>> mmt = ensure_asites("data/mobileMT/flammefjeld_greenland")
>>> len(mmt), mmt.technologies
(12, ('mobilemt',))

Four different directories, four different AFMAG-family or ZTEM/MobileMT deliveries, and the same one-line call reads all of them. technologies is not supplied by the caller anywhere above – each AirborneSite resolves its own technology from the underlying EMTF.subtype, written by whichever build_*_line constructor produced the document in the first place (ztem, mobilemt, afmag_original, or afmag_airmt – the two AFMAG generations are kept as genuinely distinct labels, not folded into one "afmag" tag, since their response shapes below are not interchangeable).

12.3.2. Four Response Families#

has_component() checks which of those response shapes a given site actually carries, without having to inspect EMTF.subtype by hand:

>>> afmag_o_site = afmag_o[0]
>>> afmag_a_site = afmag_a[4]
>>> ztem_site = ztem.get("GO_L4_008")
>>> mmt_site = mmt[0]
>>> for s in (afmag_o_site, afmag_a_site, ztem_site, mmt_site):
...     print(
...         s.name, s.technology,
...         "tipper=", s.has_component("tipper"),
...         "interstation_tensor=", s.has_component("interstation_tensor"),
...         "afmag_tilt=", s.has_component("afmag_tilt"),
...         "admittance=", s.has_component("admittance"),
...     )
AB_001 afmag_original tipper= False interstation_tensor= False afmag_tilt= True admittance= False
YU_005 afmag_airmt tipper= False interstation_tensor= True afmag_tilt= False admittance= False
GO_L4_008 ztem tipper= True interstation_tensor= False afmag_tilt= False admittance= False
FL_001 mobilemt tipper= False interstation_tensor= False afmag_tilt= False admittance= True

Each site carries exactly one of the four – a diagonal, not a coincidence: AirborneSite never fabricates a component a technology does not measure, so afmag_tilt_deg stays None on a ZTEM site and tipper stays None on a MobileMT site, rather than either silently returning zeros. Plotting all four against frequency, on their own natural axes, makes the physical difference concrete:

>>> import numpy as np
>>> import matplotlib.pyplot as plt
>>> def frobenius(arr):
...     axes = tuple(range(1, arr.ndim))
...     return np.sqrt(np.sum(np.abs(arr) ** 2, axis=axes))
>>> fig, axes = plt.subplots(2, 2, figsize=(10.0, 7.2))
>>> ax = axes[0, 0]
>>> _ = ax.plot(afmag_o_site.freq, afmag_o_site.afmag_tilt_deg, "o", ms=8, color="C0")
>>> _ = ax.axhline(0.0, color="0.6", lw=0.8)
>>> _ = ax.set_title(f"afmag_original -- {afmag_o_site.name}")
>>> _ = ax.set_xlabel("Frequency [Hz]"); _ = ax.set_ylabel("Tilt angle [deg]")
>>> ax = axes[0, 1]
>>> mag = frobenius(afmag_a_site.interstation_tensor)
>>> _ = ax.semilogx(afmag_a_site.freq, mag, "o-", color="C1")
>>> _ = ax.set_title(f"afmag_airmt -- {afmag_a_site.name}")
>>> _ = ax.set_xlabel("Frequency [Hz]"); _ = ax.set_ylabel(r"$\|\mathbf{T}\|_F$ (interstation tensor)")
>>> ax = axes[1, 0]
>>> mag = frobenius(ztem_site.tipper)
>>> _ = ax.plot(ztem_site.freq, mag, "o-", color="C2")
>>> _ = ax.set_title(f"ztem -- {ztem_site.name}")
>>> _ = ax.set_xlabel("Frequency [Hz]"); _ = ax.set_ylabel(r"$\|\mathbf{T}\|_F$ (tipper)")
>>> ax = axes[1, 1]
>>> mag = frobenius(mmt_site.admittance)
>>> _ = ax.loglog(mmt_site.freq, mag, "o-", color="C3")
>>> _ = ax.set_title(f"mobilemt -- {mmt_site.name}")
>>> _ = ax.set_xlabel("Frequency [Hz]"); _ = ax.set_ylabel(r"$\|\mathbf{Y}\|_F$ (admittance)")
>>> fig.tight_layout()
>>> fig.savefig("user-guide-airborne-site-01.png", dpi=200, bbox_inches="tight")
../../_images/user-guide-airborne-site-01.png

The four response families read from one common container, each on its own natural axis.#

The historical comparator (top left) samples only two audio-frequency bands, 150 and 510 Hz, and reports a signed tilt in degrees – station AB_001 reads slightly negative at 150 Hz and positive at 510 Hz, a genuine sign reversal between bands rather than measurement noise, since the comparator has no polarization-ellipse decomposition to average over. AirMt’s interstation tensor (top right) and ZTEM’s tipper (bottom left) are both plotted as a per-frequency Frobenius norm across every component, since neither reduces naturally to one scalar component the way the comparator’s tilt does; MobileMT’s admittance (bottom right) is shown on log-log axes because it falls off by more than an order of magnitude across its ten-decade frequency range, \(H = Y E\) weakening steadily as frequency rises exactly as Airborne Natural-Source EM: AFMAG, ZTEM, And MobileMT derives for a fixed source-receiver geometry.

12.3.3. Selecting And Grouping#

AirborneSites indexes by position or by case-insensitive name, and select() filters by an explicit name list or a predicate:

>>> ztem[0]
AirborneSite(name='GO_L1_001', technology='ztem', nfreq=6, coords=(37.98404,-114.15000,1750.0))
>>> ztem["GO_L1_001"] is ztem.get("GO_L1_001")
True
>>> line4 = ztem.select(predicate=lambda s: s.name.split("_")[1] == "L4")
>>> len(line4), line4[0].name, line4[-1].name
(15, 'GO_L4_001', 'GO_L4_015')

gold_springs_nv’s station names encode a flight-line label directly (GO_L<n>_<seq>), so grouping by line here is a one-line predicate over name. line_ids is not an alternative way to reach the same grouping, though:

>>> ztem.line_ids
()

Reading straight from a directory via from_xml_dir() (what ensure_asites does above) constructs every site with line_id=None – the file itself carries no flight-line identifier, only the station name does by convention. line_id is populated only when sites are built from an already-assembled AirborneEMLine, via from_line():

>>> from pycsamt.airborne import NavigationTrack
>>> from pycsamt.airborne.ztem import build_ztem_line, ZTEMSystemSpec
>>> from pycsamt.airborne.site import AirborneSites
>>> nav = NavigationTrack(
...     sample_ids=("S00", "S01"),
...     easting=np.array([0.0, 50.0]),
...     northing=np.array([0.0, 0.0]),
... )
>>> tip = np.zeros((2, 1, 2), dtype=complex)
>>> tip[:, 0, 0] = [0.01 + 0.002j, 0.015 + 0.001j]
>>> tip[:, 0, 1] = [0.003 - 0.001j, 0.004 - 0.0005j]
>>> line = build_ztem_line(
...     "DEMO", nav, tip, frequency=np.array([90.0]),
...     system_spec=ZTEMSystemSpec(),
... )
>>> AirborneSites.from_line(line).line_ids
('DEMO',)

The distinction matters because ensure_asites is the entry point every airborne emtools function actually receives – so any code that groups stations by line_id rather than through the document’s own metadata will silently see nothing on data read from a directory. ZTEM Total-Divergence, Phase-Rotation, And Map-View Diagnostics avoids exactly this trap by reading the flight-line label back out of EMTF.metadata["notes"]["ZTEM"]["LineId"] instead of relying on line_id; the station-name predicate used above is a second, equally valid way to reach the same grouping when the survey’s naming convention already encodes it.

closest() finds the nearest site to a target coordinate by great-circle distance, with an optional distance cutoff:

>>> nearest = ztem.closest(37.979, -114.1455)
>>> nearest.name
'GO_L5_009'
>>> ztem.closest(37.979, -114.1455, tol=60.0).name
'GO_L5_009'
>>> ztem.closest(37.979, -114.1455, tol=30.0) is None
True

GO_L5_009 sits about 39 m from the target coordinate – inside a 60 m tolerance, outside a 30 m one, so the same query returns the station or None depending on how far away “close enough” is allowed to be.

Note

AirborneSites deliberately does not mirror every Sites bulk-editing method. There is no edit_all, with_topography, or to_profile here – those are real Sites features this module does not yet need for the emtools integration it exists to unblock, left out rather than stubbed with placeholder behaviour.

12.3.4. One Container, Three Diagnostics#

The point of reading every technology through the same AirborneSites container is not just tidy code – it is that the container needs no conversion to reach each technology’s own, literature-derived diagnostic. ztem, afmag_o, and mmt above are handed directly to plot_ztem_map(), plot_original_afmag_dual_frequency_profile(), and plot_mobilemt_conductivity_psection() – each built around a specific literature source (Legault et al. 2012’s map-view grid, Ward (1959) Fig. 16’s dual-frequency profile, and the theoretical admittance-to-conductivity relationship Prikhodko et al. (2022) describe for MobileMT), none of which take anything other than an AirborneSites-like object as their first argument:

>>> from pycsamt.emtools.ztem import plot_ztem_map
>>> from pycsamt.emtools.afmag import plot_original_afmag_dual_frequency_profile
>>> from pycsamt.emtools.mobilemt import plot_mobilemt_conductivity_psection
>>> fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(16.5, 5.6))
>>> _ = plot_ztem_map(ztem, ax=ax1)
>>> _ = plot_original_afmag_dual_frequency_profile(afmag_o, ax=ax2)
>>> _ = plot_mobilemt_conductivity_psection(mmt, ax=ax3)
>>> for ax in (ax1, ax2, ax3):
...     ax.title.set_fontsize(10)
>>> fig.tight_layout(w_pad=2.5)
>>> fig.savefig("user-guide-airborne-site-02.png", dpi=200, bbox_inches="tight")
../../_images/user-guide-airborne-site-02.png

Three technologies, three EMTF-XML directories, three real diagnostics – none of them written for this page.#

Each panel is genuinely reading its own physics. The ZTEM map (left) grids the in-phase \(T_{zx}\) across all seven gold_springs_nv lines and finds the same synthetic conductor this page’s earlier figures already touched: a blue-to-red crossover striking north-south, strongest through the survey’s centre where the target’s along-strike amplitude peaks. The AFMAG profile (middle) reproduces Ward’s own reading of a comparator survey – both frequencies swing through zero together near station distance 360 m, “the axis of conductor” the annotation marks, and their differing peak-to-peak amplitudes (the panel’s title reports the ratio) are exactly the frequency-dependence Ward used to argue for a real conductor rather than topographic noise. The MobileMT pseudosection (right) images theoretical apparent conductivity against period and along-line position: the brighter band sitting under roughly 400-700 m is the same kind of central-line response the ZTEM map shows spatially, here resolved with period as a rough proxy for depth instead of a second horizontal axis. Interpreting any one of these three panels in depth is exactly what ZTEM Total-Divergence, Phase-Rotation, And Map-View Diagnostics, AFMAG Tilt-Angle Diagnostics And Motion-Coupling Physics, and MobileMT Admittance, Apparent Conductivity, And Skew Diagnostics are for; this page’s point is narrower – every panel above started from nothing more than a directory path and ensure_asites().

12.3.5. Exporting And Round-Tripping#

to_dataframe() exports one site’s data to a tidy, frequency-indexed table:

>>> ztem_site.to_dataframe(kind="tipper").round(4)
                   Tx              Ty
f
30.0  -0.0012-0.0038j -0.0004-0.0052j
45.0  -0.0041+0.0059j -0.0084+0.0078j
90.0   0.0145-0.0024j -0.0079-0.0087j
180.0  0.0075+0.0116j  0.0091-0.0018j
360.0 -0.0070-0.0078j -0.0046-0.0088j
720.0  0.0092-0.0163j  0.0052-0.0045j

and write_xml() writes an entire collection back out, one EMTF-XML file per site, which from_xml_dir() can read straight back in – a genuine round trip through the file format, not just an in-memory copy:

>>> import tempfile
>>> with tempfile.TemporaryDirectory() as tmp:
...     paths = afmag_a.write_xml(tmp)
...     reread = AirborneSites.from_xml_dir(tmp)
...     orig = afmag_a.get("YU_005")
...     back = reread.get("YU_005")
...     tensors_match = np.allclose(orig.interstation_tensor, back.interstation_tensor)
...     freqs_match = np.allclose(orig.freq, back.freq)
>>> len(paths), len(reread)
(11, 11)
>>> tensors_match, freqs_match
(True, True)

The interstation tensor and frequency grid both survive the write-then-read cycle exactly, confirming that to_xml() – called once per site under write_xml – serializes the full (n_f, 3, 2) array rather than truncating it to the (n_f, 1, 2) shape a tipper-only document would need.