Source code for pycsamt.airborne.base

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0

"""Format-neutral scientific containers for airborne EM surveys.

Three dataclasses form the common in-memory model that every technology
adapter (:mod:`pycsamt.airborne.mobilemt`, :mod:`pycsamt.airborne.ztem`,
:mod:`pycsamt.airborne.afmag`) populates instead of inventing its own
survey/line/sample containers:

* :class:`AirborneEMRecord` -- one sample-aligned EM response;
* :class:`AirborneEMLine` -- one flight line of navigation plus sparse
  records;
* :class:`AirborneEMDataset` -- the survey-level collection of lines.

All three inherit :class:`~pycsamt.core.base.CoreObject` rather than
:class:`~pycsamt.core.base.MTBase`: they organize and index scientific
content but do not themselves perform electromagnetic arithmetic. That
arithmetic lives in :class:`~pycsamt.emtf.document.EMTF` and
:class:`~pycsamt.emtf.transfer.TransferFunction`, which these containers
hold rather than duplicate.
"""

from __future__ import annotations

from collections.abc import Iterator
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

from ..core.base import CoreObject
from ..metadata import BBox, InstrumentMeta, SurveyMeta
from .navigation import NavigationTrack
from .validation import clean_identifier, emtf_class

if TYPE_CHECKING:
    from ..emtf.document import EMTF

__all__ = [
    "AirborneEMRecord",
    "AirborneEMLine",
    "AirborneEMDataset",
]


