Source code for pycsamt.tdem.io

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

"""
TEM data readers.

Currently implemented
---------------------
:func:`read_xyz`          Generic white-space / CSV with columns
                          ``time  data  [error]``.
:func:`read_temavg`       Zonge TEMAVG processed ``.AVG`` files.
:func:`read_tem_z`        Zonge TEMAVG contour ``.Z`` files.
:func:`read_tem_log`      Zonge TEMAVG processing ``.LOG`` files.
:func:`read_tem_coordinates`  TEM profile/point coordinates.
:func:`read_temavg_soundings` TEMAVG files as TEMSounding objects.
:func:`read_geosoft_dat`  Geosoft line-data ``.dat`` / ``.DAT`` files
                          (Oasis Montaj ASCII export).
:func:`read_amira`        AMIRA / EMIT ``.tem`` keyword-block files.
:func:`read_zonge`        Zonge GDP ``.avg`` / ``.tem`` sounding files.
:func:`read_walkttem`     WalkTEM / Aarhus Workbench ``.tem`` files.
"""

from __future__ import annotations

import re as _re
from pathlib import Path
from typing import Union

import numpy as np

from ._base import TEMSounding
from .avg import TEMAVG
from .coordinates import read_tem_coordinates
from .log import TEMLog
from .survey import read_temavg_survey
from .workflow import (
    read_temavg_soundings,
    transform_temavg_survey,
)
from .zplot import TEMZPlot

__all__ = [
    "read_xyz",
    "read_temavg",
    "read_tem_z",
    "read_tem_log",
    "read_tem_coordinates",
    "read_temavg_survey",
    "read_temavg_soundings",
    "transform_temavg_survey",
    "read_geosoft_dat",
    "read_amira",
    "read_zonge",
    "read_walkttem",
]

Pathish = Union[str, Path]


