Source code for pycsamt.stratagem.rename

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
stratagem.rename
================

EDI file renaming and rewriting utilities for Stratagem post-processing.

:class:`EDIRenamer`
    Rename a set of EDI files on disk according to a configurable basename,
    zero-pad width, and trailer, mirroring the ``watex.utils.rename_files``
    workflow used in the legacy pipeline.  Simultaneously updates the
    ``>HEAD`` ``DATAID`` and linked section ``SECTID`` fields so the EDI
    content stays consistent with the new filename.

:class:`EDIWriter`
    Write a list of in-memory :class:`~pycsamt.seg.edi.EDIFile` objects to
    a directory, with optional renaming and ``>HEAD`` metadata overrides
    (station name / DATAID, acquisition info, etc.).

Both classes follow the ``fit() → out()`` pattern used by all stratagem
classes.
"""

from __future__ import annotations

from pathlib import Path

from ..api.property import MetadataMixin, PyCSAMTObject
from ..exceptions import FileHandlingError, NotFittedError
from ..seg.edi import EDIFile

__all__ = ["EDIRenamer", "EDIWriter"]


# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------


def _make_new_name(
    basename: str, idx: int, zero_pad: int, trailer: str
) -> str:
    """Build a new EDI filename from parts.

    Parameters
    ----------
    basename : str
        Prefix string, e.g. ``'T2.'``.
    idx : int
        Station index (0-based).
    zero_pad : int
        Width of the zero-padded index field.
    trailer : str
        Suffix appended after the index but before ``.edi``.

    Returns
    -------
    str
        E.g. ``'T2.000.edi'``, ``'T2.086.edi'``.
    """
    return f"{basename}{idx:0{zero_pad}d}{trailer}.edi"


def _new_dataid(basename: str, idx: int, zero_pad: int, trailer: str) -> str:
    """Build the DATAID / SECTID string matching the new filename."""
    return f"{basename}{idx:0{zero_pad}d}{trailer}"


# ---------------------------------------------------------------------------
# EDIRenamer
# ---------------------------------------------------------------------------


[docs] class EDIRenamer(PyCSAMTObject): """Rename Stratagem EDI files with a standardised naming convention. Reads each source EDI, updates ``>HEAD.DATAID`` and the linked ``SECTID`` fields to match the new name, then writes the result to *dst_path* (keeping the source files untouched). Parameters ---------- basename : str, default ``'S'`` Name prefix. E.g. ``'T2.'`` produces ``T2.000.edi``, ``T2.001.edi``, … zero_pad : int, default 3 Width of the zero-padded integer part (``'T2.000'`` has ``zero_pad=3``). trailer : str, default ``''`` Optional string appended after the index (before ``.edi``). update_dataid : bool, default True When True, ``>HEAD.DATAID`` and all linked ``SECTID`` fields are updated to match the new filename stem. overwrite : bool, default False Overwrite existing files in *dst_path*. verbose : int, default 0 Attributes ---------- renamed_pairs_ : list of (Path, Path) ``(src, dst)`` path pairs for every file that was processed. skipped_ : list of Path Source files skipped because the destination already existed and ``overwrite=False``. Examples -------- Rename processed EDIs to ``T2.000.edi`` … ``T2.082.edi``: >>> rn = EDIRenamer(basename="T2.", zero_pad=3) >>> rn.fit("2/2EDIP", "2/renamedEDIs") Or rename in-memory objects produced by the processing pipeline: >>> rn.fit(nr.edi_objects_, "2/renamedEDIs") """ __repr_fields__ = ("basename", "zero_pad", "trailer", "n_renamed_") def __init__( self, *, basename: str = "S", zero_pad: int = 3, trailer: str = "", update_dataid: bool = True, overwrite: bool = False, verbose: int = 0, ) -> None: self.basename = basename self.zero_pad = zero_pad self.trailer = trailer self.update_dataid = update_dataid self.overwrite = overwrite self.verbose = verbose # ------------------------------------------------------------------
[docs] def fit( self, source: str | Path | list[EDIFile] | list[Path], dst_path: str | Path, ) -> EDIRenamer: """Rename EDI files and write them to *dst_path*. Parameters ---------- source : path-like, list of EDIFile, or list of Path Input EDI files. Accepts: * A directory path — all ``.edi`` files in it (natural-sort order). * A list of :class:`~pycsamt.seg.edi.EDIFile` objects (e.g. from ``NoiseRemover.edi_objects_``). * A list of :class:`pathlib.Path` EDI paths. dst_path : path-like Output directory. Created if absent. Returns ------- self """ out_dir = Path(dst_path).expanduser().resolve() out_dir.mkdir(parents=True, exist_ok=True) # resolve inputs to a list of (EDIFile | None, src_path | None) entries: list[tuple[EDIFile | None, Path | None]] = ( self._resolve_source(source) ) self.renamed_pairs_: list[tuple[Path, Path]] = [] self.skipped_: list[Path] = [] for i, (edi_obj, src_path) in enumerate(entries): new_fname = _make_new_name( self.basename, i, self.zero_pad, self.trailer ) dst_file = out_dir / new_fname if dst_file.exists() and not self.overwrite: self.skipped_.append(dst_file) if self.verbose: print(f"[EDIRenamer] skip existing: {new_fname}") continue # load from path if we only have a path, not an object if edi_obj is None and src_path is not None: try: edi_obj = EDIFile(src_path, verbose=0) except Exception as exc: if self.verbose: print( f"[EDIRenamer] load failed {src_path.name}: {exc}" ) continue if edi_obj is None: continue # update DATAID / SECTID if self.update_dataid: new_id = _new_dataid( self.basename, i, self.zero_pad, self.trailer ) try: edi_obj.station = new_id except Exception: pass # write try: edi_obj.write(new_edifn=new_fname, savepath=str(out_dir)) self.renamed_pairs_.append( ( src_path or Path(new_fname), dst_file, ) ) except Exception as exc: if self.verbose: print(f"[EDIRenamer] write failed {new_fname}: {exc}") self.n_renamed_ = len(self.renamed_pairs_) if self.verbose: print( f"[EDIRenamer] renamed {self.n_renamed_} files → {out_dir} " f"({len(self.skipped_)} skipped)" ) return self
# ------------------------------------------------------------------ def _resolve_source( self, source: str | Path | list[EDIFile] | list[Path], ) -> list[tuple[EDIFile | None, Path | None]]: """Normalise *source* to a list of (EDIFile | None, Path | None).""" import re as _re # already imported at module level, but kept explicit if isinstance(source, (str, Path)): d = Path(source).expanduser().resolve() if not d.is_dir(): raise FileHandlingError(f"Source directory not found: {d}") paths = sorted( d.glob("*.edi"), key=lambda p: tuple( int(t) if t.isdigit() else t for t in _re.split(r"(\d+)", p.stem.lower()) ), ) return [(None, p) for p in paths] if not source: return [] first = source[0] if isinstance(first, EDIFile): return [(e, getattr(e, "path", None)) for e in source] if isinstance(first, Path): return [(None, p) for p in source] # fallback: try to treat as EDIFile-like return [(e, getattr(e, "path", None)) for e in source] # ------------------------------------------------------------------
[docs] def dst_paths(self) -> list[Path]: """Return the list of written destination paths.""" if not hasattr(self, "renamed_pairs_"): raise NotFittedError("Call fit() first.") return [dst for _, dst in self.renamed_pairs_]
# --------------------------------------------------------------------------- # EDIWriter # ---------------------------------------------------------------------------
[docs] class EDIWriter(PyCSAMTObject, MetadataMixin): """Write in-memory EDIFile objects to disk with optional HEAD overrides. Provides a thin, consistent wrapper around :meth:`~pycsamt.seg.edi.EDIFile.write` that also allows batch update of ``>HEAD`` fields (DATAID, ACQBY, DATAID prefix, etc.) before writing. Parameters ---------- dataid_prefix : str, optional When set, each station's DATAID is overwritten with ``f"{dataid_prefix}{i:0{zero_pad}d}"``. Useful for standardising station identifiers across a profile. zero_pad : int, default 3 Zero-pad width used with *dataid_prefix*. overwrite : bool, default False verbose : int, default 0 Attributes ---------- written_ : list of Path Paths of successfully written files. failed_ : list of tuple(str, Exception) ``(filename, exc)`` for any file that could not be written. Examples -------- Write the noise-corrected EDIs, keeping original file names: >>> wr = EDIWriter() >>> wr.fit(nr.edi_objects_, "2/final") >>> wr.written_ Write with standardised DATAID ``S000`` … ``S082``: >>> wr = EDIWriter(dataid_prefix="S", zero_pad=3) >>> wr.fit(nr.edi_objects_, "2/final") """ __repr_fields__ = ("dataid_prefix", "zero_pad", "n_written_") def __init__( self, *, dataid_prefix: str | None = None, zero_pad: int = 3, overwrite: bool = False, verbose: int = 0, ) -> None: self.dataid_prefix = dataid_prefix self.zero_pad = zero_pad self.overwrite = overwrite self.verbose = verbose # ------------------------------------------------------------------
[docs] def fit( self, edi_objects: list[EDIFile], savepath: str | Path, *, head_overrides: dict[str, object] | None = None, ) -> EDIWriter: """Write *edi_objects* to *savepath*. Parameters ---------- edi_objects : list of EDIFile savepath : path-like Output directory. head_overrides : dict, optional Key-value pairs applied to every EDI's ``>HEAD`` object before writing. Keys must be valid HEAD attribute names (e.g. ``'acqby'``, ``'stdvers'``). Returns ------- self """ out_dir = Path(savepath).expanduser().resolve() out_dir.mkdir(parents=True, exist_ok=True) self.written_: list[Path] = [] self.failed_: list[tuple[str, Exception]] = [] for i, edi in enumerate(edi_objects): # determine output filename fname = ( edi.path.name if getattr(edi, "path", None) is not None else f"station_{i:0{self.zero_pad}d}.edi" ) out_path = out_dir / fname if out_path.exists() and not self.overwrite: self.written_.append(out_path) continue # apply DATAID prefix if self.dataid_prefix is not None: new_id = f"{self.dataid_prefix}{i:0{self.zero_pad}d}" try: edi.station = new_id fname = f"{new_id}.edi" out_path = out_dir / fname except Exception: pass # apply HEAD overrides if head_overrides: head = edi.get_section("head") if head is not None: for k, v in head_overrides.items(): try: setattr(head, k, v) except Exception: pass # write try: edi.write(new_edifn=fname, savepath=str(out_dir)) self.written_.append(out_path) except Exception as exc: self.failed_.append((fname, exc)) if self.verbose: print(f"[EDIWriter] write failed {fname}: {exc}") self.n_written_ = len(self.written_) if self.verbose: print( f"[EDIWriter] wrote {self.n_written_} files → {out_dir}" + (f" ({len(self.failed_)} failed)" if self.failed_ else "") ) return self