# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
from ..exceptions import EdIDataError
from ..log.logger import get_logger
from .base import EDIComponentBase
from .utils import (
gather_measurement_key_value_with_str_parser,
)
from .validation import (
IsEdi,
_split_comment,
_strip_norm,
_to_int_or_none,
)
logger = get_logger(__name__)
__all__ = ["MTEMAP", "EMAPComponents", "EMAPMixin"]
def _kv_tokens_from_lines(lines: list[str]) -> list[str]:
"""
Try project parser first, then robust regex fallback.
"""
tokens = gather_measurement_key_value_with_str_parser(lines)
if tokens:
return tokens
out: list[str] = []
pat = re.compile(
r"([A-Za-z][A-Za-z0-9_]*)\s*=\s*"
r"(" # group value (quoted or bare)
r'"[^"]*"|'
r"'[^']*'|"
r"[^,\s;]+" # bare value until comma/space/semicolon
r")"
)
for raw in lines:
before, _ = _split_comment(raw)
s = before.strip()
if not s or s.startswith(">"):
continue
for k, v in pat.findall(s):
vv = _strip_norm(v)
out.append(f"{k}={vv}")
return out
[docs]
class MTEMAP(EDIComponentBase):
r"""
Minimal header container for ``>=MTSECT`` and
``>=EMAPSECT`` blocks.
The class stores option key-values from the section
header. It keeps measurement IDs as strings to avoid
loss of formatting (e.g. ``"251.025"``). Count fields
are parsed as integers where possible.
Notes
-----
- If ``SECTID`` is missing or looks numeric, the value
from ``>HEAD/DATAID`` is used as a fallback.
- Presence of ``NDIPOLE`` or ``TYPE`` marks the block
as EMAP when writing.
- ``NFREQ`` in the header should match the number of
items in the following ``>FREQ`` block [1]_.
- The class does *not* validate that the referenced
measurement IDs exist. That is handled elsewhere.
Attributes
----------
sectid : str or None
Section identifier. May be replaced by ``DATAID``
when numeric or missing.
nfreq : int or None
Number of frequencies expected in the section.
maxblks : int or None
Optional maximum count of data blocks.
hx, hy, hz : str or None
Magnetic measurement IDs. May be absent.
ex, ey : str or None
Electric measurement IDs. May be absent.
rx, ry : str or None
Reference measurement IDs. May be absent.
ndipole : int or None
EMAP dipole count. Presence implies EMAP.
type : str or None
Optional EMAP type tag.
chksum : int or None
Optional checksum reported by some writers.
start_data_lines_num : int or None
Index in the source file where data blocks start.
temp_sectid : str or None
Internal cache of ``DATAID`` used for fallback.
See Also
--------
EMAPComponents
Small container exposing only component IDs.
EMAPMixin
Thin facade to build an ``MTEMAP`` from a file.
References
----------
.. [1] SEG EDI (MT/EMAP) standard. See the spectra
and MT section rules for frequency lists.
https://www.mtnet.info/docs/seg_mt_emap_1987.pdf
Examples
--------
Parse a header from an EDI file and write it back::
from pycsamt.seg.mtemap import MTEMAP
m = MTEMAP.from_file("site.edi")
lines = m.write()
print("".join(lines))
"""
KEY_ORDER: list[str] = [
"sectid",
"nfreq",
"maxblks",
"hx",
"hy",
"hz",
"ex",
"ey",
"rx",
"ry",
"ndipole",
"type",
"chksum",
]
sectid: str | None
nfreq: int | None
maxblks: int | None
hx: str | None
hy: str | None
hz: str | None
ex: str | None
ey: str | None
rx: str | None
ry: str | None
ndipole: int | None
type: str | None
chksum: int | None
start_data_lines_num: int | None = None
temp_sectid: str | None = None
def __init__(
self,
mt_or_emap_section_list: list[str] | None = None,
verbose: int | bool = 0,
logger=None,
**kwargs: Any,
):
super().__init__(verbose=verbose, logger=logger)
self.sectid = None
self.nfreq = None
self.maxblks = None
self.hx = None
self.hy = None
self.hz = None
self.ex = None
self.ey = None
self.rx = None
self.ry = None
self.ndipole = None
self.type = None
self.chksum = None
self._section_lines: list[str] | None = mt_or_emap_section_list
for k, v in kwargs.items():
setattr(self, k, v)
if self._section_lines is not None:
self.read(self._section_lines)
[docs]
@classmethod
def from_file(cls, edi_path: str) -> MTEMAP:
r"""
Build an instance from an EDI path. The file is first
validated, then the header lines between the section
tag and the next block are parsed.
Parameters
----------
edi_path : str
Path to a readable EDI file.
Returns
-------
MTEMAP
Parsed header object.
Raises
------
FileNotFoundError
If the path does not point to a file.
EdIDataError
If no ``>=MTSECT`` or ``>=EMAPSECT`` is found.
Notes
-----
The method also caches ``DATAID`` from ``>HEAD`` and
uses it to replace a numeric or missing ``SECTID``.
This helps with writers that output numeric section
labels.
Examples
--------
>>> m = MTEMAP.from_file("site.edi")
>>> m.sectid
'SITE_A'
"""
p = Path(edi_path)
IsEdi._assert_edi(p, deep=True)
# Read once; later slices will reuse these lines.
lines = p.read_text(
encoding="utf-8-sig", errors="replace"
).splitlines()
# Capture DATAID early for fallback SECTID.
dataid = None
for ln in lines:
u = ln.upper()
if re.match(r"^\s*DATAID\s*=", u):
try:
dataid = _strip_norm(ln.split("=", 1)[1])
except Exception:
pass
break
# Locate >=MTSECT or >=EMAPSECT
start_idx = None
for i, ln in enumerate(lines):
u = ln.upper().lstrip()
if u.startswith(">=MTSECT") or u.startswith(">=EMAPSECT"):
start_idx = i
break
if start_idx is None:
raise EdIDataError("No >=MTSECT or >=EMAPSECT found.")
# Find end of this header block: next data/meta block.
stop_idx = len(lines)
for j in range(start_idx + 1, len(lines)):
u = lines[j].upper().lstrip()
if (
u.startswith(">!")
or u.startswith(">FREQ")
or u.startswith(">ZROT")
or u.startswith(">RHOROT")
or u.startswith(">=")
):
stop_idx = j
break
section_lines = [ln for ln in lines[start_idx + 1 : stop_idx]]
tokens = _kv_tokens_from_lines(section_lines)
# inst = cls(mt_or_emap_section_list=tokens)
# inst.start_data_lines_num = stop_idx
# inst.temp_sectid = dataid
# Create instance *without* lines so __init__ doesn't
# auto-read. Then set fallback and call read() ourselves.
inst = cls(verbose=0, logger=None)
inst.start_data_lines_num = stop_idx
inst.temp_sectid = dataid
inst.read(tokens)
return inst
[docs]
def read(
self,
mt_or_emap_section_list: list[str] | None = None,
) -> MTEMAP:
r"""
Parse key-value tokens into attributes.
Parameters
----------
mt_or_emap_section_list : list of str, optional
Items like ``["NFREQ=60", "HX=251.025"]``. If
omitted, the list passed at construction is used.
Returns
-------
MTEMAP
The instance itself, for chaining.
Notes
-----
Unknown keys are ignored. Values are normalized by
stripping quotes and whitespace. Integer fields are
parsed with a best-effort cast.
"""
if mt_or_emap_section_list is not None:
self._section_lines = mt_or_emap_section_list
if not self._section_lines:
raise EdIDataError("No MT/EMAP section lines to read.")
for raw in self._section_lines:
if "=" not in raw:
continue
k, v = raw.split("=", 1)
key = _strip_norm(k).lower()
val = _strip_norm(v)
if key == "sectid":
self.sectid = val
elif key == "nfreq":
self.nfreq = _to_int_or_none(val)
elif key == "maxblks":
self.maxblks = _to_int_or_none(val)
elif key in (
"hx",
"hy",
"hz",
"ex",
"ey",
"rx",
"ry",
):
setattr(
self,
key,
val if val not in ("", "NONE") else None,
)
elif key == "ndipole":
self.ndipole = _to_int_or_none(val)
elif key == "type":
self.type = val
elif key == "chksum":
self.chksum = _to_int_or_none(val)
if (
(self.sectid is None) or self._looks_numeric(self.sectid)
) and self.temp_sectid:
self.sectid = self.temp_sectid
return self
[docs]
def write(self, nfreq: int | None = None) -> list[str]:
r"""
Serialize the header to text lines.
Parameters
----------
nfreq : int, optional
Override for ``NFREQ`` in the output.
Returns
-------
list of str
Lines including the opening section tag.
Notes
-----
If either ``NDIPOLE`` or ``TYPE`` is present the
opening tag is ``>=EMAPSECT``. Otherwise ``>=MTSECT``
is used. For EMAP, the fields ``EX``, ``EY`` and
``HZ`` are omitted by convention.
"""
is_emap = (self.ndipole is not None) or (self.type not in (None, ""))
header = ">=EMAPSECT\n" if is_emap else ">=MTSECT\n"
lines: list[str] = [header]
values: dict[str, Any] = {
"sectid": self.sectid,
"nfreq": nfreq if nfreq is not None else self.nfreq,
"maxblks": self.maxblks,
"hx": self.hx,
"hy": self.hy,
"hz": self.hz,
"ex": self.ex,
"ey": self.ey,
"rx": self.rx,
"ry": self.ry,
"ndipole": self.ndipole,
"type": self.type,
"chksum": self.chksum,
}
omit_for_emap = {"ex", "ey", "hz"} if is_emap else set()
for key in self.KEY_ORDER:
if key in omit_for_emap:
continue
val = values.get(key, None)
if val in (None, "", "None"):
continue
if key in (
"hx",
"hy",
"hz",
"ex",
"ey",
"rx",
"ry",
):
fmt = "{:>12}"
else:
fmt = "{:>7}"
lines.append(f" {key.upper()}={fmt.format(str(val).upper())}\n")
return lines
@staticmethod
def _looks_numeric(s: str) -> bool:
try:
float(s)
return True
except Exception:
return False
[docs]
class EMAPComponents(EDIComponentBase):
r"""
Lightweight view of EMAP/MT component IDs.
The container exposes only component identifiers and
does not include section counts or metadata. It is
useful when you need the mapping from channel type to
measurement ID without other header details.
Attributes
----------
hx, hy, hz : str or None
Magnetic component measurement IDs.
ex, ey : str or None
Electric component measurement IDs.
rx, ry : str or None
Reference component measurement IDs.
Notes
-----
The class does not normalize or validate that the IDs
exist in the ``>=DEFINEMEAS`` block. It simply carries
the strings.
See Also
--------
MTEMAP
Full section header with counts and flags.
Examples
--------
Create from an existing ``MTEMAP``::
m = MTEMAP.from_file("site.edi")
comp = EMAPComponents.from_mtemap(m)
comp.as_dict()
"""
def __init__(
self,
*args,
verbose: int | bool = 0,
logger=None,
**kws: Any,
):
super().__init__(verbose=verbose, logger=logger)
self.hx: str | None = kws.get("hx")
self.hy: str | None = kws.get("hy")
self.hz: str | None = kws.get("hz")
self.ex: str | None = kws.get("ex")
self.ey: str | None = kws.get("ey")
self.rx: str | None = kws.get("rx")
self.ry: str | None = kws.get("ry")
[docs]
@classmethod
def from_mtemap(cls, m: MTEMAP) -> EMAPComponents:
r"""
Construct a component view from an ``MTEMAP``.
Parameters
----------
m : MTEMAP
A parsed section header.
Returns
-------
EMAPComponents
Container populated with IDs from ``m``.
"""
return cls(
hx=m.hx,
hy=m.hy,
hz=m.hz,
ex=m.ex,
ey=m.ey,
rx=m.rx,
ry=m.ry,
)
[docs]
def as_dict(self) -> dict[str, str | None]:
r"""
Return a plain mapping of component names to IDs.
Returns
-------
dict
Keys are ``"hx"``, ``"hy"``, ``"hz"``, ``"ex"``,
``"ey"``, ``"rx"``, ``"ry"``. Values are strings
or ``None``.
"""
return {
"hx": self.hx,
"hy": self.hy,
"hz": self.hz,
"ex": self.ex,
"ey": self.ey,
"rx": self.rx,
"ry": self.ry,
}
[docs]
class EMAPMixin:
r"""
Thin facade that lets a host class load an ``MTEMAP``
with a consistent API.
Attach this mixin to any class that should expose a
``from_file`` constructor returning an ``MTEMAP``. The
mixin does not keep state; it only forwards the call.
See Also
--------
MTEMAP.from_file
The underlying constructor that performs parsing.
Examples
--------
Add the mixin and use it on a host type::
class Host(EMAPMixin):
pass
hdr = Host.from_file("site.edi")
assert hdr.nfreq is not None
"""
[docs]
@classmethod
def from_file(cls, edi_fn: str) -> MTEMAP:
return MTEMAP.from_file(edi_fn)