12.4. Technologies, Formats, and Native I/O#

pycsamt.airborne.registry separates two concepts that a single “format” tag would blur together: a technology describes scientific semantics – what physical quantity a response represents, whether a fixed ground reference is required, which transfer-function names are unique enough to infer it from – while a format describes one concrete way that technology’s response is delivered on disk. pycsamt.airborne.io then dispatches read_airborne()/ write_airborne() through the format half of that registry. Every name used on this page is imported straight from the top-level pycsamt.airborne package – from pycsamt.airborne import ... – since the registry and I/O dispatcher are both part of its public surface; only the tiny illustrative ZTEM line built partway through needs a submodule import, because build_ztem_line is a technology-specific constructor that pycsamt.airborne itself deliberately does not re-export.

This registry is not the one that reads the data/ZTEM/, data/AFMAG/, and data/mobileMT/ sample surveys used elsewhere in this guide – that path is ensure_asites(), covered in The Airborne Site View, reading EMTF-XML directly with no format lookup at all. The registry on this page exists for a different, still-open problem: a genuine native vendor delivery (whatever binary or proprietary text format a MobileMT, ZTEM, or AFMAG system actually writes in the field), which no vendor has yet supplied a verified sample of.

12.4.1. Built-In Technologies#

Four technology contracts are registered the moment pycsamt.airborne is imported – list_airborne_technologies() lists them in registration order:

>>> from pycsamt.airborne import list_airborne_technologies, get_airborne_technology
>>> for t in list_airborne_technologies():
...     print(
...         t.name, "|", t.label, "| family=", t.family,
...         "| aliases=", t.aliases,
...         "| reference_required=", t.reference_required,
...         "| infer_from_tf=", t.infer_from_tf,
...     )
mobilemt | MobileMT | family= natural_field_airborne_em | aliases= ('mobile_mt',) | reference_required= True | infer_from_tf= True
ztem | ZTEM | family= natural_field_airborne_em | aliases= ('z_tem',) | reference_required= True | infer_from_tf= False
afmag | AFMAG (original comparator) | family= afmag | aliases= ('original_afmag', 'comparator_afmag') | reference_required= False | infer_from_tf= True
airmt | AirMt / tensor AFMAG | family= afmag | aliases= ('tensor_afmag', 'afmag_tensor') | reference_required= True | infer_from_tf= False

Two fields decide how much a technology can be inferred rather than declared. reference_required records a scientific fact – qc treats a missing fixed ground reference as a QC issue only for technologies where one is genuinely needed, which is every technology here except the original comparator. afmag and mobilemt allow the stronger infer_from_tf=True, meaning identify_airborne_technologies() may recognize them purely from a matching transfer-function name (afmag_tilt, mobilemt_admittance); ztem and airmt deliberately do not, because their signature transfer-function names – a standard tipper, an interstation transfer function – are not unique enough by themselves to be trusted as proof.

Lookup is by canonical name or by any registered alias, and an unregistered name returns None rather than raising, since callers use this for best-effort inference over untrusted metadata:

>>> get_airborne_technology("mobile_mt") is get_airborne_technology("mobilemt")
True
>>> get_airborne_technology("nope") is None
True

12.4.2. Identifying A Technology#

identify_airborne_technologies() inspects an already-built object – an EMTF document, record, line, or dataset – and returns every technology it can safely recognize. detect_airborne_technology() collapses that to one name, or raises when more than one is found. Running it on one real EMTF document from each of the four sample surveys makes a naming distinction worth being deliberate about:

>>> from pycsamt.airborne import ensure_asites, identify_airborne_technologies
>>> docs = {
...     "abitibi_on": ensure_asites("data/AFMAG/abitibi_on")[0].emtf,
...     "yulong_belt_cn": ensure_asites("data/AFMAG/yulong_belt_cn")[0].emtf,
...     "gold_springs_nv": ensure_asites("data/ZTEM/gold_springs_nv")[0].emtf,
...     "flammefjeld_greenland": ensure_asites("data/mobileMT/flammefjeld_greenland")[0].emtf,
... }
>>> for survey, doc in docs.items():
...     print(survey, "subtype=", doc.subtype, "identify=", identify_airborne_technologies(doc))
abitibi_on subtype= afmag_original identify= ('afmag',)
yulong_belt_cn subtype= afmag_airmt identify= ('airmt',)
gold_springs_nv subtype= ztem identify= ('ztem',)
flammefjeld_greenland subtype= mobilemt identify= ('mobilemt',)

