# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
from __future__ import annotations
import csv
from collections.abc import Iterable, Iterator, Sequence
from dataclasses import dataclass, field
from os import PathLike
from os.path import commonpath
from pathlib import Path
from typing import (
Any,
Callable,
)
import numpy as np
from ..api.view import progress_enabled
from ..seg.edi import EDIFile
from .base import Sites, to_edis
from .edit import (
_get_attr_any,
_rotm,
_set_attr_first,
fill_missing,
recompute_res_phase,
rename,
select_freq,
)
from .edit import (
rotate as _rotate_site,
)
from .utils import maybe_copy, station_name
__all__ = [
"EDIRecomputeRecord",
"EDIRecomputeResult",
"EDIRecomputer",
"recompute_edi",
"recompute_edis",
]
[docs]
@dataclass
class EDIRecomputeRecord:
r"""
Per-station outcome for an EDI recomputation workflow.
Attributes
----------
source : pathlib.Path or None
Source EDI path when known.
output : pathlib.Path or None
Written EDI path when ``write=True`` and the station
was written successfully.
line : str or None
Line/group name inferred from the source directory.
station : str
Station name after recomputation and optional renaming.
status : str
``"ok"`` for success or ``"failed"`` when processing
continued after an error.
message : str, default ""
Optional diagnostic message.
"""
source: Path | None
output: Path | None
line: str | None
station: str
status: str
message: str = ""
[docs]
@dataclass
class EDIRecomputeResult:
r"""
Result returned by :class:`EDIRecomputer`.
Attributes
----------
sites : pycsamt.site.base.Sites
Recomputed EDI objects wrapped as sites.
records : list of EDIRecomputeRecord
Per-station processing and writing outcomes.
output_root : pathlib.Path or None
Root directory used for exported EDI files.
"""
sites: Sites
records: list[EDIRecomputeRecord]
output_root: Path | None = None
items: list[EDIFile] = field(default_factory=list, repr=False)
[docs]
@property
def edis(self) -> list[EDIFile]:
"""Return recomputed raw EDI objects."""
return list(self.items) if self.items else self.sites.to_edis()
[docs]
@property
def paths(self) -> list[Path]:
"""Return successfully written output paths."""
return [
rec.output
for rec in self.records
if rec.output is not None and rec.status == "ok"
]
[docs]
@property
def failed(self) -> list[EDIRecomputeRecord]:
"""Return failed records."""
return [rec for rec in self.records if rec.status != "ok"]
[docs]
def to_manifest(self, path: str | Path) -> Path:
"""Write the records to a CSV manifest."""
return _write_manifest(self.records, Path(path))
@dataclass
class _LineGroup:
name: str | None
sources: list[Path] | list[EDIFile]
source_root: Path | None = None
[docs]
@dataclass
class EDIRecomputer:
r"""
Recompute and rewrite EDI files using pyCSAMT conventions.
This class is a workflow layer over the lower-level site
helpers. It accepts EDI objects, EDI collections, files,
line folders, or survey folders, applies a sequence of
optional normalizations, and can write pyCSAMT-generated
EDI files to a new output tree.
Parameters
----------
output_root : str or pathlib.Path, optional
Root directory for recomputed EDI files. If omitted,
a ``recomputed_edis`` directory is created next to a
selected line folder, or inside a selected survey folder.
output_name : str, default "recomputed_edis"
Directory name used when ``output_root`` is not provided.
preserve_line_dirs : bool, default True
If ``True``, write each inferred line into its own
subdirectory under ``output_root``. If ``False``, write
all recomputed EDI files directly under ``output_root``.
template : str, default "{station}.edi"
Filename template. Available keys are ``station``,
``index``, ``line``, and ``source_stem``.
overwrite : bool, default False
Allow replacing existing output files.
write : bool, default True
If ``False``, only return recomputed in-memory objects.
manifest_csv : bool or str or pathlib.Path, default True
Write a CSV manifest. ``True`` writes
``<output_root>/manifest.csv``. A path writes there.
rotate_angle : float, optional
Rotation angle in degrees. If omitted, no rotation is
applied.
rotate_components : iterable of str, default ("Z", "Tip")
Components to rotate. Accepts values such as ``"Z"``,
``"impedance"``, ``"Tip"``, and ``"tipper"``.
fmin, fmax : float, optional
Frequency range to keep before recomputation.
keep_freq : iterable of int, optional
Explicit frequency indices or mask passed to
:func:`pycsamt.site.edit.select_freq`.
fill_missing_values : {"zero", "nan"}, optional
Missing-value policy passed to
:func:`pycsamt.site.edit.fill_missing`.
recompute_resphase : bool, default True
Recompute apparent resistivity and phase from impedance.
rename_policy : callable, optional
Function mapping the current station name to a new name.
datatype : str, optional
EDI writer datatype override, for example ``"mt"`` or
``"emap"``.
synthesize_spectra : bool, default False
Ask the pyCSAMT EDI writer to synthesize spectra when
possible and missing.
recursive : bool, default True
Recurse while discovering EDI files inside line folders.
strict : bool, default False
Raise on read/process/write errors instead of recording
failed manifest rows.
on_dup : {"replace", "keep"}, default "replace"
Duplicate station policy during loading.
copy : bool, default True
Recompute copies of loaded EDI objects. Keep this enabled
when the original objects should remain unchanged.
progress : bool or {"auto"}, default False
Show progress while recomputing.
verbose : int, default 0
Verbosity for loading, processing, and writing.
"""
output_root: str | Path | None = None
output_name: str = "recomputed_edis"
preserve_line_dirs: bool = True
template: str = "{station}.edi"
overwrite: bool = False
write: bool = True
manifest_csv: bool | str | Path = True
rotate_angle: float | None = None
rotate_components: Iterable[str] = field(
default_factory=lambda: ("Z", "Tip")
)
fmin: float | None = None
fmax: float | None = None
keep_freq: Iterable[int] | None = None
fill_missing_values: str | None = None
recompute_resphase: bool = True
rename_policy: Callable[[str], str] | None = None
datatype: str | None = None
synthesize_spectra: bool = False
recursive: bool = True
strict: bool = False
on_dup: str = "replace"
copy: bool = True
progress: bool | str = False
verbose: int = 0
progress_callback: Callable[[int, int, str, str, str], None] | None = (
field(
default=None,
repr=False,
)
)
[docs]
def run(self, source: Any) -> EDIRecomputeResult:
"""Run the recomputation workflow."""
groups = _discover_groups(
source,
recursive=self.recursive,
strict=self.strict,
on_dup=self.on_dup,
verbose=self.verbose,
)
output_root = self._resolve_output_root(source, groups)
total = sum(len(group.sources) for group in groups)
edis: list[EDIFile] = []
records: list[EDIRecomputeRecord] = []
counter = 0
iterator = _iter_progress_groups(
groups,
enabled=self.progress,
total=total,
)
for group, item in iterator:
counter += 1
name = _safe_station(item) or _source_stem(item)
try:
ed = _read_group_item(
item,
strict=self.strict,
verbose=self.verbose,
)
if ed is None:
self._notify_progress(counter, total, name, "skipped", "")
continue
out = self.recompute_one(ed)
edis.append(out)
dest = None
if self.write:
dest = self._destination_for(
out,
source=ed,
line=group.name,
index=counter - 1,
output_root=output_root,
)
if dest.exists() and not self.overwrite:
raise FileExistsError(dest)
self._write_one(out, dest)
rec = EDIRecomputeRecord(
source=_source_path(ed),
output=dest,
line=group.name,
station=station_name(out),
status="ok",
)
records.append(rec)
self._notify_progress(counter, total, rec.station, "ok", "")
if self.verbose:
msg = f"recomputed {rec.station}"
if rec.output is not None:
msg += f" -> {rec.output}"
print(msg)
except Exception as exc:
if self.strict:
raise
message = f"{type(exc).__name__}: {exc}"
rec = EDIRecomputeRecord(
source=_source_path(item),
output=None,
line=group.name,
station=name,
status="failed",
message=message,
)
records.append(rec)
self._notify_progress(counter, total, name, "failed", message)
if self.verbose:
print(f"failed {rec.station}: {rec.message}")
sites = Sites(edis)
result = EDIRecomputeResult(
sites=sites,
records=records,
output_root=output_root if self.write else None,
items=edis,
)
if self.write and self.manifest_csv:
manifest = (
output_root / "manifest.csv"
if self.manifest_csv is True
else Path(self.manifest_csv)
)
result.to_manifest(manifest)
return result
def _notify_progress(
self,
done: int,
total: int,
station: str,
status: str,
message: str = "",
) -> None:
if self.progress_callback is not None:
self.progress_callback(done, total, station, status, message)
[docs]
def recompute_one(self, edi: Any) -> EDIFile:
"""Recompute one EDI object and return the result."""
return recompute_edi(
edi,
rotate_angle=self.rotate_angle,
rotate_components=self.rotate_components,
fmin=self.fmin,
fmax=self.fmax,
keep_freq=self.keep_freq,
fill_missing_values=self.fill_missing_values,
recompute_resphase=self.recompute_resphase,
rename_policy=self.rename_policy,
copy=self.copy,
)
def _resolve_output_root(
self,
source: Any,
groups: Sequence[_LineGroup],
) -> Path:
if self.output_root is not None:
return Path(self.output_root).expanduser().resolve()
if groups:
root = groups[0].source_root
if root is not None:
return (root / self.output_name).resolve()
if _is_pathlike(source):
p = Path(source).expanduser().resolve()
base = p.parent if p.is_file() else p
return (base / self.output_name).resolve()
return (Path.cwd() / self.output_name).resolve()
def _destination_for(
self,
edi: EDIFile,
*,
source: Any,
line: str | None,
index: int,
output_root: Path,
) -> Path:
ctx = {
"station": station_name(edi) or f"site_{index:04d}",
"index": index,
"line": line or "",
"source_stem": _source_stem(source),
}
name = _render_name(self.template, ctx) or ctx["station"]
if not name.lower().endswith(".edi"):
name += ".edi"
outdir = output_root
if self.preserve_line_dirs and line:
outdir = outdir / line
return outdir / name
def _write_one(self, edi: EDIFile, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
try:
edi.write(
new_edifn=dest.name,
savepath=dest.parent,
datatype=self.datatype,
synthesize_spectra=self.synthesize_spectra,
verbose=self.verbose,
)
except TypeError:
edi.write(new_edifn=str(dest))
[docs]
def recompute_edi(
edi: Any,
*,
rotate_angle: float | None = None,
rotate_components: Iterable[str] = ("Z", "Tip"),
fmin: float | None = None,
fmax: float | None = None,
keep_freq: Iterable[int] | None = None,
fill_missing_values: str | None = None,
recompute_resphase: bool = True,
rename_policy: Callable[[str], str] | None = None,
copy: bool = True,
) -> EDIFile:
r"""
Recompute one EDI object.
The operation is copy-returning by default. It can rotate
transfer functions, subset frequencies, fill missing values,
recompute apparent resistivity/phase, and rename station ids.
"""
out = maybe_copy(edi) if copy else edi
if rotate_angle is not None:
out = _rotate_selected(
out,
float(rotate_angle),
components=rotate_components,
)
if fmin is not None or fmax is not None or keep_freq is not None:
out = select_freq(
out,
fmin=fmin,
fmax=fmax,
keep=keep_freq,
inplace=True,
)
if fill_missing_values is not None:
out = fill_missing(out, how=fill_missing_values, inplace=True)
if recompute_resphase:
out = recompute_res_phase(out, inplace=True)
if rename_policy is not None:
out = rename(out, policy=rename_policy, inplace=True)
return out
[docs]
def recompute_edis(source: Any, **kwargs: Any) -> EDIRecomputeResult:
r"""
Convenience function for :class:`EDIRecomputer`.
Examples
--------
>>> result = recompute_edis("WILLY_DATA", rotate_angle=30.0)
>>> result.paths
[...]
"""
return EDIRecomputer(**kwargs).run(source)
def _rotate_selected(
edi: Any,
angle_deg: float,
*,
components: Iterable[str],
) -> Any:
comps = {str(c).strip().lower() for c in components}
do_z = bool(comps & {"z", "imp", "impedance"})
do_t = bool(comps & {"t", "tip", "tipper"})
if not comps or (do_z and do_t):
return _rotate_site(edi, angle_deg, inplace=True)
r, rinv = _rotm(angle_deg)
zobj = getattr(edi, "Z", None)
if do_z and zobj is not None:
zz = _get_attr_any(zobj, "z", "impedance", "_z")
if zz is not None:
arr = np.asarray(zz, complex)
if arr.ndim == 3 and arr.shape[-2:] == (2, 2):
_set_attr_first(
zobj,
("z", "impedance", "_z"),
r[None, :, :] @ arr @ rinv[None, :, :],
)
ze = _get_attr_any(
zobj, "z_error", "z_err", "impedance_err", "_z_err"
)
if ze is not None:
err = np.asarray(ze, float)
if err.ndim == 3 and err.shape[-2:] == (2, 2):
ar = np.abs(r)
ari = np.abs(rinv)
_set_attr_first(
zobj,
("z_error", "z_err", "impedance_err", "_z_err"),
ar[None, :, :] * err * ari[None, :, :],
)
if do_t:
tip_obj = None
for cand in ("T", "TIP", "Tip"):
tip_obj = getattr(edi, cand, None)
if tip_obj is not None:
break
if tip_obj is not None:
tip = _get_attr_any(tip_obj, "tipper", "_tipper")
if tip is not None:
arr = np.asarray(tip, complex)
if arr.ndim == 2 and arr.shape[1] == 2:
_set_attr_first(
tip_obj,
("tipper", "_tipper"),
arr @ rinv.T,
)
elif arr.ndim == 3 and arr.shape[-1] == 2:
_set_attr_first(
tip_obj,
("tipper", "_tipper"),
arr @ rinv.T,
)
elif zobj is not None:
tip = _get_attr_any(zobj, "tipper", "tip", "_tipper")
if tip is not None:
arr = np.asarray(tip, complex)
if arr.ndim == 2 and arr.shape[1] == 2:
_set_attr_first(
zobj,
("tipper", "tip", "_tipper"),
arr @ rinv.T,
)
return edi
def _discover_groups(
source: Any,
*,
recursive: bool,
strict: bool,
on_dup: str,
verbose: int,
) -> list[_LineGroup]:
if _is_pathlike(source):
return _discover_path_groups(Path(source), recursive=recursive)
if _is_seq_of_pathlike(source):
return _discover_many_path_groups(source, recursive=recursive)
edis = _object_source_to_edis(source, strict=strict, verbose=verbose)
return _discover_edi_object_groups(edis)
def _object_source_to_edis(
source: Any,
*,
strict: bool,
verbose: int,
) -> list[Any]:
if hasattr(source, "get_section") and hasattr(source, "Z"):
return [source]
try:
items = list(source)
except Exception:
items = [source]
edis: list[Any] = []
for item in items:
raw = to_edis(
item,
copy=False,
strict=strict,
on_dup="keep",
verbose=verbose,
)
if isinstance(raw, list):
edis.extend(raw)
elif raw is not None:
edis.append(raw)
return edis
def _discover_edi_object_groups(edis: Sequence[Any]) -> list[_LineGroup]:
edis = list(edis)
if not edis:
return [_LineGroup(name=None, sources=[], source_root=None)]
paths = [_source_path(ed) for ed in edis]
valid_paths = [p for p in paths if p is not None]
if not valid_paths:
return [_LineGroup(name=None, sources=edis, source_root=None)]
parents = [p.parent for p in valid_paths]
unique_parents = sorted(set(parents))
if len(unique_parents) == 1:
parent = unique_parents[0]
return [
_LineGroup(
name=parent.name,
sources=edis,
source_root=parent.parent,
)
]
try:
root = Path(commonpath([str(parent) for parent in parents]))
except Exception:
root = unique_parents[0].parent
grouped: dict[str | None, list[Any]] = {}
for ed, path in zip(edis, paths):
if path is None:
grouped.setdefault(None, []).append(ed)
continue
try:
rel = path.parent.relative_to(root)
line = rel.parts[0] if rel.parts else path.parent.name
except Exception:
line = path.parent.name
grouped.setdefault(line, []).append(ed)
return [
_LineGroup(name=line, sources=items, source_root=root)
for line, items in grouped.items()
]
def _discover_path_groups(
source: Path,
*,
recursive: bool,
) -> list[_LineGroup]:
p = source.expanduser().resolve()
if p.is_file():
return [
_LineGroup(
name=p.parent.name,
sources=[p],
source_root=p.parent,
)
]
if not p.is_dir():
return [_LineGroup(name=None, sources=[p], source_root=p.parent)]
direct = sorted(p.glob("*.edi"))
child_groups: list[_LineGroup] = []
for child in sorted(p.iterdir()):
if not child.is_dir():
continue
files = sorted(
child.rglob("*.edi") if recursive else child.glob("*.edi")
)
if files:
child_groups.append(
_LineGroup(
name=child.name,
sources=files,
source_root=p,
)
)
groups: list[_LineGroup] = []
if direct:
groups.append(
_LineGroup(
name=p.name,
sources=direct,
source_root=p.parent,
)
)
if child_groups:
groups.extend(child_groups)
if groups:
return groups
files = sorted(p.rglob("*.edi") if recursive else p.glob("*.edi"))
if files:
return [
_LineGroup(
name=p.name,
sources=files,
source_root=p.parent,
)
]
return [_LineGroup(name=p.name, sources=[], source_root=p.parent)]
def _discover_many_path_groups(
sources: Any,
*,
recursive: bool,
) -> list[_LineGroup]:
groups: list[_LineGroup] = []
for src in sources:
groups.extend(_discover_path_groups(Path(src), recursive=recursive))
return groups
def _read_group_item(
item: Any,
*,
strict: bool,
verbose: int,
) -> EDIFile | None:
if isinstance(item, EDIFile):
return item
edi = getattr(item, "edi", None)
if edi is not None:
if hasattr(edi, "get_section") and hasattr(edi, "Z"):
return edi
if _is_pathlike(edi):
return EDIFile(Path(edi), verbose=verbose)
if _is_pathlike(item):
return EDIFile(Path(item), verbose=verbose)
if hasattr(item, "get_section") and hasattr(item, "Z"):
return item
if strict:
raise TypeError(f"Not an EDI-like item: {type(item).__name__}")
return None
def _iter_progress_groups(
groups: Sequence[_LineGroup],
*,
enabled: bool | str,
total: int,
) -> Iterator[tuple[_LineGroup, Any]]:
items = [(group, item) for group in groups for item in group.sources]
if not progress_enabled(enabled):
yield from items
return
try:
from tqdm import tqdm
except Exception:
yield from items
return
bar = tqdm(items, total=total, unit="edi", leave=False)
for group, item in bar:
name = _safe_station(item) or _source_stem(item)
prefix = f"{group.name}/" if group.name else ""
bar.set_description(f"Recomputing {prefix}{name}")
yield group, item
def _write_manifest(
records: Sequence[EDIRecomputeRecord],
path: Path,
) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
fields = [
"source",
"output",
"line",
"station",
"status",
"message",
]
with path.open("w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fields)
writer.writeheader()
for rec in records:
writer.writerow(
{
"source": "" if rec.source is None else str(rec.source),
"output": "" if rec.output is None else str(rec.output),
"line": rec.line or "",
"station": rec.station,
"status": rec.status,
"message": rec.message,
}
)
return path
class _SafeDict(dict):
def __missing__(self, key: str) -> str:
return ""
def _render_name(template: str, ctx: dict[str, Any]) -> str:
return str(template).format_map(_SafeDict(ctx))
def _is_pathlike(obj: Any) -> bool:
return isinstance(obj, (str, bytes, Path, PathLike))
def _is_seq_of_pathlike(x: Any) -> bool:
if _is_pathlike(x):
return False
try:
xs = list(x)
except Exception:
return False
return bool(xs) and all(_is_pathlike(item) for item in xs)
def _source_path(obj: Any) -> Path | None:
if _is_pathlike(obj):
return Path(obj).expanduser().resolve()
path = getattr(obj, "path", None)
if path:
try:
return Path(path).expanduser().resolve()
except Exception:
return None
return None
def _source_stem(obj: Any) -> str:
path = _source_path(obj)
if path is not None:
return path.stem
return _safe_station(obj)
def _safe_station(obj: Any) -> str:
try:
if _is_pathlike(obj):
return Path(obj).stem
return station_name(obj) or ""
except Exception:
return ""