Source code for pycsamt.tdem.log

"""Readers for Zonge TEMAVG processing ``.LOG`` files."""

from __future__ import annotations

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

from ..api.property import PyCSAMTObject

PathLike = Union[str, Path]

__all__ = [
    "TEMLog",
    "TEMLogRecord",
    "is_tem_log_file",
]


_VERSION_RE = re.compile(
    r"TEMAVG\s+(?P<version>[\d.]+),\s+Processed:\s+(?P<processed>.+)"
)
_READING_RE = re.compile(
    r'Reading\s+"(?P<source>[^"]+)"',
    re.IGNORECASE,
)
_CLOCK_RE = re.compile(
    r"Data uses\s+(?P<clock>[A-Z]+)\s+CLOCK\s+"
    r"\((?P<resolution>[-+\d.]+)\s*(?P<unit>[A-Za-z]+)\)",
    re.IGNORECASE,
)
_DATA_CLOCK_RE = re.compile(
    r"Data uses\s+(?P<frequency>[-+\d.]+)\s*MHz\s+"
    r"\((?P<clock>[A-Za-z]+)\)\s+clock\s+"
    r"\((?P<resolution>[-+\d.]+)\s*(?P<unit>[A-Za-z]+)\)",
    re.IGNORECASE,
)
_AVG_COUNT_RE = re.compile(
    r'File\s+"(?P<file>[^"]+)"\s+contains averaged data for\s+'
    r"(?P<count>\d+)\s+data sets",
    re.IGNORECASE,
)
_CLOSED_RE = re.compile(r'Log file\s+"(?P<file>[^"]+)"\s+closed', re.I)


