# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Explicit conversion between SEG EDI and the format-neutral EMTF model.
This module is deliberately an adapter. It does not make :class:`EDIFile`
the scientific core and it does not move rotation mathematics into parsing.
The bridge preserves the historical pyCSAMT ``Z.z_err`` convention while
representing EDI ``*.VAR`` blocks explicitly as EMTF complex variances.
"""
from __future__ import annotations
from datetime import datetime
from math import atan2, degrees
from os import PathLike
from pathlib import Path
from typing import Any, Iterable
import warnings
import numpy as np
from ...metadata import (
ChannelMeta,
LocationMeta,
OrientationMeta,
Person,
ProcessingMeta,
ProvenanceMeta,
RemoteReferenceMeta,
SiteLayout,
SiteMeta,
Software,
)
from ..base import (
IMPEDANCE_INPUT_CHANNELS,
IMPEDANCE_OUTPUT_CHANNELS,
LEGACY_STANDARD_ERROR_KIND,
TIPPER_INPUT_CHANNELS,
TIPPER_OUTPUT_CHANNELS,
)
from ..document import EMTF
from ..estimates import StatisticalEstimate
from ..transfer import TransferFunction
__all__ = [
"DataLossWarning",
"EMTFEDIConversionError",
"edi_to_emtf",
"emtf_to_edi",
"write_edi",
]
[docs]
class DataLossWarning(UserWarning):
"""Warning emitted when an EDI conversion cannot preserve information."""
[docs]
class EMTFEDIConversionError(ValueError):
"""Raised when an EDI conversion would be scientifically invalid."""
_VALID_LOSS_POLICIES = frozenset({"warn", "raise", "ignore"})
def _loss(policy: str, message: str) -> None:
policy = str(policy).strip().lower()
if policy not in _VALID_LOSS_POLICIES:
raise ValueError(
"on_loss must be one of " f"{sorted(_VALID_LOSS_POLICIES)}"
)
if policy == "raise":
raise EMTFEDIConversionError(message)
if policy == "warn":
warnings.warn(message, DataLossWarning, stacklevel=3)
def _nonempty(value: Any) -> bool:
return value not in (None, "", "None")
def _first(*values: Any) -> Any:
for value in values:
if _nonempty(value):
return value
return None
def _explicit_keys(lines: Iterable[str] | None) -> set[str]:
keys: set[str] = set()
for raw in lines or ():
text = str(raw).strip()
if "=" not in text:
continue
keys.add(text.split("=", 1)[0].strip().lower())
return keys
def _year_from_date(value: Any) -> int | None:
if value is None:
return None
if isinstance(value, datetime):
return int(value.year)
text = str(value).strip()
if not text:
return None
for fmt in (
"%m/%d/%y",
"%m/%d/%Y",
"%Y/%m/%d",
"%Y-%m-%d",
"%Y-%m-%dT%H:%M:%S",
):
try:
return int(datetime.strptime(text[:19], fmt).year)
except ValueError:
pass
for token in text.replace("-", "/").split("/"):
if token.isdigit() and len(token) == 4:
year = int(token)
if 1800 <= year <= 2500:
return year
return None
def _as_edi_date(value: Any) -> str | None:
"""Return a conservative EDI date string without fabricating a date."""
if value is None:
return None
if isinstance(value, datetime):
return value.strftime("%m/%d/%y")
text = str(value).strip()
if not text:
return None
for fmt in (
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d",
"%Y/%m/%d",
"%m/%d/%Y",
"%m/%d/%y",
):
try:
return datetime.strptime(text[:19], fmt).strftime("%m/%d/%y")
except ValueError:
pass
return text
def _elevation_units_to_emtf(value: Any) -> str:
text = str(value or "m").strip().lower()
if text in {"m", "meter", "meters", "metre", "metres"}:
return "meters"
if text in {"ft", "foot", "feet"}:
return "feet"
return text or "meters"
def _elevation_units_to_edi(value: Any) -> str:
text = str(value or "meters").strip().lower()
if text in {"m", "meter", "meters", "metre", "metres"}:
return "m"
if text in {"ft", "foot", "feet"}:
return "ft"
return text or "m"
def _array_or_none(value: Any) -> np.ndarray | None:
if value is None:
return None
arr = np.asarray(value)
if arr.size == 0:
return None
return arr
def _rotation_vector(value: Any, n_freq: int) -> np.ndarray | None:
arr = _array_or_none(value)
if arr is None:
return None
arr = np.asarray(arr, dtype=float).ravel()
if arr.size == 1:
return np.full(n_freq, float(arr[0]))
if arr.size == n_freq:
return arr
return None
def _raw_file_has_block(edi: Any, tag: str) -> bool:
path = getattr(edi, "path", None)
if path is None:
return False
try:
text = Path(path).read_text(
encoding="utf-8-sig", errors="replace"
).upper()
except (OSError, TypeError, ValueError):
return False
wanted = f">{str(tag).strip().upper()}"
return any(line.lstrip().startswith(wanted) for line in text.splitlines())
def _measurement_angle(measurement: Any) -> float | None:
azm = getattr(measurement, "azm", None)
if azm is not None:
try:
return float(azm)
except (TypeError, ValueError):
pass
x = getattr(measurement, "x", None)
y = getattr(measurement, "y", None)
x2 = getattr(measurement, "x2", None)
y2 = getattr(measurement, "y2", None)
if None in (x, y, x2, y2):
return None
dx = float(x2) - float(x)
dy = float(y2) - float(y)
if dx == 0.0 and dy == 0.0:
return None
# EDI site-layout convention: +X is 0 degrees and +Y is 90 degrees.
return float(degrees(atan2(dy, dx)) % 360.0)
def _canonical_channel_name(value: Any) -> str | None:
if not _nonempty(value):
return None
raw = str(value).strip().upper()
mapping = {
"HX": "Hx",
"HY": "Hy",
"HZ": "Hz",
"EX": "Ex",
"EY": "Ey",
"RHX": "RHx",
"RHY": "RHy",
}
return mapping.get(raw, str(value).strip())
def _channel_from_measurement(
measurement: Any,
*,
fallback_name: str,
units: str | None,
reference: str | None = "site",
) -> ChannelMeta:
name = _canonical_channel_name(
getattr(measurement, "chtype", None)
) or fallback_name
upper = name.upper()
field_type = "electric" if upper.startswith("E") else "magnetic"
extra: dict[str, Any] = {}
for source, target in (
("id", "edi_id"),
("acqchan", "acquisition_channel"),
("filter", "filter"),
("gain", "gain"),
("measdate", "measurement_date"),
):
value = getattr(measurement, source, None)
if _nonempty(value):
extra[target] = value
return ChannelMeta(
name=name,
field_type=field_type,
orientation=_measurement_angle(measurement),
tilt=getattr(measurement, "dip", None),
x=getattr(measurement, "x", None),
y=getattr(measurement, "y", None),
z=getattr(measurement, "z", None),
x2=getattr(measurement, "x2", None),
y2=getattr(measurement, "y2", None),
z2=getattr(measurement, "z2", None),
units=units,
reference=reference,
sensor_id=getattr(measurement, "sensor", None),
extra=extra,
)
def _edi_site_layout(edi: Any) -> SiteLayout | None:
define = edi.get_section("definemeas")
mtsect = edi.get_section("mtsect")
if define is None:
return None
measurements = list(getattr(define, "all_meas", lambda: [])())
by_id = {
str(getattr(item, "id", "")).strip(): item
for item in measurements
if _nonempty(getattr(item, "id", None))
}
by_type: dict[str, Any] = {}
for item in measurements:
chtype = getattr(item, "chtype", None)
if _nonempty(chtype):
by_type.setdefault(str(chtype).strip().upper(), item)
units = getattr(define, "units", None)
input_channels: list[ChannelMeta] = []
output_channels: list[ChannelMeta] = []
def resolve(role: str, name: str) -> Any | None:
ref = getattr(mtsect, role, None) if mtsect is not None else None
if _nonempty(ref) and str(ref).strip() in by_id:
return by_id[str(ref).strip()]
return by_type.get(name.upper())
for role, name in (("hx", "Hx"), ("hy", "Hy")):
item = resolve(role, name)
if item is not None:
input_channels.append(
_channel_from_measurement(
item,
fallback_name=name,
units=units,
)
)
for role, name in (("hz", "Hz"), ("ex", "Ex"), ("ey", "Ey")):
item = resolve(role, name)
if item is not None:
output_channels.append(
_channel_from_measurement(
item,
fallback_name=name,
units=units,
)
)
if not input_channels and not output_channels:
return None
extra: dict[str, Any] = {}
if mtsect is not None:
for key in ("rx", "ry"):
value = getattr(mtsect, key, None)
if _nonempty(value):
extra[f"edi_{key}"] = str(value)
for key in ("reflat", "reflong", "refelev", "reftype"):
value = getattr(define, key, None)
if _nonempty(value):
extra[f"edi_{key}"] = value
return SiteLayout(
input_channels=input_channels,
output_channels=output_channels,
input_units=units,
output_units=units,
input_reference="site",
output_reference="site",
extra=extra,
)
def _edi_site(edi: Any) -> SiteMeta | None:
head = edi.get_section("head")
info = edi.get_section("info")
mtsect = edi.get_section("mtsect")
if head is None and info is None and mtsect is None:
return None
head_keys = _explicit_keys(getattr(head, "edi_header", None))
info_keys = _explicit_keys(getattr(info, "ediinfo", None))
source = getattr(info, "Source", None) if info is not None else None
head_project = getattr(head, "project", None) if head else None
head_survey = getattr(head, "survey", None) if head else None
info_project = (
getattr(source, "project", None) if "project" in info_keys else None
)
info_survey = (
getattr(source, "survey", None) if "survey" in info_keys else None
)
site_name = (
getattr(source, "sitename", None)
if "sitename" in info_keys
else None
)
if site_name is None and head is not None:
site_name = _first(
getattr(head, "loc", None) if "loc" in head_keys else None,
getattr(head, "prospect", None)
if "prospect" in head_keys
else None,
)
site_id = _first(
getattr(head, "dataid", None) if head is not None else None,
getattr(mtsect, "sectid", None) if mtsect is not None else None,
)
location = None
if head is not None:
lat = getattr(head, "lat", None) if "lat" in head_keys else None
lon = getattr(head, "long", None) if "long" in head_keys else None
elev = getattr(head, "elev", None) if "elev" in head_keys else None
decl = (
getattr(head, "declination", None)
if "declination" in head_keys
else None
)
datum = getattr(head, "datum", None) if "datum" in head_keys else None
if any(value is not None for value in (lat, lon, elev, decl, datum)):
location = LocationMeta(
latitude=lat,
longitude=lon,
elevation=elev,
datum=datum,
elevation_units=_elevation_units_to_emtf(
getattr(head, "units", None)
),
declination=decl,
)
start = (
getattr(head, "acqdate", None)
if head is not None and "acqdate" in head_keys
else None
)
end = (
getattr(head, "enddate", None)
if head is not None and "enddate" in head_keys
else None
)
extra: dict[str, Any] = {}
if head is not None:
for key in (
"state",
"county",
"prospect",
"loc",
"chainage",
"coordsys",
"stdvers",
):
value = getattr(head, key, None)
if _nonempty(value):
extra[f"edi_{key}"] = value
if (
head_project
and info_project
and str(head_project) != str(info_project)
):
extra["edi_head_project"] = head_project
if head_survey and info_survey and str(head_survey) != str(info_survey):
extra["edi_head_survey"] = head_survey
year = _year_from_date(start)
country = (
getattr(head, "country", None)
if head is not None and "country" in head_keys
else None
)
acquired_by = (
getattr(head, "acqby", None)
if head is not None and "acqby" in head_keys
else None
)
if not any(
value is not None
for value in (
site_id,
site_name,
location,
start,
end,
country,
acquired_by,
head_project,
info_project,
head_survey,
info_survey,
)
):
return None
return SiteMeta(
project=_first(info_project, head_project),
survey=_first(info_survey, head_survey),
year_collected=year,
country=country,
site_id=site_id,
name=site_name,
location=location,
acquired_by=acquired_by,
start=start,
end=end,
extra=extra,
)
def _edi_provenance(edi: Any) -> ProvenanceMeta | None:
head = edi.get_section("head")
info = edi.get_section("info")
head_keys = _explicit_keys(getattr(head, "edi_header", None))
info_keys = _explicit_keys(getattr(info, "ediinfo", None))
source = getattr(info, "Source", None) if info is not None else None
create_time = None
if head is not None and "filedate" in head_keys:
create_time = getattr(head, "filedate", None)
elif source is not None and "creationdate" in info_keys:
create_time = getattr(source, "creationdate", None)
creating_application = None
if head is not None and "progvers" in head_keys:
creating_application = getattr(head, "progvers", None)
creator = None
if head is not None and "fileby" in head_keys:
name = getattr(head, "fileby", None)
if _nonempty(name):
creator = Person(name=str(name))
if (
create_time is None
and creating_application is None
and creator is None
):
return None
return ProvenanceMeta(
create_time=create_time,
creating_application=creating_application,
creator=creator,
)
def _edi_processing(edi: Any) -> ProcessingMeta | None:
info = edi.get_section("info")
mtsect = edi.get_section("mtsect")
if info is None and mtsect is None:
return None
info_keys = _explicit_keys(getattr(info, "ediinfo", None))
old = getattr(info, "Processing", None) if info is not None else None
sign = (
getattr(old, "signconvention", None)
if old is not None and "signconvention" in info_keys
else None
)
processed_by = (
getattr(old, "processedby", None)
if old is not None and "processedby" in info_keys
else None
)
processing_tag = (
getattr(old, "processingtag", None)
if old is not None and "processingtag" in info_keys
else None
)
software = None
if old is not None and "processingsoftware" in info_keys:
old_sw = getattr(old, "ProcessingSoftware", None)
sw_name = getattr(old_sw, "name", None)
if _nonempty(sw_name):
software = Software(name=str(sw_name))
remote_type = (
getattr(old, "remoteref", None)
if old is not None and "remoteref" in info_keys
else None
)
remote_site = (
getattr(old, "remotesite", None)
if old is not None and "remotesite" in info_keys
else None
)
remote_extra: dict[str, Any] = {}
if mtsect is not None:
for key in ("rx", "ry"):
value = getattr(mtsect, key, None)
if _nonempty(value):
remote_extra[f"edi_{key}"] = str(value)
remote = None
if remote_type or remote_site or remote_extra:
if remote_type is None and remote_extra:
# RX/RY are explicit reference-channel identifiers in MTSECT.
# Preserve that scientific fact in the format-neutral metadata
# even when the historical INFO block did not name the method.
remote_type = "Remote Reference"
remote = RemoteReferenceMeta(
reference_type=remote_type,
site=remote_site,
extra=remote_extra,
)
run_list = None
if old is not None and "runlist" in info_keys:
raw = getattr(old, "runlist", None)
if isinstance(raw, list):
run_list = [str(item) for item in raw]
elif _nonempty(raw):
text = str(raw).replace(",", " ")
run_list = [item for item in text.split() if item]
if not any(
value is not None
for value in (
sign,
processed_by,
software,
remote,
processing_tag,
run_list,
)
):
return None
return ProcessingMeta(
sign_convention=sign,
processed_by=processed_by,
software=software,
remote_reference=remote,
processing_tag=processing_tag,
run_list=run_list,
)
def _edi_orientation(edi: Any, n_freq: int) -> OrientationMeta | None:
head = edi.get_section("head")
coordsys = getattr(head, "coordsys", None) if head is not None else None
zrot = _rotation_vector(getattr(edi.Z, "rotation_angle", None), n_freq)
trot = _rotation_vector(getattr(edi.Tip, "rotation_angle", None), n_freq)
had_zrot = _raw_file_has_block(edi, "ZROT")
had_trot = _raw_file_has_block(edi, "TROT")
if zrot is None and trot is None:
return None
extra: dict[str, Any] = {
"edi_coordsys": coordsys,
"edi_had_zrot": had_zrot,
"edi_had_trot": had_trot,
}
if zrot is not None:
extra["edi_zrot"] = zrot.tolist()
if trot is not None:
extra["edi_trot"] = trot.tolist()
mode = None
angle = None
rotation_info = None
if zrot is not None and zrot.size and np.allclose(zrot, zrot[0]):
coordinate_text = str(coordsys or "").lower()
if "geographic" in coordinate_text:
mode = "orthogonal"
angle = float(zrot[0])
else:
rotation_info = (
"EDI ZROT is constant at "
f"{float(zrot[0]):g} deg relative to "
f"HEAD.COORDSYS={coordsys!r}; it was not promoted to an "
"angle_to_geographic_north because historical EDI rotation "
"metadata can be ambiguous."
)
elif zrot is not None:
rotation_info = (
"EDI contains frequency-dependent ZROT values. They are retained "
"in OrientationMeta.extra for in-memory EDI round-tripping; "
"Phase 7 rotation is required before archival normalization."
)
return OrientationMeta(
mode=mode,
angle_to_geographic_north=angle,
rotation_info=rotation_info,
extra=extra,
)
def _edi_field_notes(edi: Any) -> dict[str, Any]:
info = edi.get_section("info")
lines = list(getattr(info, "info_text", []) or []) if info else []
values = [line.rstrip("\n") for line in lines if str(line).strip()]
return {"edi_info": values} if values else {}
def _edi_auxiliary_metadata(edi: Any) -> dict[str, Any]:
head = edi.get_section("head")
info = edi.get_section("info")
mtsect = edi.get_section("mtsect")
define = edi.get_section("definemeas")
result: dict[str, Any] = {
"source_format": "edi",
"edi_dtype": getattr(edi, "dtype", None),
}
if getattr(edi, "path", None) is not None:
result["source_path"] = str(edi.path)
if head is not None:
result["edi_head"] = getattr(head, "as_dict", lambda: {})()
result["edi_head_explicit"] = list(
getattr(head, "edi_header", []) or []
)
if info is not None:
result["edi_info"] = getattr(info, "as_dict", lambda: {})()
result["edi_info_explicit"] = list(
getattr(info, "ediinfo", []) or []
)
if mtsect is not None:
result["edi_mtsect"] = {
key: getattr(mtsect, key, None)
for key in (
"sectid",
"nfreq",
"maxblks",
"hx",
"hy",
"hz",
"ex",
"ey",
"rx",
"ry",
)
}
if define is not None:
result["edi_definemeas"] = {
key: getattr(define, key, None)
for key in (
"maxchan",
"maxrun",
"maxmeas",
"units",
"reftype",
"reflat",
"reflong",
"refelev",
)
}
measurements: list[dict[str, Any]] = []
for item in list(getattr(define, "hmeas", []) or []):
payload = getattr(item, "to_dict", lambda: {})()
measurements.append({"kind": "H", **dict(payload)})
for item in list(getattr(define, "emeas", []) or []):
payload = getattr(item, "to_dict", lambda: {})()
measurements.append({"kind": "E", **dict(payload)})
if measurements:
# Keep the complete historical measurement declarations as an
# adapter-level escape hatch. SiteLayout holds the scientifically
# important local channels; these raw records additionally retain
# remote-reference HMEAS entries needed for an EDI round-trip.
result["edi_measurements"] = measurements
return result
def _ensure_edi_file(source: Any) -> Any:
from ...seg.edi import EDIFile
if isinstance(source, EDIFile):
return source
if isinstance(source, (str, PathLike)):
return EDIFile(source)
raise TypeError("EDI source must be an EDIFile or filesystem path")
[docs]
def edi_to_emtf(
source: Any,
*,
prefer_spectra: bool = True,
spectra_nfreq_policy: str = "raise",
spectra_missing_policy: str = "raise",
spectra_avgt_policy: str = "raise",
) -> EMTF:
"""Convert a historical SEG EDI object/path into :class:`EMTF`.
EDI SPECTRA are preferred when present because they retain enough
cross-power information to recover the full inverse-signal and residual
covariance matrices. Set ``prefer_spectra=False`` to force the historical
impedance/tipper blocks.
Notes
-----
pyCSAMT historically stores ``Z.z_err = sqrt(EDI complex variance)``.
The non-SPECTRA path therefore reconstructs EMTF ``VAR`` as ``z_err**2``
exactly, preserving the EDI file's complex-variance convention without
redefining the public ``Z.z_err`` API. The standard error of a real or
imaginary component, when needed statistically, is ``sqrt(VAR / 2)``.
"""
edi = _ensure_edi_file(source)
spectra = edi.get_section("spectra")
if (
prefer_spectra
and spectra is not None
and int(getattr(spectra, "n_freq", 0)) > 0
):
from .spectra import spectra_to_emtf
return spectra_to_emtf(
edi,
spectra=spectra,
nfreq_policy=spectra_nfreq_policy,
missing_policy=spectra_missing_policy,
avgt_policy=spectra_avgt_policy,
)
freq = _array_or_none(getattr(edi.Z, "freq", None))
if freq is None:
if spectra is not None and int(getattr(spectra, "n_freq", 0)) > 0:
raise EMTFEDIConversionError(
"EDI contains SPECTRA but no parsed impedance blocks; use "
"prefer_spectra=True for FCU-compatible recovery"
)
raise EMTFEDIConversionError(
"EDI conversion requires parsed impedance/tipper or usable "
"SPECTRA content"
)
freq = np.asarray(freq, dtype=float)
periods = 1.0 / freq
document = EMTF(
subtype="MT_TF" if getattr(edi, "dtype", None) == "mt" else None,
periods=periods,
site=_edi_site(edi),
site_layout=_edi_site_layout(edi),
orientation=_edi_orientation(edi, freq.size),
processing=_edi_processing(edi),
provenance=_edi_provenance(edi),
field_notes=_edi_field_notes(edi),
metadata=_edi_auxiliary_metadata(edi),
attrs={"source_format": "edi"},
)
z = _array_or_none(getattr(edi.Z, "z", None))
if z is not None:
ztf = TransferFunction(
name="impedance",
data=np.asarray(z),
input_channels=IMPEDANCE_INPUT_CHANNELS,
output_channels=IMPEDANCE_OUTPUT_CHANNELS,
periods=periods,
attrs={
"source_format": "edi",
"edi_error_carrier": "sqrt_complex_variance",
},
)
zerr = _array_or_none(getattr(edi.Z, "z_err", None))
if zerr is not None:
ztf.add_estimate(
StatisticalEstimate(
name="VAR",
kind="variance",
data=np.square(np.asarray(zerr, dtype=float)),
attrs={
"source_format": "edi",
"definition": "complex_transfer_function_variance",
},
)
)
document.add_transfer_function(ztf, replace=True)
tip = _array_or_none(getattr(edi.Tip, "tipper", None))
if tip is not None:
tip = np.asarray(tip)
if tip.ndim == 3 and tip.shape[0] == freq.size:
ttf = TransferFunction(
name="tipper",
data=tip,
input_channels=TIPPER_INPUT_CHANNELS,
output_channels=TIPPER_OUTPUT_CHANNELS,
periods=periods,
attrs={
"source_format": "edi",
"edi_error_carrier": "sqrt_complex_variance",
},
)
terr = _array_or_none(getattr(edi.Tip, "tipper_err", None))
if terr is not None:
ttf.add_estimate(
StatisticalEstimate(
name="VAR",
kind="variance",
data=np.square(np.asarray(terr, dtype=float)),
attrs={
"source_format": "edi",
"definition": (
"complex_transfer_function_variance"
),
},
)
)
document.add_transfer_function(ttf, replace=True)
if document.impedance is None:
raise EMTFEDIConversionError(
"EDI did not expose a standard impedance tensor; conversion of "
"spectra-only or nonstandard EDI data is deferred to Phase 8"
)
document.tags = tuple(document.transfer_functions)
return document
def _extract_edi_info_lines(document: EMTF) -> list[str]:
raw = document.field_notes.get("edi_info")
values = (
raw if isinstance(raw, list) else ([raw] if raw is not None else [])
)
lines: list[str] = []
for value in values:
if isinstance(value, str):
lines.append(value)
continue
if isinstance(value, dict):
text = value.get("#text")
if text is not None:
lines.append(str(text))
return lines
def _preferred_variance(tf: TransferFunction) -> np.ndarray | None:
estimate = tf.get_estimate("variance") or tf.get_estimate("VAR")
if estimate is None:
return None
data = np.asarray(estimate.data)
if data.shape != tf.data.shape:
raise EMTFEDIConversionError(
f"{tf.name} VAR shape {data.shape!r} does not match TF shape "
f"{tf.data.shape!r}"
)
return np.asarray(data, dtype=float)
def _legacy_error(tf: TransferFunction) -> np.ndarray | None:
estimate = tf.get_estimate(LEGACY_STANDARD_ERROR_KIND)
if estimate is None:
return None
data = np.asarray(estimate.data, dtype=float)
if data.shape != tf.data.shape:
raise EMTFEDIConversionError(
f"legacy standard-error shape {data.shape!r} does not match "
f"{tf.data.shape!r}"
)
semantics = str(estimate.attrs.get("semantics", "")).strip().lower()
if semantics != "pycsamt_legacy_z_err":
raise EMTFEDIConversionError(
"a generic standard error cannot be mapped to EDI *.VAR without "
"knowing its statistical convention; expected explicit "
"semantics='pycsamt_legacy_z_err'"
)
return data
def _tf_to_edi_error(tf: TransferFunction) -> np.ndarray | None:
variance = _preferred_variance(tf)
if variance is not None:
# EDIFile.write squares its historical z_err/tipper_err carrier.
# sqrt(VAR) therefore preserves the complex EDI variance exactly.
return np.sqrt(np.abs(variance))
return _legacy_error(tf)
def _collect_unrepresentable(document: EMTF) -> list[str]:
losses: list[str] = []
representable = {"impedance", "tipper"}
extras = [
key for key in document.transfer_functions if key not in representable
]
if extras:
losses.append(
"EDI writer will omit transfer-function/data types: "
+ ", ".join(sorted(extras))
)
for key in ("impedance", "tipper"):
tf = document.get_transfer_function(key)
if tf is None:
continue
covariance = [
estimate.name
for estimate in tf.estimates.values()
if estimate.kind
in {
"inverse_signal_covariance",
"residual_covariance",
"covariance",
}
]
if covariance:
losses.append(
f"standard EDI cannot preserve {key} full covariance "
f"estimate(s): {', '.join(sorted(set(covariance)))}"
)
supported_names: set[str] = set()
variance = tf.get_estimate("variance") or tf.get_estimate("VAR")
if variance is not None:
supported_names.add(variance.name)
legacy = tf.get_estimate(LEGACY_STANDARD_ERROR_KIND)
if variance is None and legacy is not None:
semantics = str(
legacy.attrs.get("semantics", "")
).strip().lower()
if semantics == "pycsamt_legacy_z_err":
supported_names.add(legacy.name)
other_estimates = [
estimate.name
for estimate in tf.estimates.values()
if estimate.name not in supported_names
and estimate.kind
not in {
"inverse_signal_covariance",
"residual_covariance",
"covariance",
}
]
if other_estimates:
losses.append(
f"standard EDI conversion will omit unsupported {key} "
"statistical estimate(s): "
+ ", ".join(sorted(set(other_estimates)))
)
if document.copyright is not None:
losses.append(
"current SEG EDI writer has no lossless mapping for EMTF "
"Copyright/Citation metadata"
)
if document.quality is not None:
losses.append(
"current SEG EDI writer has no lossless mapping for EMTF "
"DataQualityNotes/DataQualityWarnings"
)
provenance = document.provenance
if provenance is not None:
if provenance.submitter is not None:
losses.append("EDI cannot preserve EMTF Submitter provenance")
creator = provenance.creator
if creator is not None and any(
(
creator.email,
creator.organization,
creator.organization_url,
)
):
losses.append(
"EDI FILEBY can preserve only the creator name, not full "
"creator contact metadata"
)
software = document.processing.software if document.processing else None
if software is not None and any(
(
software.version,
software.release,
software.author.name if software.author else None,
software.author.email if software.author else None,
)
):
losses.append(
"EDI INFO PROCESSINGSOFTWARE preserves only the software name"
)
return losses
def _channel_lookup(document: EMTF) -> dict[str, ChannelMeta]:
layout = document.site_layout
if layout is None:
return {}
return {
channel.name.lower(): channel
for channel in (*layout.input_channels, *layout.output_channels)
}
def _measurement_id(
channel: ChannelMeta | None,
*,
fallback: str,
) -> str:
if channel is not None:
value = channel.extra.get("edi_id")
if _nonempty(value):
return str(value)
return fallback
def _build_measurements(document: EMTF, n_freq: int) -> tuple[Any, Any]:
from ...seg.meas import DefineMeas, Emeasurement, Hmeasurement
from ...seg.mtemap import MTEMAP
channels = _channel_lookup(document)
units = None
if document.site_layout is not None:
units = _first(
document.site_layout.input_units,
document.site_layout.output_units,
)
units = str(units or "m")
ids = {
"hx": _measurement_id(channels.get("hx"), fallback="1001.001"),
"hy": _measurement_id(channels.get("hy"), fallback="1002.001"),
"hz": _measurement_id(channels.get("hz"), fallback="1003.001"),
"ex": _measurement_id(channels.get("ex"), fallback="1004.001"),
"ey": _measurement_id(channels.get("ey"), fallback="1005.001"),
}
def magnetic(name: str, key: str) -> Hmeasurement:
channel = channels.get(name.lower())
kwargs: dict[str, Any] = {
"id": ids[key],
"chtype": name.upper(),
"x": None,
"y": None,
"z": None,
"azm": None,
"dip": None,
"acqchan": None,
"filter": None,
"sensor": None,
"gain": None,
"measdate": None,
}
if channel is not None:
kwargs.update(
x=channel.x,
y=channel.y,
z=channel.z,
azm=channel.orientation,
dip=channel.tilt,
sensor=channel.sensor_id,
acqchan=channel.extra.get("acquisition_channel"),
filter=channel.extra.get("filter"),
gain=channel.extra.get("gain"),
measdate=channel.extra.get("measurement_date"),
)
return Hmeasurement(**kwargs)
def electric(name: str, key: str) -> Emeasurement:
channel = channels.get(name.lower())
kwargs: dict[str, Any] = {
"id": ids[key],
"chtype": name.upper(),
"x": None,
"y": None,
"z": None,
"x2": None,
"y2": None,
"z2": None,
"acqchan": None,
"filter": None,
"sensor": None,
"gain": None,
"measdate": None,
}
if channel is not None:
kwargs.update(
x=channel.x,
y=channel.y,
z=channel.z,
x2=channel.x2,
y2=channel.y2,
z2=channel.z2,
sensor=channel.sensor_id,
acqchan=channel.extra.get("acquisition_channel"),
filter=channel.extra.get("filter"),
gain=channel.extra.get("gain"),
measdate=channel.extra.get("measurement_date"),
)
return Emeasurement(**kwargs)
original_define = document.metadata.get("edi_definemeas", {})
if not isinstance(original_define, dict):
original_define = {}
define = DefineMeas(
maxchan=5,
maxrun=original_define.get("maxrun") or 999,
maxmeas=5,
units=units,
reftype=original_define.get("reftype") or "CART",
reflat=original_define.get("reflat"),
reflong=original_define.get("reflong"),
refelev=original_define.get("refelev"),
)
define.hmeas = [
magnetic("Hx", "hx"),
magnetic("Hy", "hy"),
magnetic("Hz", "hz"),
]
define.emeas = [electric("Ex", "ex"), electric("Ey", "ey")]
site_id = (
document.site.site_id
if document.site is not None
else document.station
)
mtsect = MTEMAP(
sectid=str(site_id) if site_id is not None else None,
nfreq=n_freq,
hx=ids["hx"],
hy=ids["hy"],
hz=ids["hz"],
ex=ids["ex"],
ey=ids["ey"],
)
original = document.metadata.get("edi_mtsect", {})
if not isinstance(original, dict):
original = {}
remote = (
document.processing.remote_reference
if document.processing is not None
else None
)
requested_remote: dict[str, str] = {}
for key in ("rx", "ry"):
value = original.get(key)
if remote is not None and _nonempty(remote.extra.get(f"edi_{key}")):
value = remote.extra.get(f"edi_{key}")
if _nonempty(value):
requested_remote[key] = str(value)
raw_records = document.metadata.get("edi_measurements", [])
if not isinstance(raw_records, list):
raw_records = []
by_id: dict[str, dict[str, Any]] = {}
for record in raw_records:
if not isinstance(record, dict):
continue
ident = record.get("id")
if _nonempty(ident):
by_id[str(ident).strip()] = record
existing_ids = {
str(item.id).strip()
for item in define.all_meas()
if _nonempty(getattr(item, "id", None))
}
# An EDI-derived EMTF keeps all original HMEAS/EMEAS records in adapter
# metadata. Restore records that are not already represented by the
# canonical local SiteLayout so a direct EDI -> EMTF -> EDI conversion
# does not discard auxiliary/remote measurement declarations.
for record in raw_records:
if not isinstance(record, dict):
continue
ident = record.get("id")
if not _nonempty(ident) or str(ident).strip() in existing_ids:
continue
payload = {
field: value
for field, value in record.items()
if field != "kind"
}
kind = str(record.get("kind", "H")).upper()
item = (
Hmeasurement(**payload)
if kind == "H"
else Emeasurement(**payload)
)
if kind == "H":
define.hmeas.append(item)
else:
define.emeas.append(item)
existing_ids.add(str(ident).strip())
restored_remote = 0
missing_remote: list[str] = []
for key, ident in requested_remote.items():
record = by_id.get(ident)
if record is None:
missing_remote.append(f"{key.upper()}={ident}")
continue
if ident not in existing_ids:
payload = {
field: value
for field, value in record.items()
if field != "kind"
}
kind = str(record.get("kind", "H")).upper()
if kind != "H":
missing_remote.append(f"{key.upper()}={ident}")
continue
define.hmeas.append(Hmeasurement(**payload))
existing_ids.add(ident)
restored_remote += 1
setattr(mtsect, key, ident)
# Remote IDs without their HMEAS declarations would create structurally
# inconsistent EDI. Do not write dangling references. The caller turns
# this marker into an explicit DataLossWarning.
if missing_remote:
setattr(mtsect, "_pycsamt_missing_remote_measurements", missing_remote)
define.maxchan = len(define.all_meas())
define.maxmeas = len(define.all_meas())
if restored_remote:
define.maxchan = max(define.maxchan, 5 + restored_remote)
return define, mtsect
def _build_head_info(document: EMTF) -> tuple[Any, Any]:
from ...seg.heads import Head, Info
site = document.site
location = site.location if site is not None else None
station = _first(
site.site_id if site is not None else None,
document.station,
document.product_id,
)
head = Head()
# ``Head`` has convenience defaults intended for newly authored EDI
# files. A format converter must not silently claim geomagnetic
# coordinates or invent file/program provenance, so clear those defaults
# and repopulate only mapped values. Structural defaults such as STDVERS,
# datum/units, and EMPTY remain available when needed by the EDI writer.
head.filedate = None
head.progvers = None
head.progdate = None
head.coordsys = None
if station is not None:
head.dataid = str(station)
if site is not None:
head.acqby = site.acquired_by
head.acqdate = _as_edi_date(site.start)
head.enddate = _as_edi_date(site.end)
head.country = site.country
head.project = site.project
head.survey = site.survey
head.loc = site.name
for source, target in (
("edi_state", "state"),
("edi_county", "county"),
("edi_prospect", "prospect"),
("edi_chainage", "chainage"),
):
value = site.extra.get(source)
if _nonempty(value):
setattr(head, target, value)
if location is not None:
head.lat = location.latitude
head.long = location.longitude
head.elev = location.elevation
head.units = _elevation_units_to_edi(location.elevation_units)
head.datum = location.datum or "WGS84"
head.declination = location.declination
if document.provenance is not None:
creator = document.provenance.creator
if creator is not None:
head.fileby = creator.name
head.filedate = _as_edi_date(document.provenance.create_time)
head.progvers = document.provenance.creating_application
orientation = document.orientation
if orientation is not None:
source_coordsys = orientation.extra.get("edi_coordsys")
if _nonempty(source_coordsys):
head.coordsys = str(source_coordsys)
elif orientation.is_orthogonal:
head.coordsys = "Geographic North"
# Restore EDI-specific header details only when this document actually
# originated from EDI and the key was explicit in the source. Scientific
# metadata above remain authoritative for fields shared with EMTF.
raw_head = document.metadata.get("edi_head", {})
explicit_head = _explicit_keys(
document.metadata.get("edi_head_explicit", [])
)
if isinstance(raw_head, dict):
for key in (
"filedate",
"stdvers",
"progdate",
"maxsect",
"bindata",
"empty",
):
if key in explicit_head and _nonempty(raw_head.get(key)):
setattr(head, key, raw_head[key])
if "progvers" in explicit_head and document.provenance is None:
head.progvers = raw_head.get("progvers")
if "coordsys" in explicit_head and orientation is None:
head.coordsys = raw_head.get("coordsys")
info = Info()
# Avoid Info's convenience processing defaults becoming fabricated
# scientific provenance during conversion.
info.Processing.processedby = None
info.Processing.ProcessingSoftware.name = None
if site is not None:
info.Source.project = site.project
info.Source.survey = site.survey
info.Source.sitename = site.name
if document.provenance is not None:
if document.provenance.create_time is not None:
info.Source.creationdate = str(document.provenance.create_time)
if document.processing is not None:
processing = document.processing
info.Processing.processedby = processing.processed_by
info.Processing.processingtag = processing.processing_tag
info.Processing.signconvention = processing.sign_convention
info.Processing.runlist = processing.run_list
if processing.software is not None:
info.Processing.ProcessingSoftware.name = processing.software.name
if processing.remote_reference is not None:
info.Processing.remoteref = (
processing.remote_reference.reference_type
)
info.Processing.remotesite = processing.remote_reference.site
info_lines = _extract_edi_info_lines(document)
raw_info = document.metadata.get("edi_info", {})
explicit_info = list(document.metadata.get("edi_info_explicit", []) or [])
explicit_keys = _explicit_keys(explicit_info)
if isinstance(raw_info, dict) and "maxinfo" in explicit_keys:
value = raw_info.get("maxinfo")
if value is not None:
try:
info.maxinfo = int(value)
except (TypeError, ValueError):
pass
known_info = {
"maxinfo",
"project",
"survey",
"creationdate",
"processedby",
"processingsoftware",
"processingtag",
"sitename",
"runlist",
"remoteref",
"remotesite",
"signconvention",
}
for raw in explicit_info:
text = str(raw).strip()
if "=" not in text:
continue
key = text.split("=", 1)[0].strip().lower()
if key not in known_info:
# INFO supports free text, so retaining an unknown historical KV
# line here is preferable to silently dropping it.
info_lines.append(text)
info.info_text = info_lines
return head, info
def _restore_rotation(document: EMTF, edi: Any, n_freq: int) -> None:
orientation = document.orientation
zrot = None
trot = None
if orientation is not None:
raw_zrot = orientation.extra.get("edi_zrot")
raw_trot = orientation.extra.get("edi_trot")
zrot = _rotation_vector(raw_zrot, n_freq)
trot = _rotation_vector(raw_trot, n_freq)
if zrot is None and orientation.is_orthogonal:
angle = orientation.angle_to_geographic_north
if angle is not None:
zrot = np.full(n_freq, float(angle))
if zrot is None:
zrot = np.zeros(n_freq, dtype=float)
if trot is None:
trot = np.array(zrot, copy=True)
edi.Z.rotation_angle = zrot
if getattr(edi.Tip, "tipper", None) is not None:
edi.Tip.rotation_angle = trot
[docs]
def emtf_to_edi(
document: EMTF,
*,
on_loss: str = "warn",
) -> Any:
"""Convert :class:`EMTF` into an in-memory historical ``EDIFile``.
The standard EDI representation used here stores impedance/tipper and
component variances. Rich covariance matrices, arbitrary TF types, and
several EMTF metadata blocks have no lossless destination; ``on_loss``
controls whether those reductions warn, raise, or are explicitly ignored.
"""
if not isinstance(document, EMTF):
raise TypeError("document must be a pycsamt.emtf.EMTF")
policy = str(on_loss).strip().lower()
if policy not in _VALID_LOSS_POLICIES:
raise ValueError(
"on_loss must be one of " f"{sorted(_VALID_LOSS_POLICIES)}"
)
ztf = document.impedance
if ztf is None:
raise EMTFEDIConversionError(
"standard EDI writing requires an impedance transfer function"
)
if ztf.data.shape[1:] != (2, 2):
raise EMTFEDIConversionError(
"EDI impedance must have matrix shape (n_period, 2, 2)"
)
freq = document.frequency
if freq is None or len(freq) != ztf.n_periods:
raise EMTFEDIConversionError(
"EDI writing requires a frequency vector matching impedance"
)
if tuple(name.lower() for name in ztf.input_channels) != ("hx", "hy"):
raise EMTFEDIConversionError(
"EDI impedance input channels must be Hx/Hy"
)
if tuple(name.lower() for name in ztf.output_channels) != ("ex", "ey"):
raise EMTFEDIConversionError(
"EDI impedance output channels must be Ex/Ey"
)
for message in _collect_unrepresentable(document):
_loss(policy, message)
station = _first(
document.site.site_id if document.site is not None else None,
document.station,
document.product_id,
)
if station is None:
_loss(
policy,
"EMTF has no site identifier; EDI requires DATAID and would need "
"a synthesized identifier",
)
station = "site"
from ...seg.edi import EDIFile
from ...z.tipper import Tipper
from ...z.z import Z
edi = EDIFile()
head, info = _build_head_info(document)
if head.dataid is None:
head.dataid = str(station)
define, mtsect = _build_measurements(document, len(freq))
if mtsect.sectid is None:
mtsect.sectid = str(station)
missing_remote = getattr(
mtsect, "_pycsamt_missing_remote_measurements", []
)
if missing_remote:
_loss(
policy,
"EDI remote-reference IDs could not be restored because their "
"HMEAS declarations are unavailable: "
+ ", ".join(missing_remote)
+ ". The dangling RX/RY references were omitted.",
)
edi.add_section("head", head)
edi.add_section("info", info)
edi.add_section("definemeas", define)
edi.add_section("mtsect", mtsect)
zerr = _tf_to_edi_error(ztf)
# Build Z without uncertainty first. The compatibility Z class computes
# rho/phase during ``freq`` assignment and requires finite errors, while
# EMTF legitimately allows missing variance components. The EDI serializer
# can represent those missing uncertainties using EMPTY, so attach the
# legacy error carrier after Z initialization.
edi.Z = Z(
z_array=np.array(ztf.data, copy=True),
freq=np.asarray(freq, dtype=float),
name=str(station),
)
if zerr is not None:
edi.Z._z_err = np.array(zerr, copy=True)
ttf = document.tipper_tf
if ttf is not None:
if ttf.data.shape[1:] != (1, 2):
raise EMTFEDIConversionError(
"EDI tipper must have matrix shape (n_period, 1, 2)"
)
if tuple(name.lower() for name in ttf.input_channels) != ("hx", "hy"):
raise EMTFEDIConversionError("EDI tipper inputs must be Hx/Hy")
if tuple(name.lower() for name in ttf.output_channels) != ("hz",):
raise EMTFEDIConversionError("EDI tipper output must be Hz")
terr = _tf_to_edi_error(ttf)
edi.Tip = Tipper(
tipper_array=np.array(ttf.data, copy=True),
tipper_err_array=(
None if terr is None else np.array(terr, copy=True)
),
freq=np.asarray(freq, dtype=float),
name=str(station),
)
edi._force_tipper_on_write = True
_restore_rotation(document, edi, len(freq))
return edi
[docs]
def write_edi(
obj: Any,
target: str | PathLike[str],
*,
on_loss: str = "warn",
preserve_zero: bool = True,
stamp_headers: bool = False,
force_tipper: bool | None = None,
**kwargs: Any,
) -> Path:
"""Write an ``EMTF`` or ``EDIFile`` to an exact filesystem target.
Existing :meth:`EDIFile.write` behavior remains unchanged by default.
This neutral adapter opts into ``preserve_zero=True`` so finite physical
zeros are not turned into the EDI missing-value sentinel during format
conversion; NaN/Inf values are still serialized as the sentinel.
"""
if not isinstance(target, (str, PathLike)):
raise TypeError("EDI writing currently requires a filesystem target")
target_path = Path(target).expanduser()
parent = target_path.parent if str(target_path.parent) else Path.cwd()
parent.mkdir(parents=True, exist_ok=True)
from ...seg.edi import EDIFile
if isinstance(obj, EMTF):
edi = emtf_to_edi(obj, on_loss=on_loss)
if force_tipper is None:
force_tipper = obj.tipper_tf is not None
elif isinstance(obj, EDIFile):
edi = obj
if force_tipper is None:
force_tipper = False
else:
raise TypeError("EDI writer accepts EMTF or EDIFile objects")
result = Path(
edi.write(
new_edifn=target_path.name,
savepath=parent,
preserve_zero=preserve_zero,
stamp_headers=stamp_headers,
force_tipper=bool(force_tipper),
**kwargs,
)
)
resolved_target = target_path.resolve()
if result.resolve() != resolved_target:
result.replace(resolved_target)
return resolved_target