Source code for pycsamt.zonge.schema

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0-or-later
"""
pycsamt.zonge.schema

Defines the data schema and column name mappings for Zonge
AVG files. This module serves as the single source of truth for
translating between legacy (kind-1), modern (kind-2), and the
internal canonical column names used throughout the package.
"""

from __future__ import annotations

from collections import defaultdict
from typing import Literal

__all__ = [
    "_CANONICAL_MAP",
    "_CANON_TO_MODERN",
    "_CANON_TO_LEGACY",
    "_FLEXIBLE_LOOKUP",
    "_CSAVGW_ORDERED",
    "ALL_ALIASES",
    "QC_ALIASES",
    "UNIT_MAP",
    "get_unit",
    "get_aliases",
]


_CANONICAL_MAP: dict[str, str] = {
    # Coords $ Identifiers ---
    "Station": "station",
    "Stn": "station",
    "Freq": "freq",
    "Freq.": "freq",
    "Comp": "comp",
    "skp": "skp",
    # tipper,
    "tx": "tx",
    "ty": "ty",
    # --- Measurements
    "Amps": "amps",
    "Tx.Amp": "amps",
    "Emag": "emag",
    "E.mag": "emag",
    "Ephz": "ephz",
    "E.phz": "ephz",
    "Hmag": "hmag",
    "B.mag": "hmag",
    "H.mag": "hmag",
    "Hphz": "hphz",
    "B.phz": "hphz",
    "Resistivity": "rho",
    "ARes.mag": "rho",
    "Phase": "phase",
    "Z.phz": "phase",
    "Z.mag": "zmag",
    "SRes": "rho_sc",
    "TMARES": "rho_sc",
    "TMARES/SRES": "rho_sc",
    # Quality Control (QC)
    "%Emag": "pc_emag",
    "E.%err": "pc_emag",
    "sEphz": "s_ephz",
    "E.perr": "s_ephz",
    "%Hmag": "pc_hmag",
    "B.%err": "pc_hmag",
    "H.%err": "pc_hmag",
    "sHphz": "s_hphz",
    "B.perr": "s_hphz",
    "H.perr": "s_hphz",
    "%Rho": "pc_rho",
    "ARes.%err": "pc_rho",
    "sPhz": "s_phz",
    "Z.perr": "s_phz",
    # Weights & Other Modern Fields
    "Z.mwgt": "z_mwgt",
    "Z.pwgt": "z_pwgt",
    "E.wgt": "e_wgt",
    "H.wgt": "h_wgt",
    "B.wgt": "h_wgt",
    "Choer": "coh",
    "Gdp.Blk": "gdp_blk",
    "Gdp.Chn": "gdp_chn",
    "Gdp.Time": "gdp_time",
    "|Z|": "zabs",
    "Z.%err": "z.%err",
}

_CANON_TO_MODERN: dict[str, str] = {
    # survey logistics
    "station": "Station",
    "freq": "Freq",
    "comp": "Comp",
    # transmitter / field data
    "amps": "Tx.Amp",
    "emag": "E.mag",
    "ephz": "E.phz",
    "hmag": "B.mag",
    "hphz": "B.phz",
    "zmag": "Z.mag",
    "phase": "Z.phz",
    "rho": "ARes.mag",
    # quality metrics
    "rho_sc": "SRes",
    "pc_emag": "E.%err",
    "s_ephz": "E.perr",
    "pc_hmag": "B.%err",
    "s_hphz": "B.perr",
    "pc_rho": "ARes.%err",
    "s_phz": "Z.perr",
    "z_mwgt": "Z.mwgt",
    "z_pwgt": "Z.pwgt",
    "e_wgt": "E.wgt",
    "h_wgt": "H.wgt",
    "coh": "Choer",
    "gdp_blk": "Gdp.Blk",
    "gdp_chn": "Gdp.Chn",
    "gdp_time": "Gdp.Time",
    "zabs": "|Z|",
    # alternatives_config,
    "e.%err": "E.%err",
    "e.perr": "E.perr",
    "h.%err": "B.%err",
    "h.perr": "B.perr",
    "rho.%err": "ARes.%err",
    "phase.%err": "Z.perr",
    "z.%err": "Z.%err",
    "z.perr": "Z.perr",
    "z.mwgt": "Z.mwgt",
    "z.pwgt": "Z.pwgt",
    "e.wgt": "E.wgt",
    "b.wgt": "B.wgt",
    # e.g Tipper,
    "tx": "Tx",
    "ty": "Ty",
}

