"""Readers for Zonge TEMAVG processed ``.AVG`` files."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Union
import numpy as np
from ..api.property import PyCSAMTObject
from ._base import TEMSounding
PathLike = Union[str, Path]
__all__ = [
"TEMAVG",
"TEMAVGRecord",
"is_temavg_file",
]
_META_RE = re.compile(r"^\$\s*TEM:\s*([^=]+)=\s*(.+?)\s*$")
_VERSION_RE = re.compile(
r"TEMAVG\s+(?P<version>[\d.]+).*?"
r"Dated\s+(?P<dated>[^,]+),\s+Processed\s+(?P<processed>.+)"
)
[docs]
@dataclass(frozen=True)
class TEMAVGRecord:
"""One processed TEMAVG gate value.
Parameters
----------
skip : int
TEMAVG skip or block flag stored in the first column.
tx : float
Transmitter identifier from the ``Tx`` column.
station : float
Station coordinate or station number along the survey
profile.
frequency : float
Repetition frequency in hertz.
component : str
Measured component label, for example ``"Hz"``.
current : float
Transmitter current in amperes from the ``Amps``
column.
window : int
Time-window number.
time : float
Gate centre time in milliseconds, as written by
TEMAVG.
magnitude : float
Processed transient magnitude. For TEMAVG contour
files this is commonly reported in microvolts per
ampere.
ramp_app_res : float
Ramp-corrected apparent resistivity in ohm metres.
depth : float
TEMAVG depth estimate in metres.
percent_magnitude : float
Percent magnitude or percent error column.
"""
skip: int
tx: float
station: float
frequency: float
component: str
current: float
window: int
time: float
magnitude: float
ramp_app_res: float
depth: float
percent_magnitude: float
[docs]
@property
def time_s(self) -> float:
"""Gate centre time in seconds."""
return self.time * 1e-3
[docs]
@dataclass(repr=False)
class TEMAVG(PyCSAMTObject):
"""Processed content of one Zonge TEMAVG ``.AVG`` file.
``TEMAVG`` reads the human-readable output produced by the
Zonge TEMAVG processing program. The object stores global
metadata such as transmitter ramp, loop dimensions, and
receiver area together with one row per station/time gate.
It is a processed time-domain EM container, not an EDI or
frequency-domain impedance object.
Parameters
----------
path : pathlib.Path
Source file path.
metadata : dict
Parsed header metadata. Keys include entries such as
``"TXramp"``, ``"TXdx"``, ``"TXdy"``, ``"TXarea"``,
and ``"RXarea"`` when present.
records : list of TEMAVGRecord
Parsed processed data rows.
version : str, optional
TEMAVG program version parsed from the first line.
dated : str, optional
Field data date parsed from the first line.
processed : str, optional
Processing date parsed from the first line.
Examples
--------
>>> from pycsamt.tdem.avg import TEMAVG
>>> avg = TEMAVG.read("data/TEMAVG/JIANGSU/TEM100.AVG")
>>> avg.n_records > 0
True
>>> avg.stations[:3]
[100.0, 120.0, 140.0]
"""
path: Path
metadata: dict[str, Any] = field(default_factory=dict)
records: list[TEMAVGRecord] = field(default_factory=list)
version: str | None = None
dated: str | None = None
processed: str | None = None
verbose: int = 0
logger: object | None = None
[docs]
@classmethod
def read(
cls,
path: PathLike,
*,
verbose: int = 0,
logger: object | None = None,
) -> TEMAVG:
"""Read a Zonge TEMAVG ``.AVG`` file.
Parameters
----------
path : path-like
Processed TEMAVG file. The reader expects the
standard Zonge header followed by the table whose
columns include station, frequency, component,
current, window, time, magnitude, apparent
resistivity, depth, and percent magnitude.
Returns
-------
TEMAVG
Parsed TEMAVG file.
"""
p = Path(path)
if not p.exists():
raise FileNotFoundError(p)
metadata: dict[str, Any] = {}
records: list[TEMAVGRecord] = []
version = dated = processed = None
in_table = False
with p.open("r", errors="replace") as fh:
for line_no, raw in enumerate(fh, 1):
line = raw.strip()
if not line:
continue
if line_no == 1:
match = _VERSION_RE.search(line)
if match:
version = match.group("version")
dated = match.group("dated").strip()
processed = match.group("processed").strip()
metadata["title"] = line.lstrip("\\").strip()
continue
meta = _META_RE.match(line)
if meta:
key = meta.group(1).strip()
metadata[key] = _parse_metadata_value(meta.group(2))
continue
if line.startswith("skp"):
in_table = True
continue
if not in_table or line.startswith("\\-"):
continue
if _looks_like_record(line):
records.append(_parse_avg_record(line, p, line_no))
if not records:
msg = f"No TEMAVG data rows found in {p!s}."
raise ValueError(msg)
return cls(
path=p,
metadata=metadata,
records=records,
version=version,
dated=dated,
processed=processed,
verbose=verbose,
logger=logger,
)
[docs]
@property
def n_records(self) -> int:
"""Number of processed gate rows."""
return len(self.records)
[docs]
@property
def stations(self) -> list[float]:
"""Sorted station values represented in the file."""
return sorted({rec.station for rec in self.records})
[docs]
@property
def windows(self) -> list[int]:
"""Sorted time-window numbers represented in the file."""
return sorted({rec.window for rec in self.records})
[docs]
@property
def tx_area(self) -> float | None:
"""Transmitter loop area in square metres when present."""
value = self.metadata.get("TXarea")
return float(value) if value is not None else None
[docs]
@property
def rx_area(self) -> float | None:
"""Receiver loop or coil area in square metres when present."""
value = self.metadata.get("RXarea")
return float(value) if value is not None else None
[docs]
@property
def tx_dx(self) -> float | None:
"""Transmitter loop x dimension in metres when present."""
value = self.metadata.get("TXdx")
return float(value) if value is not None else None
[docs]
@property
def tx_dy(self) -> float | None:
"""Transmitter loop y dimension in metres when present."""
value = self.metadata.get("TXdy")
return float(value) if value is not None else None
[docs]
def rows_for_station(self, station: float) -> list[TEMAVGRecord]:
"""Return all gate rows for one station value."""
return [rec for rec in self.records if rec.station == station]
[docs]
def to_soundings(
self,
*,
stations: list[float] | 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,
coordinate_table: Any | None = None,
profile: float | None = None,
station_name_template: str = "{stem}_{station:g}",
min_gates: int = 1,
verbose: int = 0,
logger: object | None = None,
) -> list[TEMSounding]:
"""Build one :class:`TEMSounding` per station.
Parameters
----------
stations : list of float, optional
Station values to export. When omitted, every station
in the file is converted.
component : str, default "Hz"
Component label to select from the TEMAVG rows.
frequency : float, optional
Repetition frequency to select. When omitted, all
rows matching ``component`` are used.
data_column : {"magnitude"}, default "magnitude"
TEMAVG data column used for the sounding decay.
The processed magnitude column is currently the only
supported transient column.
magnitude_unit : str, default "uV/A"
Unit of the TEMAVG magnitude column. ``"uV/A"``
means microvolts per ampere. ``"V/A"``, ``"uV"``,
``"V"``, and ``"SI"`` are also accepted.
data_type : str, default "voltage"
Output ``TEMSounding`` data type. ``"voltage"``
keeps the decay as receiver voltage. ``"dBdt"``
divides by receiver turns and area. ``"dHdt"``
additionally divides by :math:`\\mu_0`.
rx_turns, tx_turns : int, default 1
Receiver and transmitter turn counts passed to the
resulting soundings.
coordinate_table : object, optional
Coordinate table exposing ``get(profile, point)``.
Matching coordinates are copied to the sounding
``x``, ``y``, and ``elevation`` fields.
profile : float, optional
Profile id used with ``coordinate_table``. If omitted
and no coordinate table is supplied, coordinates stay
at their default values.
station_name_template : str, default "{stem}_{station:g}"
Format string used to create each station name.
Available fields are ``stem``, ``station``,
``profile``, and ``component``.
min_gates : int, default 1
Minimum number of selected time gates required to
create a sounding.
Returns
-------
list of TEMSounding
Station-wise soundings ready for late-time or Fourier
conversion.
"""
if data_column != "magnitude":
msg = "Only data_column='magnitude' is currently supported."
raise ValueError(msg)
selected_stations = self.stations if stations is None else stations
soundings: list[TEMSounding] = []
for station in selected_stations:
rows = [
rec
for rec in self.rows_for_station(station)
if rec.component == component
]
if frequency is not None:
rows = [rec for rec in rows if rec.frequency == frequency]
rows = sorted(rows, key=lambda rec: rec.time_s)
if len(rows) < min_gates:
continue
current = float(rows[0].current)
time_gates = np.array([rec.time_s for rec in rows], dtype=float)
values = np.array([rec.magnitude for rec in rows], dtype=float)
voltage = _scale_temavg_magnitude(
values,
current=current,
magnitude_unit=magnitude_unit,
)
rx_area = self.rx_area or 1.0
data = _temavg_data_for_type(
voltage,
current=current,
tx_area=self._required_tx_area(),
rx_area=rx_area,
rx_turns=rx_turns,
data_type=data_type,
)
errors = _temavg_percent_errors(data, rows)
x = y = elevation = 0.0
coord = None
if coordinate_table is not None and profile is not None:
coord = coordinate_table.get(profile, station)
if coord is not None:
x = coord.x
y = coord.y
elevation = coord.elevation
name = station_name_template.format(
stem=self.path.stem,
station=float(station),
profile=profile if profile is not None else "",
component=component,
)
soundings.append(
TEMSounding.from_arrays(
time_gates,
data,
current=current,
tx_area=self._required_tx_area(),
data_type=data_type,
tx_turns=tx_turns,
rx_area=rx_area,
rx_turns=rx_turns,
loop_shape=self._loop_shape(),
loop_dims=self._loop_dims(),
station_name=name,
x=x,
y=y,
elevation=elevation,
error=errors,
verbose=verbose,
logger=logger,
)
)
return soundings
[docs]
def to_records(self) -> list[dict[str, Any]]:
"""Return records as dictionaries with file metadata."""
rows: list[dict[str, Any]] = []
for rec in self.records:
row = {
"source_file": self.path.name,
"station": rec.station,
"tx": rec.tx,
"frequency": rec.frequency,
"component": rec.component,
"current": rec.current,
"window": rec.window,
"time_ms": rec.time,
"time_s": rec.time_s,
"magnitude": rec.magnitude,
"ramp_app_res": rec.ramp_app_res,
"depth": rec.depth,
"percent_magnitude": rec.percent_magnitude,
"tx_ramp": self.metadata.get("TXramp"),
"tx_area": self.metadata.get("TXarea"),
"rx_area": self.metadata.get("RXarea"),
}
rows.append(row)
return rows
[docs]
def to_dataframe(self):
"""Return the parsed table as a :class:`pandas.DataFrame`."""
try:
import pandas as pd
except ImportError as exc:
msg = "TEMAVG.to_dataframe requires pandas."
raise ImportError(msg) from exc
return pd.DataFrame(self.to_records())
def _required_tx_area(self) -> float:
"""Return transmitter area or raise a clear error."""
if self.tx_area is None:
msg = f"TEMAVG file {self.path!s} does not define TXarea."
raise ValueError(msg)
return self.tx_area
def _loop_shape(self) -> str:
"""Infer transmitter loop shape from TEMAVG dimensions."""
if self.tx_dx is None or self.tx_dy is None:
return "square"
if np.isclose(self.tx_dx, self.tx_dy):
return "square"
return "rectangular"
def _loop_dims(self) -> tuple[float, ...]:
"""Infer transmitter loop dimensions from TEMAVG metadata."""
if self.tx_dx is None or self.tx_dy is None:
side = np.sqrt(self._required_tx_area())
return (float(side),)
if np.isclose(self.tx_dx, self.tx_dy):
return (float(self.tx_dx),)
return (float(self.tx_dx), float(self.tx_dy))
[docs]
def is_temavg_file(path: PathLike) -> bool:
"""Return ``True`` when ``path`` looks like a TEMAVG file."""
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(8))
except OSError:
return False
return "TEMAVG" in head and "TXarea" in head
def _parse_metadata_value(text: str) -> Any:
"""Parse a TEMAVG metadata value while preserving units."""
value = text.strip()
number = re.match(r"^([-+]?\d+(?:\.\d+)?(?:[Ee][-+]?\d+)?)", value)
if number:
parsed = float(number.group(1))
if parsed.is_integer():
return int(parsed)
return parsed
return value
def _looks_like_record(line: str) -> bool:
"""Return whether ``line`` starts like a TEMAVG data row."""
return bool(re.match(r"^\d+\s+[-+]?\d", line))
def _parse_avg_record(
line: str,
path: Path,
line_no: int,
) -> TEMAVGRecord:
"""Parse one whitespace-delimited TEMAVG data row."""
parts = line.split()
if len(parts) < 12:
msg = f"Malformed TEMAVG row in {path!s} at line {line_no}."
raise ValueError(msg)
try:
return TEMAVGRecord(
skip=int(parts[0]),
tx=float(parts[1]),
station=float(parts[2]),
frequency=float(parts[3]),
component=parts[4],
current=float(parts[5]),
window=int(parts[6]),
time=float(parts[7]),
magnitude=float(parts[8]),
ramp_app_res=float(parts[9]),
depth=float(parts[10]),
percent_magnitude=_parse_float(parts[11]),
)
except ValueError as exc:
msg = f"Cannot parse TEMAVG row in {path!s} at line {line_no}."
raise ValueError(msg) from exc
def _parse_float(value: str) -> float:
"""Parse numeric TEMAVG tokens, mapping quality marks to nan."""
if value.strip() in {"*", "-"}:
return float("nan")
return float(value)
def _scale_temavg_magnitude(
values: np.ndarray,
*,
current: float,
magnitude_unit: str,
) -> np.ndarray:
"""Scale TEMAVG magnitudes to the units expected downstream."""
unit = magnitude_unit.strip().lower()
if unit in {"uv/a", "microvolt/a", "microvolts/a"}:
return values * current * 1e-6
if unit in {"v/a", "volt/a", "volts/a"}:
return values * current
if unit in {"uv", "microvolt", "microvolts"}:
return values * 1e-6
if unit in {"v", "volt", "volts", "si"}:
return values.copy()
msg = "magnitude_unit must be one of 'uV/A', 'V/A', 'uV', 'V', or 'SI'."
raise ValueError(msg)
def _temavg_percent_errors(
data: np.ndarray,
rows: list[TEMAVGRecord],
) -> np.ndarray | None:
"""Build absolute errors from the TEMAVG percent column."""
percent = np.array(
[rec.percent_magnitude for rec in rows],
dtype=float,
)
if not np.isfinite(percent).any() or np.nanmax(np.abs(percent)) == 0.0:
return None
return np.abs(data) * percent / 100.0
def _temavg_data_for_type(
voltage: np.ndarray,
*,
current: float,
tx_area: float,
rx_area: float,
rx_turns: int,
data_type: str,
) -> np.ndarray:
"""Convert receiver voltage to a TEMSounding data convention."""
if data_type == "voltage":
return voltage
if data_type == "dBdt":
return voltage / (float(rx_turns) * float(rx_area))
if data_type == "dHdt":
mu0 = 4.0 * np.pi * 1e-7
return voltage / (float(rx_turns) * float(rx_area) * mu0)
if data_type == "normalized_voltage":
return voltage / (float(current) * float(tx_area))
msg = (
"data_type must be one of 'voltage', 'dBdt', "
"'dHdt', or 'normalized_voltage'."
)
raise ValueError(msg)