[docs] def read_temavg( path: Pathish, *, verbose: int = 0, logger: object | None = None, ) -> TEMAVG: """Read a Zonge TEMAVG processed ``.AVG`` file. Parameters ---------- path : path-like Path to a processed TEMAVG ``.AVG`` file. Returns ------- TEMAVG Parsed processed TEMAVG data and metadata. """ return TEMAVG.read(path, verbose=verbose, logger=logger)
[docs] def read_tem_z( path: Pathish, *, verbose: int = 0, logger: object | None = None, ) -> TEMZPlot: """Read a Zonge TEMAVG contour ``.Z`` file. Parameters ---------- path : path-like Path to a TEMAVG contour ``.Z`` file. Returns ------- TEMZPlot Parsed contour rows and metadata. """ return TEMZPlot.read(path, verbose=verbose, logger=logger)
[docs] def read_tem_log( path: Pathish, *, verbose: int = 0, logger: object | None = None, ) -> TEMLog: """Read a Zonge TEMAVG processing ``.LOG`` file. Parameters ---------- path : path-like Path to a TEMAVG processing log. Returns ------- TEMLog Parsed processing provenance and acquisition summary. """ return TEMLog.read(path, verbose=verbose, logger=logger)
# --------------------------------------------------------------------- # Generic XYZ / CSV reader (fully implemented) # ---------------------------------------------------------------------
[docs] def read_xyz( path: Pathish, *, current: float, tx_area: float | None = None, loop_side: float | None = None, loop_radius: float | None = None, data_type: str = "dBdt", rx_area: float = 1.0, rx_turns: int = 1, tx_turns: int = 1, offset: float = 0.0, station_name: str = "", x: float = 0.0, y: float = 0.0, elevation: float = 0.0, time_col: int = 0, data_col: int = 1, error_col: int | None = None, skip_header: int = 0, delimiter: str | None = None, time_unit: str = "s", data_unit: str = "SI", ) -> TEMSounding: r""" Read a TEM sounding from a plain-text XYZ / CSV file. The file may contain any number of columns; only ``time_col`` and ``data_col`` (and optionally ``error_col``) are read. Comment lines starting with ``#`` or ``!`` are ignored. Parameters ---------- path : str or Path Path to the text file. current : float Transmitter current in Amperes. tx_area : float, optional Transmitter loop area in square metres. Supply this *or* ``loop_side`` *or* ``loop_radius``. loop_side : float, optional Side of a square transmitter loop in metres. loop_radius : float, optional Radius of a circular transmitter loop in metres. data_type : str, optional Units / type of the ``data_col`` column. One of ``"dBdt"``, ``"dHdt"``, ``"voltage"``, ``"normalized_voltage"``. Default ``"dBdt"``. rx_area : float, optional Receiver coil area in square metres. Default 1.0. rx_turns : int, optional Receiver coil turns. Default 1. tx_turns : int, optional Transmitter turns. Default 1. offset : float, optional Tx-Rx horizontal separation in m. Default 0 (coincident). station_name : str, optional Name to store in the output :class:`TEMSounding`. x, y, elevation : float, optional Station coordinates in metres. time_col : int, optional Zero-based column index for time values. Default 0. data_col : int, optional Zero-based column index for the decay data. Default 1. error_col : int, optional Zero-based column index for uncertainty. Default ``None`` (no error column). skip_header : int, optional Number of header lines to skip before data rows. Default 0. delimiter : str, optional Column separator. ``None`` means any whitespace (default). time_unit : str, optional Unit of the time column: ``"s"`` (default), ``"ms"``, ``"us"``. data_unit : str, optional Unit of the data column: ``"SI"`` (no conversion), ``"nT/s"`` (convert nT/s to T/s), ``"pT/s"``, ``"nV/Am2"``. Returns ------- TEMSounding Populated :class:`~pycsamt.tdem.TEMSounding` instance. Examples -------- A minimal two-column file (time in ms, dB/dt in nT/s): .. code-block:: text # TEM sounding, site A01 0.010 1.24e+04 0.018 8.91e+03 0.031 5.23e+03 >>> snd = read_xyz( ... "site_A01.xyz", ... current=8.0, ... loop_side=100.0, ... time_unit="ms", ... data_unit="nT/s", ... ) """ path = Path(path) if not path.exists(): raise FileNotFoundError(f"TEM data file not found: {path}") rows: list[list[str]] = [] with open(path) as fh: for ln in fh: stripped = ln.strip() if not stripped or stripped[0] in ("#", "!"): continue rows.append(stripped.split(delimiter)) if skip_header: rows = rows[skip_header:] if not rows: raise ValueError(f"No data rows found in {path}") def _col(rows, idx): try: return np.array([float(r[idx]) for r in rows]) except (IndexError, ValueError) as exc: raise ValueError( f"Cannot read column {idx} from {path}: {exc}" ) from exc t = _col(rows, time_col) d = _col(rows, data_col) err = _col(rows, error_col) if error_col is not None else None # unit conversions _time_scale = {"s": 1.0, "ms": 1e-3, "us": 1e-6, "\u00b5s": 1e-6} if time_unit not in _time_scale: msg = ( f"time_unit must be one of {list(_time_scale)}, got {time_unit!r}" ) raise ValueError(msg) t = t * _time_scale[time_unit] _data_scale = { "SI": 1.0, "nT/s": 1e-9, "pT/s": 1e-12, "nV/Am2": 1e-9, "uV/Am2": 1e-6, } if data_unit not in _data_scale: msg = ( f"data_unit must be one of {list(_data_scale)}, got {data_unit!r}" ) raise ValueError(msg) scale = _data_scale[data_unit] d = d * scale if err is not None: err = err * scale return TEMSounding.from_arrays( t, d, current=current, tx_area=tx_area, loop_side=loop_side, loop_radius=loop_radius, data_type=data_type, tx_turns=tx_turns, rx_area=rx_area, rx_turns=rx_turns, offset=offset, station_name=station_name or path.stem, x=x, y=y, elevation=elevation, error=err, )
def read_xyz_multisite( path: Pathish, *, site_col: int = 0, time_col: int = 1, data_col: int = 2, error_col: int | None = None, **kwargs, ) -> dict[str, TEMSounding]: r""" Read multiple TEM soundings from a single XYZ file where the first column is a site identifier. Returns ------- dict[str, TEMSounding] Mapping from site name to :class:`TEMSounding`. """ path = Path(path) if not path.exists(): raise FileNotFoundError(path) records: dict[str, list] = {} with open(path) as fh: for ln in fh: stripped = ln.strip() if not stripped or stripped[0] in ("#", "!"): continue parts = stripped.split() site = parts[site_col] if site not in records: records[site] = [] records[site].append(parts) out = {} for site, rows in records.items(): t = np.array([float(r[time_col]) for r in rows]) d = np.array([float(r[data_col]) for r in rows]) err = None if error_col is not None: err = np.array([float(r[error_col]) for r in rows]) snd = TEMSounding.from_arrays( t, d, station_name=site, error=err, **kwargs, ) out[site] = snd return out # --------------------------------------------------------------------- # Internal helpers shared by the four format readers # --------------------------------------------------------------------- def _to_float(s: str) -> float: """Parse a float tolerant of Fortran-style exponents (1.2D+04 → 1.2e+04).""" return float(s.upper().replace("D", "E")) def _gate_times_to_seconds(times: list[float], unit: str) -> np.ndarray: """Convert a list of gate-centre times to seconds.""" scale = {"s": 1.0, "ms": 1e-3, "us": 1e-6, "µs": 1e-6} if unit not in scale: raise ValueError( f"gate time unit must be one of {list(scale)}, got {unit!r}" ) return np.array(times, dtype=float) * scale[unit] def _data_to_si(data: np.ndarray, unit: str) -> np.ndarray: """Convert TEM decay data to SI units (T/s for dBdt, V/(Am²) for Hz).""" scale = { "T/s": 1.0, "nT/s": 1e-9, "pT/s": 1e-12, "V/Am2": 1.0, "V/(Am2)": 1.0, "nV/Am2": 1e-9, "nV/(Am2)": 1e-9, "uV/Am2": 1e-6, "µV/Am2": 1e-6, "uV/(Am2)": 1e-6, "mV/Am2": 1e-3, "mV/(Am2)": 1e-3, "SI": 1.0, } if unit not in scale: raise ValueError( f"data unit must be one of {list(scale)}, got {unit!r}" ) return data * scale[unit] # --------------------------------------------------------------------- # Geosoft DAT reader # ---------------------------------------------------------------------
[docs] def read_geosoft_dat( path: Pathish, *, current: float | None = None, tx_area: float | None = None, loop_side: float | None = None, loop_radius: float | None = None, rx_area: float = 1.0, rx_turns: int = 1, data_unit: str = "nV/Am2", data_type: str = "dBdt", gate_times: list[float] | None = None, gate_times_unit: str = "ms", x_col: str = "X", y_col: str = "Y", elev_col: str = "ELEV", station_col: str | None = None, gate_prefix: str = "G", line_col: str | None = None, ) -> list[TEMSounding]: r"""Read TEM data from a Geosoft ASCII line-data ``.dat`` file. Geosoft Oasis Montaj exports TEM soundings in a fixed-width or space-delimited ASCII file where: * Comment/metadata lines start with ``/`` or ``!``. * Line identifiers start with ``L`` (``L100``, ``L200``, …). * A single header row names every numeric channel. * Subsequent rows are one station (fiducial) per line. Metadata embedded in ``/`` comment lines is parsed automatically: .. code-block:: text / Current: 8.0 / LoopSide: 200.0 (or LoopArea / LoopRadius) / GateTimes(ms): 0.021 0.037 0.065 ... / DataUnit: nV/Am2 These override the keyword arguments of the same name. Parameters ---------- path : str or Path Path to the ``.dat`` file. current : float or None Transmitter current in Amperes. May also be given as a ``/ Current:`` header line in the file. tx_area : float or None Transmitter loop area in m². loop_side : float or None Side of a square loop in m (used to compute *tx_area*). loop_radius : float or None Radius of a circular loop in m (used to compute *tx_area*). rx_area : float Receiver coil area in m². Default 1.0. rx_turns : int Receiver coil turns. Default 1. data_unit : str Units of the gate-data columns before conversion to SI. Recognised: ``"nV/Am2"``, ``"uV/Am2"``, ``"mV/Am2"``, ``"nT/s"``, ``"T/s"``, ``"SI"``. Default ``"nV/Am2"``. data_type : str ``"dBdt"`` or ``"normalized_voltage"``. Default ``"dBdt"``. gate_times : list of float or None Explicit gate-centre times (in *gate_times_unit*). When ``None`` the reader looks for a ``/ GateTimes:`` header line. gate_times_unit : str Time unit for *gate_times*: ``"ms"`` (default), ``"s"``, ``"us"``. x_col, y_col, elev_col : str Column names for easting, northing, elevation. station_col : str or None Column containing station identifiers. ``None`` → auto-number. gate_prefix : str Prefix shared by all gate columns (e.g. ``"G"`` matches ``G1``, ``G2``, …, or ``"TEM_G"`` for ``TEM_G01``, …). Default ``"G"``. line_col : str or None Column name carrying the line number, if present. Returns ------- list of TEMSounding One :class:`~pycsamt.tdem.TEMSounding` per data row. Raises ------ FileNotFoundError ValueError If gate times cannot be determined from the file or arguments. Examples -------- .. code-block:: text / Geosoft TEM export / Current: 8.0 / LoopSide: 200.0 / GateTimes(ms): 0.021 0.037 0.065 0.090 / DataUnit: nV/Am2 X Y ELEV G1 G2 G3 G4 1000.0 2000.0 0.0 1.24e4 8.91e3 5.23e3 3.12e3 2000.0 2000.0 0.0 9.87e3 6.54e3 4.32e3 2.61e3 >>> soundings = read_geosoft_dat( ... "survey.dat", ... current=8.0, ... loop_side=200.0, ... ) """ path = Path(path) if not path.exists(): raise FileNotFoundError(f"Geosoft DAT file not found: {path}") lines = path.read_text(errors="replace").splitlines() # ── Parse metadata from comment lines ───────────────────────────────────── _meta_float = {} # keyword → float _gate_times_f = None _data_unit_f = None _KW = { "current": "current", "loopside": "loop_side", "loopradius": "loop_radius", "looparea": "tx_area", "txarea": "tx_area", } for ln in lines: stripped = ln.strip() if not (stripped.startswith("/") or stripped.startswith("!")): continue body = stripped.lstrip("/!").strip() # GateTimes(ms): 0.021 0.037 ... import re as _re m_gt = _re.match( r"GateTimes?\s*(?:\((\w+)\))?\s*[:=]\s*(.+)", body, _re.IGNORECASE, ) if m_gt: unit_tag = m_gt.group(1) or gate_times_unit gate_times_unit = unit_tag.lower() _gate_times_f = [ _to_float(v) for v in m_gt.group(2).split() if v.strip() ] continue # DataUnit: nV/Am2 m_du = _re.match(r"DataUnit\s*[:=]\s*(\S+)", body, _re.IGNORECASE) if m_du: _data_unit_f = m_du.group(1) continue # Current: 8.0 / LoopSide: 200 / etc. m_kv = _re.match(r"(\w+)\s*[:=]\s*([0-9.eEdD+\-]+)", body) if m_kv: key = m_kv.group(1).lower().replace(" ", "") if key in _KW: _meta_float[_KW[key]] = _to_float(m_kv.group(2)) # Merge file metadata with caller arguments (caller wins) if current is None: current = _meta_float.get("current") if tx_area is None: tx_area = _meta_float.get("tx_area") if loop_side is None: loop_side = _meta_float.get("loop_side") if loop_radius is None: loop_radius = _meta_float.get("loop_radius") if _data_unit_f is not None and data_unit == "nV/Am2": data_unit = _data_unit_f if _gate_times_f is not None and gate_times is None: gate_times = _gate_times_f if current is None: raise ValueError( "Transmitter current not found in file or arguments. " "Pass current=<value>." ) if tx_area is None and loop_side is None and loop_radius is None: raise ValueError( "Transmitter geometry not found. " "Pass one of tx_area, loop_side, or loop_radius." ) # ── Find header row and data rows ───────────────────────────────────────── header: list[str] = [] data_rows: list[list[str]] = [] current_line_id: str = "" for ln in lines: stripped = ln.strip() if not stripped: continue if stripped[0] in ("/", "!", "\\"): continue if stripped.upper().startswith("L") and not stripped[0].isdigit(): # Line label (L100, L200 …) current_line_id = stripped continue # First non-comment, non-L row that is not all-numeric → header parts = stripped.split() try: float(parts[0]) is_numeric = True except (ValueError, IndexError): is_numeric = False if not is_numeric and not header: header = [p.upper() for p in parts] continue if is_numeric or header: row = parts + ([""] if line_col and current_line_id else []) if line_col and current_line_id: row = [current_line_id.lstrip("Ll")] + parts data_rows.append(parts) if not header and data_rows: # No explicit header — generate G1, G2, … for all columns after 3 n_cols = len(data_rows[0]) header = [x_col.upper(), y_col.upper(), elev_col.upper()] + [ f"{gate_prefix.upper()}{i + 1}" for i in range(n_cols - 3) ] if not data_rows: raise ValueError(f"No numeric data rows found in {path}") # Map column names → indices h = {name: i for i, name in enumerate(header)} def _idx(name: str, fallback: int | None = None) -> int | None: return h.get(name.upper(), fallback) xi = _idx(x_col, 0) yi = _idx(y_col, 1) eli = _idx(elev_col, 2) sti = _idx(station_col) if station_col else None # Gate columns: all columns whose name starts with gate_prefix (case-insensitive) pfx = gate_prefix.upper() gate_cols = sorted( [i for name, i in h.items() if name.startswith(pfx)], key=lambda idx: header[idx], ) # Fallback: if no gate_prefix columns, everything after the first 3 cols if not gate_cols: start = max(xi or 0, yi or 1, eli or 2) + 1 gate_cols = list(range(start, len(header))) if gate_times is None: raise ValueError( "Gate times not found in the file. " "Pass gate_times=[...] (in gate_times_unit)." ) t_s = _gate_times_to_seconds(gate_times, gate_times_unit) if len(t_s) != len(gate_cols): # Trim to the shorter length n = min(len(t_s), len(gate_cols)) t_s = t_s[:n] gate_cols = gate_cols[:n] soundings: list[TEMSounding] = [] for row_i, row in enumerate(data_rows): try: x_val = ( _to_float(row[xi]) if xi is not None and xi < len(row) else 0.0 ) y_val = ( _to_float(row[yi]) if yi is not None and yi < len(row) else 0.0 ) el_val = ( _to_float(row[eli]) if eli is not None and eli < len(row) else 0.0 ) st_name = ( str(row[sti]) if sti is not None and sti < len(row) else f"S{row_i + 1:04d}" ) d_raw = np.array( [_to_float(row[c]) for c in gate_cols if c < len(row)], dtype=float, ) except (ValueError, IndexError) as exc: raise ValueError( f"Cannot parse data row {row_i + 1} in {path}: {row!r}" ) from exc d_si = _data_to_si(d_raw, data_unit) snd = TEMSounding.from_arrays( t_s[: len(d_si)], d_si, current=current, tx_area=tx_area, loop_side=loop_side, loop_radius=loop_radius, data_type=data_type, rx_area=rx_area, rx_turns=rx_turns, station_name=st_name, x=x_val, y=y_val, elevation=el_val, ) soundings.append(snd) return soundings
# --------------------------------------------------------------------- # AMIRA / EMIT .tem reader # ---------------------------------------------------------------------
[docs] def read_amira( path: Pathish, *, current: float | None = None, tx_area: float | None = None, loop_side: float | None = None, loop_radius: float | None = None, rx_area: float = 1.0, rx_turns: int = 1, data_unit: str = "nV/Am2", data_type: str = "dBdt", gate_times: list[float] | None = None, gate_times_unit: str = "ms", ) -> list[TEMSounding]: r"""Read TEM data from an AMIRA / EMIT ``.tem`` keyword-block file. The EMIT ``.tem`` format organises acquisition parameters in keyword-value blocks separated by blank lines. Comment lines begin with ``;`` or ``#``. A minimal complete file looks like: .. code-block:: text ; EMIT TEM data — site survey TRANSMITTER_AREA 40000 TRANSMITTER_CURRENT 8.0 TRANSMITTER_TURNS 1 RECEIVER_AREA 1.0 RECEIVER_TURNS 1 GATE_TIMES_UNIT ms GATE_TIMES 0.021 0.037 0.065 0.090 0.135 0.200 DATA_UNIT nV/Am2 DATA ; StnName X Y Elev D1 D2 ... S001 1000.0 2000.0 0.0 1.24e+04 8.91e+03 ... S002 2000.0 2000.0 0.0 9.87e+03 6.54e+03 ... END DATA Keyword recognition is case-insensitive. The ``DATA`` … ``END DATA`` block may alternatively start with ``[DATA]`` or ``DATASETS``. Parameters ---------- path : str or Path current : float or None Transmitter current [A]. Overrides ``TRANSMITTER_CURRENT`` in file. tx_area : float or None Transmitter loop area [m²]. Overrides ``TRANSMITTER_AREA``. loop_side : float or None Square loop side length [m]. loop_radius : float or None Circular loop radius [m]. rx_area : float Receiver coil area [m²]. Overrides ``RECEIVER_AREA``. Default 1.0. rx_turns : int Receiver coil turns. Overrides ``RECEIVER_TURNS``. Default 1. data_unit : str Units of gate-data values. Overrides ``DATA_UNIT`` in file. data_type : str ``"dBdt"`` or ``"normalized_voltage"``. Default ``"dBdt"``. gate_times : list of float or None Gate-centre times (in *gate_times_unit*). Overrides ``GATE_TIMES`` in file. gate_times_unit : str Unit for *gate_times*. Overrides ``GATE_TIMES_UNIT`` in file. Returns ------- list of TEMSounding Raises ------ FileNotFoundError ValueError """ path = Path(path) if not path.exists(): raise FileNotFoundError(f"AMIRA/EMIT .tem file not found: {path}") lines = path.read_text(errors="replace").splitlines() # ── Parse keyword-value header ───────────────────────────────────────────── _kv: dict[str, str] = {} _gate_raw: list[float] = [] data_lines: list[str] = [] in_data = False _DATA_START = _re.compile(r"^\s*(DATA|DATASETS|\[DATA\])", _re.IGNORECASE) _DATA_END = _re.compile( r"^\s*(END\s*DATA|END\s*DATASETS|\[END\])", _re.IGNORECASE ) for ln in lines: stripped = ln.strip() if not stripped or stripped[0] in (";", "#", "!"): continue if _DATA_END.match(stripped): in_data = False continue if _DATA_START.match(stripped): in_data = True continue if in_data: if not stripped.startswith(";") and not stripped.startswith("#"): data_lines.append(stripped) continue # keyword-value line parts = stripped.split(None, 1) if len(parts) == 2: _kv[parts[0].upper()] = parts[1].strip() elif len(parts) == 1: _kv[parts[0].upper()] = "" # Resolve acquisition parameters (caller wins over file) def _kv_float(key: str, fallback=None): v = _kv.get(key) return _to_float(v) if v else fallback def _kv_int(key: str, fallback: int) -> int: v = _kv.get(key) return int(_to_float(v)) if v else fallback if current is None: current = ( _kv_float("TRANSMITTER_CURRENT") or _kv_float("CURRENT") or _kv_float("TX_CURRENT") ) if tx_area is None: tx_area = ( _kv_float("TRANSMITTER_AREA") or _kv_float("TXAREA") or _kv_float("TX_AREA") ) if loop_side is None: loop_side = _kv_float("LOOP_SIDE") or _kv_float("LOOPSIDE") if loop_radius is None: loop_radius = _kv_float("LOOP_RADIUS") or _kv_float("LOOPRADIUS") if rx_area == 1.0: rx_area = _kv_float("RECEIVER_AREA") or _kv_float("RX_AREA") or 1.0 if rx_turns == 1: rx_turns = _kv_int("RECEIVER_TURNS", 1) or _kv_int("RX_TURNS", 1) # Gate times _file_gt_unit = ( _kv.get("GATE_TIMES_UNIT") or _kv.get("TIME_UNIT") or gate_times_unit ).lower() if gate_times is None: raw_gt = _kv.get("GATE_TIMES") or _kv.get("GATETIMES") or "" if raw_gt: gate_times = [_to_float(v) for v in raw_gt.split() if v.strip()] gate_times_unit = _file_gt_unit # Data unit _file_du = _kv.get("DATA_UNIT") or _kv.get("DATAUNIT") or "" if _file_du and data_unit == "nV/Am2": data_unit = _file_du.strip() if current is None: raise ValueError( "Transmitter current not found in AMIRA file or arguments." ) if tx_area is None and loop_side is None and loop_radius is None: raise ValueError( "Transmitter geometry not found. " "Pass tx_area, loop_side, or loop_radius." ) if gate_times is None: raise ValueError( "GATE_TIMES not found in AMIRA file. Pass gate_times=[...]." ) t_s = _gate_times_to_seconds(gate_times, gate_times_unit) # ── Parse data block ─────────────────────────────────────────────────────── soundings: list[TEMSounding] = [] for row_i, ln in enumerate(data_lines): parts = ln.split() if not parts: continue # First token: station name (may start with letters) try: _to_float(parts[0]) has_station_col = False except ValueError: has_station_col = True if has_station_col: st_name = parts[0] cols = parts[1:] else: st_name = f"S{row_i + 1:04d}" cols = parts if len(cols) < 1: continue x_val = _to_float(cols[0]) if len(cols) > 0 else 0.0 y_val = _to_float(cols[1]) if len(cols) > 1 else 0.0 el_val = _to_float(cols[2]) if len(cols) > 2 else 0.0 data_start = 3 n_gates = len(t_s) d_raw = np.array( [ _to_float(cols[data_start + k]) for k in range(n_gates) if data_start + k < len(cols) ], dtype=float, ) if d_raw.size == 0: continue n = min(len(t_s), len(d_raw)) d_si = _data_to_si(d_raw[:n], data_unit) snd = TEMSounding.from_arrays( t_s[:n], d_si, current=current, tx_area=tx_area, loop_side=loop_side, loop_radius=loop_radius, data_type=data_type, rx_area=rx_area, rx_turns=rx_turns, station_name=st_name, x=x_val, y=y_val, elevation=el_val, ) soundings.append(snd) return soundings
# --------------------------------------------------------------------- # Zonge GDP reader # ---------------------------------------------------------------------
[docs] def read_zonge( path: Pathish, *, current: float | None = None, tx_area: float | None = None, loop_side: float | None = None, loop_radius: float | None = None, rx_area: float = 1.0, rx_turns: int = 1, data_unit: str = "nV/Am2", data_type: str = "dBdt", gate_times: list[float] | None = None, gate_times_unit: str = "ms", ) -> list[TEMSounding]: r"""Read TEM sounding data from a Zonge GDP ``.avg`` / ``.tem`` file. The Zonge GDP receiver outputs an ASCII sounding file whose structure mirrors the TEMAVG processed format but at a per-sounding (not per-station-per-window) granularity. Comment / metadata lines begin with ``*`` or ``\``. A representative file: .. code-block:: text * Zonge GDP-32 TEM sounding * Station: S001 * X: 1000.0 Y: 2000.0 Elev: 0.0 * Current: 8.00 A * TxArea: 40000 m^2 * GateTimes(ms): 0.021 0.037 0.065 0.090 * DataUnit: nV/Am2 Win Time(ms) Hz(nV/Am2) Error 1 0.021 1.2400e+04 5.0e+02 2 0.037 8.9100e+03 4.0e+02 3 0.065 5.2300e+03 2.5e+02 4 0.090 3.1200e+03 1.8e+02 * Station: S002 ... Multiple soundings in one file are separated by a new ``* Station:`` line. Parameters ---------- path : str or Path current : float or None Transmitter current [A]. tx_area : float or None loop_side : float or None loop_radius : float or None rx_area : float rx_turns : int data_unit : str Data column units. Overrides ``* DataUnit:`` in file. data_type : str ``"dBdt"`` (default) or ``"normalized_voltage"``. gate_times : list of float or None Explicit gate times (override ``* GateTimes:`` in file). gate_times_unit : str Returns ------- list of TEMSounding Raises ------ FileNotFoundError ValueError """ path = Path(path) if not path.exists(): raise FileNotFoundError(f"Zonge GDP file not found: {path}") lines = path.read_text(errors="replace").splitlines() # ── Helpers ──────────────────────────────────────────────────────────────── def _parse_meta_line(body: str, state: dict) -> None: """Extract key-value pairs from a `* Key: value` comment line.""" m_gt = _re.match( r"GateTimes?\s*(?:\((\w+)\))?\s*[:=]\s*(.+)", body, _re.IGNORECASE, ) if m_gt: unit_tag = ( m_gt.group(1) or state.get("gt_unit", gate_times_unit) ).lower() state["gt_unit"] = unit_tag state["gt_raw"] = [ _to_float(v) for v in m_gt.group(2).split() if v.strip() ] return for kw, key in ( ("current", "current"), ("amp", "current"), ("txarea", "tx_area"), ("looparea", "tx_area"), ("loopside", "loop_side"), ("loopradius", "loop_radius"), ("rxarea", "rx_area"), ("rxturns", "rx_turns"), ("dataunit", "data_unit"), ): m = _re.match( rf"{kw}\s*[:=\s]\s*([^\s,;]+)", body, _re.IGNORECASE ) if m: state[key] = m.group(1).strip() return # Station metadata m_st = _re.match(r"Station\s*[:=]?\s*(\S+)", body, _re.IGNORECASE) if m_st: state["station_name"] = m_st.group(1) return for coord, key in (("X", "x"), ("Y", "y"), ("Elev", "elevation")): m_c = _re.match( rf"{coord}\s*[:=]\s*([0-9.eEdD+\-]+)", body, _re.IGNORECASE ) if m_c: state[key] = float(_to_float(m_c.group(1))) # ── First pass: collect global defaults ──────────────────────────────────── global_state: dict = {} for ln in lines: stripped = ln.strip() if stripped and stripped[0] in ("*", "\\"): _parse_meta_line(stripped.lstrip("*\\").strip(), global_state) # ── Build per-sounding blocks ────────────────────────────────────────────── # A new sounding starts when a `* Station:` line is encountered. # Each block consists of its own metadata + data rows. blocks: list[tuple[dict, list[list[str]]]] = [] cur_state: dict = dict(global_state) cur_rows: list[list[str]] = [] for ln in lines: stripped = ln.strip() if not stripped: continue if stripped[0] in ("*", "\\"): body = stripped.lstrip("*\\").strip() # New station → flush previous block if _re.match(r"Station\s*[:=]?\s*\S+", body, _re.IGNORECASE): if cur_rows: blocks.append((cur_state, cur_rows)) cur_state = dict(global_state) cur_rows = [] _parse_meta_line(body, cur_state) continue # Header row (non-numeric first token → skip) parts = stripped.split() if not parts: continue try: _to_float(parts[0]) except ValueError: continue # skip header or label lines cur_rows.append(parts) if cur_rows: blocks.append((cur_state, cur_rows)) # ── Build caller-wins parameter resolver ─────────────────────────────────── def _resolve(state: dict, key: str, caller_val, convert=float): if caller_val is not None: return caller_val v = state.get(key) return convert(v) if v is not None else None soundings: list[TEMSounding] = [] for blk_i, (state, rows) in enumerate(blocks): blk_current = _resolve(state, "current", current) blk_tx_area = _resolve(state, "tx_area", tx_area) blk_loop_side = _resolve(state, "loop_side", loop_side) blk_loop_radius = _resolve(state, "loop_radius", loop_radius) blk_rx_area = _resolve(state, "rx_area", rx_area) blk_rx_turns = _resolve(state, "rx_turns", rx_turns, int) blk_du = state.get("data_unit", data_unit) blk_st = state.get("station_name", f"S{blk_i + 1:04d}") blk_x = state.get("x", 0.0) blk_y = state.get("y", 0.0) blk_el = state.get("elevation", 0.0) if blk_current is None: raise ValueError( f"Transmitter current not found for block {blk_i + 1}." ) if ( blk_tx_area is None and blk_loop_side is None and blk_loop_radius is None ): raise ValueError( f"Transmitter geometry not found for block {blk_i + 1}." ) # Determine time / data columns dynamically from rows # Expected columns: Win Time(ms) Data [Error] # or: Time(ms) Data [Error] t_list: list[float] = [] d_list: list[float] = [] e_list: list[float] = [] gt_raw = state.get("gt_raw") or (gate_times or []) gt_unit = state.get("gt_unit", gate_times_unit).lower() for row in rows: if not row: continue # Detect column layout by number of columns n = len(row) if gt_raw: # Gate times from header → rows are data only (may have Win prefix) try: gate_i = ( int(_to_float(row[0])) - 1 ) # window index 1-based if 0 <= gate_i < len(gt_raw): d_val = _to_float(row[-2] if n >= 3 else row[-1]) e_val = _to_float(row[-1]) if n >= 3 else None t_list.append(gt_raw[gate_i]) d_list.append(d_val) if e_val is not None: e_list.append(e_val) continue except (ValueError, IndexError): pass # No header gate times → columns: [Win] Time Data [Error] if n >= 3: try: int(_to_float(row[0])) # first col is window number t_list.append(_to_float(row[1])) d_list.append(_to_float(row[2])) if n >= 4: e_list.append(_to_float(row[3])) continue except ValueError: pass if n >= 2: t_list.append(_to_float(row[0])) d_list.append(_to_float(row[1])) if n >= 3: e_list.append(_to_float(row[2])) if not t_list: continue t_arr = _gate_times_to_seconds( t_list, gt_unit if gt_raw else gate_times_unit ) d_arr = _data_to_si(np.array(d_list, dtype=float), blk_du) err = np.array(e_list, dtype=float) if e_list else None if err is not None: err = _data_to_si(err, blk_du) snd = TEMSounding.from_arrays( t_arr, d_arr, current=blk_current, tx_area=blk_tx_area, loop_side=blk_loop_side, loop_radius=blk_loop_radius, data_type=data_type, rx_area=blk_rx_area or rx_area, rx_turns=blk_rx_turns or rx_turns, station_name=str(blk_st), x=float(blk_x), y=float(blk_y), elevation=float(blk_el), error=err, ) soundings.append(snd) return soundings
# --------------------------------------------------------------------- # WalkTEM / Aarhus Workbench reader # ---------------------------------------------------------------------
[docs] def read_walkttem( path: Pathish, *, current: float | None = None, tx_area: float | None = None, loop_side: float | None = None, loop_radius: float | None = None, rx_area: float = 1.0, rx_turns: int = 1, data_unit: str = "nV/Am2", data_type: str = "dBdt", gate_times: list[float] | None = None, gate_times_unit: str = "ms", low_moment: bool = True, ) -> list[TEMSounding]: r"""Read WalkTEM / Aarhus Workbench ``.tem`` keyword-block files. The Aarhus Workbench TEM format uses ``/``-prefixed comment lines and section keywords in ALL-CAPS followed by their contents. Sections end with ``END_<KEYWORD>``. A representative file: .. code-block:: text / WalkTEM data — Aarhus Workbench export / GENERALHEADER WalkTEM System END_GENERALHEADER TXOPERATION LoopSize 40.000 Current 6.60 TurnsNumber 1 Waveform Full_Waveform END_TXOPERATION RXOPERATION CoilMoment 0.01 END_RXOPERATION GATESET NumberOfGates 20 GateTimes 0.008 0.015 0.025 0.043 0.072 0.120 0.200 GateSigns 1 1 1 1 1 1 1 END_GATESET DATA ! Line Fid X Y Elev d1 d2 ... 100 1000 1000.0 2000.0 0.0 1.5e-7 9.2e-8 ... 100 2000 1200.0 2000.0 0.0 1.3e-7 8.1e-8 ... END_DATA Section names and keyword matching are case-insensitive. Parameters ---------- path : str or Path current : float or None Transmitter current [A]. Overrides file value. tx_area : float or None loop_side : float or None loop_radius : float or None rx_area : float rx_turns : int data_unit : str Units for data gate columns (default ``"nV/Am2"``). WalkTEM data may also be in ``"T/s"`` or ``"SI"``. data_type : str ``"dBdt"`` (default) or ``"normalized_voltage"``. gate_times : list of float or None Explicit gate times (overrides ``GATESET`` block). gate_times_unit : str low_moment : bool WalkTEM instruments record two moments (LM and HM) per sounding. When ``True`` (default) only the low-moment (first) channel is read; set ``False`` to read the high-moment channel. Returns ------- list of TEMSounding Raises ------ FileNotFoundError ValueError """ path = Path(path) if not path.exists(): raise FileNotFoundError(f"WalkTEM file not found: {path}") lines = path.read_text(errors="replace").splitlines() # ── Section-block parser ─────────────────────────────────────────────────── _sections: dict[str, list[str]] = {} current_section: str | None = None section_lines: list[str] = [] data_lines: list[str] = [] in_data = False _SECT_START = _re.compile( r"^(TXOPERATION|RXOPERATION|GATESET|GENERALHEADER|DATASTATUS)", _re.IGNORECASE, ) _SECT_END = _re.compile(r"^END_(\w+)", _re.IGNORECASE) _DATA_START = _re.compile(r"^DATA\s*$", _re.IGNORECASE) _DATA_END = _re.compile(r"^END_DATA", _re.IGNORECASE) for ln in lines: stripped = ln.strip() if not stripped or stripped[0] in ("/", "!", ";", "#"): continue if _DATA_END.match(stripped): in_data = False continue if _DATA_START.match(stripped): in_data = True continue if in_data: data_lines.append(stripped) continue m_end = _SECT_END.match(stripped) if m_end: if current_section: _sections[current_section.upper()] = section_lines current_section = None section_lines = [] continue m_start = _SECT_START.match(stripped) if m_start: current_section = m_start.group(1) section_lines = [] continue if current_section: section_lines.append(stripped) # ── Parse TX section ─────────────────────────────────────────────────────── def _sect_float(section: str, *keys) -> float | None: for ln in _sections.get(section.upper(), []): parts = ln.split() if len(parts) >= 2 and parts[0].lower() in [ k.lower() for k in keys ]: try: return _to_float(parts[1]) except ValueError: pass return None _tx = _sections.get("TXOPERATION", []) for ln in _tx: parts = ln.split() if not parts: continue kl = parts[0].lower() val = parts[1] if len(parts) >= 2 else None if val is None: continue if ( kl in ("loopsize", "loopside") and loop_side is None and tx_area is None ): loop_side = _to_float(val) elif ( kl in ("loopradius",) and loop_radius is None and tx_area is None ): loop_radius = _to_float(val) elif kl in ("looparea", "txarea") and tx_area is None: tx_area = _to_float(val) elif kl == "current" and current is None: current = _to_float(val) elif kl == "turnsnumber" and rx_turns == 1: rx_turns = int(_to_float(val)) _rx = _sections.get("RXOPERATION", []) for ln in _rx: parts = ln.split() if len(parts) >= 2 and parts[0].lower() in ( "coilmoment", "rxarea", "coilarea", ): if rx_area == 1.0: rx_area = _to_float(parts[1]) # ── Parse gate times ─────────────────────────────────────────────────────── _gs = _sections.get("GATESET", []) _file_gt: list[float] = [] _file_gt_unit = gate_times_unit for ln in _gs: parts = ln.split() if not parts: continue kl = parts[0].lower() if kl in ("gatetimes", "times", "gatetime"): _file_gt = [_to_float(v) for v in parts[1:] if v.strip()] elif kl in ("gatetimesunit", "timeunit"): _file_gt_unit = parts[1].lower() if len(parts) > 1 else "ms" if gate_times is None and _file_gt: gate_times = _file_gt gate_times_unit = _file_gt_unit if current is None: raise ValueError( "Transmitter current not found in WalkTEM file or arguments." ) if tx_area is None and loop_side is None and loop_radius is None: raise ValueError( "Transmitter geometry not found. " "Pass tx_area, loop_side, or loop_radius." ) if gate_times is None: raise ValueError( "Gate times not found in WalkTEM GATESET block or arguments." ) t_s = _gate_times_to_seconds(gate_times, gate_times_unit) n_gates = len(t_s) # ── Parse DATA block ─────────────────────────────────────────────────────── # Expected columns (from the ! comment header): # Line Fid X Y Elev d1 d2 ... [e1 e2 ...] # The first non-numeric column line is a header; skip it. soundings: list[TEMSounding] = [] for _row_i, ln in enumerate(data_lines): parts = ln.split() if not parts: continue # Skip header lines (non-numeric first token) try: _to_float(parts[0]) except ValueError: continue # Layout: Line Fid X Y Elev d1 … dn [e1 … en] if len(parts) < 5: continue try: line_id = parts[0] fid_id = parts[1] x_val = _to_float(parts[2]) y_val = _to_float(parts[3]) el_val = _to_float(parts[4]) except (ValueError, IndexError): continue rest = parts[5:] n_rest = len(rest) # Detect whether error columns follow data columns # Heuristic: if n_rest == 2*n_gates, first half = data, second = errors if n_rest >= 2 * n_gates: d_cols = rest[:n_gates] e_cols = rest[n_gates : 2 * n_gates] else: d_cols = rest[: min(n_gates, n_rest)] e_cols = [] if not d_cols: continue try: d_raw = np.array([_to_float(v) for v in d_cols], dtype=float) e_raw = ( np.array([_to_float(v) for v in e_cols], dtype=float) if e_cols else None ) except ValueError: continue n = min(len(t_s), len(d_raw)) d_si = _data_to_si(d_raw[:n], data_unit) e_si = ( _data_to_si(e_raw[:n], data_unit) if e_raw is not None else None ) st_name = f"L{line_id}_F{fid_id}" snd = TEMSounding.from_arrays( t_s[:n], d_si, current=current, tx_area=tx_area, loop_side=loop_side, loop_radius=loop_radius, data_type=data_type, rx_area=rx_area, rx_turns=rx_turns, station_name=st_name, x=x_val, y=y_val, elevation=el_val, error=e_si, ) soundings.append(snd) return soundings