_CANON_TO_LEGACY: dict[str, str] = {
    "station": "Station",
    "freq": "Freq",
    "comp": "Comp",
    "amps": "Amps",
    "emag": "Emag",
    "ephz": "Ephz",
    "hmag": "Hmag",
    "hphz": "Hphz",
    "rho": "Resistivity",
    "phase": "Phase",
    "pc_emag": "%Emag",
    "s_ephz": "sEphz",
    "pc_hmag": "%Hmag",
    "s_hphz": "sHphz",
    "pc_rho": "%Rho",
    "s_phz": "sPhz",
    "skp": "skp",
}

_CSAVGW_ORDERED = [
    "Z.mwgt",
    "Z.pwgt",
    "Freq",
    "Tx.Amp",
    "E.mag",
    "E.phz",
    "B.mag",
    "B.phz",
    "Z.mag",
    "Z.phz",
    "ARes.mag",
    "SRes",
    "E.wgt",
    "H.wgt",
    "E.%err",
    "E.perr",
    "B.%err",
    "B.perr",
    "Z.%err",
    "Z.perr",
    "ARes.%err",
    # Expected CSAVGW order;
    # extras will be appended after these.
    "Choer",
    "Gdp.Blk",
    "Gdp.Chn",
    "Gdp.Time",
    "|Z|",
    "use",
]
# 3. Dynamically Built Alias Lookups
_canon_to_aliases: dict[str, list[str]] = defaultdict(list)
for alias, canon in _CANONICAL_MAP.items():
    _canon_to_aliases[canon].append(alias)

ALL_ALIASES: dict[str, tuple[str, ...]] = {
    canon: tuple(sorted(aliases))
    for canon, aliases in _canon_to_aliases.items()
}

QC_ALIASES: dict[str, tuple[str, ...]] = {
    "pc_emag": tuple(
        sorted([k for k, v in _CANONICAL_MAP.items() if v == "pc_emag"])
    ),
    "s_ephz": tuple(
        sorted([k for k, v in _CANONICAL_MAP.items() if v == "s_ephz"])
    ),
    "pc_hmag": tuple(
        sorted([k for k, v in _CANONICAL_MAP.items() if v == "pc_hmag"])
    ),
    "s_hphz": tuple(
        sorted([k for k, v in _CANONICAL_MAP.items() if v == "s_hphz"])
    ),
    "pc_rho": tuple(
        sorted([k for k, v in _CANONICAL_MAP.items() if v == "pc_rho"])
    ),
    "s_phz": tuple(
        sorted([k for k, v in _CANONICAL_MAP.items() if v == "s_phz"])
    ),
}

_FLEX_CANON_NAMES = {
    "pc_emag",
    "s_ephz",
    "pc_hmag",
    "s_hphz",
    "pc_rho",
    "s_phz",
    "z_mwgt",
    "z_pwgt",
    "e_wgt",
    "h_wgt",
}


def _create_flexible_lookup() -> dict[str, str]:
    """
    Creates a lookup dict that maps normalized variations of
    aliases ONLY for QC and weight columns to their canonical name.
    """
    lookup = {}
    for raw_alias, canon_value in _CANONICAL_MAP.items():
        if canon_value in _FLEX_CANON_NAMES:
            # Normalize the raw alias for broader matching
            norm_key = raw_alias.lower()
            variations = {
                norm_key.replace(".", ""),
                norm_key.replace("_", ""),
                norm_key.replace("%", ""),
            }
            for var in variations:
                lookup[var] = canon_value

    # Remove any keys that are also core data names
    # to prevent collisions (e.g., 'rho' should not map to 'pc_rho').
    core_data_keys = {
        "emag",
        "hmag",
        "rho",
        "phase",
        "ephz",
        "hphz",
        "zmag",
        "rho_sc",
    }
    for key in core_data_keys:
        lookup.pop(key, None)

    return lookup


# Create the flexible map once at module level
_FLEXIBLE_LOOKUP = _create_flexible_lookup()

