Source code for pycsamt.airborne.site

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

r"""Airborne station containers: the non-EDI counterpart of ``pycsamt.site``.

:class:`~pycsamt.site.base.Site`/:class:`~pycsamt.site.base.Sites`
are, by design, EDI-shaped: a 2x2 impedance :math:`Z`, a 1x2 tipper,
one flat station per file. Every numeric accessor on ``Site``
(``.tipper``, ``.z``, ``.rho``, ``.phase``) routes through
:attr:`~pycsamt.site.base.Site.edi`, and
:func:`pycsamt.emtf.converters.edi.emtf_to_edi` deliberately refuses
to build an EDI from a tipper-only document (no impedance transfer
function) -- ``pycsamt.airborne.ztem.tests.test_ztem_interop
.test_ztem_is_not_silently_coerced_to_standard_edi`` enforces exactly
this, and ``pycsamt.airborne.mobilemt.tests.test_mobilemt_interop``
gives MobileMT's admittance the identical guarantee. So a genuine
ZTEM/AFMAG measurement -- tipper-only, no electric channel at all --
or a MobileMT measurement -- a 3x2 admittance tensor, not a tipper --
cannot reach ``Site``/``Sites`` today, and forcing either into that
2x2-impedance-shaped model would mean either bending ``Site`` until
it stops meaning "ground EDI station," or leaving MobileMT
permanently unrepresentable regardless.

This module is the parallel container family built instead, directly
on the airborne scientific model that already exists
(:class:`~pycsamt.airborne.AirborneEMRecord`/
:class:`~pycsamt.airborne.AirborneEMLine`/
:class:`~pycsamt.airborne.AirborneEMDataset`, holding a real
:class:`~pycsamt.emtf.document.EMTF` document per sample -- no EDI
bridge, no impedance requirement):

* :class:`AirborneSite` -- one flight-line sample. Read directly
  from its :class:`~pycsamt.emtf.document.EMTF` document:
  ``.tipper``, ``.freq``, and, unlike ``Site``, ``.admittance`` (for
  MobileMT) alongside the typed metadata surface ``Site`` already
  exposes (``.site_meta``, ``.processing``, ``.provenance``, ...),
  since ``AirborneEMRecord.emtf`` *is* an ``EMTF`` document too.
* :class:`AirborneSites` -- an ordered collection, mirroring
  :class:`~pycsamt.site.base.Sites`'s indexing/lookup/select/map/
  closest/write_xml surface where the concepts genuinely carry over.
  Bulk-editing helpers (``edit_all``, ``with_topography``,
  ``to_profile``) are deliberately not mirrored yet -- they are
  real ``Sites`` features this module does not need for the
  ``emtools`` integration it exists to unblock, so they are left out
  rather than stubbed.
* :func:`ensure_asites` -- the ``ensure_sites`` counterpart: the
  single entry point every future airborne-aware ``emtools``
  function should coerce its input through.

Reads EMTF-XML today (:meth:`AirborneSites.from_xml_dir`,
:meth:`AirborneSite.from_xml`); a native H5 reader is a real,
separate task for whenever a verified delivery schema exists (see
:mod:`pycsamt.airborne.io`'s own module docstring for why pyCSAMT
does not guess native formats), not something this module invents
now.

Ordering
--------
:class:`~pycsamt.site.base.Sites` has to *infer* a spatial line
order from station coordinates, because ground stations can arrive
in arbitrary order. Airborne samples do not have that problem --
:class:`~pycsamt.airborne.NavigationTrack` already records the
definitive flight-line order -- so :class:`AirborneSites` has no
``ordered()``/``ordering`` analogue: :meth:`AirborneSites.from_line`
and :meth:`AirborneSites.from_dataset` simply preserve navigation
order, which is already correct.
"""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING, Any

import numpy as np
import pandas as pd

from ..constants import _EARTH_R
from ..core.base import CoreObject
from .base import AirborneEMDataset, AirborneEMLine, AirborneEMRecord
from .navigation import NavigationTrack
from .validation import clean_identifier, emtf_class

if TYPE_CHECKING:
    from ..emtf.document import EMTF

__all__ = ["AirborneSite", "AirborneSites", "ensure_asites"]

_MOBILEMT_ADMITTANCE_TAG = "mobilemt_admittance"
_AFMAG_INTERSTATION_TAG = "interstation_transfer_functions"
_AFMAG_TILT_TAG = "afmag_tilt"
_AFMAG_AP_TAG = "airmt_amplification_parameter"
_VALID_ON_DUP = frozenset({"replace", "keep_first", "keep_last", "raise"})


