Source code for pycsamt.seg.validation

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

"""SEG-EDI validators (no base-class deps)."""

from __future__ import annotations

import os
import re
from abc import ABC, abstractmethod
from collections.abc import Iterable, Sequence
from pathlib import Path
from typing import (
    Any,
)

from ..exceptions import EdIDataError, FileHandlingError

_TAG_RE = re.compile(r"^\s*>(=)?\s*([A-Za-z0-9!.]+)")
_FLOAT_RE = re.compile(r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eEdD][-+]?\d+)?")
_NFREQ_RE = re.compile(r"\bNFREQ\s*=\s*(\d+)\b", re.IGNORECASE)


def _strip_norm(s: str) -> str:
    return s.strip().strip('"').strip("'")


def _to_int_or_none(v: Any) -> int | None:
    if v in (None, "", "None"):
        return None
    try:
        return int(float(v))
    except Exception:
        return None


def _to_float_or_none(v: Any) -> float | None:
    if v in (None, "", "None"):
        return None
    try:
        return float(v)
    except Exception:
        return None


def _split_comment(s: str) -> tuple[str, str | None]:
    """Return (before, after) around '//' if present."""
    i = s.find("//")
    if i < 0:
        return (s, None)
    return (s[:i], s[i + 2 :].strip())


def _norm_str(value: Any) -> str | None:
    if value is None:
        return None
    s = str(value).strip().strip('"').strip("'")
    return s if s != "" else None


def _is_tag(line: str, tag: str) -> bool:
    s = line.strip()
    if not s.startswith(">"):
        return False
    return s.upper().startswith(tag.upper())


def _extract_tag(line: str) -> str | None:
    s = line.strip()
    if not s.startswith(">"):
        return None
    # up to first space
    parts = s.split(None, 1)
    return parts[0]


def _extract_tag_in(line: str) -> str | None:
    m = _TAG_RE.match(line)
    if not m:
        return None
    eq, tag = m.groups()
    prefix = ">=" if eq else ">"
    return f"{prefix}{tag.upper()}"


def _iter_blocks(lines: Sequence[str]) -> list[str]:
    tags: list[str] = []
    for ln in lines:
        t = _extract_tag_in(ln)
        if t:
            tags.append(t)
    return tags


def _count_freq_values(
    lines: Sequence[str], start_idx: int
) -> tuple[int, int]:
    n = 0
    i = start_idx + 1
    N = len(lines)
    while i < N:
        if _extract_tag_in(lines[i]) is not None:
            break
        n += len(_FLOAT_RE.findall(lines[i]))
        i += 1
    return n, i


def _expected_nfreq_from_header(line: str) -> int | None:
    m = _NFREQ_RE.search(line)
    if m:
        return int(m.group(1))
    mm = re.search(r"//\s*(\d+)\s*$", line)
    if mm:
        return int(mm.group(1))
    return None


def _has_any_data_block(tags: Iterable[str]) -> bool:
    tagset = set(tags)
    cand = {
        ">ZXXR",
        ">ZXXI",
        ">ZYYR",
        ">ZYYI",
        ">ZXYR",
        ">ZXYI",
        ">ZYXR",
        ">ZYXI",
        ">RHOXX",
        ">RHOXY",
        ">RHOYX",
        ">RHOYY",
        ">PHSXX",
        ">PHSXY",
        ">PHSYX",
        ">PHSYY",
        ">TXR.EXP",
        ">TYR.EXP",
        ">TIPMAG",
        ">TIPPHS",
    }
    return any(t in tagset for t in cand)