UNIT_MAP: dict[str, tuple[str, str]] = {
    # Canonical Name -> (Simple Unit, Formatted Label for Plots)
    "rho": ("ohm.m", r"App. Res. ($\Omega \cdot m$)"),
    "rho_sc": ("ohm.m", r"Corrected Res. ($\Omega \cdot m$)"),
    "phase": ("mrad", "Phase (mrad)"),
    "phase_deg": ("deg", r"Phase ($\degree$)"),
    "emag": ("nV/Am", "E-Magnitude (nV/Am)"),
    "hmag": ("pT/A", "H-Magnitude (pT/A)"),
    "ephz": ("mrad", "E-Phase (mrad)"),
    "ephz_deg": ("deg", r"E-Phase ($\degree$)"),
    "hphz": ("mrad", "H-Phase (mrad)"),
    "hphz_deg": ("deg", r"H-Phase ($\degree$)"),
    "zmag": ("km/s", "Impedance Mag. (km/s)"),
    "zabs": ("ohm", r"$|Z|$ ($\Omega$)"),
    "pc_rho": ("%", r"$\rho_a$ Error (%)"),
    "pc_emag": ("%", r"$|E|$ Error (%)"),
    "pc_hmag": ("%", r"$|H|$ Error (%)"),
    "s_phz": ("mrad", r"$\sigma_{\phi}$ (mrad)"),
    "s_phz_deg": ("deg", r"$\sigma_{\phi}$ ($\degree$)"),
    "s_ephz": ("mrad", r"$\sigma_{E\phi}$ (mrad)"),
    "s_ephz_deg": ("deg", r"$\sigma_{E\phi}$ ($\degree$)"),
    "s_hphz": ("mrad", r"$\sigma_{H\phi}$ (mrad)"),
    "s_hphz_deg": ("deg", r"$\sigma_{H\phi}$ ($\degree$)"),
    "skew": ("", "Skew"),
    "strike_angle": ("degrees", "Strike Angle (degrees)"),
    "tx": ("", "Tipper Tx"),
    "ty": ("", "Tipper Ty"),
    "freq": ("Hz", "Frequency (Hz)"),
    "station": ("m", "Station Distance (m)"),
}


[docs] def get_unit(canonical_name: str, formatted: bool = True) -> str | None: """ Fetch the unit string for a canonical variable name. """ unit_tuple = UNIT_MAP.get(canonical_name) if unit_tuple: return unit_tuple[1] if formatted else unit_tuple[0] return None
[docs] def get_aliases( canonical_name: str, *, kind: Literal["qc", "all"] | None = "all", custom_aliases: dict[str, tuple[str, ...]] | None = None, normalize: bool = True, ) -> tuple[str, ...]: r"""Fetch all known aliases for a canonical column name. Parameters ---------- canonical_name : str The internal, standardized name for the column. kind : {'qc', 'all'}, default 'all' - 'qc': Search only within quality control aliases. - 'all': Search within all known aliases. custom_aliases : dict, optional A dictionary to temporarily add or override aliases. Keys are canonical names, values are tuples of aliases. normalize : bool, default True If ``True``, the `canonical_name` and keys in the `custom_aliases` dictionary are treated as case-insensitive. Returns ------- tuple[str, ...] A tuple of all raw (legacy and modern) names that map to the given canonical name. Examples -------- >>> get_aliases('pc_emag', kind='qc') ('%Emag', 'E.%err') >>> get_aliases('rho') ('ARes.mag', 'Resistivity') >>> get_aliases( ... 'rho', ... custom_aliases={'rho': ('resistivity_ohm_m',)} ... ) ('ARes.mag', 'Resistivity', 'resistivity_ohm_m') """ active_qc = QC_ALIASES.copy() active_all = ALL_ALIASES.copy() def _normalize_key(key): return key.lower() if normalize else key if custom_aliases: # Normalize keys of custom dict if requested norm_custom = ( {_normalize_key(k): v for k, v in custom_aliases.items()} if normalize else custom_aliases ) for canon, aliases in norm_custom.items(): # Normalize canonical name for lookup lookup_canon = _normalize_key(canon) # Update all aliases map existing = list(active_all.get(lookup_canon, ())) existing.extend(aliases) active_all[lookup_canon] = tuple(sorted(set(existing))) # Update QC aliases map if applicable if kind == "qc" and lookup_canon in active_qc: existing_qc = list(active_qc.get(lookup_canon, ())) existing_qc.extend(aliases) active_qc[lookup_canon] = tuple(sorted(set(existing_qc))) # Select the correct map to use target_map = active_qc if kind == "qc" else active_all # Normalize the final lookup key lookup_name = _normalize_key(canonical_name) return target_map.get(lookup_name, ())