[docs] @dataclass(frozen=True) class TEMLogRecord: """One acquisition-summary row from a TEMAVG log. Parameters ---------- station : float Station coordinate or station number. frequency : float Repetition frequency in hertz. loop : str Loop geometry or acquisition code, for example ``"INL"`` for in-loop data. component : str Measured component label. duty : str Duty-cycle string written by TEMAVG, for example ``"50%"``. first_window : float First gate centre time in microseconds, parsed from the ``Window1`` column. rx_moment : float Receiver moment or area value from the ``RxMoment`` column. time_base : str Time-base or clock sample label from the ``Ts`` column. """ station: float frequency: float loop: str component: str duty: str first_window: float rx_moment: float time_base: str
[docs] @dataclass(repr=False) class TEMLog(PyCSAMTObject): """Parsed TEMAVG processing log. ``TEMLog`` stores processing provenance emitted by the Zonge TEMAVG program. It captures the stable ASCII metadata and acquisition-summary table while preserving the raw processing-mode text for later inspection. Parameters ---------- path : pathlib.Path Source ``.LOG`` file. metadata : dict Parsed metadata such as source field file, clock type, clock resolution, output AVG file, data-set count, and close status. records : list of TEMLogRecord Parsed acquisition-summary rows. version : str, optional TEMAVG program version. processed : str, optional Processing date string. raw_modes : list of str Lines from the TEMAVG global and processing-mode sections. The original table uses legacy box-drawing characters, so it is preserved as text. """ path: Path metadata: dict[str, Any] = field(default_factory=dict) records: list[TEMLogRecord] = field(default_factory=list) version: str | None = None processed: str | None = None raw_modes: list[str] = field(default_factory=list) verbose: int = 0 logger: object | None = None
[docs] @classmethod def read( cls, path: PathLike, *, verbose: int = 0, logger: object | None = None, ) -> TEMLog: """Read a TEMAVG ``.LOG`` processing file. Parameters ---------- path : path-like TEMAVG log file produced while averaging and writing ``.AVG`` and ``.Z`` outputs. Returns ------- TEMLog Parsed processing log. """ p = Path(path) if not p.exists(): raise FileNotFoundError(p) metadata: dict[str, Any] = {} records: list[TEMLogRecord] = [] raw_modes: list[str] = [] version = processed = None in_records = False in_modes = False with p.open("r", errors="replace") as fh: for line_no, raw in enumerate(fh, 1): line = raw.rstrip("\n") stripped = line.strip() if not stripped: continue if line_no == 1: match = _VERSION_RE.search(stripped) if match: version = match.group("version") processed = match.group("processed").strip() metadata["title"] = stripped continue if stripped == "GLOBAL MODE LIST:": in_modes = True if stripped.startswith("Reading "): in_modes = False _update_reading_metadata(stripped, metadata) continue if in_modes: raw_modes.append(stripped) continue if stripped.startswith("[CLOCK]"): _update_clock_metadata(stripped, metadata) continue if stripped.startswith("Blk"): in_records = True continue if in_records and stripped.startswith("AVG"): record = _parse_log_record(stripped, p, line_no) records.append(record) continue if stripped.startswith("Combine "): steps = metadata.setdefault("combine_steps", []) steps.append(stripped) continue _update_output_metadata(stripped, metadata) return cls( path=p, metadata=metadata, records=records, version=version, processed=processed, raw_modes=raw_modes, verbose=verbose, logger=logger, )
[docs] @property def n_records(self) -> int: """Number of acquisition-summary rows.""" return len(self.records)
[docs] @property def stations(self) -> list[float]: """Sorted station values represented in the log.""" return sorted({rec.station for rec in self.records})
[docs] def to_records(self) -> list[dict[str, Any]]: """Return acquisition rows as dictionaries.""" rows: list[dict[str, Any]] = [] for rec in self.records: rows.append( { "source_file": self.path.name, "station": rec.station, "frequency": rec.frequency, "loop": rec.loop, "component": rec.component, "duty": rec.duty, "first_window_us": rec.first_window, "rx_moment": rec.rx_moment, "time_base": rec.time_base, "field_file": self.metadata.get("field_file"), } ) return rows
[docs] def to_dataframe(self): """Return the acquisition-summary table as a DataFrame.""" try: import pandas as pd except ImportError as exc: msg = "TEMLog.to_dataframe requires pandas." raise ImportError(msg) from exc return pd.DataFrame(self.to_records())
[docs] def is_tem_log_file(path: PathLike) -> bool: """Return ``True`` when ``path`` looks like a TEMAVG log.""" p = Path(path) if not p.exists() or not p.is_file(): return False try: with p.open("r", errors="replace") as fh: head = "".join(fh.readline() for _ in range(20)) except OSError: return False return "TEMAVG" in head and "GLOBAL MODE LIST" in head
def _update_reading_metadata( line: str, metadata: dict[str, Any], ) -> None: """Parse the source field file line.""" match = _READING_RE.search(line) if match: metadata["field_file"] = match.group("source") def _update_clock_metadata( line: str, metadata: dict[str, Any], ) -> None: """Parse the first clock metadata line.""" match = _CLOCK_RE.search(line) if match: metadata["clock_type"] = match.group("clock").upper() metadata["clock_resolution"] = float(match.group("resolution")) metadata["clock_unit"] = match.group("unit") def _update_output_metadata( line: str, metadata: dict[str, Any], ) -> None: """Parse output and close-status metadata lines.""" avg_count = _AVG_COUNT_RE.search(line) if avg_count: metadata["avg_file"] = avg_count.group("file") metadata["avg_dataset_count"] = int(avg_count.group("count")) return data_clock = _DATA_CLOCK_RE.search(line) if data_clock: frequency = data_clock.group("frequency") metadata["data_clock_mhz"] = float(frequency) metadata["data_clock_type"] = data_clock.group("clock").lower() metadata["data_clock_resolution"] = float( data_clock.group("resolution") ) metadata["data_clock_unit"] = data_clock.group("unit") return closed = _CLOSED_RE.search(line) if closed: metadata["closed_file"] = closed.group("file") metadata["closed"] = True def _parse_log_record( line: str, path: Path, line_no: int, ) -> TEMLogRecord: """Parse one TEMAVG log acquisition-summary row.""" parts = line.split() if len(parts) < 9: msg = f"Malformed TEMAVG log row in {path!s} at line {line_no}." raise ValueError(msg) try: first_window = _parse_window_us(parts[6]) return TEMLogRecord( station=float(parts[1]), frequency=float(parts[2]), loop=parts[3], component=parts[4], duty=parts[5], first_window=first_window, rx_moment=float(parts[7]), time_base=parts[8], ) except ValueError as exc: msg = f"Cannot parse TEMAVG log row in {path!s} at line {line_no}." raise ValueError(msg) from exc def _parse_window_us(value: str) -> float: """Parse a TEMAVG window value and return microseconds.""" match = re.match(r"([-+\d.]+)\s*([A-Za-z]*)", value) if match is None: return float(value) number = float(match.group(1)) unit = match.group(2).lower() if unit in {"u", "us", "usec"}: return number if unit in {"m", "ms", "msec"}: return number * 1000.0 if unit in {"s", "sec"}: return number * 1_000_000.0 return number