12.2. Flight Lines and Datasets#

NavigationTrack, AirborneEMRecord, AirborneEMLine, and AirborneEMDataset are the four containers every technology subpackage builds on. Navigation is the definitive spine: a flight line’s sample identifiers, position, and attitude are recorded once, independently of whether every sample actually produced a usable transfer function. Records are then attached sparsely, keyed by the same sample identifiers, so a rejected or missing EM sample never forces deleting its navigation point, and never gets a fabricated response to fill the gap. Every name below imports from the top-level pycsamt.airborne package; this page builds everything from scratch with small synthetic examples, since reading a real committed survey through this same model is The Airborne Site View’s job.

12.2.2. Records and Lines#

An AirborneEMLine pairs one NavigationTrack with a sparse {sample_id: AirborneEMRecord} mapping. A freshly built line has navigation but no records at all – missing_sample_ids reports every sample as missing until records are actually attached:

>>> from pycsamt.airborne import AirborneEMLine
>>> line = AirborneEMLine(line_id="DEMO01", navigation=nav)
>>> line.n_samples, line.n_records
(20, 0)
>>> len(line.missing_sample_ids)
20

build_ztem_record() – the same kind of technology constructor The Airborne Site View and Technologies, Formats, and Native I/O already use – builds one AirborneEMRecord at a time, ready for add_record(). Deliberately skipping sample S07 leaves the line genuinely sparse, not just theoretically capable of it:

>>> from pycsamt.airborne.ztem import build_ztem_record, ZTEMSystemSpec
>>> freqs = np.array([90.0, 180.0, 360.0])
>>> for i, sid in enumerate(nav.sample_ids):
...     if i == 7:
...         continue
...     tip = np.zeros((3, 2), dtype=complex)
...     tip[:, 0] = 0.05 + 0.01j
...     tip[:, 1] = 0.02 - 0.005j
...     record = build_ztem_record(sid, tip, frequency=freqs, system_spec=ZTEMSystemSpec())
...     _ = line.add_record(record)
>>> line.n_records
19
>>> line.missing_sample_ids
('S07',)
>>> line.transfer_function_names
('tipper',)
>>> line.record_at(0).sample_id
'S00'
>>> line.record_at(7) is None
True

record_at() and get_record() both return None for a missing sample rather than raising – S07 is a perfectly valid navigation index, it simply has nothing attached. iter_records() skips it silently and yields every other record in navigation order, not insertion order, which happens to be the same order here only because records were added in navigation order to begin with:

>>> order = [r.sample_id for r in line.iter_records()]
>>> order == [s for s in nav.sample_ids if s != "S07"]
True

12.2.3. Assembling A Dataset#

AirborneEMDataset collects lines the same way lines collect records – keyed, this time by line_id – and adds survey-level bookkeeping on top: iterating every line or every record across the whole survey, and recovering just the EMTF payloads that actually exist. Two more small lines, offset 100 m apart along northing, make that concrete:

>>> from pycsamt.airborne import AirborneEMDataset
>>> def make_offset_line(line_id, northing_offset):
...     nav2 = NavigationTrack(
...         sample_ids=tuple(f"{line_id}_S{i:02d}" for i in range(n)),
...         easting=x, northing=np.full(n, northing_offset),
...         terrain_elevation=terrain, platform_elevation=platform,
...     )
...     ln = AirborneEMLine(line_id=line_id, navigation=nav2)
...     for sid in nav2.sample_ids:
...         tip = np.zeros((3, 2), dtype=complex)
...         tip[:, 0] = 0.05 + 0.01j
...         tip[:, 1] = 0.02 - 0.005j
...         rec = build_ztem_record(sid, tip, frequency=freqs, system_spec=ZTEMSystemSpec())
...         ln.add_record(rec)
...     return ln
>>> line_a = make_offset_line("A", 0.0)
>>> line_b = make_offset_line("B", 100.0)
>>> dataset = AirborneEMDataset(name="demo_survey", lines={"A": line_a, "B": line_b})
>>> dataset.n_lines, dataset.n_samples, dataset.n_records
(2, 40, 40)
>>> dataset.line_ids
('A', 'B')
>>> dataset.get_line("A") is line_a
True
>>> _ = dataset.add_line(line, replace=True)
>>> dataset.n_lines
3
>>> len(dataset.emtf_records())
59

59, not 60dataset.emtf_records() folds DEMO01’s own sparsity into the dataset total automatically, since emtf_records() omits any record with no attached EMTF rather than representing the gap with a placeholder, the same convention iter_records() already applies at the line level.

inspect()/ qc() are thin, lazily- imported convenience wrappers around exactly the functions Structural Quality Control covers directly – inspect_airborne()/ assess_airborne_qc() – so a dataset can inspect or assess itself without an extra import:

>>> insp = dataset.inspect()
>>> insp.object_type, insp.n_lines, insp.n_samples, insp.n_records
('dataset', 3, 60, 59)
>>> report = dataset.qc()
>>> report.status
'warning'
>>> len(report.warnings), len(report.errors)
(59, 0)

Fifty-nine warnings – one per attached record across all three lines, DEMO01’s missing S07 sample already counted separately as an "info"-severity finding above – is expected rather than a bug: every record here was built with build_ztem_record() and no reference_station argument, so none of them carry the fixed ground-reference metadata Technologies, Formats, and Native I/O already showed ZTEM’s reference_required=True contract demands. status is still only "warning", not "error", because a missing reference station is incomplete metadata, not an internally inconsistent one – the exact severity philosophy Structural Quality Control explains in full, with a dataset built to actually pass.