Source code for pycsamt.tdem.survey

"""Folder-level readers for time-domain EM surveys."""

from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Union

from ..api.property import PyCSAMTObject
from ._base import TEMSounding
from .avg import TEMAVG
from .coordinates import (
    TEMCoordinate,
    TEMCoordinateTable,
    read_tem_coordinates,
)
from .log import TEMLog
from .zplot import TEMZPlot

PathLike = Union[str, Path]

__all__ = [
    "TEMSurvey",
    "read_temavg_survey",
]


[docs] @dataclass(repr=False) class TEMSurvey(PyCSAMTObject): """Collection of processed TEM files from one survey folder. Parameters ---------- root : pathlib.Path Directory scanned for time-domain EM files. avg_files : dict of str to TEMAVG Parsed ``.AVG`` files keyed by file stem. z_files : dict of str to TEMZPlot Parsed ``.Z`` contour files keyed by file stem. log_files : dict of str to TEMLog Parsed ``.LOG`` processing files keyed by file stem. coordinates : TEMCoordinateTable, optional Profile/point coordinate table used to enrich exported survey records. companion_files : dict Available companion files grouped by stem. Each value may contain ``"log"``, ``"z"``, and other format keys that can be parsed in later processing stages. Examples -------- >>> from pycsamt.tdem.survey import read_temavg_survey >>> survey = read_temavg_survey("data/TEMAVG/JIANGSU") >>> survey.n_avg_files > 0 True """ root: Path avg_files: dict[str, TEMAVG] = field(default_factory=dict) z_files: dict[str, TEMZPlot] = field(default_factory=dict) log_files: dict[str, TEMLog] = field(default_factory=dict) coordinates: TEMCoordinateTable | None = None companion_files: dict[str, dict[str, Path]] = field( default_factory=dict, ) verbose: int = 0 logger: object | None = None
[docs] @property def n_avg_files(self) -> int: """Number of parsed ``.AVG`` files.""" return len(self.avg_files)
[docs] @property def n_z_files(self) -> int: """Number of parsed ``.Z`` contour files.""" return len(self.z_files)
[docs] @property def n_log_files(self) -> int: """Number of parsed ``.LOG`` processing files.""" return len(self.log_files)
[docs] @property def stems(self) -> list[str]: """Sorted file stems represented by parsed ``.AVG`` files.""" return sorted(self.avg_files)
[docs] @property def stations(self) -> list[float]: """Sorted union of all station values in parsed data.""" values: set[float] = set() for avg in self.avg_files.values(): values.update(avg.stations) return sorted(values)
[docs] def to_records(self) -> list[dict[str, Any]]: """Return all parsed ``.AVG`` rows as dictionaries.""" rows: list[dict[str, Any]] = [] for stem in self.stems: profile = _profile_from_stem(stem) for row in self.avg_files[stem].to_records(): row["profile"] = profile coord = self.coordinate_for(profile, row["station"]) if coord is not None: row.update(_coordinate_record(coord)) rows.append(row) return rows
[docs] def to_dataframe(self): """Return all parsed rows as a :class:`pandas.DataFrame`.""" try: import pandas as pd except ImportError as exc: msg = "TEMSurvey.to_dataframe requires pandas." raise ImportError(msg) from exc return pd.DataFrame(self.to_records())
[docs] def get(self, stem: str) -> TEMAVG: """Return one parsed ``.AVG`` file by stem.""" return self.avg_files[stem]
[docs] def get_z(self, stem: str) -> TEMZPlot: """Return one parsed ``.Z`` file by stem.""" return self.z_files[stem]
[docs] def get_log(self, stem: str) -> TEMLog: """Return one parsed ``.LOG`` file by stem.""" return self.log_files[stem]
[docs] def coordinate_for( self, profile: float, point: float, ) -> TEMCoordinate | None: """Return coordinate metadata for a profile/station pair.""" if self.coordinates is None: return None return self.coordinates.get(profile, point)
[docs] def to_soundings( self, *, stems: list[str] | None = None, component: str = "Hz", frequency: float | None = None, data_column: str = "magnitude", magnitude_unit: str = "uV/A", data_type: str = "voltage", rx_turns: int = 1, tx_turns: int = 1, min_gates: int = 1, verbose: int | None = None, logger: object | None = None, ) -> list[TEMSounding]: """Build station-wise :class:`TEMSounding` objects. Parameters ---------- stems : list of str, optional File stems to export. When omitted, all parsed ``.AVG`` files are converted in sorted order. component : str, default "Hz" TEMAVG component to select. frequency : float, optional Repetition frequency to select. When omitted, all rows for ``component`` are used. data_column : {"magnitude"}, default "magnitude" TEMAVG transient column used for the sounding decay. magnitude_unit : str, default "uV/A" Unit of the TEMAVG magnitude column before conversion to the requested ``data_type`` representation. data_type : str, default "voltage" Data type stored in each sounding. The default is receiver voltage in volts. rx_turns, tx_turns : int, default 1 Receiver and transmitter turn counts passed to each sounding. min_gates : int, default 1 Minimum number of selected time gates required for a station to be exported. Returns ------- list of TEMSounding Soundings suitable for :class:`LateTimeTransform` or :class:`TEMtoEDI`. """ selected = self.stems if stems is None else stems soundings: list[TEMSounding] = [] for stem in selected: avg = self.avg_files[stem] profile = _profile_from_stem(stem) soundings.extend( avg.to_soundings( component=component, frequency=frequency, data_column=data_column, magnitude_unit=magnitude_unit, data_type=data_type, rx_turns=rx_turns, tx_turns=tx_turns, coordinate_table=self.coordinates, profile=profile, min_gates=min_gates, verbose=self.verbose if verbose is None else verbose, logger=self.logger if logger is None else logger, ) ) return soundings
[docs] def read_temavg_survey( path: PathLike, *, pattern: str = "*.AVG", coordinate_file: PathLike | None = None, verbose: int = 0, logger: object | None = None, ) -> TEMSurvey: """Read a directory of Zonge TEMAVG processed files. Parameters ---------- path : path-like Directory containing TEMAVG ``.AVG`` files and optional companion files such as ``.LOG``, ``.Z``, and ``.mde``. pattern : str, default "*.AVG" Glob pattern used to select processed average files. coordinate_file : path-like, optional Profile/point coordinate file. If omitted, the reader looks for common coordinate-table names in ``path``. Returns ------- TEMSurvey Parsed survey collection. Examples -------- >>> from pycsamt.tdem import read_temavg_survey >>> survey = read_temavg_survey("data/TEMAVG/JIANGSU") >>> len(survey.stations) > 0 True """ root = Path(path) if not root.exists(): raise FileNotFoundError(root) if not root.is_dir(): msg = f"TEMAVG survey path is not a directory: {root!s}" raise NotADirectoryError(msg) avg_files: dict[str, TEMAVG] = {} for avg_path in sorted(root.glob(pattern)): avg_files[avg_path.stem] = TEMAVG.read( avg_path, verbose=verbose, logger=logger, ) if not avg_files: msg = f"No TEMAVG files matching {pattern!r} found in {root!s}." raise ValueError(msg) companions = _group_companion_files(root) z_files: dict[str, TEMZPlot] = {} log_files: dict[str, TEMLog] = {} for stem, files in companions.items(): z_path = files.get("z") if z_path is not None: z_files[stem] = TEMZPlot.read( z_path, verbose=verbose, logger=logger, ) log_path = files.get("log") if log_path is not None: log_files[stem] = TEMLog.read( log_path, verbose=verbose, logger=logger, ) coordinates = _read_coordinates_if_available( root, coordinate_file, verbose=verbose, logger=logger, ) return TEMSurvey( root=root, avg_files=avg_files, z_files=z_files, log_files=log_files, companion_files=companions, coordinates=coordinates, verbose=verbose, logger=logger, )
def _group_companion_files(root: Path) -> dict[str, dict[str, Path]]: """Group recognized companion files by stem.""" grouped: dict[str, dict[str, Path]] = {} suffix_map = { ".log": "log", ".z": "z", ".mde": "mde", } for p in root.iterdir(): key = suffix_map.get(p.suffix.lower()) if key is None: continue grouped.setdefault(p.stem, {})[key] = p return grouped def _read_coordinates_if_available( root: Path, coordinate_file: PathLike | None, *, verbose: int = 0, logger: object | None = None, ) -> TEMCoordinateTable | None: """Read a coordinate table when one can be found.""" if coordinate_file is not None: coord_path = Path(coordinate_file) if not coord_path.is_absolute(): coord_path = root / coord_path return read_tem_coordinates( coord_path, verbose=verbose, logger=logger, ) candidates = [ "Coordinate of measuring point.xls", "Coordinate of measuring point.xlsx", "coordinates.csv", "Coordinates.csv", "coordinate.csv", ] for name in candidates: candidate = root / name if candidate.exists(): try: return read_tem_coordinates( candidate, verbose=verbose, logger=logger, ) except (ImportError, OSError, RuntimeError, ValueError): return None return None def _profile_from_stem(stem: str) -> float: """Extract a numeric profile id from a TEM file stem.""" digits = "".join(ch for ch in stem if ch.isdigit()) return float(digits) if digits else float("nan") def _coordinate_record(coord: TEMCoordinate) -> dict[str, Any]: """Return coordinate fields for a survey row.""" return { "coord_profile": coord.profile, "coord_point": coord.point, "gauss_x": coord.gauss_x, "gauss_y": coord.gauss_y, "x": coord.x, "y": coord.y, "elevation": coord.elevation, "remark": coord.remark, }