EMTF.subtype – the same string The Airborne Site View’s technology reads – and the registry’s canonical technology name agree for ztem and mobilemt, but not for either AFMAG generation: subtype carries "afmag_original"/"afmag_airmt", while the registry canonicalizes those to "afmag"/"airmt" (an internal mapping inside pycsamt.airborne.registry). Code that compares AirborneSite.technology against a name returned by identify_airborne_technologies/detect_airborne_technology needs to know both spellings exist for AFMAG; ztem and mobilemt happen to make that mistake invisible, which is exactly why it is easy to miss until an AFMAG survey hits it.

Identification does not just trust one tag blindly – it walks attrs["technology"], EMTF.subtype, and per-transfer-function tags, and accumulates every match rather than stopping at the first. Deliberately mislabelling a real ZTEM document’s owning record demonstrates what a genuine conflict looks like:

>>> from pycsamt.airborne import (
...     AirborneEMRecord, AirborneTechnologyAmbiguityError, detect_airborne_technology,
... )
>>> mislabeled = AirborneEMRecord(
...     sample_id="S00", emtf=docs["gold_springs_nv"], attrs={"technology": "mobilemt"},
... )
>>> identify_airborne_technologies(mislabeled)
('mobilemt', 'ztem')
>>> try:
...     detect_airborne_technology(mislabeled)
... except AirborneTechnologyAmbiguityError as exc:
...     print("AirborneTechnologyAmbiguityError:", exc)
AirborneTechnologyAmbiguityError: multiple airborne technologies are present: mobilemt, ztem
>>> detect_airborne_technology(mislabeled, strict=False) is None
True

The explicit attrs tag ("mobilemt") and the document’s own subtype-derived signal ("ztem") are both genuine, so identify_airborne_technologies reports both instead of silently preferring one; detect_airborne_technology turns that into a hard error by default, and only swallows it to None when the caller has explicitly opted into strict=False. This is precisely the kind of inconsistency Structural Quality Control checks for structurally, on a whole dataset rather than one hand-built record.

12.4.3. No Native Reader Yet#

The format half of the registry starts empty, because no vendor has supplied pyCSAMT with a verified native delivery to build a reader against – registering a decoder from a published system description alone risks silently guessing a schema wrong:

>>> from pycsamt.airborne import (
...     list_airborne_formats, available_airborne_readers, available_airborne_writers,
...     detect_airborne_format, read_airborne, AirborneIOError,
... )
>>> list_airborne_formats()
()
>>> available_airborne_readers(), available_airborne_writers()
((), ())
>>> detect_airborne_format("data/ZTEM/gold_springs_nv/GO_L1_001.xml") is None
True
>>> try:
...     read_airborne("data/ZTEM/gold_springs_nv/GO_L1_001.xml")
... except AirborneIOError as exc:
...     print("AirborneIOError:", exc)
AirborneIOError: no registered native airborne format recognized the source; register a verified reader after obtaining a representative delivery file

Note that this is not the same failure a corrupt or unsupported file would raise elsewhere in pyCSAMT – AirborneIOError is a RuntimeError, not a ValueError, precisely because every failure reachable here today means “no reader has been registered yet,” a capability gap rather than bad input. Passing that same .xml path to ensure_asites() instead reads it immediately, since EMTF-XML flows through an entirely separate path that was never routed through this registry in the first place.

12.4.4. Registering A Format#

Once a verified sample exists, a format is registered by pairing a reader/writer with a way to recognize it – a detector callable, a tuple of file extensions, or both. The example below stands in for that real future reader with a small in-memory ZTEM line (three stations, two frequencies, built the same way Flight Lines and Datasets constructs one from scratch) so the mechanics can be shown without a real vendor file to point at:

>>> import numpy as np
>>> from pycsamt.airborne import NavigationTrack, AirborneEMDataset
>>> from pycsamt.airborne.ztem import build_ztem_line, ZTEMSystemSpec
>>> nav = NavigationTrack(
...     sample_ids=("S00", "S01", "S02"),
...     easting=np.array([0.0, 50.0, 100.0]),
...     northing=np.zeros(3),
... )
>>> tip = np.zeros((3, 2, 2), dtype=complex)
>>> tip[:, :, 0] = 0.01 + 0.002j
>>> tip[:, :, 1] = 0.003 - 0.001j
>>> demo_line = build_ztem_line(
...     "DEMO01", nav, tip, frequency=np.array([90.0, 180.0]),
...     system_spec=ZTEMSystemSpec(),
... )
>>> demo_dataset = AirborneEMDataset(name="demo_survey", lines={"DEMO01": demo_line})

