Source code for pycsamt.seg.meas

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

from __future__ import annotations

import re
from collections.abc import Sequence
from datetime import datetime
from pathlib import Path
from typing import (
    Any,
)

from ..exceptions import (
    EdIDataError,
    FileHandlingError,
    HeaderError,
)
from ..gis.utils import decimal_to_dms, dms_to_decimal
from .base import EDIComponentBase
from .validation import (
    IsEdi,
    _extract_tag,
    _is_tag,
    _norm_str,
    _to_float_or_none,
)

__all__ = [
    "Hmeasurement",
    "Emeasurement",
    "DefineMeas",
    "MeasMixin",
    "EMeasMixin",
    "DefineMeasMixin",
    "HMeasMixin",
]


# TOKEN =  key=value   (value may contain +/-, dot, colon or text)
_TOKEN_RE = re.compile(r"(?P<k>[A-Za-z][A-Za-z0-9_]*)\s*=\s*(?P<v>[^\s]+)")


def _kv_tokens_from_line(line: str) -> dict[str, str]:
    out: dict[str, str] = {}
    for m in _TOKEN_RE.finditer(line):
        k = m.group("k").strip().lower()
        v = m.group("v").strip().strip('"').strip("'")
        out[k] = v
    return out


def _slice_section(
    lines: Sequence[str],
    start_tag: str,
    after_tags: Sequence[str] | None = None,
) -> tuple[list[str], int, int]:
    """
    Return payload between `start_tag` and the next section begin
    (a line that starts with '>=' and is not `start_tag`).
    """
    after_tags = list(after_tags or [])
    start = None
    for i, ln in enumerate(lines):
        if _is_tag(ln, start_tag):
            start = i
            break
    if start is None:
        raise EdIDataError(f"Missing section: {start_tag!r}")

    j = start + 1
    stop = None
    while j < len(lines):
        t = _extract_tag(lines[j])
        if t and t.startswith(">=") and not t.startswith(start_tag):
            stop = j
            break
        # allow measurement lines (>HMEAS / >EMEAS) and comments (>!)
        j += 1
    if stop is None:
        stop = len(lines)

    # if after_tags are provided, prefer them as hard stops
    if after_tags:
        for k in range(start + 1, len(lines)):
            for a in after_tags:
                if _is_tag(lines[k], a):
                    stop = min(stop, k)
                    break

    payload = [ln.rstrip("\n") for ln in lines[start + 1 : stop]]
    return payload, start, stop