def _nan3() -> tuple[float, float, float]:
    return (float("nan"), float("nan"), float("nan"))


def _navigation_coords(
    nav: NavigationTrack, index: int
) -> tuple[float, float, float]:
    """Return ``(lat, lon, elev)`` for one navigation sample index."""
    lat = (
        float(nav.latitude[index])
        if nav.latitude is not None
        else float("nan")
    )
    lon = (
        float(nav.longitude[index])
        if nav.longitude is not None
        else float("nan")
    )
    if nav.platform_elevation is not None:
        elev = float(nav.platform_elevation[index])
    elif nav.terrain_elevation is not None:
        elev = float(nav.terrain_elevation[index])
    else:
        elev = float("nan")
    return (lat, lon, elev)


def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
    """Great-circle distance [m] between two lat/lon points.

    Self-contained on purpose: :func:`pycsamt.site.location.distance`
    would work too, but importing it pulls in ``pycsamt.site``'s own
    EDI-oriented helpers, which this module deliberately stays free
    of (see the module docstring).
    """
    p1, p2 = np.radians(lat1), np.radians(lat2)
    dphi = np.radians(lat2 - lat1)
    dlmb = np.radians(lon2 - lon1)
    a = (
        np.sin(dphi / 2.0) ** 2
        + np.cos(p1) * np.cos(p2) * np.sin(dlmb / 2.0) ** 2
    )
    return float(2.0 * _EARTH_R * np.arcsin(np.sqrt(np.clip(a, 0.0, 1.0))))


def _stem_from_source(source: Any) -> str | None:
    if isinstance(source, (str, Path)):
        stem = Path(source).stem
        return stem or None
    return None