A detector gets first refusal at recognizing a source; an extension is only consulted as a fallback hint, and only once no detector claims it. Registering two formats under the same technology – one that recognizes a header string, one that only knows the extension – makes that ordering concrete:

>>> from pathlib import Path
>>> from pycsamt.airborne import AirborneFormatDefinition, register_airborne_format
>>> def _has_pycsamt_header(source):
...     try:
...         with open(source) as f:
...             return f.readline().strip() == "PYCSAMT-DEMO-ZTEM-V1"
...     except OSError:
...         return False
>>> def _demo_reader(source, **kwargs):
...     return demo_dataset
>>> def _demo_writer(dataset, target, **kwargs):
...     with open(target, "w") as f:
...         f.write("PYCSAMT-DEMO-ZTEM-V1\n")
...         f.write(f"n_lines={dataset.n_lines} n_records={dataset.n_records}\n")
...     return Path(target)
>>> _ = register_airborne_format(AirborneFormatDefinition(
...     name="demo_ztem_detected", technology="ztem",
...     reader=_demo_reader, writer=_demo_writer, detector=_has_pycsamt_header,
...     description="Detector-recognized illustrative format (has the header).",
... ))
>>> _ = register_airborne_format(AirborneFormatDefinition(
...     name="demo_ztem_by_ext", technology="ztem",
...     reader=_demo_reader, extensions=(".demo",),
...     description="Extension-only fallback illustrative format.",
... ))
>>> [(f.name, f.readable, f.writable, f.extensions) for f in list_airborne_formats(technology="ztem")]
[('demo_ztem_detected', True, True, ()), ('demo_ztem_by_ext', True, False, ('.demo',))]
>>> available_airborne_readers(technology="ztem")
('demo_ztem_detected', 'demo_ztem_by_ext')
>>> available_airborne_writers(technology="ztem")
('demo_ztem_detected',)

Two files sharing the exact same .demo extension resolve to two different formats depending only on their content, confirming the detector really is checked first rather than the extension winning by registration order:

>>> import tempfile
>>> from pycsamt.airborne import write_airborne
>>> with tempfile.TemporaryDirectory() as tmp:
...     tmp = Path(tmp)
...     with_header = tmp / "survey_a.demo"
...     without_header = tmp / "survey_b.demo"
...     _ = with_header.write_text("PYCSAMT-DEMO-ZTEM-V1\nfake bytes\n")
...     _ = without_header.write_text("not a pycsamt demo file\n")
...     detected_with = detect_airborne_format(with_header)
...     detected_without = detect_airborne_format(without_header)
...     result = read_airborne(with_header)
...     out_path = tmp / "roundtrip.demo"
...     written = write_airborne(demo_dataset, out_path, format="demo_ztem_detected")
...     written_text = written.read_text()
>>> detected_with, detected_without
('demo_ztem_detected', 'demo_ztem_by_ext')
>>> result is demo_dataset
True
>>> print(written_text)
PYCSAMT-DEMO-ZTEM-V1
n_lines=1 n_records=3

with_header and without_header differ only in their first line, not their extension, yet resolve to different formats – exactly the detector-before-extension priority the module docstring promises. Registering a second extension-only format that collides with the first, with no detector to break the tie, turns that same lookup into an error instead of a silent guess:

>>> from pycsamt.airborne import AirborneFormatDetectionError
>>> _ = register_airborne_format(AirborneFormatDefinition(
...     name="demo_ztem_alt_ext", technology="ztem", extensions=(".demo",),
...     description="A second, deliberately colliding extension-only format.",
... ))
>>> try:
...     detect_airborne_format("another_survey.demo")
... except AirborneFormatDetectionError as exc:
...     print("AirborneFormatDetectionError:", exc)
AirborneFormatDetectionError: file extension is ambiguous across airborne formats: demo_ztem_by_ext, demo_ztem_alt_ext

An ambiguous extension is a real failure mode, not a corner case invented for this page: it is exactly what would happen if two technologies’ native deliveries genuinely shared a generic extension like .csv, which is one more reason a verified detector is worth writing alongside a reader rather than relying on extensions alone once real formats are registered here.