Source code for pycsamt.seg.cbase

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

from __future__ import annotations

from collections.abc import Iterable, Iterator, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import (
    Callable,
    Union,
)

import numpy as np

from ..log.logger import get_logger
from .edi import EDIFile
from .validation import IsEdi

logger = get_logger(__name__)

__all__ = ["ParseMixin", "CoreParser", "CBBase"]


Pathish = Union[str, Path]
SrcType = Union[Pathish, Sequence[Pathish]]


[docs] class ParseMixin: r""" Helpers for discovering and normalizing EDI sources. The mixin accepts files, folders, and glob patterns, and yields only paths that point to ``.edi`` files. All path inputs are normalized with user-home and relative segments resolved. Parameters ---------- None This is a mixin. It does not define its own public constructor parameters. Attributes ---------- EDI_SUFFIXES : set of str Allowed filename suffixes. Defaults to ``{'.edi'}``. recursive : bool Expected to be provided by the host class. If ``True``, directory searches use ``rglob``. _errors : list of tuple(Path, BaseException) Optional sink for discovery issues. When present, helpers record unmatched patterns or missing files. Notes ----- The mixin exposes small helpers that higher-level parsers can reuse: * ``_as_path`` converts any pathish value into an absolute :class:`pathlib.Path`. * ``_is_edi_path`` returns ``True`` for files whose suffix belongs to ``EDI_SUFFIXES``. * ``_iter_paths`` normalizes a single source or a sequence of sources into absolute paths. * ``_iter_edi_files`` walks files, directories, and glob patterns and yields only existing EDI files. * ``_fast_station`` performs a cheap scan of ``>HEAD`` to locate ``DATAID``, when available. * ``_push_error`` records discovery problems into the host ``_errors`` list if present. Examples -------- Basic file enumeration:: class Finder(ParseMixin): recursive = True f = Finder() paths = list(f._iter_edi_files( ['data', 'logs/*.edi'], root=Path('.') )) See Also -------- CoreParser High-level parser that builds :class:`EDIFile` objects and aggregates errors. EDIFile Reader and writer for single EDI files. References ---------- .. [1] SEG EDI MT/EMAP standard (1987), MTNet. https://www.mtnet.info/docs/seg_mt_emap_1987.pdf """ EDI_SUFFIXES = {".edi"} def _as_path(self, p: Pathish) -> Path: return Path(str(p)).expanduser().resolve() def _is_edi_path(self, p: Path) -> bool: return p.is_file() and (p.suffix.lower() in self.EDI_SUFFIXES) def _iter_paths(self, src: SrcType) -> Iterator[Path]: if isinstance(src, (str, Path)): yield self._as_path(src) # type: ignore[arg-type] return for s in src: yield self._as_path(s) def _push_error(self, src: Pathish, msg: str) -> None: """ Record a parsing discovery error on the host if it exposes `_errors`. Create the store if missing. """ store = getattr(self, "_errors", None) if store is None: self._errors = [] # type: ignore[attr-defined] store = self._errors err = FileNotFoundError(msg) store.append((self._as_path(src), err)) def _iter_edi_files( self, sources: list[str | Path], *, root: Path | None = None, ) -> Iterator[Path]: base = Path(root) if root is not None else Path.cwd() def _is_glob(s: str) -> bool: return any(ch in s for ch in "*?[]") for src in sources: p = Path(str(src)) # direct file if p.exists() and p.is_file(): yield p continue # directory -> (r)glob for *.edi if p.exists() and p.is_dir(): it = ( p.rglob("*.edi") if getattr(self, "recursive", True) else p.glob("*.edi") ) for m in it: if m.is_file(): yield m continue # glob pattern (absolute or relative) if _is_glob(str(p)): if p.is_absolute(): parent, name = p.parent, p.name it = ( parent.rglob(name) if "**" in name else parent.glob(name) ) else: pat = str(p) it = base.rglob(pat) if "**" in pat else base.glob(pat) any_yielded = False for m in it: if m.is_file(): any_yielded = True yield m if not any_yielded: self._push_error(src, f"No match for pattern: {src}") continue # anything else: record as not found self._push_error(src, f"Not found or unsupported: {src}") def _fast_station(self, p: Path) -> str | None: """ Quick scan for DATAID inside >HEAD. Best effort. """ try: with p.open("r", encoding="utf-8") as f: in_head = False for raw in f: s = raw.strip() if not s: continue if s.startswith(">=") or ( s.startswith(">") and not s.upper().startswith(">HEAD") ): if in_head: break if s.upper().startswith(">HEAD"): in_head = True continue if in_head and s.upper().startswith("DATAID="): return s.split("=", 1)[1].strip() except Exception: return None return None
@dataclass class _ParseResult: path: Path edi: EDIFile | None error: BaseException | None
[docs] class CoreParser(ParseMixin): r""" Robust multi-source EDI parser with error tracking and duplicate policies. The parser consumes any combination of files, folders, or glob patterns, builds :class:`EDIFile` objects, and records failures. Duplicates can be handled by station id or kept as they appear. Parameters ---------- recursive : bool, default ``True`` Recurse into subdirectories when scanning folders. strict : bool, default ``False`` If ``True``, any read error is raised. If ``False``, errors are collected and the parse continues. on_dup : {'replace', 'keep'}, default ``'replace'`` Duplicate policy by station id. With ``'replace'``, the last item wins. With ``'keep'``, the first seen item is preserved. verbose : int, default ``0`` Verbosity passed to :class:`EDIFile`. Attributes ---------- results : list of _ParseResult Structured outcomes for each discovered input. Each entry stores the path, the optional :class:`EDIFile`, and the read error if one occurred. _errors : list of tuple(Path, BaseException) Discovery issues, such as unmatched glob patterns or missing files. Filled by :meth:`ParseMixin._push_error`. Returns ------- list of EDIFile The parsed and de-duplicated EDI objects. Notes ----- Station identity is taken from ``EDIFile.station`` when available. If missing, a fast scan of ``>HEAD`` is used as a fallback. When no station can be resolved, the file path string is used as the key. Examples -------- Parse a folder and a glob, keep the first copy:: cp = CoreParser(on_dup='keep', recursive=True) edis = cp.parse(['data/edi', 'more/*.edi']) errs = cp.errors() See Also -------- ParseMixin Source discovery utilities used by the parser. EDIFile Single-file reader used to load each path. References ---------- .. [1] SEG EDI MT/EMAP standard (1987), MTNet. https://www.mtnet.info/docs/seg_mt_emap_1987.pdf """ def __init__( self, *, recursive: bool = True, strict: bool = False, on_dup: str = "replace", verbose: int = 0, ) -> None: self.recursive = bool(recursive) self.strict = bool(strict) self.on_dup = str(on_dup).lower() self.verbose = int(verbose) if self.on_dup not in {"replace", "keep"}: raise ValueError("on_dup must be keep|replace") self.results: list[_ParseResult] = [] self._errors: list[tuple[Path, BaseException]] = [] # parse a single file (with error policy) def _read_one(self, p: Path) -> _ParseResult: try: IsEdi._assert_edi(p, deep=False) edi = EDIFile(p, verbose=self.verbose) return _ParseResult(path=p, edi=edi, error=None) except BaseException as exc: # noqa: BLE001 if self.strict: raise logger.debug("Skip %s: %s", p, exc) return _ParseResult(path=p, edi=None, error=exc)
[docs] def parse(self, sources: SrcType) -> list[EDIFile]: edis: list[EDIFile] = [] self.results.clear() self._errors.clear() for p in self._iter_edi_files( sources if isinstance(sources, list) else list(self._iter_paths(sources)) ): res = self._read_one(p) self.results.append(res) if res.edi is None: continue edis.append(res.edi) # handle duplicates by station (if any) by_station: dict[str, EDIFile] = {} seen: dict[str, int] = {} out: list[EDIFile] = [] for ed in edis: sid = getattr(ed, "station", None) if not sid: sid = self._fast_station(ed.path) if ed.path else None sid = sid or (str(ed.path) if ed.path else "-") if sid in by_station and self.on_dup == "keep": continue by_station[sid] = ed seen[sid] = 1 out.extend(by_station.values()) return out
[docs] def errors(self) -> list[tuple[Path, BaseException]]: out = [ (r.path, r.error) # type: ignore[misc] for r in self.results if r.error is not None ] # ---------- NEW ---------- out.extend(self._errors) return out
[docs] class CBBase: r""" Lightweight base for EDI collections. The class provides a minimal container over multiple :class:`EDIFile` objects. It focuses on indexing by station id, fast lookup by path, and simple iteration. Subclasses can add project-specific logic, caching, or derived computations. Parameters ---------- edis : sequence of EDIFile, optional Initial items to populate the collection. When omitted the container starts empty. index_by : {'station', 'path'}, default ``'station'`` Key to index items. With ``'station'`` the ``EDIFile.station`` is used, with fallback to file path if missing. With ``'path'`` the absolute path string is used as the key. on_dup : {'replace', 'keep'}, default ``'replace'`` Duplicate policy when inserting items that share the same key. Attributes ---------- items : dict Mapping from key to :class:`EDIFile`. order : list of str Insertion order of keys, which preserves a stable iteration sequence. meta : dict Arbitrary metadata for client code. This is not used internally. Methods ------- add(ed) Insert one :class:`EDIFile` respecting the duplicate policy. get(key) Return the stored :class:`EDIFile` by its key. __iter__() Iterate over stored :class:`EDIFile` objects in insertion order. keys() Yield collection keys in insertion order. values() Yield :class:`EDIFile` objects in insertion order. items() Yield ``(key, EDIFile)`` pairs in insertion order. Notes ----- The base does not perform I/O or parsing. Use :class:`CoreParser` to build items, then feed them into the collection. The class aims to be small and easy to extend rather than fully featured. Examples -------- Build a collection indexed by station:: edis = CoreParser().parse(['data/edi']) coll = CBBase(edis, index_by='station') for key, ed in coll.items(): print(key, ed.Z.n_freq) See Also -------- CoreParser Parser that produces :class:`EDIFile` objects to be stored in collections. EDIFile Single-file container stored in the collection. References ---------- .. [1] SEG EDI MT/EMAP standard (1987), MTNet. https://www.mtnet.info/docs/seg_mt_emap_1987.pdf """ def __init__( self, items: Iterable[EDIFile] | None = None, *, verbose: int = 0, ) -> None: self.verbose = int(verbose) self._items: list[EDIFile] = [] self._index: dict[str, int] = {} if items is not None: for ed in items: self.add(ed) # --------------- core collection protocol ---------------- def __len__(self) -> int: return len(self._items)
[docs] def __iter__(self) -> Iterator[EDIFile]: return iter(self._items)
def __getitem__(self, key: int | str) -> EDIFile: if isinstance(key, int): return self._items[key] idx = self._index.get(str(key), None) if idx is None: raise KeyError(f"unknown station: {key!r}") return self._items[idx]
[docs] def add(self, ed: EDIFile) -> None: sid = getattr(ed, "station", None) sid = sid or (str(ed.path) if ed.path else f"#{len(self)}") if sid in self._index: # replace semantics: keep last one i = self._index[sid] self._items[i] = ed return self._index[sid] = len(self._items) self._items.append(ed)
[docs] def stations(self) -> list[str]: return list(self._index.keys())
# --------------- ingest and bulk utilities ---------------
[docs] @classmethod def load( cls, sources: SrcType, *, parser: CoreParser | None = None, recursive: bool = True, strict: bool = False, on_dup: str = "replace", verbose: int = 0, ) -> CBBase: pr = parser or CoreParser( recursive=recursive, strict=strict, on_dup=on_dup, verbose=verbose, ) items = pr.parse(sources) col = cls(items=items, verbose=verbose) errs = pr.errors() if errs: logger.info("Load completed with %d errors.", len(errs)) return col
[docs] def map( self, fn: Callable[[EDIFile], object], ) -> list[object]: out: list[object] = [] for ed in self._items: out.append(fn(ed)) return out
[docs] def interpolate( self, new_freq: np.ndarray | list[float], *, kind: str = "slinear", bounds_error: bool = True, period_buffer: float | None = None, ) -> CBBase: out_items: list[EDIFile] = [] for ed in self._items: z2 = ed.interpolate( new_freq, kind=kind, bounds_error=bounds_error, period_buffer=period_buffer, ) fresh = EDIFile(ed.path, verbose=ed.verbose) fresh.Z = z2 fresh.Tip = ed.Tip # carry over parsed aux sections if any for k in ( "head", "info", "definemeasurement", "mtsect", "spectra_sect", "spectra_io", "spectra", "timeseries_sect", "timeseries_io", "timeseries", "other", "otherio", ): sec = ed.get_section(k) # type: ignore[misc] # noqa: E501 if sec is not None: fresh.add_section(k, sec) out_items.append(fresh) return CBBase(items=out_items, verbose=self.verbose)
[docs] def write( self, savepath: Pathish, *, pattern: str = "{station}.edi", **kwargs, ) -> list[str]: out_dir = Path(str(savepath)).expanduser().resolve() out_dir.mkdir(parents=True, exist_ok=True) paths: list[str] = [] for ed in self._items: sid = getattr(ed, "station", None) or "site" name = pattern.format(station=sid) p = out_dir / name s = ed.write(new_edifn=str(p), **kwargs) paths.append(s) return paths
[docs] def summary(self) -> list[dict[str, object]]: rows: list[dict[str, object]] = [] for ed in self._items: sid = getattr(ed, "station", None) or "-" nf = int(getattr(ed.Z, "n_freq", 0) or 0) tip = getattr(ed.Tip, "tipper", None) tip_ok = False if tip is not None: tip_ok = bool(tip.size and not np.all(tip == 0.0)) rows.append( { "station": sid, "path": str(ed.path) if ed.path else "-", "n_freq": nf, "tipper": tip_ok, "spectra": ed.get_section("spectra") is not None, "ts": ed.get_section("timeseries") is not None, } ) return rows
[docs] @property def items(self): """Internal: unified iterator over stored items.""" return getattr(self, "_items", [])
def __repr__(self) -> str: # pragma: no cover return f"CBBase(n={len(self)}, stations={self.stations()!r})" def __str__(self) -> str: # pragma: no cover lines = ["CBBase"] for r in self.summary(): lines.append( f" {r['station']}: nf={r['n_freq']} " f"tip={'Y' if r['tipper'] else 'N'} " f"sp={'Y' if r['spectra'] else 'N'} " f"ts={'Y' if r['ts'] else 'N'}" ) return "\n".join(lines)