[docs] class Hmeasurement(EDIComponentBase): # Magnetic field measurement (HMEAS) hmeasurementkey = [ "id", "chtype", "x", "y", "z", "azm", "dip", "acqchan", "filter", "sensor", "gain", "measdate", ] _ALLOWED = {"HX", "HY", "HZ", "RHX", "RHY"} def __init__(self, **kws: Any): self.id: str | None = None self.chtype: str | None = None self.x: float | None = 0.0 self.y: float | None = 0.0 self.z: float | None = 0.0 self.azm: float | None = 0.0 self.dip: float | None = None self.acqchan: str | None = None self.filter: str | None = None self.sensor: str | None = None self.gain: float | None = None self.measdate: str | None = datetime.utcnow().strftime( "%Y-%m-%d %H:%M:%S" ) if kws: self.update_from_dict(kws)
[docs] def update_from_dict(self, d: dict[str, Any]) -> Hmeasurement: for k, v in d.items(): kk = str(k).lower() if kk == "id": self.id = _norm_str(v) elif kk == "chtype": vv = _norm_str(v) self.chtype = vv.upper() if vv else None elif kk in ("x", "y", "z", "azm", "dip", "gain"): setattr(self, kk, _to_float_or_none(v)) elif kk in ("acqchan", "filter", "sensor", "measdate"): setattr(self, kk, _norm_str(v)) if self.chtype and self.chtype.upper() not in self._ALLOWED: self.chtype = self.chtype.upper() return self
[docs] @classmethod def from_line(cls, line: str) -> Hmeasurement: kv = _kv_tokens_from_line(line) return cls(**kv)
[docs] def to_dict(self) -> dict[str, Any]: return {k: getattr(self, k) for k in self.hmeasurementkey}
[docs] def to_line(self) -> str: parts: list[str] = [] for k in self.hmeasurementkey: v = getattr(self, k, None) if v in (None, "", "None"): continue parts.append(f"{k.upper()}={v}") return ">HMEAS " + " ".join(parts)
[docs] class Emeasurement(EDIComponentBase): # Electric field measurement (EMEAS) emeasurementkey = [ "id", "chtype", "x", "y", "z", "x2", "y2", "z2", "acqchan", "filter", "sensor", "gain", "measdate", ] _ALLOWED = {"EX", "EY"} def __init__(self, **kws: Any): self.id: str | None = None self.chtype: str | None = None self.x: float | None = 0.0 self.y: float | None = 0.0 self.z: float | None = 0.0 self.x2: float | None = 0.0 self.y2: float | None = 0.0 self.z2: float | None = 0.0 self.acqchan: str | None = None self.filter: str | None = None self.sensor: str | None = None self.gain: float | None = None self.measdate: str | None = datetime.utcnow().strftime( "%Y-%m-%d %H:%M:%S" ) if kws: self.update_from_dict(kws)
[docs] def update_from_dict(self, d: dict[str, Any]) -> Emeasurement: for k, v in d.items(): kk = str(k).lower() if kk == "id": self.id = _norm_str(v) elif kk == "chtype": vv = _norm_str(v) self.chtype = vv.upper() if vv else None elif kk in ("x", "y", "z", "x2", "y2", "z2", "gain"): setattr(self, kk, _to_float_or_none(v)) elif kk in ("acqchan", "filter", "sensor", "measdate"): setattr(self, kk, _norm_str(v)) if self.chtype and self.chtype.upper() not in self._ALLOWED: self.chtype = self.chtype.upper() return self
[docs] @classmethod def from_line(cls, line: str) -> Emeasurement: kv = _kv_tokens_from_line(line) return cls(**kv)
[docs] def to_dict(self) -> dict[str, Any]: return {k: getattr(self, k) for k in self.emeasurementkey}
[docs] def to_line(self) -> str: parts: list[str] = [] for k in self.emeasurementkey: v = getattr(self, k, None) if v in (None, "", "None"): continue parts.append(f"{k.upper()}={v}") return ">EMEAS " + " ".join(parts)
[docs] class DefineMeas(EDIComponentBase): """ Container for >=DEFINEMEAS payload including reference origin and lists of HMEAS / EMEAS. """ def __init__(self, **kws: Any): self.maxchan: int | None = None self.maxrun: int | None = None self.maxmeas: int | None = None self.units: str | None = None self.reftype: str | None = None self.reflat: float | None = None self.reflong: float | None = None self.refelev: float | None = None self.hmeas: list[Hmeasurement] = [] self.emeas: list[Emeasurement] = [] if kws: for k, v in kws.items(): setattr(self, str(k).lower(), v)
[docs] @classmethod def from_file(cls, edi_fn: str | Path) -> DefineMeas: if edi_fn is None: raise FileHandlingError("No EDI path provided.") p = Path(edi_fn) IsEdi._assert_edi(p, deep=True) lines = p.read_text( encoding="utf-8-sig", errors="replace" ).splitlines() payload, _, _ = _slice_section( lines, ">=DEFINEMEAS", after_tags=[">=MTSECT", ">=SPECTRASECT"] ) obj = cls() return obj.read(payload)
[docs] def read(self, lines: Sequence[str]) -> DefineMeas: if not lines: raise HeaderError("Empty DEFINEMEAS payload.") for raw in lines: s = raw.strip() if not s: continue if _is_tag(s, ">HMEAS"): self.hmeas.append(Hmeasurement.from_line(s)) continue if _is_tag(s, ">EMEAS"): self.emeas.append(Emeasurement.from_line(s)) continue # plain KV lines (MAX*, UNITS, REF*, etc.) m = _TOKEN_RE.match(s) if not m: # allow comments like >!**** continue kv = _kv_tokens_from_line(s) for k, v in kv.items(): kk = k.lower() if kk in {"maxchan", "maxrun", "maxmeas"}: try: setattr(self, kk, int(float(v))) except Exception: setattr(self, kk, None) elif kk in {"units", "reftype"}: setattr(self, kk, v.upper()) elif kk == "reflat": self.reflat = dms_to_decimal(v) elif kk == "reflong": self.reflong = dms_to_decimal(v) elif kk == "refelev": try: self.refelev = float(v) except Exception: self.refelev = None # ignore unknown keys silently return self
[docs] def write(self) -> list[str]: out: list[str] = [">=DEFINEMEAS\n"] if self.maxchan is not None: out.append(f" MAXCHAN={self.maxchan}\n") if self.maxrun is not None: out.append(f" MAXRUN={self.maxrun}\n") if self.maxmeas is not None: out.append(f" MAXMEAS={self.maxmeas}\n") if self.units: out.append(f" UNITS={self.units}\n") if self.reftype: out.append(f" REFTYPE={self.reftype}\n") # optional reference origin (write DMS if numeric) if self.reflat is not None: out.append(f" REFLAT={decimal_to_dms(float(self.reflat))}\n") if self.reflong is not None: out.append(f" REFLONG={decimal_to_dms(float(self.reflong))}\n") if self.refelev is not None: out.append(f" REFELEV={self.refelev}\n") # measurement lines for hm in self.hmeas: out.append(" " + hm.to_line() + "\n") for em in self.emeas: out.append(" " + em.to_line() + "\n") out.append("\n") return out
# convenience
[docs] def all_meas(self) -> list[Hmeasurement | Emeasurement]: return list(self.hmeas) + list(self.emeas)
[docs] class MeasMixin: """ Facade: provide one-call access to parsed DefineMeas. """
[docs] @classmethod def from_file(cls, edi_fn: str | Path) -> DefineMeas: return DefineMeas.from_file(edi_fn)
[docs] class DefineMeasMixin: """ Facade for >=DEFINEMEAS container only. """
[docs] @classmethod def from_file(cls, edi_fn: str | Path) -> DefineMeas: return DefineMeas.from_file(edi_fn)
[docs] class EMeasMixin: """ Facade that returns only EMEAS list from file. """
[docs] @classmethod def from_file(cls, edi_fn: str | Path) -> list[Emeasurement]: dm = DefineMeas.from_file(edi_fn) return dm.emeas
[docs] class HMeasMixin: """ Facade that returns only HMEAS list from file. """
[docs] @classmethod def from_file(cls, edi_fn: str | Path) -> list[Hmeasurement]: dm = DefineMeas.from_file(edi_fn) return dm.hmeas