[docs] class IsEdi(ABC): r""" Abstract base for SEG-EDI validation helpers. Subclasses implement :pyattr:`is_valid`. The static method :py:meth:`_assert_edi` provides a robust, file-level validator that accepts either a path or an existing :class:`IsEdi` instance. The check is heuristic, fast, and tolerant of minor formatting issues. The validator recognizes three EDI *families*: 1. *Impedance-style* files that contain a ``>FREQ`` block and a measurement section such as ``>=MTSECT``, ``>=DEFINEMEAS``, ``>=EMAPSECT`` or ``>=OTHERSECT``. 2. *Spectra-style* files that include ``>=SPECTRASECT`` and or at least one ``>SPECTRA`` block. 3. *Time-series* files that include ``>=TSERIESSECT`` and or at least one ``>TSERIES`` block. In addition, the first top-level tag must be ``>HEAD`` and the last must be ``>END``. On failure the method raises a descriptive :class:`~pycsamt.exceptions.EdIDataError`. Notes ----- The check is *structural* rather than semantic. It does not validate numerical values or cross-block consistency. Files are read as text using ``utf-8-sig`` with ``errors="replace"`` to gracefully handle odd encodings. Examples -------- Validate an EDI file path: >>> from pycsamt.seg.validation import IsEdi >>> IsEdi._assert_edi("path/to/site.edi") True Use with an object that implements ``is_valid``: >>> class MyEdi(IsEdi): ... @property ... def is_valid(self): ... return True ... >>> IsEdi._assert_edi(MyEdi()) True See Also -------- pycsamt.seg.mtemap.MTEMAP.from_file : Parse ``>=MTSECT`` / ``>=EMAPSECT`` headers. pycsamt.seg.spectra.SpectraSECT.from_file : Parse ``>=SPECTRASECT`` headers. pycsamt.seg.time_series.TSect.from_file : Parse ``>=TSERIESSECT`` headers. References ---------- .. [1] SEG (1987). *MT/EMAP EDI Format Standard*. Society of Exploration Geophysicists. Available online: https://www.mtnet.info/docs/seg_mt_emap_1987.pdf """
[docs] @property @abstractmethod def is_valid(self) -> bool: r""" Indicate whether the current instance represents a structurally valid EDI object. This property is used by :py:meth:`IsEdi._assert_edi` when a concrete :class:`IsEdi` instance is provided instead of a file path. Returns ------- bool ``True`` when the instance is valid. Subclasses decide the exact criteria. Notes ----- Implementations should be lightweight and side-effect free. Heavy validation belongs to dedicated readers (e.g., MTEMAP, Spectra, Time-Series parsers). """ raise NotImplementedError
@staticmethod def _assert_edi( file: str | os.PathLike | IsEdi, *, deep: bool = True ) -> bool: r""" Validate that ``file`` is a SEG-EDI file (or object). The function accepts either a path-like object or an :class:`IsEdi` instance. When ``deep`` is ``False``, only the extension check ``.edi`` is performed. When ``deep`` is ``True`` (default), the file is scanned for required structural tags: - First tag must be ``>HEAD`` and last tag ``>END``. - At least one of the following families must be seen: * *Impedance-style*: ``>FREQ`` plus a measurement section (``>=MTSECT``, ``>=DEFINEMEAS``, ``>=EMAPSECT`` or ``>=OTHERSECT``). * *Spectra-style*: ``>=SPECTRASECT`` and/or ``>SPECTRA``. * *Time-series*: ``>=TSERIESSECT`` and/or ``>TSERIES``. Parameters ---------- file : str or os.PathLike or IsEdi Path to an EDI file, or an :class:`IsEdi` instance. deep : bool, default: True If ``False``, perform a light extension check. If ``True``, scan the file contents and tags. Returns ------- bool ``True`` if the input satisfies the structural checks. Otherwise an exception is raised. Raises ------ FileHandlingError If ``file`` is ``None``. FileNotFoundError If the path does not exist or is not a file. PermissionError If the file cannot be read due to permissions. EdIDataError If the structure does not match any supported EDI family, or if ``>HEAD``/``>END`` ordering fails. Notes ----- The validator is intentionally permissive and aims to support EDI variants found in the field. It does not guarantee that downstream readers will succeed. Examples -------- >>> from pycsamt.seg.validation import IsEdi >>> IsEdi._assert_edi("survey01.edi", deep=False) True >>> IsEdi._assert_edi("survey01.edi", deep=True) True """ seg_url = "https://www.mtnet.info/docs/seg_mt_emap_1987.pdf" base_msg = f"Unrecognized SEG EDI file. See standard: {seg_url}" if file is None: raise FileHandlingError("NoneType not allowed") if isinstance(file, IsEdi): if not file.is_valid: raise EdIDataError("EDI object reports invalid state") return True path = Path(file) # type: ignore[arg-type] if not path.exists() or not path.is_file(): raise FileNotFoundError(f"{str(path)!r} not a file") if not deep: if path.suffix.lower().lstrip(".") == "edi": return True raise EdIDataError("SEG-EDI files usually use '.edi' extension") try: text = path.read_text(encoding="utf-8-sig", errors="replace") except PermissionError: raise PermissionError("Permission denied while reading file") lines = text.splitlines() first_tag = None for ln in lines: t = _extract_tag_in(ln) if t: first_tag = t break last_tag = None for ln in reversed(lines): t = _extract_tag_in(ln) if t: last_tag = t break if first_tag is None or last_tag is None: raise EdIDataError(base_msg) if not first_tag.startswith(">HEAD"): raise EdIDataError(f"{base_msg} (first block should be >HEAD)") if not last_tag.startswith(">END"): raise EdIDataError(f"{base_msg} (last block should be >END)") # All top-level tags (upper-cased: '>HEAD', '>=MTSECT', ...) tags = list(_iter_blocks(lines)) # Impedance-style markers have_freq = any(t.startswith(">FREQ") for t in tags) have_meas_section = any( t in { ">=MTSECT", ">=DEFINEMEAS", ">=EMAPSECT", ">=OTHERSECT", } for t in tags ) or any(t.startswith(">=DEFINEMEAS") for t in tags) # Spectra-style markers have_spectra_sect = any(t.startswith(">=SPECTRASECT") for t in tags) have_spectra_blocks = any(t.startswith(">SPECTRA") for t in tags) # Time-series markers have_tseries_sect = any(t.startswith(">=TSERIESSECT") for t in tags) have_tseries_blocks = any(t.startswith(">TSERIES") for t in tags) # NEW: Other-style markers (may exist without >FREQ) have_other_sect = any(t.startswith(">=OTHERSECT") for t in tags) # You can be permissive and accept header-only OTHER valid_other = have_other_sect valid_impedance = have_freq and ( have_meas_section or have_spectra_sect ) valid_spectra = have_spectra_sect or have_spectra_blocks valid_tseries = have_tseries_sect or have_tseries_blocks if not ( valid_impedance or valid_spectra or valid_tseries or valid_other ): raise EdIDataError( f"{base_msg} (need >FREQ+section OR " f">=SPECTRASECT/>SPECTRA OR " f">=TSERIESSECT/>TSERIES OR " f">=OTHERSECT; found: " f"have_freq={have_freq}, " f"have_meas_section={have_meas_section}, " f"have_spectra_sect={have_spectra_sect}, " f"have_spectra_blocks={have_spectra_blocks}, " f"have_tseries_sect={have_tseries_sect}, " f"have_tseries_blocks={have_tseries_blocks}, " f"have_other_sect={have_other_sect})" ) return True