[docs] class AirborneSite(CoreObject): r"""One airborne flight-line sample, read directly from its EMTF. Unlike :class:`~pycsamt.site.base.Site`, every numeric accessor here reads straight from the wrapped :class:`~pycsamt.airborne.AirborneEMRecord`'s :class:`~pycsamt.emtf.document.EMTF` document -- there is no EDI bridge to materialize and no impedance requirement to satisfy. Parameters ---------- record : AirborneEMRecord The sample this site wraps. line_id : str, optional Owning flight-line identifier, for provenance/grouping. ``None`` when unknown (e.g. a bare, line-less record). technology : str, optional Technology label (``"ztem"``, ``"mobilemt"``, ``"afmag"``, ...), the explicit differentiator against ground MT. Falls back to the record's ``EMTF.subtype`` when not given explicitly; see :attr:`technology`. coords : (float, float, float), optional Explicit ``(lat, lon, elev)`` override, normally supplied by the constructing classmethod (:meth:`AirborneSites.from_line`/``from_dataset``) from the parent line's :class:`~pycsamt.airborne.NavigationTrack`. When omitted, :attr:`coords` falls back to the record's own ``EMTF.site.location``, then to ``(nan, nan, nan)``. Raises ------ TypeError If *record* is not an :class:`AirborneEMRecord`. See Also -------- AirborneSites : Ordered collection of these. pycsamt.site.base.Site : The ground-MT, EDI-shaped counterpart. """ def __init__( self, record: AirborneEMRecord, *, line_id: str | None = None, technology: str | None = None, coords: tuple[float, float, float] | None = None, ) -> None: if not isinstance(record, AirborneEMRecord): raise TypeError("record must be an AirborneEMRecord") self._record = record self._line_id = ( None if line_id is None else clean_identifier( line_id, name="line_id", ) ) self._technology = ( str(technology).strip().lower() if technology else None ) self._coords = coords
[docs] @classmethod def from_xml( cls, source: Any, *, line_id: str | None = None, technology: str | None = None, ) -> AirborneSite: r"""Build one site directly from an EMTF-XML file or document. Parameters ---------- source : pycsamt.emtf.document.EMTF or str or pathlib.Path Parsed document, or a path read via :meth:`~pycsamt.emtf.document.EMTF.from_xml`. line_id, technology : str, optional Forwarded to the constructor. Returns ------- AirborneSite Notes ----- The sample identifier is resolved from ``document.site.site_id``, then ``document.station``, then the source file's stem, in that order. """ EMTF = emtf_class() doc = source if isinstance(source, EMTF) else EMTF.from_xml(source) sample_id = None if doc.site is not None and doc.site.site_id: sample_id = str(doc.site.site_id) elif doc.station: sample_id = str(doc.station) else: sample_id = _stem_from_source(source) record = AirborneEMRecord( sample_id=sample_id or "site", emtf=doc, ) return cls(record, line_id=line_id, technology=technology)
[docs] @property def record(self) -> AirborneEMRecord: """The wrapped :class:`~pycsamt.airborne.AirborneEMRecord`.""" return self._record
[docs] @property def emtf(self) -> EMTF | None: """The underlying :class:`~pycsamt.emtf.document.EMTF`.""" return self._record.emtf
[docs] @property def tf(self) -> EMTF | None: """Alias for :attr:`emtf`, matching ``Site.tf``'s naming.""" return self._record.emtf
[docs] @property def sample_id(self) -> str: """Navigation sample identifier (stable; independent of any richer :attr:`name` resolved from metadata).""" return self._record.sample_id
[docs] @property def line_id(self) -> str | None: """Owning flight-line identifier, or ``None`` if unknown.""" return self._line_id
[docs] @property def technology(self) -> str | None: """Technology label (``"ztem"``, ``"mobilemt"``, ...). Explicit at construction, else the record's ``EMTF.subtype``, else ``None``. """ if self._technology: return self._technology doc = self._record.emtf if doc is not None and doc.subtype: return str(doc.subtype).strip().lower() return None
[docs] @property def name(self) -> str: r"""Station identifier resolved from metadata, else :attr:`sample_id`. Resolution order: ``EMTF.site.site_id``, ``EMTF.station``, :attr:`sample_id`. """ doc = self._record.emtf if doc is not None: if doc.site is not None and doc.site.site_id: return str(doc.site.site_id) if doc.station: return str(doc.station) return self._record.sample_id
[docs] @property def station(self) -> str: """Alias for :attr:`name`, matching ``Site.name``'s role.""" return self.name
[docs] @property def coords(self) -> tuple[float, float, float]: r"""``(lat, lon, elev)`` in decimal degrees and metres. Explicit constructor override first, then the record's own ``EMTF.site.location``, then ``(nan, nan, nan)`` -- never a fabricated position. """ if self._coords is not None: return self._coords doc = self._record.emtf if doc is not None and doc.site is not None: loc = doc.site.location if loc is not None: lat = loc.latitude if loc.latitude is not None else np.nan lon = loc.longitude if loc.longitude is not None else np.nan elev = ( loc.elevation if loc.elevation is not None else np.nan ) return (float(lat), float(lon), float(elev)) return _nan3()
[docs] @property def freq(self) -> np.ndarray | None: """Frequency vector [Hz], or ``None`` if unknown.""" doc = self._record.emtf return None if doc is None else doc.frequency
@property def tipper(self) -> np.ndarray | None: """Tipper array, shape ``(nf, 1, 2)``, or ``None`` if absent.""" doc = self._record.emtf return None if doc is None else doc.tipper
[docs] @tipper.setter def tipper(self, value: Any) -> None: r"""Write back into the underlying tipper transfer function. Accepts either the canonical ``(nf, 1, 2)`` shape or the ``(nf, 2)`` form (broadcast to the former). This exists so in-place QC edits (masking a frequency band, for example) write straight through to :attr:`emtf` -- mirroring what ground EDI-backed ``Site`` objects already allow via their mutable ``Tip.tipper`` attribute. Raises ------ ValueError If this site has no ``EMTF`` document, or the document has no tipper transfer function to write into. """ doc = self._record.emtf if doc is None: raise ValueError(f"{self.name!r} has no EMTF document") tf = doc.tipper_tf if tf is None: raise ValueError( f"{self.name!r} has no tipper transfer function" ) arr = np.asarray(value, dtype=complex) if arr.ndim == 2 and arr.shape[-1] == 2: arr = arr[:, None, :] tf.data = arr
[docs] @property def z(self) -> np.ndarray | None: """Impedance array, shape ``(nf, 2, 2)``, or ``None`` if absent (the normal case for ZTEM/AFMAG/MobileMT).""" doc = self._record.emtf return None if doc is None else doc.z
[docs] @property def admittance(self) -> np.ndarray | None: r"""MobileMT admittance array, shape ``(nf, 3, 2)``. ``None`` for any record without an attached ``mobilemt_admittance`` transfer function -- there is no analogue of this accessor on ``Site``, since a 3x2 admittance cannot be represented by ``Site.z``'s 2x2 shape. """ doc = self._record.emtf if doc is None: return None tf = doc.get_transfer_function(_MOBILEMT_ADMITTANCE_TAG) return None if tf is None else tf.data
[docs] @property def interstation_tensor(self) -> np.ndarray | None: r"""Tensor AFMAG/AirMt interstation magnetic TF, shape ``(nf, 3, 2)``. ``None`` for any record without an attached ``interstation_transfer_functions`` transfer function. Deliberately a separate accessor from :attr:`admittance` (also ``(nf, 3, 2)``-shaped): AirMt's tensor relates ground-reference *magnetic* fields to airborne magnetic fields (Hx,Hy -> Hx,Hy,Hz), while MobileMT's admittance relates ground *electric* fields to airborne magnetic fields (Ex,Ey -> Hx,Hy,Hz) -- physically different responses that happen to share a matrix shape, so this module keeps them under different names rather than one generic "3x2 tensor" accessor that would blur which is which. See :mod:`pycsamt.airborne.afmag`'s module docstring for why the original-comparator generation (:attr:`afmag_tilt_deg`) is kept separate again from this one. """ doc = self._record.emtf if doc is None: return None tf = doc.get_transfer_function(_AFMAG_INTERSTATION_TAG) return None if tf is None else tf.data
[docs] @property def afmag_tilt_deg(self) -> np.ndarray | None: r"""Original comparator AFMAG scalar tilt/deflection, shape ``(nf,)``. ``None`` for any record without an attached ``afmag_tilt`` transfer function. Unlike :attr:`interstation_tensor` or a ground tipper, this is a single real number per frequency -- the historical comparator has no polarization-ellipse decomposition available at all, only a line-direction deflection (see :mod:`pycsamt.airborne.afmag`'s module docstring). """ doc = self._record.emtf if doc is None: return None tf = doc.get_transfer_function(_AFMAG_TILT_TAG) if tf is None: return None return np.asarray(tf.data, dtype=complex).real.reshape(-1)
[docs] @property def afmag_amplification_parameter(self) -> np.ndarray | None: r"""AirMt rotation-invariant amplification parameter, shape ``(nf,)``. ``None`` for any record without an attached ``airmt_amplification_parameter`` transfer function; see :func:`pycsamt.airborne.afmag.compute_airmt_amplification_parameter` for the formula that derives it from :attr:`interstation_tensor`. """ doc = self._record.emtf if doc is None: return None tf = doc.get_transfer_function(_AFMAG_AP_TAG) if tf is None: return None return np.asarray(tf.data, dtype=complex).reshape(-1)
[docs] @property def quality(self) -> dict[str, Any]: """Sample-level quality flags/scores (technology-defined).""" return dict(self._record.quality)
[docs] @property def fields(self) -> dict[str, Any]: r"""Auxiliary decoded fields (technology-defined). For MobileMT, this is where a vendor-delivered native ``apparent_conductivity`` vector lives when present; see :data:`pycsamt.airborne.mobilemt.MOBILEMT_APPARENT_CONDUCTIVITY_FIELD`. """ return dict(self._record.fields)
[docs] @property def site_meta(self) -> Any: """``pycsamt.metadata.SiteMeta`` (via :attr:`emtf`).""" doc = self._record.emtf return None if doc is None else doc.site
[docs] @property def site_layout(self) -> Any: """``pycsamt.metadata.SiteLayout`` channel geometry.""" doc = self._record.emtf return None if doc is None else doc.site_layout
[docs] @property def provenance(self) -> Any: """``pycsamt.metadata.ProvenanceMeta`` creator/submitter info.""" doc = self._record.emtf return None if doc is None else doc.provenance
[docs] @property def processing(self) -> Any: """``pycsamt.metadata.ProcessingMeta`` processing/software info.""" doc = self._record.emtf return None if doc is None else doc.processing
[docs] @property def copyright(self) -> Any: """``pycsamt.metadata.CopyrightInfo`` release/citation info.""" doc = self._record.emtf return None if doc is None else doc.copyright
[docs] @property def quality_meta(self) -> Any: """``pycsamt.metadata.TransferFunctionQuality`` QC rating.""" doc = self._record.emtf return None if doc is None else doc.quality
[docs] def has_component(self, comp: str) -> bool: r"""Whether *comp* exists and has at least one finite value. Parameters ---------- comp : str ``"tip"``/``"tx"``/``"ty"``/``"tipper"`` for the tipper; ``"admittance"``/``"y"`` for the MobileMT admittance; ``"interstation_tensor"``/``"ti"`` for the AirMt tensor; ``"afmag_tilt"``/``"tilt"`` for the original-comparator AFMAG scalar; ``"amplification_parameter"``/``"ap"`` for the AirMt derived parameter; anything else is looked up against :attr:`z`. Returns ------- bool """ c = str(comp).strip().lower() if c in ("tip", "tx", "ty", "tipper"): arr = self.tipper elif c in ("admittance", "y"): arr = self.admittance elif c in ("interstation_tensor", "ti"): arr = self.interstation_tensor elif c in ("afmag_tilt", "tilt"): arr = self.afmag_tilt_deg elif c in ("amplification_parameter", "ap"): arr = self.afmag_amplification_parameter else: arr = self.z if arr is None: return False a = np.asarray(arr) return a.size > 0 and bool(np.any(np.isfinite(a)))
[docs] def to_dataframe(self, kind: str = "tipper") -> pd.DataFrame: r"""Export this site's data to a tidy :class:`pandas.DataFrame`. Parameters ---------- kind : {"tipper", "admittance", "z"}, default "tipper" Returns ------- pandas.DataFrame Indexed by frequency (name ``"f"``). Columns depend on *kind*: ``Tx, Ty``; ``Yxx, Yxy, Yyx, Yyy, Yhzx, Yhzy``; or ``Zxx, Zxy, Zyx, Zyy``. Raises ------ ValueError If *kind* is not recognized. """ k = str(kind).strip().lower() if k in ("tip", "tipper", "t"): arr, cols = self.tipper, ("Tx", "Ty") elif k in ("admittance", "y"): arr = self.admittance cols = ("Yxx", "Yxy", "Yyx", "Yyy", "Yhzx", "Yhzy") elif k in ("z", "imp", "impedance"): arr, cols = self.z, ("Zxx", "Zxy", "Zyx", "Zyy") else: raise ValueError(f"Unknown kind: {kind!r}") freq = self.freq idx = pd.Index( np.asarray(freq, dtype=float) if freq is not None else np.array([], dtype=float), name="f", ) if arr is None: return pd.DataFrame(index=idx) flat = np.asarray(arr).reshape(np.asarray(arr).shape[0], -1) data: dict[str, Any] = {} for i, c in enumerate(cols): data[c] = ( flat[:, i] if i < flat.shape[1] else np.full(idx.size, np.nan) ) return pd.DataFrame(data, index=idx)
[docs] def summary(self) -> dict[str, Any]: r"""Summarize identity, geometry, and data coverage. Returns ------- dict Keys: ``name``, ``line_id``, ``sample_id``, ``technology``, ``nfreq``, ``lat``, ``lon``, ``elev``, ``tipper``, ``admittance`` (booleans). """ lat, lon, elev = self.coords freq = self.freq nfreq = 0 if freq is None else int(np.asarray(freq).size) return { "name": self.name, "line_id": self.line_id, "sample_id": self.sample_id, "technology": self.technology, "nfreq": nfreq, "lat": lat, "lon": lon, "elev": elev, "tipper": self.has_component("tipper"), "admittance": self.has_component("admittance"), }
[docs] def to_xml(self, target: Any = None, **kwargs: Any) -> Any: r"""Serialize this site's document to EMTF XML. Parameters ---------- target : str or pathlib.Path, optional Destination path. If ``None``, the XML is returned as a string. **kwargs Forwarded to :meth:`~pycsamt.emtf.document.EMTF.write_xml`/``to_xml``. Returns ------- str or Any Raises ------ ValueError If this site has no attached ``EMTF`` document. """ doc = self._record.emtf if doc is None: raise ValueError(f"{self.name!r} has no EMTF document") if target is None: return doc.to_xml(**kwargs) return doc.write_xml(target, **kwargs)
def __repr__(self) -> str: s = self.summary() return ( f"AirborneSite(name={s['name']!r}, " f"technology={s['technology']!r}, nfreq={s['nfreq']}, " f"coords=({s['lat']:.5f},{s['lon']:.5f},{s['elev']:.1f}))" )
def _coerce_one( item: Any, *, line_id: str | None = None ) -> AirborneSite: """Coerce one heterogeneous item to an :class:`AirborneSite`.""" if isinstance(item, AirborneSite): return item if isinstance(item, AirborneEMRecord): return AirborneSite(item, line_id=line_id) if isinstance(item, (str, Path)): return AirborneSite.from_xml(item, line_id=line_id) EMTF = emtf_class() if isinstance(item, EMTF): return AirborneSite.from_xml(item, line_id=line_id) raise TypeError( "cannot coerce to AirborneSite: " f"{type(item).__name__}; pass an AirborneSite, " "AirborneEMRecord, EMTF, or a path to an EMTF-XML file" ) def _is_single_item(x: Any) -> bool: """Whether *x* is one item rather than an iterable of items.""" if isinstance(x, (AirborneSite, AirborneEMRecord, str, Path)): return True return not hasattr(x, "__iter__") def _dedupe(sites: AirborneSites, *, on_dup: str) -> AirborneSites: r"""Apply a duplicate-name policy over an :class:`AirborneSites`. Mirrors :func:`pycsamt.site.base.to_sites`'s ``on_dup`` contract. """ policy = str(on_dup).strip().lower() if policy not in _VALID_ON_DUP: raise ValueError(f"on_dup must be one of {sorted(_VALID_ON_DUP)}") seen: dict[str, AirborneSite] = {} order: list[str] = [] for s in sites: key = s.name.lower() if key not in seen: order.append(key) seen[key] = s continue if policy == "raise": raise ValueError(f"duplicate airborne site name: {s.name!r}") if policy == "keep_first": continue # "keep_last" and "replace" both take the newest occurrence. seen[key] = s return AirborneSites([seen[k] for k in order])
[docs] class AirborneSites(CoreObject): r"""Ordered collection of :class:`AirborneSite` objects. The airborne counterpart of :class:`~pycsamt.site.base.Sites`. See the module docstring for why order here is simply preserved navigation order rather than something to infer. Parameters ---------- items : AirborneSite, AirborneEMRecord, EMTF, str, Path, or iterable A single item, or an iterable of them. Raw :class:`~pycsamt.airborne.AirborneEMRecord`/``EMTF``/path items are coerced via :meth:`AirborneSite.from_xml`-style construction with no line context; prefer :meth:`from_line`/:meth:`from_dataset` when that context is available. Raises ------ TypeError If an item cannot be coerced to :class:`AirborneSite`. See Also -------- ensure_asites : Flexible entry-point coercion, including directories of EMTF-XML files and duplicate-name policy. pycsamt.site.base.Sites : The ground-MT counterpart. """ def __init__(self, items: Any) -> None: if _is_single_item(items): items = [items] self._items: list[AirborneSite] = [ _coerce_one(it) for it in list(items) ] # -- construction -----------------------------------------------
[docs] @classmethod def from_xml_dir( cls, path: str | Path, *, recursive: bool = True, pattern: str = "*.xml", line_id: str | None = None, strict: bool = False, ) -> AirborneSites: r"""Read every EMTF-XML file under *path* into one container. Parameters ---------- path : str or pathlib.Path A single EMTF-XML file, or a directory to search. recursive : bool, default True Search subdirectories too (``Path.rglob``) instead of only the top level (``Path.glob``). pattern : str, default "\*.xml" Glob pattern used when *path* is a directory. line_id : str, optional Forwarded to every :meth:`AirborneSite.from_xml` call. strict : bool, default False If ``True``, a file that fails to parse raises instead of being skipped, and an empty result raises too. Returns ------- AirborneSites Sites in sorted-filename order. Raises ------ ValueError If *strict* and nothing could be read. """ p = Path(path) files = ( [p] if p.is_file() else sorted(p.rglob(pattern) if recursive else p.glob(pattern)) ) items: list[AirborneSite] = [] for f in files: try: items.append(AirborneSite.from_xml(f, line_id=line_id)) except Exception: if strict: raise continue if strict and not items: raise ValueError( f"no EMTF-XML files could be read from {path!r}" ) return cls(items)
[docs] @classmethod def from_line( cls, line: AirborneEMLine, *, technology: str | None = None, ) -> AirborneSites: r"""Build a container from one already-constructed flight line. Parameters ---------- line : AirborneEMLine Records are visited via :meth:`~pycsamt.airborne.AirborneEMLine.iter_records`, i.e. in navigation order, skipping samples with no attached record. technology : str, optional Forwarded to every :class:`AirborneSite`; falls back to ``line.attrs["technology"]`` when not given. Returns ------- AirborneSites Raises ------ TypeError If *line* is not an :class:`AirborneEMLine`. """ if not isinstance(line, AirborneEMLine): raise TypeError("line must be an AirborneEMLine") tech = technology or line.attrs.get("technology") nav = line.navigation items = [ AirborneSite( record, line_id=line.line_id, technology=tech, coords=_navigation_coords( nav, nav.index_of(record.sample_id) ), ) for record in line.iter_records() ] return cls(items)
[docs] @classmethod def from_dataset( cls, dataset: AirborneEMDataset, *, technology: str | None = None, ) -> AirborneSites: r"""Flatten every line of a dataset into one container. Parameters ---------- dataset : AirborneEMDataset Lines are visited via :meth:`~pycsamt.airborne.AirborneEMDataset.iter_lines` in insertion order; see :meth:`from_line` for the per-line ordering. technology : str, optional Forwarded to :meth:`from_line` for every line; falls back to ``dataset.attrs["technology"]`` when not given. Returns ------- AirborneSites Raises ------ TypeError If *dataset* is not an :class:`AirborneEMDataset`. """ if not isinstance(dataset, AirborneEMDataset): raise TypeError("dataset must be an AirborneEMDataset") tech = technology or dataset.attrs.get("technology") items: list[AirborneSite] = [] for line in dataset.iter_lines(): items.extend(cls.from_line(line, technology=tech).as_list()) return cls(items)
# -- container protocol ------------------------------------------ def __len__(self) -> int: return len(self._items) def __iter__(self): return iter(self._items) def __getitem__(self, key: int | str) -> AirborneSite: r"""Retrieve by zero-based index or case-insensitive name. Raises ------ KeyError If *key* is a name and no site matches. """ if isinstance(key, int): return self._items[key] nm = str(key).lower() for s in self._items: if s.name.lower() == nm: return s raise KeyError(key)
[docs] def by_index(self, i: int) -> AirborneSite: """Retrieve by zero-based index.""" return self._items[i]
[docs] def get(self, name: str) -> AirborneSite | None: """Safe lookup by case-insensitive name; ``None`` if absent.""" try: return self[name] except Exception: return None
[docs] def as_list(self) -> list[AirborneSite]: """The underlying list of :class:`AirborneSite` objects.""" return list(self._items)
[docs] def to_emtf_list(self) -> list[Any]: """The underlying :class:`~pycsamt.emtf.document.EMTF` documents, in site order.""" return [s.emtf for s in self._items]
[docs] @property def technologies(self) -> tuple[str, ...]: """Sorted, deduplicated :attr:`AirborneSite.technology` values present in this container.""" return tuple( sorted({s.technology for s in self._items if s.technology}) )
[docs] @property def line_ids(self) -> tuple[str, ...]: """Sorted, deduplicated :attr:`AirborneSite.line_id` values present in this container.""" return tuple(sorted({s.line_id for s in self._items if s.line_id}))
# -- selection / mapping ------------------------------------------
[docs] def select( self, names: Any = None, predicate: Any = None, ) -> AirborneSites: r"""Filter by explicit names or by a boolean predicate. Parameters ---------- names : sequence of str, optional Case-insensitive names to retain; takes precedence over *predicate*. predicate : callable, optional ``predicate(site) -> bool``. Returns ------- AirborneSites A new container; a shallow copy when neither argument is given. """ if names: wanted = {str(n).lower() for n in names} out = [s for s in self._items if s.name.lower() in wanted] elif predicate: out = [s for s in self._items if predicate(s)] else: out = list(self._items) return AirborneSites(out)
[docs] def map(self, fn: Any) -> list[Any]: """Apply ``fn(site) -> Any`` to every site; collect results.""" return [fn(s) for s in self._items]
[docs] def closest( self, lat: float, lon: float, tol: float | None = None, ) -> AirborneSite | None: r"""Nearest site to a target coordinate (great-circle distance). Parameters ---------- lat, lon : float Target location in decimal degrees. tol : float, optional Maximum allowed distance in metres; farther than that returns ``None``. Returns ------- AirborneSite or None ``None`` if every site lacks finite coordinates, or the nearest is farther than *tol*. """ best: AirborneSite | None = None best_d = float("inf") for s in self._items: sla, slo, _ = s.coords if not (np.isfinite(sla) and np.isfinite(slo)): continue d = _haversine_m(lat, lon, sla, slo) if d < best_d: best, best_d = s, d if best is None: return None if tol is not None and best_d > tol: return None return best
# -- persistence ---------------------------------------------------
[docs] def write_xml(self, outdir: str | Path, **kwargs: Any) -> list[Path]: r"""Write one EMTF-XML file per site into a directory. Parameters ---------- outdir : str or pathlib.Path Destination directory; created if missing. **kwargs Forwarded to :meth:`AirborneSite.to_xml` for each site. Returns ------- list of pathlib.Path Paths written, named ``"{name}.xml"``. """ outdir = Path(outdir) outdir.mkdir(parents=True, exist_ok=True) paths: list[Path] = [] for s in self._items: p = outdir / f"{s.name}.xml" s.to_xml(p, **kwargs) paths.append(p) return paths
def __repr__(self) -> str: techs = ",".join(self.technologies) or "?" return f"AirborneSites(n={len(self)}, technologies={techs!r})"
[docs] def ensure_asites( obj: Any, *, recursive: bool = True, on_dup: str = "replace", strict: bool = False, verbose: int = 0, ) -> AirborneSites: r"""Normalize arbitrary airborne input to an :class:`AirborneSites`. The single entry-point coercion for airborne-aware ``emtools`` functions, mirroring the role :func:`~pycsamt.emtools._core.ensure_sites` plays for the rest of ``emtools``. Parameters ---------- obj : Any Accepts: an existing :class:`AirborneSites`; an :class:`~pycsamt.airborne.AirborneEMDataset` (:meth:`AirborneSites.from_dataset`); an :class:`~pycsamt.airborne.AirborneEMLine` (:meth:`AirborneSites.from_line`); a path to a single EMTF-XML file or a directory of them (:meth:`AirborneSites.from_xml_dir`); or an iterable mixing any of the above with bare :class:`~pycsamt.airborne.AirborneEMRecord`/``EMTF``/path items (each coerced with no line context). recursive : bool, default True Forwarded to :meth:`AirborneSites.from_xml_dir` for any directory encountered. on_dup : {"replace", "keep_first", "keep_last", "raise"}, default "replace" Duplicate-name policy; see :func:`pycsamt.site.base.to_sites` for the identical semantics on the ground side. strict : bool, default False If ``True``, raise when nothing can be resolved (or, for a directory, when no file parses). verbose : int, default 0 ``>0`` warns when the result is empty and *strict* is ``False``. Returns ------- AirborneSites Raises ------ ValueError If *obj* is ``None``; if *on_dup* is invalid; or, in *strict* mode, if nothing could be resolved. """ if obj is None: raise ValueError( "ensure_asites: got None. Provide a path, AirborneEMLine, " "AirborneEMDataset, AirborneSites, or an iterable of " "AirborneSite-like items." ) if isinstance(obj, AirborneSites): # Matches to_sites()'s identity contract: an already-coerced # container is returned unchanged, not silently rebuilt. if str(on_dup).strip().lower() not in _VALID_ON_DUP: raise ValueError(f"on_dup must be one of {sorted(_VALID_ON_DUP)}") if len(obj) == 0 and strict: raise ValueError( "ensure_asites(strict=True): no airborne sites were " "resolved from the given input." ) return obj elif isinstance(obj, AirborneEMDataset): result = AirborneSites.from_dataset(obj) elif isinstance(obj, AirborneEMLine): result = AirborneSites.from_line(obj) elif isinstance(obj, (str, Path)): result = AirborneSites.from_xml_dir( obj, recursive=recursive, strict=strict, ) else: items: list[AirborneSite] = [] candidates = list(obj) if hasattr(obj, "__iter__") else [obj] for it in candidates: if isinstance(it, AirborneEMDataset): items.extend(AirborneSites.from_dataset(it).as_list()) elif isinstance(it, AirborneEMLine): items.extend(AirborneSites.from_line(it).as_list()) elif isinstance(it, (str, Path)) and Path(it).is_dir(): items.extend( AirborneSites.from_xml_dir( it, recursive=recursive, strict=False, ).as_list() ) else: try: items.append(_coerce_one(it)) except TypeError: if strict: raise continue result = AirborneSites(items) result = _dedupe(result, on_dup=on_dup) if len(result) == 0: if strict: raise ValueError( "ensure_asites(strict=True): no airborne sites were " "resolved from the given input." ) if verbose > 0: import warnings warnings.warn( "ensure_asites: no airborne sites were resolved from " "the given input.", RuntimeWarning, stacklevel=2, ) return result