[docs] @dataclass(repr=False) class AirborneEMRecord(CoreObject): """One sample-aligned airborne EM scientific record. Parameters ---------- sample_id : str Identifier matching one entry of the owning line's ``navigation.sample_ids``. Stripped and required to be non-empty. emtf : EMTF, optional Transfer-function payload for this sample. ``EMTF`` is reused directly rather than introduced as a parallel matrix class, so MobileMT, ZTEM, AFMAG, and future passive systems share one scientific representation instead of duplicating it. fields : dict, optional Auxiliary decoded scalar/array fields with no stronger scientific type yet, for example a processed apparent conductivity vector. Content is technology-defined. quality : dict, optional Sample-level quality flags or scores. Content is technology-defined. attrs : dict, optional Free-form extension metadata. Raises ------ ValueError If ``sample_id`` is empty after stripping. TypeError If ``emtf`` is supplied and is not an :class:`~pycsamt.emtf.EMTF` instance. Examples -------- >>> from pycsamt.airborne import AirborneEMRecord >>> record = AirborneEMRecord(sample_id=" S001 ") >>> record.sample_id 'S001' >>> record.transfer_function_names () """ sample_id: str emtf: EMTF | None = None fields: dict[str, Any] = field(default_factory=dict) quality: dict[str, Any] = field(default_factory=dict) attrs: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: self.validate()
[docs] def validate(self) -> None: """Normalize identifier/dict fields and check the EMTF type.""" self.sample_id = clean_identifier( self.sample_id, name="sample_id", ) if self.emtf is not None and not isinstance(self.emtf, emtf_class()): raise TypeError("emtf must be an EMTF instance or None") self.fields = dict(self.fields or {}) self.quality = dict(self.quality or {}) self.attrs = dict(self.attrs or {})
[docs] @property def transfer_function_names(self) -> tuple[str, ...]: """Transfer-function names available for this sample.""" if self.emtf is None: return () return tuple(self.emtf.transfer_functions)
[docs] @dataclass(repr=False) class AirborneEMLine(CoreObject): """One airborne flight line with navigation and sparse EM records. Parameters ---------- line_id : str Flight-line identifier. Stripped and required to be non-empty. navigation : NavigationTrack Sample-aligned navigation/attitude track defining the line's common sample axis. Every record's ``sample_id`` must appear in ``navigation.sample_ids``. records : dict of str to AirborneEMRecord, optional Records keyed by ``sample_id``. The mapping key must equal ``record.sample_id`` for every entry. attrs : dict, optional Free-form extension metadata. Raises ------ ValueError If ``line_id`` is empty, a record key does not match ``record.sample_id``, or a record's ``sample_id`` is not a known navigation sample. TypeError If ``navigation`` is not a :class:`NavigationTrack`, or ``records`` contains a non-:class:`AirborneEMRecord` value. Notes ----- Records are keyed by navigation ``sample_id`` and may be sparse. This is deliberate: a missing or rejected EM sample should not require deleting the corresponding navigation point, nor fabricating a transfer function to fill the gap. Examples -------- >>> from pycsamt.airborne import AirborneEMLine, NavigationTrack >>> nav = NavigationTrack(sample_ids=("S1", "S2")) >>> line = AirborneEMLine(line_id="L001", navigation=nav) >>> line.n_samples, line.n_records (2, 0) >>> line.missing_sample_ids ('S1', 'S2') """ line_id: str navigation: NavigationTrack records: dict[str, AirborneEMRecord] = field(default_factory=dict) attrs: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: self.validate()
[docs] def validate(self) -> None: """Normalize the identifier and re-attach incoming records.""" self.line_id = clean_identifier(self.line_id, name="line_id") if not isinstance(self.navigation, NavigationTrack): raise TypeError("navigation must be a NavigationTrack") incoming = dict(self.records or {}) self.records = {} for key, record in incoming.items(): if not isinstance(record, AirborneEMRecord): raise TypeError("records must contain AirborneEMRecord values") if str(key).strip() != record.sample_id: raise ValueError( "record mapping key must match record.sample_id" ) self.add_record(record) self.attrs = dict(self.attrs or {})
[docs] @property def n_samples(self) -> int: """Number of navigation samples on the line.""" return self.navigation.n_samples
[docs] @property def n_records(self) -> int: """Number of EM records currently attached to the line.""" return len(self.records)
[docs] @property def bbox(self) -> BBox | None: """Geographic bounding box when navigation coordinates exist.""" return self.navigation.bbox
[docs] @property def missing_sample_ids(self) -> tuple[str, ...]: """Navigation samples that currently have no EM record.""" return tuple( sample_id for sample_id in self.navigation.sample_ids if sample_id not in self.records )
[docs] @property def transfer_function_names(self) -> tuple[str, ...]: """Sorted union of transfer-function names present on this line.""" names: set[str] = set() for record in self.records.values(): names.update(record.transfer_function_names) return tuple(sorted(names))
[docs] def add_record( self, record: AirborneEMRecord, *, replace: bool = False, ) -> AirborneEMLine: """Attach one record after verifying navigation alignment. Parameters ---------- record : AirborneEMRecord Record whose ``sample_id`` must already exist on ``navigation``. replace : bool, default False Whether to overwrite an existing record for the same sample instead of raising. Returns ------- AirborneEMLine ``self``, to support call chaining. Raises ------ TypeError If ``record`` is not an :class:`AirborneEMRecord`. KeyError If ``record.sample_id`` is not a known navigation sample. ValueError If a record already exists for that sample and ``replace`` is ``False``. """ if not isinstance(record, AirborneEMRecord): raise TypeError("record must be an AirborneEMRecord") self.navigation.index_of(record.sample_id) if record.sample_id in self.records and not replace: raise ValueError( f"airborne record already exists: {record.sample_id}" ) self.records[record.sample_id] = record return self
[docs] def add_emtf( self, sample_id: str, emtf: EMTF, *, fields: dict[str, Any] | None = None, quality: dict[str, Any] | None = None, attrs: dict[str, Any] | None = None, replace: bool = False, ) -> AirborneEMRecord: """Build and attach one :class:`AirborneEMRecord` from an EMTF. Convenience wrapper around :meth:`add_record` for the common case of attaching a decoded EMTF response without constructing the record explicitly. Parameters ---------- sample_id : str Navigation sample identifier for the new record. emtf : EMTF Transfer-function payload for the sample. fields, quality, attrs : dict, optional Forwarded to :class:`AirborneEMRecord`. replace : bool, default False Forwarded to :meth:`add_record`. Returns ------- AirborneEMRecord The record that was attached. """ record = AirborneEMRecord( sample_id=str(sample_id), emtf=emtf, fields={} if fields is None else fields, quality={} if quality is None else quality, attrs={} if attrs is None else attrs, ) self.add_record(record, replace=replace) return record
[docs] def get_record(self, sample_id: str) -> AirborneEMRecord | None: """Return a record by sample identifier, or ``None`` when absent. Raises ------ KeyError If ``sample_id`` is not a known navigation sample. """ key = str(sample_id).strip() self.navigation.index_of(key) return self.records.get(key)
[docs] def record_at(self, index: int) -> AirborneEMRecord | None: """Return the record aligned with navigation index *index*.""" sample_id = self.navigation.sample_ids[index] return self.records.get(sample_id)
[docs] def iter_records(self) -> Iterator[AirborneEMRecord]: """Iterate records in navigation order, skipping missing samples.""" for sample_id in self.navigation.sample_ids: record = self.records.get(sample_id) if record is not None: yield record
[docs] @dataclass(repr=False) class AirborneEMDataset(CoreObject): """Format-neutral collection of airborne EM flight lines. Parameters ---------- name : str Survey/dataset name. Stripped and required to be non-empty. lines : dict of str to AirborneEMLine, optional Flight lines keyed by ``line_id``. The mapping key must equal ``line.line_id`` for every entry. survey : SurveyMeta, optional Survey-level metadata. instrument : InstrumentMeta, optional System/instrument metadata. method : str, default "AEM" Survey method label, upper-cased on construction (for example ``"AEM"``). attrs : dict, optional Free-form extension metadata. Raises ------ ValueError If ``name`` or ``method`` is empty, or a line mapping key does not match ``line.line_id``. TypeError If ``survey``, ``instrument``, or an entry of ``lines`` has the wrong type. Notes ----- The dataset is intentionally an organisational layer above :class:`~pycsamt.emtf.EMTF`. It does not define a MobileMT, ZTEM, or AFMAG file schema, and it inherits :class:`~pycsamt.core.base.CoreObject` rather than :class:`~pycsamt.core.base.MTBase`: aggregating flight lines is not itself electromagnetic arithmetic, so this class should not carry :class:`~pycsamt.core.base.MTBase`'s numeric EM utilities (those belong to the :class:`~pycsamt.emtf.EMTF`/ :class:`~pycsamt.emtf.TransferFunction` objects it holds). Technology adapters populate this object rather than introducing separate transfer-function mathematics. Examples -------- >>> from pycsamt.airborne import AirborneEMDataset >>> dataset = AirborneEMDataset(name="survey-001") >>> dataset.method, dataset.n_lines ('AEM', 0) """ name: str lines: dict[str, AirborneEMLine] = field(default_factory=dict) survey: SurveyMeta | None = None instrument: InstrumentMeta | None = None method: str = "AEM" attrs: dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: self.validate()
[docs] def validate(self) -> None: """Normalize identifier/method fields and re-attach lines.""" self.name = clean_identifier(self.name, name="name") method = str(self.method).strip().upper() if not method: raise ValueError("method must be non-empty") self.method = method if self.survey is not None and not isinstance(self.survey, SurveyMeta): raise TypeError("survey must be a SurveyMeta or None") if self.instrument is not None and not isinstance( self.instrument, InstrumentMeta, ): raise TypeError("instrument must be an InstrumentMeta or None") incoming = dict(self.lines or {}) self.lines = {} for key, line in incoming.items(): if not isinstance(line, AirborneEMLine): raise TypeError("lines must contain AirborneEMLine values") if str(key).strip() != line.line_id: raise ValueError("line mapping key must match line.line_id") self.add_line(line) self.attrs = dict(self.attrs or {})
[docs] @property def line_ids(self) -> tuple[str, ...]: """Flight-line identifiers in insertion order.""" return tuple(self.lines)
[docs] @property def n_lines(self) -> int: """Number of flight lines.""" return len(self.lines)
[docs] @property def n_samples(self) -> int: """Total number of navigation samples across all lines.""" return sum(line.n_samples for line in self.lines.values())
[docs] @property def n_records(self) -> int: """Total number of attached EM records across all lines.""" return sum(line.n_records for line in self.lines.values())
[docs] @property def transfer_function_names(self) -> tuple[str, ...]: """Sorted union of transfer-function types in the dataset.""" names: set[str] = set() for line in self.lines.values(): names.update(line.transfer_function_names) return tuple(sorted(names))
[docs] @property def bbox(self) -> BBox | None: """Geographic bounding box over all lines with finite coordinates.""" boxes = [line.bbox for line in self.lines.values()] boxes = [box for box in boxes if box is not None] if not boxes: return None return BBox( lat_min=min(box.lat_min for box in boxes), lat_max=max(box.lat_max for box in boxes), lon_min=min(box.lon_min for box in boxes), lon_max=max(box.lon_max for box in boxes), )
[docs] def add_line( self, line: AirborneEMLine, *, replace: bool = False, ) -> AirborneEMDataset: """Attach one flight line. Parameters ---------- line : AirborneEMLine Flight line to attach. replace : bool, default False Whether to overwrite an existing line with the same ``line_id`` instead of raising. Returns ------- AirborneEMDataset ``self``, to support call chaining. Raises ------ TypeError If ``line`` is not an :class:`AirborneEMLine`. ValueError If a line already exists for that ``line_id`` and ``replace`` is ``False``. """ if not isinstance(line, AirborneEMLine): raise TypeError("line must be an AirborneEMLine") if line.line_id in self.lines and not replace: raise ValueError(f"airborne line already exists: {line.line_id}") self.lines[line.line_id] = line return self
[docs] def get_line(self, line_id: str) -> AirborneEMLine | None: """Return a line by identifier, or ``None`` when absent.""" return self.lines.get(str(line_id).strip())
[docs] def iter_lines(self) -> Iterator[AirborneEMLine]: """Iterate flight lines in insertion order.""" yield from self.lines.values()
[docs] def iter_records( self, ) -> Iterator[tuple[str, AirborneEMRecord]]: """Iterate ``(line_id, record)`` pairs in navigation order.""" for line in self.lines.values(): for record in line.iter_records(): yield line.line_id, record
[docs] def emtf_records(self) -> dict[tuple[str, str], EMTF]: """Return all non-empty EMTF records keyed by line/sample ID. Returns ------- dict of (str, str) to EMTF Mapping from ``(line_id, sample_id)`` to the attached :class:`~pycsamt.emtf.EMTF`. Records with no EMTF payload are omitted rather than represented with a placeholder. """ out: dict[tuple[str, str], EMTF] = {} for line_id, record in self.iter_records(): if record.emtf is not None: out[(line_id, record.sample_id)] = record.emtf return out
[docs] def inspect(self): """Return the common airborne inspection summary lazily. Returns ------- AirborneInspection Compact scientific inventory; see :func:`pycsamt.airborne.qc.inspect_airborne`. Notes ----- The import is deferred to avoid a hard import-time dependency between :mod:`pycsamt.airborne.base` and :mod:`pycsamt.airborne.qc`, which itself imports this module. """ from .qc import inspect_airborne return inspect_airborne(self)
[docs] def qc(self): """Return the common structural airborne QC report lazily. Returns ------- AirborneQCReport Structural/metadata completeness report; see :func:`pycsamt.airborne.qc.assess_airborne_qc`. """ from .qc import assess_airborne_qc return assess_airborne_qc(self)