# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
from __future__ import annotations
from pathlib import Path
from typing import Any
# import os
import numpy as np
from ..exceptions import EdIDataError
from ..log.logger import get_logger
from ..utils.validation import has_read
from ..z.base import BaseEM
from ..z.tipper import Tipper
from ..z.z import Z
from .base import EDIComponentBase
from .ops import (
compute_errors_from_S,
effective_dof_from_meta,
synthesize_spectra_from_z,
)
from .validation import (
IsEdi,
_split_comment,
_strip_norm,
_to_float_or_none,
_to_int_or_none,
)
logger = get_logger(__name__)
__all__ = ["SpectraSECT", "SpectraIO", "SpectraMixin", "Spectra"]
_EMPTY = 1.0e32
[docs]
class SpectraSECT(EDIComponentBase):
r"""
Minimal container for the ``>=SPECTRASECT`` header.
The class parses and serializes the spectra section
header that precedes one or more ``>SPECTRA`` data
blocks. It collects the option key/values and the
ordered set of measurement IDs that the spectra apply
to, as described by the SEG EDI convention [1]_.
Parameters
----------
verbose : int or bool, optional
Verbosity flag propagated from :class:`Base`.
logger : object, optional
Logger instance to use. If ``None``, a default
null-safe logger is attached.
**kws :
Additional field overrides. Keys may include
any attribute listed below.
Attributes
----------
sectid : str or None
Section identifier, often a site name. Some
files omit this or use a numeric ID.
nchan : int or None
Number of channels in the spectra set.
nfreq : int or None
Number of frequencies expected in the section.
maxblks : int or None
Maximum number of blocks. Rarely used.
meas_ids : list of str
Ordered measurement ID list that follows the
option lines in ``>=SPECTRASECT``.
start_data_lines_num : int or None
Line index in the EDI where the first
``>SPECTRA`` block begins. Set by
:meth:`from_file`.
Notes
-----
* Parsing is tolerant to case and extra whitespace.
* Unknown header keys are ignored instead of raising.
* The measurement ID list is collected from the
header body once option lines end.
* The start of the spectra data is detected by the
first ``>SPECTRA`` tag, by the next ``>=...`` tag,
or by end of file, whichever comes first.
* For consistent processing, maintain the same
frequency set across related data sections,
as recommended in the EDI spec [1]_.
See Also
--------
SpectraIO
Reader/writer for the ``>SPECTRA`` data blocks.
MTEMAP
Header for ``>=MTSECT`` or ``>=EMAPSECT``. The
spectra frequency set should match the MT set.
TSect
Header for ``>=TSERIESSECT`` (time series).
Examples
--------
Read only the header and measurement IDs:
>>> sect = SpectraSECT.from_file("site.edi")
>>> sect.nfreq, sect.nchan
(128, 5)
>>> sect.meas_ids[:2]
['HX1', 'HY1']
Serialize a header:
>>> sect.nfreq = 3
>>> sect.meas_ids = ["HX", "HY", "EX", "EY"]
>>> lines = sect.write()
>>> print("".join(lines).strip()) # doctest: +ELLIPSIS
>=SPECTRASECT
SECTID=...
NCHAN=...
NFREQ=3
MAXBLKS=...
// 4
HX
HY
EX
EY
References
----------
.. [1] SEG EDI standard, "Spectra Data Sections".
"""
KEY_ORDER: list[str] = [
"sectid",
"nchan",
"nfreq",
"maxblks",
]
def __init__(self, *args: Any, verbose: int = 0, logger=None, **kws: Any):
super().__init__(verbose=verbose, logger=logger)
self.sectid: str | None = None
self.nchan: int | None = None
self.nfreq: int | None = None
self.maxblks: int | None = None
self.meas_ids: list[str] = []
self.start_data_lines_num: int | None = None
self.id_to_chtype: dict[str, str] = {}
for k, v in kws.items():
setattr(self, k, v)
[docs]
@classmethod
def from_file(cls, edi_path: str) -> SpectraSECT:
# Pseudo test with >Head missing
# if not os.path.isfile(edi_path):
# raise FileNotFoundError(
# f"{edi_path!r} is not a file."
# )
# with open(edi_path, "r", encoding="utf-8") as f:
# lines = f.readlines()
p = Path(edi_path)
IsEdi._assert_edi(p, deep=True)
lines = p.read_text(
encoding="utf-8-sig", errors="replace"
).splitlines()
# find >=SPECTRASECT
start = None
for i, ln in enumerate(lines):
if ln.lstrip().upper().startswith(">=SPECTRASECT"):
start = i
break
if start is None:
raise EdIDataError("No >=SPECTRASECT found.")
# stop at first >SPECTRA, next >=..., or EOF
stop = len(lines)
for j in range(start + 1, len(lines)):
u = lines[j].lstrip().upper()
if u.startswith(">SPECTRA") or u.startswith(">="):
stop = j
break
inst = cls()
for raw in lines[start + 1 : stop]:
s = raw.strip()
if not s or s.startswith("//"):
continue
if "=" in s:
k, v = s.split("=", 1)
key = _strip_norm(k).lower()
val = _strip_norm(v)
if key == "sectid":
inst.sectid = val
elif key == "nchan":
inst.nchan = _to_int_or_none(val)
elif key == "nfreq":
inst.nfreq = _to_int_or_none(val)
elif key == "maxblks":
inst.maxblks = _to_int_or_none(val)
else:
if s:
inst.meas_ids.append(_strip_norm(s))
inst.start_data_lines_num = stop
inst.id_to_chtype = cls._collect_id_to_chtype(lines)
return inst
[docs]
def write(self) -> list[str]:
out: list[str] = [">=SPECTRASECT\n"]
values: dict[str, Any] = {
"sectid": self.sectid,
"nchan": self.nchan,
"nfreq": self.nfreq,
"maxblks": self.maxblks,
}
for key in self.KEY_ORDER:
val = values.get(key, None)
if val in (None, "", "None"):
continue
out.append(f" {key.upper()}={str(val).upper()}\n")
if self.meas_ids:
out.append(f" // {len(self.meas_ids)}\n")
for mid in self.meas_ids:
out.append(f" {str(mid)}\n")
return out
@staticmethod
def _collect_id_to_chtype(lines: list[str]) -> dict[str, str]:
"""
Scan >HMEAS / >EMEAS lines and build {ID -> CHTYPE}.
IDs are kept as strings; CHTYPE is upper-cased.
"""
out: dict[str, str] = {}
for raw in lines:
s = raw.lstrip()
u = s.upper()
if not (u.startswith(">HMEAS") or u.startswith(">EMEAS")):
continue
toks = s[1:].split()
kv: dict[str, str] = {}
for t in toks[1:]:
if "=" in t:
k, v = t.split("=", 1)
kv[_strip_norm(k).upper()] = _strip_norm(v)
mid = kv.get("ID", None)
cht = (
kv.get("CHTYPE")
or kv.get("CH")
or kv.get("COMP")
or kv.get("TYPE")
)
if mid and cht:
out[str(mid)] = str(cht).upper()
return out
class _SpectraBlock(EDIComponentBase):
"""
Single >SPECTRA block container.
"""
def __init__(
self, *args: Any, verbose: int | bool = 0, logger=None, **kws: Any
):
super().__init__(verbose=verbose, logger=logger)
self.freq: float | None = None
self.rotspec: int | None = None
self.bw: float | None = None
self.avgt: float | None = None
self.nvals_hint: int | None = None
self.options: dict[str, Any] = {}
self.values: list[float] = []
for k, v in kws.items():
setattr(self, k, v)
def header_dict(self) -> dict[str, Any]:
d = dict(self.options)
if self.freq is not None:
d["freq"] = self.freq
if self.rotspec is not None:
d["rotspec"] = self.rotspec
if self.bw is not None:
d["bw"] = self.bw
if self.avgt is not None:
d["avgt"] = self.avgt
return d
[docs]
class SpectraIO(EDIComponentBase):
r"""
Read and write ``>SPECTRA`` data blocks.
A spectra section contains one block per frequency.
Each block begins with a ``>SPECTRA`` line that holds
options such as frequency and bandwidth, optionally
followed by a comment with the number of values,
then one or more lines of numeric values.
Known options are normalized:
* ``FREQ`` : float
* ``ROTSPEC`` : int
* ``BW`` : float
* ``AVGT`` : float
Unrecognized options are preserved in a free-form
mapping so that vendor-specific metadata is not lost.
Parameters
----------
verbose : int or bool, optional
Verbosity flag propagated from :class:`Base`.
logger : object, optional
Logger instance to use. If ``None``, a default
null-safe logger is attached.
**kws :
Additional field overrides.
Attributes
----------
blocks : list of _SpectraBlock
Parsed spectra blocks, one per frequency. Each
block stores header options, the optional value
count hint, and the numeric values.
Notes
-----
* :meth:`from_file` reads successive ``>SPECTRA``
blocks starting from a given line or from the
first match in the file.
* Values are parsed as floats; non-numeric tokens
in data lines are ignored rather than raising.
* The writer orders known options first in the
header line, then appends extra options sorted
by key. Both option keys and values are written
in upper case.
* Line formatting uses the per-line and float
format defaults from :class:`Base` unless you
provide explicit overrides.
See Also
--------
SpectraSECT
Header container for spectra sections.
TSIO
Time-series counterpart for ``>TSERIES``.
Examples
--------
Read all spectra blocks:
>>> io = SpectraIO.from_file("site.edi")
>>> len(io.blocks)
128
>>> b0 = io.blocks[0]
>>> b0.freq, b0.bw # doctest: +ELLIPSIS
(..., ...)
Build and serialize blocks:
>>> from pycsamt.seg.spectra import _SpectraBlock
>>> io = SpectraIO()
>>> blk = _SpectraBlock()
>>> blk.freq = 10.0
>>> blk.rotspec = 1
>>> blk.values = [0.1, 0.2, 0.3]
>>> io.blocks.append(blk)
>>> lines = io.write(per_line=2, float_fmt="{: .3E}")
>>> print("".join(lines).strip())
>SPECTRA FREQ=10.0 ROTSPEC=1 // 3
1.000E-01 2.000E-01
3.000E-01
References
----------
.. [1] SEG EDI standard, "Spectra Data Sections".
"""
def __init__(
self, *args: Any, verbose: int | bool = 0, logger=None, **kws: Any
):
super().__init__(verbose=verbose, logger=logger)
self.blocks: list[_SpectraBlock] = []
for k, v in kws.items():
setattr(self, k, v)
# --------------------------
# Load from file
# --------------------------
[docs]
@classmethod
def from_file(
cls,
edi_path: str,
start_line: int | None = None,
) -> SpectraIO:
# if not os.path.isfile(edi_path):
# raise FileNotFoundError(
# f"{edi_path!r} is not a file."
# )
# with open(edi_path, "r", encoding="utf-8") as f:
# lines = f.readlines()
p = Path(edi_path)
IsEdi._assert_edi(p, deep=True)
lines = p.read_text(
encoding="utf-8-sig", errors="replace"
).splitlines()
if start_line is None:
# find first >SPECTRA
for i, ln in enumerate(lines):
if ln.upper().lstrip().startswith(">SPECTRA"):
start_line = i
break
if start_line is None:
raise EdIDataError("No >SPECTRA blocks found.")
inst = cls()
i = start_line
n = len(lines)
while i < n:
ln = lines[i].rstrip("\n")
u = ln.upper().lstrip()
if u.startswith(">="):
break
if not u.startswith(">SPECTRA"):
i += 1
continue
blk, next_i = cls._parse_block(lines, i)
inst.blocks.append(blk)
i = next_i
return inst
@staticmethod
def _parse_block(
lines: list[str],
i: int,
) -> tuple[_SpectraBlock, int]:
head = lines[i].rstrip("\n")
body, cmt = _split_comment(head)
toks = body.split()
# toks[0] is ">SPECTRA"
opts = toks[1:]
blk = _SpectraBlock()
if cmt is not None:
try:
blk.nvals_hint = int(float(cmt))
except Exception:
blk.nvals_hint = None
for t in opts:
if "=" not in t:
continue
k, v = t.split("=", 1)
key = _strip_norm(k).lower()
val = _strip_norm(v)
if key == "freq":
blk.freq = _to_float_or_none(val)
elif key == "rotspec":
blk.rotspec = _to_int_or_none(val)
elif key == "bw":
blk.bw = _to_float_or_none(val)
elif key == "avgt":
blk.avgt = _to_float_or_none(val)
else:
# keep unknown options
blk.options[key] = val
j = i + 1
while j < len(lines):
s = lines[j].strip()
if not s:
j += 1
continue
if s.startswith(">"):
break
if s.startswith("//"):
j += 1
continue
# collect floats
before, _ = _split_comment(s)
for tok in before.split():
try:
blk.values.append(float(tok))
except Exception:
# tolerate bad tokens
pass
j += 1
return blk, j
[docs]
def write(
self,
per_line: int | None = None,
float_fmt: str | None = None,
) -> list[str]:
kpl = self.PER_LINE if per_line is None else per_line
ffmt = self.FLOAT_FMT if float_fmt is None else float_fmt
out: list[str] = []
for blk in self.blocks:
head = [">SPECTRA"]
hd = blk.header_dict()
# keep stable order in output
for key in ("freq", "rotspec", "bw", "avgt"):
if key in hd and hd[key] is not None:
head.append(f"{key.upper()}={hd[key]}")
# include any extra options
for k, v in sorted(blk.options.items()):
head.append(f"{k.upper()}={str(v).upper()}")
n_hint = (
blk.nvals_hint
if blk.nvals_hint is not None
else len(blk.values)
)
head_line = " ".join(head) + f" // {n_hint}\n"
out.append(head_line)
line_vals: list[str] = []
cnt = 0
for v in blk.values:
line_vals.append(ffmt.format(v))
cnt += 1
if cnt == kpl:
out.append(" " + " ".join(line_vals) + "\n")
line_vals = []
cnt = 0
if line_vals:
out.append(" " + " ".join(line_vals) + "\n")
return out
def __iter__(self):
return iter(self.blocks)
def __len__(self):
return len(self.blocks)
def __getitem__(self, idx):
return self.blocks[idx]
[docs]
class SpectraMixin:
r"""
Convenience facade for spectra access.
This mixin exposes a compact API that host classes
can reuse to discover and read spectra sections in
an EDI file.
Methods
-------
from_file(edi_fn)
Return a :class:`SpectraSECT` parsed from the
first ``>=SPECTRASECT`` header in ``edi_fn``.
read_blocks(edi_fn)
Return a :class:`SpectraIO` by scanning all
subsequent ``>SPECTRA`` blocks that belong to the
section discovered by :class:`SpectraSECT`.
Notes
-----
Use this mixin in higher-level readers so spectra
handling remains consistent and centralized. The
method pair mirrors the design used for MT/EMAP
headers and for time series sections.
See Also
--------
SpectraSECT
Header parsing and serialization.
SpectraIO
Data block reader/writer.
MTEMAP
MT/EMAP section header, often used alongside
spectra for the same dataset.
Examples
--------
>>> class Reader(SpectraMixin):
... pass
>>> sect = Reader.from_file("site.edi")
>>> io = Reader.read_blocks("site.edi")
>>> len(io.blocks) > 0
True
References
----------
.. [1] SEG EDI standard, "Spectra Data Sections".
"""
[docs]
@classmethod
def from_file(cls, edi_fn: str) -> SpectraSECT:
return SpectraSECT.from_file(edi_fn)
[docs]
@classmethod
def read_blocks(cls, edi_fn: str) -> SpectraIO:
sect = SpectraSECT.from_file(edi_fn)
return SpectraIO.from_file(
edi_fn, start_line=sect.start_data_lines_num
)
[docs]
class Spectra(BaseEM):
r"""
Container for ``>SPECTRA`` blocks grouped per frequency.
The class gathers one spectra record per frequency and
exposes typed header fields (frequency, rotation flag,
bandwidth, and averaging time) together with the numeric
values stored in each block. It is a compact, array-
oriented view on top of :class:`~pycsamt.seg.spectra.
SpectraSECT` and :class:`~pycsamt.seg.spectra.SpectraIO`.
Parameters
----------
name : str, optional
Display name forwarded to :class:`BaseEM`.
verbose : int, default 0
Verbosity level, forwarded to :class:`BaseEM`.
Attributes
----------
freq : ndarray, shape ``(n_blk,)``
Frequency (Hz) per block. Missing values are set to
``np.nan``.
rotspec : ndarray of int, shape ``(n_blk,)``
Rotation specifier per block. Missing values are set
to ``-1``.
bw : ndarray, shape ``(n_blk,)``
Nominal bandwidth (Hz) per block or ``np.nan``.
avgt : ndarray, shape ``(n_blk,)``
Averaging time (s) per block or ``np.nan``.
values : list of ndarray
Numeric payload for each block. Lengths may differ
across blocks, as allowed by the SEG format.
n_values : ndarray of int, shape ``(n_blk,)``
Number of values in each block (as parsed or counted).
Notes
-----
Blocks may contain vendor-specific options beyond the
canonical ``FREQ``, ``ROTSPEC``, ``BW``, and ``AVGT``.
Those options are preserved when round-tripping via
:meth:`to_io`. The class does **not** impose a common
length across spectra vectors; if you require a 2-D array,
pad the :attr:`values` list explicitly.
The constructor itself does not read files. Use
:meth:`from_io` or :meth:`from_file` to populate an
instance from sections and data blocks.
Methods
-------
from_io(sect, io) : classmethod
Build a :class:`Spectra` from :class:`SpectraSECT`
and :class:`SpectraIO`.
from_file(path) : classmethod
Convenience that calls :class:`SpectraSECT.from_file`
and :class:`SpectraIO.from_file`, then delegates to
:meth:`from_io`.
to_io()
Serialize the current state to a fresh pair
(:class:`SpectraSECT`, :class:`SpectraIO`) that can be
written back to an EDI file.
Examples
--------
Read, inspect, and serialize spectra::
from pycsamt.seg.spectra import Spectra
sp = Spectra.from_file("site.edi")
f = sp.freq
first = sp.values[0]
sect2, io2 = sp.to_io()
# writer can now combine sect2.write() and io2.write()
See Also
--------
pycsamt.seg.spectra.SpectraSECT
Header for ``>=SPECTRASECT`` sections.
pycsamt.seg.spectra.SpectraIO
Reader/writer for ``>SPECTRA`` blocks.
pycsamt.seg.EDIFile
High-level dispatcher that can attach spectra to an
EDI session.
References
----------
.. [1] SEG EDI standard, *Spectra Data Sections*.
Society of Exploration Geophysicists.
.. [2] Chave, A. D., & Jones, A. G. (2012). *The
Magnetotelluric Method: Theory and Practice*.
Cambridge Univ. Press.
"""
# Holds: freq(nf,), S(nf,nc,nc)
# Hermitian, per-block meta.
def __init__(
self,
name: str | None = None,
*,
verbose: int = 0,
) -> None:
super().__init__(name=name, verbose=verbose)
self._freq = np.zeros(0, float)
self._S = np.zeros((0, 0, 0), complex)
self.bw = np.zeros(0, float)
self.avgt = np.zeros(0, float)
self.avgf = np.zeros(0, float)
self.rotspec = np.zeros(0, float)
self.segnum = np.zeros(0, int)
self.band: list[str] = []
self.chan_ids: list[str] = []
self.id_to_chtype: dict[str, str] = {}
# ---------------- basic props
[docs]
@property
def freq(self) -> np.ndarray:
return self._freq
[docs]
@property
def S(self) -> np.ndarray:
return self._S
[docs]
@property
def n_freq(self) -> int:
return int(self._freq.size)
[docs]
@property
def n_chan(self) -> int:
return int(self._S.shape[1]) if self._S.ndim else 0
# -------------- pack/unpack helpers
@staticmethod
def _unpack(vals: np.ndarray, n: int, *, empty: float) -> np.ndarray:
v = np.asarray(vals, float)
need = n * n
if v.size < need:
raise EdIDataError("SPECTRA payload too short")
M = v[:need].reshape(n, n)
M = np.where(M == float(empty), 0.0, M)
H = np.zeros((n, n), complex)
for i in range(n):
H[i, i] = M[i, i] + 0.0j
for j in range(i + 1, n):
re = M[j, i]
im = M[i, j]
H[i, j] = re + 1j * im
H[j, i] = re - 1j * im
return H
@staticmethod
def _pack(H: np.ndarray) -> np.ndarray:
if H.ndim != 2 or H.shape[0] != H.shape[1]:
raise ValueError("H must be square")
n = H.shape[0]
M = np.zeros((n, n), float)
for i in range(n):
M[i, i] = float(H[i, i].real)
for j in range(i + 1, n):
z = H[i, j]
M[j, i] = float(z.real)
M[i, j] = float(z.imag)
return M.ravel()
[docs]
@classmethod
def from_io(
cls,
sect: SpectraSECT,
io: SpectraIO,
*,
empty: float = 1.0e32,
verbose: int = 0,
) -> Spectra:
# infer nchan robustly
nc: int | None = None
# 1) prefer explicit header nchan
if getattr(sect, "nchan", None) is not None:
try:
nc = int(sect.nchan) # type: ignore[arg-type]
except Exception:
nc = None
# 2) try channel ids length
if not nc or nc <= 0:
ids = getattr(sect, "meas_ids", None)
if ids:
try:
nc = int(len(ids))
except Exception:
nc = None
# 3) try block hint or values length of the first block
if (not nc or nc <= 0) and getattr(io, "blocks", None):
first = next(
(b for b in io.blocks if getattr(b, "values", None)), None
)
if first is not None:
hint = getattr(first, "nvals_hint", None)
if isinstance(hint, (int, float)) and hint > 0:
root = int(round(np.sqrt(float(hint))))
if root * root == int(hint):
nc = root
if not nc or nc <= 0:
nv = len(np.asarray(getattr(first, "values", []), float))
if nv > 0:
root = int(round(np.sqrt(nv)))
if root > 0 and root * root <= nv:
nc = root
if not nc or nc <= 0:
raise EdIDataError("bad SPECTRA header: cannot infer nchan")
if not hasattr(io, "blocks") or not io.blocks:
raise EdIDataError("no >SPECTRA blocks")
self = cls(
name=getattr(sect, "sectid", None),
verbose=verbose,
)
self.chan_ids = list(sect.meas_ids or [])
# carry DefineMeas mapping (if present on the header)
idm = getattr(sect, "id_to_chtype", None)
if isinstance(idm, dict) and idm:
try:
self.id_to_chtype = dict(idm)
except Exception:
pass
def _f(x, dv=0.0) -> float:
try:
v = float(x)
return v
except Exception:
return float(dv)
def _i(x, dv=0) -> int:
try:
return int(float(x))
except Exception:
return int(dv)
def _opt(blk) -> dict:
o = getattr(blk, "options", None)
return o if isinstance(o, dict) else {}
def _get_freq(blk) -> float | None:
# attr first
fv = getattr(blk, "freq", None)
if fv is None:
opts = _opt(blk)
for k in ("freq", "cfreq", "f", "frequency"):
if k in opts:
fv = opts.get(k)
break
try:
v = float(fv)
return v if np.isfinite(v) else None
except Exception:
return None
def _get_float(
blk, *names: str, default: float | None = None
) -> float | None:
# attribute names then options
for nm in names:
v = getattr(blk, nm, None)
if v is not None:
try:
vv = float(v)
return vv
except Exception:
pass
o = _opt(blk)
for nm in names:
if nm in o:
try:
vv = float(o.get(nm))
return vv
except Exception:
pass
return default
def _get_int(
blk, *names: str, default: int | None = None
) -> int | None:
for nm in names:
v = getattr(blk, nm, None)
if v is not None:
try:
return int(float(v))
except Exception:
pass
o = _opt(blk)
for nm in names:
if nm in o:
try:
return int(float(o.get(nm)))
except Exception:
pass
return default
freqs: list[float] = []
mats: list[np.ndarray] = []
bw: list[float] = []
avgt: list[float] = []
avgf: list[float] = []
rots: list[float] = []
segnum: list[int] = []
band: list[str] = []
for blk in io.blocks:
f0 = _get_freq(blk)
if f0 is None:
continue
vals = np.asarray(getattr(blk, "values", []), float)
H = cls._unpack(vals, nc, empty=empty)
freqs.append(float(f0))
mats.append(H)
# bw
v = _get_float(blk, "bw", default=0.0)
bw.append(0.0 if v is None else float(v))
# avgt
v = _get_float(blk, "avgt", default=1.0)
avgt.append(1.0 if v is None else float(v))
# avgf
v = _get_float(blk, "avgf", default=np.nan)
avgf.append(np.nan if v is None else float(v))
# rotspec (this was causing your failure)
v = _get_float(blk, "rotspec", default=np.nan)
rots.append(np.nan if v is None else float(v))
# segnum
sn = _get_int(blk, "segnum", default=0)
segnum.append(0 if sn is None else int(sn))
# band
bo = _opt(blk).get("band", "")
band.append(str(bo).upper() if bo else "")
n = len(freqs)
if n == 0:
# tolerate empty after filtering
self._freq = np.zeros(0, dtype=float)
self._S = np.zeros((0, nc, nc), dtype=complex)
self.bw = np.zeros(0, dtype=float)
self.avgt = np.zeros(0, dtype=float)
self.avgf = np.zeros(0, dtype=float)
self.rotspec = np.zeros(0, dtype=float)
self.segnum = np.zeros(0, dtype=int)
self.band = []
return self
self._freq = np.asarray(freqs, dtype=float)
self._S = np.stack(mats, axis=0)
self.bw = np.asarray(bw, dtype=float)
self.avgt = np.asarray(avgt, dtype=float)
self.avgf = np.asarray(avgf, dtype=float)
self.rotspec = np.asarray(rots, dtype=float)
self.segnum = np.asarray(segnum, dtype=int)
self.band = list(band)
# ensure high→low order
if n > 1 and self._freq[-1] > self._freq[0]:
sl = slice(None, None, -1)
self._freq = self._freq[sl]
self._S = self._S[sl]
self.bw = self.bw[sl]
self.avgt = self.avgt[sl]
self.avgf = self.avgf[sl]
self.rotspec = self.rotspec[sl]
self.segnum = self.segnum[sl]
self.band = self.band[::-1]
return self
[docs]
@classmethod
def from_file(
cls,
path: str,
*,
empty: float = 1.0e32,
verbose: int = 0,
) -> Spectra:
"""Read a :class:`Spectra` directly from an EDI file path.
Convenience wrapper around :meth:`from_io` that calls
:class:`SpectraSECT.from_file` and :class:`SpectraIO.from_file`
internally.
Parameters
----------
path : str or Path
Path to the EDI file containing ``>=SPECTRASECT`` and
``>SPECTRA`` blocks.
empty : float
Sentinel value for missing spectra entries. Default 1e32.
verbose : int
Verbosity level forwarded to :meth:`from_io`.
Returns
-------
Spectra
"""
sect = SpectraSECT.from_file(str(path))
sio = SpectraIO.from_file(
str(path), start_line=sect.start_data_lines_num
)
return cls.from_io(sect, sio, empty=empty, verbose=verbose)
[docs]
def to_edi(
self,
source_edi=None,
*,
station_name: str | None = None,
e_labels: tuple[str, str] = ("EX", "EY"),
h_labels: tuple[str, str] = ("HX", "HY"),
ridge: float | None = None,
estimate_error: bool = False,
dof: Any | None = None,
) -> Any:
r"""Convert cross-spectra to an MT-impedance :class:`~pycsamt.seg.edi.EDIFile`.
Calls :meth:`to_Z` and assembles a complete
``>=MTSECT`` / ``>FREQ`` / ``>ZXXR`` / ``>ZXYR`` / … EDI
ready to be saved with :meth:`~pycsamt.seg.edi.EDIFile.write`.
The structural sections (``>HEAD``, ``>INFO``, ``>=DEFINEMEAS``)
are *re-used* from *source_edi* when provided, preserving all
acquisition metadata; otherwise a minimal header is synthesised
from the :class:`Spectra` metadata.
According to the SEG EDI standard (§ 7.53, 12.1), an MT data
section requires:
.. code-block:: text
>=MTSECT
SECTID=...
NFREQ=...
HX=... HY=... HZ=... EX=... EY=...
>FREQ //N
...
>ZXXR ROT=ZROT //N
...
>ZXXI ROT=ZROT //N
...
...
>END
The measurement IDs for ``HX``, ``HY``, … in ``>=MTSECT`` are
resolved from :attr:`id_to_chtype` (populated by
:class:`SpectraSECT` from ``>HMEAS`` / ``>EMEAS`` lines).
Parameters
----------
source_edi : str, Path, or EDIFile, optional
Spectra EDI file whose ``>HEAD``, ``>INFO``, and
``>=DEFINEMEAS`` sections are copied into the output.
Pass the same path used with :meth:`from_file` to produce
a fully metadata-rich result. When ``None``, a minimal
header is synthesised.
station_name : str, optional
Override for the ``DATAID`` in ``>HEAD`` and ``SECTID`` in
``>=MTSECT``. Defaults to :attr:`name`.
e_labels : tuple of str
Electric channel type labels forwarded to :meth:`to_Z`.
h_labels : tuple of str
Horizontal magnetic channel type labels forwarded to :meth:`to_Z`.
ridge : float, optional
Tikhonov regularisation forwarded to :meth:`to_Z`.
estimate_error : bool
If ``True``, propagate 1-σ errors into ``>ZXX.VAR`` … blocks.
dof : float or ndarray, optional
Effective degrees of freedom forwarded to :meth:`to_Z`.
Returns
-------
EDIFile
Fully populated MT-impedance container. Call
:meth:`~pycsamt.seg.edi.EDIFile.write` to save.
Raises
------
EdIDataError
If :meth:`to_Z` fails (channel types not resolved, singular
magnetic block, etc.).
Examples
--------
Convert and save::
sp = Spectra.from_file("site.edi")
ed = sp.to_edi("site.edi", estimate_error=False)
out = ed.write(savepath="mt_output/")
Convert with a custom station name and error propagation::
ed = sp.to_edi("site.edi", station_name="HBH03_imp",
estimate_error=True, dof=24.0)
out = ed.write(savepath="mt_output/")
Verify the round-trip::
from pycsamt.seg.edi import EDIFile
ed2 = EDIFile(out)
assert ed2.Z.n_freq == sp.n_freq
"""
# deferred import to avoid circular deps at module level
from .edi import EDIFile as _EDIFile
from .mtemap import MTEMAP as _MTEMAP
# ── Step 1: recover Z and Tipper from cross-spectra ──────────────
z_obj, tip = self.to_Z(
e_labels=e_labels,
h_labels=h_labels,
ridge=ridge,
estimate_error=estimate_error,
dof=dof,
)
# ── Step 2: build or load the structural EDI template ─────────────
if isinstance(source_edi, _EDIFile):
ed = source_edi
elif source_edi is not None:
ed = _EDIFile(str(source_edi), verbose=0)
else:
# No source: create a blank container; caller must fill header
ed = _EDIFile.__new__(_EDIFile)
ed.path = None
ed.verbose = 0
ed._init_registry()
ed._data_start = None
from ..z.resphase import ResPhase as _RP
from ..z.tipper import Tipper as _Tip
from ..z.z import Z as _Z
ed.Z = _Z(verbose=0)
ed.Res = _RP(verbose=0)
ed.Tip = _Tip()
ed.block_size = 6
ed.float_fmt = "{: .6E}"
ed.header_tpl = ">!****{title}****!\n"
# ── Step 3: build >=MTSECT section ───────────────────────────────
# Invert id_to_chtype: CHTYPE → first matching measurement ID
id_map: dict[str, str] = dict(getattr(self, "id_to_chtype", {}) or {})
chtype_to_id: dict[str, str] = {}
for mid, cht in id_map.items():
key = "".join(c for c in str(cht).upper() if c.isalpha())
if key and key not in chtype_to_id:
chtype_to_id[key] = str(mid)
# Also try chan_ids directly when they look like channel type labels
if not chtype_to_id and self.chan_ids:
for cid in self.chan_ids:
key = "".join(c for c in str(cid).upper() if c.isalpha())
if key in {"HX", "HY", "HZ", "EX", "EY", "RX", "RY"}:
chtype_to_id[key] = str(cid)
mtsect_kw: dict[str, Any] = {}
for attr in ("hx", "hy", "hz", "ex", "ey", "rx", "ry"):
cht = attr.upper()
if cht in chtype_to_id:
mtsect_kw[attr] = chtype_to_id[cht]
sid = station_name or self.name or "SITE"
mtsect = _MTEMAP(
sectid=str(sid),
nfreq=int(z_obj.n_freq),
**mtsect_kw,
)
ed.add_section("mtsect", mtsect)
# ── Step 4: attach Z and Tipper ───────────────────────────────────
ed.Z = z_obj
if tip is not None:
ed.Tip = tip
# ── Step 5: propagate station name to >HEAD if possible ───────────
if station_name:
try:
ed.station = str(station_name)
except Exception:
pass
return ed
# -------------- round-trip to IO
[docs]
def to_io(self) -> tuple[SpectraSECT, SpectraIO]:
nc = self.n_chan
nf = self.n_freq
sect = SpectraSECT(
sectid=self.name,
nchan=nc,
nfreq=nf,
)
sect.meas_ids = list(self.chan_ids)
io = SpectraIO()
for k in range(nf):
blk = _SpectraBlock()
blk.freq = float(self._freq[k])
blk.rotspec = float(self.rotspec[k])
blk.bw = float(self.bw[k])
blk.avgt = float(self.avgt[k])
blk.options["avgf"] = float(self.avgf[k])
if int(self.segnum[k]) != 0:
blk.options["segnum"] = int(self.segnum[k])
if self.band[k]:
blk.options["band"] = str(self.band[k])
blk.nvals_hint = nc * nc
blk.values = self._pack(self._S[k]).tolist()
io.blocks.append(blk)
return sect, io
# -------------- conveniences
[docs]
def matrix(self, k: int) -> np.ndarray:
return np.array(self._S[k], copy=True)
[docs]
def psd(self, idx: int) -> np.ndarray:
return np.asarray(self._S[:, idx, idx].real)
[docs]
def cross(self, i: int, j: int) -> np.ndarray:
return np.asarray(self._S[:, i, j])
[docs]
def rotate(
self,
theta_deg: float,
*,
pairs: list[tuple[int, int]] | None = None,
) -> None:
if self.n_chan < 2:
return
th = float(theta_deg) * np.pi / 180.0
R2 = np.array(
[[np.cos(th), -np.sin(th)], [np.sin(th), np.cos(th)]],
float,
)
if pairs is None:
pairs = []
for b in range(0, self.n_chan - 1, 2):
pairs.append((b, b + 1))
for a, b in pairs:
T = np.eye(self.n_chan, dtype=float)
T[a : b + 1, a : b + 1] = R2
# S' = T S Tᴴ per frequency
self._S = np.einsum(
"ij,fjk,lk->fil", T, self._S, T, optimize=True
)
[docs]
def to_Z(
self,
*,
id_to_chtype: dict[str, str] | None = None,
e_labels: tuple[str, str] = ("EX", "EY"),
h_labels: tuple[str, str] = ("HX", "HY"),
use_remote: bool = False,
ridge: float | None = None,
estimate_error: bool = True,
dof: float | np.ndarray | None = None,
):
r"""
Recover an impedance tensor ``Z`` and, if available, the
tipper from cross-spectra stored in this :class:`Spectra`.
The method resolves channel types, extracts the electric
and magnetic sub-blocks, and computes per-frequency
``Z = S_EH @ inv(S_HH)``. If a vertical magnetic channel
is present it also computes the tipper
``T = S_ZH @ inv(S_HH)``. Optional ridge regularization
can be applied to stabilize the magnetic block.
Parameters
----------
id_to_chtype : dict of str to str, optional
Mapping from measurement IDs (as recorded in
``>=SPECTRASECT`` or ``DefineMeas``) to channel
types (``"HX"``, ``"HY"``, ``"HZ"``, ``"EX"``,
``"EY"``). If omitted, the method uses
``self.id_to_chtype`` when available, otherwise it
interprets ``self.chan_ids`` directly as labels.
e_labels : tuple of str, default (``"EX"``, ``"EY"``)
Labels that identify the two electric channels used
for the ``E`` block.
h_labels : tuple of str, default (``"HX"``, ``"HY"``)
Labels that identify the two horizontal magnetic
channels used for the ``H`` block.
use_remote : bool, default ``False``
When duplicate electric channels exist (e.g., local
and remote), choose the second occurrence for the
``E`` block if ``True``; otherwise choose the first.
ridge : float, optional
Non-negative Tikhonov regularization added to
``S_HH`` prior to inversion, ``S_HH + ridge * I``.
estimate_error : bool, default ``True``
If ``True``, estimate per-component 1-sigma standard
errors for ``Z`` (and tipper when available) using
``compute_errors_from_S`` and the degrees of freedom
given by ``dof`` (or inferred; see Notes).
dof : float or ndarray, optional
Effective degrees of freedom per frequency. If an
array is provided it must broadcast to ``n_freq``.
If ``None`` and ``estimate_error`` is ``True``, the
method tries to infer DoF from metadata via
``effective_dof_from_meta`` using ``segnum``, or
``avgt * bw`` as a fallback.
Returns
-------
z_obj : :class:`pycsamt.z.z.Z`
Impedance object on the spectra frequency grid with
``z`` populated and, when estimated, ``z_err`` set.
tip : :class:`pycsamt.z.tipper.Tipper` or ``None``
Tipper on the same grid when ``HZ`` is available.
When errors are estimated, tipper uncertainties are
attached.
Raises
------
EdIDataError
If spectra are empty, channel types cannot be
resolved, or the stabilized magnetic block is
singular.
Notes
-----
Per frequency, ``Z`` is formed as ``Z = S_EH @ inv(S_HH)``,
where ``S_EH`` is the cross-spectra between E and H, and
``S_HH`` is the magnetic auto/cross block. If a vertical
magnetic channel is available, the tipper is computed as
``T = S_ZH @ inv(S_HH)``.
Channel type resolution proceeds in this order:
1. explicit ``id_to_chtype`` argument,
2. ``self.id_to_chtype`` from the section header or
``DefineMeas``,
3. direct interpretation of ``self.chan_ids``.
When both local and remote electric channels are present,
setting ``use_remote=True`` chooses the second occurrence
as a simple heuristic. Frequency ordering is preserved.
Uncertainties are computed by first-order propagation
under a complex-Wishart model and scale as
``1 / sqrt(DoF)``. If DoF cannot be determined, errors
are left as NaN.
Examples
--------
>>> Zhat, That = spectra.to_Z()
>>> Zhat, _ = spectra.to_Z(
... use_remote=True, ridge=1e-6
... )
>>> Zhat, _ = spectra.to_Z(
... dof=np.full(spectra.n_freq, 24.0)
... )
See Also
--------
Spectra.from_Z
Inverse operation that synthesizes spectra.
spectra_from_Z
Functional wrapper for the inverse operation.
effective_dof_from_meta
Infer DoF from ``segnum``, ``avgt`` and ``bw``.
compute_errors_from_S
Per-frequency uncertainty estimator.
References
----------
.. [1] Chave, A. D., & Jones, A. G. (2012). *The
Magnetotelluric Method: Theory and Practice*.
Cambridge University Press.
.. [2] Bendat, J. S., & Piersol, A. G. (2011). *Random
Data: Analysis and Measurement Procedures*. Wiley.
"""
has_read(
self,
msg="Spectra not populated; call from_file()/from_io() first.",
)
if self.n_freq == 0 or self.n_chan < 2:
raise EdIDataError("Spectra is empty or too small.")
# default to mapping collected on SpectraSECT (if any)
if id_to_chtype is None:
id_to_chtype = getattr(self, "id_to_chtype", None)
def _norm(s: object) -> str:
t = "".join(ch for ch in str(s).upper() if ch.isalpha())
return t
# Resolve per-channel types in the order used by S (self.chan_ids)
# - If the file stored numeric IDs in SPECTRASECT, you *must* pass
# `id_to_chtype` (built from >HMEAS/>EMEAS). Otherwise, we accept
# chan_ids that are already like ["HX","HY","HZ","EX","EY",...].
if id_to_chtype:
kinds_raw = [
id_to_chtype.get(str(mid), "") for mid in self.chan_ids
]
else:
kinds_raw = list(self.chan_ids)
kinds = [_norm(x) for x in kinds_raw]
# pick indices for H and E
def _all_idx(lbl: str) -> list[int]:
L = _norm(lbl)
return [
i for i, k in enumerate(kinds) if (k == L) or k.startswith(L)
]
ex_all, ey_all = _all_idx(e_labels[0]), _all_idx(e_labels[1])
hx_all, hy_all = _all_idx(h_labels[0]), _all_idx(h_labels[1])
hz_all = _all_idx("HZ")
def _choose(pair):
# choose the first by default; second if remote requested
if not pair:
return None
return pair[1] if (use_remote and len(pair) > 1) else pair[0]
idx_ex = _choose(ex_all)
idx_ey = _choose(ey_all)
idx_hx = _choose(hx_all)
idx_hy = _choose(hy_all)
idx_hz = _choose(hz_all) # optional
if None in (idx_ex, idx_ey, idx_hx, idx_hy):
raise EdIDataError(
"Missing required channels to build Z "
f"(EX={idx_ex}, EY={idx_ey}, HX={idx_hx}, HY={idx_hy})."
)
h_idx = [idx_hx, idx_hy]
e_idx = [idx_ex, idx_ey]
# Build Z(f) = S_EH @ inv(S_HH) per frequency; optionally
# estimate errors using DoF. Preallocate outputs.
z_arr = np.zeros((self.n_freq, 2, 2), dtype=complex)
z_err = (
np.full((self.n_freq, 2, 2), np.nan, dtype=float)
if estimate_error
else None
)
tip_arr = (
None
if idx_hz is None
else np.zeros((self.n_freq, 1, 2), dtype=complex)
)
tip_err = (
None
if (idx_hz is None or not estimate_error)
else np.full((self.n_freq, 1, 2), np.nan, float)
)
I2 = np.eye(2, dtype=float)
for k in range(self.n_freq):
S = self._S[k] # (nchan, nchan), Hermitian complex
S_HH = S[np.ix_(h_idx, h_idx)].astype(complex)
S_EH = S[np.ix_(e_idx, h_idx)].astype(complex)
if ridge is not None and ridge > 0.0:
S_HH = S_HH + float(ridge) * I2
try:
inv_SHH = np.linalg.inv(S_HH)
except np.linalg.LinAlgError as exc:
raise EdIDataError(f"S_HH singular at k={k}: {exc}") from exc
z_arr[k] = S_EH @ inv_SHH
if tip_arr is not None:
S_ZH = S[np.ix_([idx_hz], h_idx)].astype(complex)
tip_arr[k, 0, :] = S_ZH @ inv_SHH
# error estimates (optional)
if not estimate_error or z_err is None:
continue
# Resolve DoF M_k:
# priority: explicit `dof` -> `segnum` -> avgt*bw.
M_k = None
if dof is not None:
M_k = float(np.asarray(dof)[k])
else:
M_k = effective_dof_from_meta(
segnum=(
self.segnum[k] if hasattr(self, "segnum") else None
),
avgt=(self.avgt[k] if hasattr(self, "avgt") else None),
bw=(self.bw[k] if hasattr(self, "bw") else None),
)
if M_k is not None:
M_k = float(M_k)
if M_k and M_k > 0.0:
z_e, t_e = compute_errors_from_S(
S=S,
e_idx=tuple(e_idx),
h_idx=tuple(h_idx),
hz_idx=idx_hz,
M=M_k,
ridge=ridge,
)
z_err[k] = z_e
if tip_err is not None and t_e is not None:
tip_err[k, 0, :] = t_e
# --- finalize/attach uncertainties with user-facing warnings
valid_ze = (
estimate_error
and (z_err is not None)
and np.all(np.isfinite(z_err))
and np.all(z_err >= 0.0)
)
valid_te = (
(tip_arr is not None)
and estimate_error
and (tip_err is not None)
and np.all(np.isfinite(tip_err))
and np.all(tip_err >= 0.0)
)
if estimate_error and not valid_ze:
n_nan_ze = (
0
if z_err is None
else int(np.count_nonzero(~np.isfinite(z_err)))
)
n_neg_ze = (
0 if z_err is None else int(np.count_nonzero(z_err < 0.0))
)
logger.warning(
"Z error estimation skipped: invalid entries "
"(nan=%d, neg=%d) and/or missing DoF. "
"Errors will not be attached. Consider passing "
"`dof`, or disable with `estimate_error=False`.",
n_nan_ze,
n_neg_ze,
)
z_err = None
if estimate_error and (tip_arr is not None) and not valid_te:
n_nan_te = (
0
if tip_err is None
else int(np.count_nonzero(~np.isfinite(tip_err)))
)
n_neg_te = (
0 if tip_err is None else int(np.count_nonzero(tip_err < 0.0))
)
logger.warning(
"Tipper error estimation skipped: invalid entries "
"(nan=%d, neg=%d) and/or missing DoF. "
"Errors will not be attached.",
n_nan_te,
n_neg_te,
)
tip_err = None
# --- build objects (attach uncertainties only when valid)
nm = getattr(self, "name", None)
if z_err is not None:
z_obj = Z(
z_array=z_arr,
freq=self.freq,
name=nm,
z_err_array=z_err,
verbose=self.verbose,
)
else:
z_obj = Z(
z_array=z_arr,
freq=self.freq,
name=nm,
verbose=self.verbose,
)
tip_obj = None
if tip_arr is not None:
tip_obj = Tipper()
tip_obj._freq = np.array(self.freq, dtype=float)
tip_obj._tipper = tip_arr
if tip_err is not None:
tip_obj._tipper_err = tip_err
# compute derived quantities (no uncertainties needed)
tip_obj.compute_amp_phase()
tip_obj.compute_mag_direction()
return z_obj, tip_obj
def __has_read__(self) -> bool:
"""
Lightweight check used by utils.validation.has_read().
Returns True only if a non-empty spectral stack is present
and shapes are coherent.
"""
try:
# freq present and 1-D
if not isinstance(self._freq, np.ndarray) or self._freq.ndim != 1:
return False
if self._freq.size == 0:
return False
# spectral cube present and consistent
S = getattr(self, "_S", None)
if not isinstance(S, np.ndarray) or S.ndim != 3:
return False
nf, n1, n2 = S.shape
if nf != self._freq.size or n1 != n2 or n1 < 2:
return False
return True
except Exception:
return False
[docs]
@classmethod
def from_Z(
cls,
z_obj: Z,
**kws: Any,
) -> Spectra:
r"""
Create a :class:`Spectra` from a transfer function
:class:`~pycsamt.z.z.Z`.
This class method is a thin, convenience wrapper
around :func:`spectra_from_Z`. It synthesizes a full
Hermitian cross–spectral density tensor from the
impedance tensor ``Z(f)`` and optional inputs that
control magnetic power and tipper usage.
Parameters
----------
z_obj : :class:`~pycsamt.z.z.Z`
Input impedance object. The attributes
``z_obj.z`` (shape ``(n, 2, 2)``) and
``z_obj.freq`` (shape ``(n,)``) must be set.
**kws : Any
Forwarded to :func:`spectra_from_Z`. See that
function for the complete set of options such as
``S_HH``, ``H_psd``, ``tipper``, ``include_hz``,
and ``chan_order``.
Returns
-------
Spectra
A spectra container on the same frequency grid as
``z_obj``. Channel order follows the requested
``chan_order`` (default: ``HX, HY, EX, EY``).
Raises
------
EdIDataError
If ``z_obj`` is incomplete (missing ``z`` or
``freq``).
Notes
-----
Absolute spectral levels are not carried by the
impedance tensor. To obtain physically scaled
spectra, provide magnetic spectra via ``S_HH`` or
``H_psd``. If neither is given, a unit–power
assumption is used (``S_HH = I``), which is suitable
for tests but not for quantitative analysis.
This method does not infer per–frequency metadata
such as bandwidth or averaging time; those fields
are initialized with zeros/NaNs.
Examples
--------
>>> ed = EDIFile("site_imp.edi")
>>> sp = Spectra.from_Z(
... ed.Z,
... H_psd=(np.ones(ed.Z.n_freq),
... np.ones(ed.Z.n_freq),
... None),
... )
>>> sect, io = sp.to_io()
>>> _ = ed.write_new_edi(
... edi_fn="site_with_synth_spec.edi",
... Spectra=sp,
... )
See Also
--------
spectra_from_Z
Functional API that performs the synthesis.
pycsamt.seg.ops.synthesize_spectra_from_z
Low–level array helper used under the hood.
Spectra.to_Z
Inverse operation (spectra → Z).
References
----------
.. [1] Chave, A. D., & Jones, A. G. (2012). *The
Magnetotelluric Method: Theory and Practice*.
Cambridge Univ. Press.
.. [2] Bendat, J. S., & Piersol, A. G. (2011).
*Random Data: Analysis and Measurement
Procedures*. Wiley.
.. [3] SEG EDI MT/EMAP standard (1987). MTNet.
"""
return spectra_from_Z(z_obj=z_obj, **kws)
def spectra_from_Z(
z_obj: Z,
*,
S_HH: np.ndarray | None = None,
H_psd: tuple[
np.ndarray,
np.ndarray,
np.ndarray | None,
]
| None = None,
tipper: Tipper | np.ndarray | None = None,
include_hz: bool = False,
chan_order: tuple[str, ...] = (
"HX",
"HY",
"EX",
"EY",
),
e_noise: float | np.ndarray | None = None,
h_noise: float | np.ndarray | None = None,
name: str | None = None,
verbose: int = 0,
) -> Spectra:
r"""
Synthesize a :class:`Spectra` from a transfer function
:class:`~pycsamt.z.z.Z` and optional tipper.
Given the relation ``E = Z H``, the cross–spectral
blocks are
* ``S_EH = Z S_HH``
* ``S_EE = Z S_HH Z^H``
and, if tipper ``T`` and HZ are supplied,
* ``S_ZH = T S_HH``, ``S_ZZ = T S_HH T^H``,
* ``S_EZ = Z S_HH T^H``.
If magnetic spectra are not provided, a unit–power
assumption (``S_HH = I``) is used. The result is
Hermitian by construction.
Parameters
----------
z_obj : :class:`~pycsamt.z.z.Z`
Input impedance with ``z`` (``(n, 2, 2)``) and
``freq`` (``(n,)``) populated.
S_HH : ndarray, optional
Magnetic spectra per frequency, shape ``(n, 2, 2)``.
Must be Hermitian. Overrides ``H_psd`` if both are
provided.
H_psd : tuple of arrays, optional
``(Pxx, Pyy, Pxy)`` with ``Pxx, Pyy`` real
``(n,)`` and optional complex ``Pxy (n,)``. Used
to assemble ``S_HH`` when ``S_HH`` is not given.
tipper : :class:`~pycsamt.z.tipper.Tipper` or ndarray, \
optional
Horizontal tipper as object or array. Arrays must
have shape ``(n, 1, 2)`` or ``(n, 2)``. Required
when ``include_hz`` is ``True`` or when ``HZ`` is
in ``chan_order``.
include_hz : bool, default False
If ``True``, include HZ and all cross–terms.
Requires ``tipper``.
chan_order : tuple of str, default (\"HX\",\"HY\",\"EX\",\"EY\")
Order of channels in the synthesized spectra. If
``HZ`` is included, the tipper must be provided.
e_noise : float or ndarray, optional
Diagonal noise power added to the electric block
``S_EE``. Scalar or ``(n,)``.
h_noise : float or ndarray, optional
Diagonal noise power added to the magnetic block
``S_HH``. Scalar or ``(n,)``.
name : str, optional
Display name for the resulting :class:`Spectra`.
Defaults to ``z_obj.name``.
verbose : int, default 0
Verbosity flag propagated to :class:`Spectra`.
Returns
-------
Spectra
A spectra container on ``z_obj.freq``. The
attribute ``chan_ids`` matches ``chan_order``.
Raises
------
EdIDataError
If ``z_obj`` lacks ``z`` or ``freq``, or if HZ is
requested without a tipper.
ValueError
If provided arrays have incompatible shapes.
Notes
-----
Absolute scaling depends entirely on the magnetic
spectra. Without ``S_HH`` or ``H_psd``, the result
is unit–power and useful mainly for testing or
structural checks.
Per–frequency metadata (e.g., bandwidth, averaging
time) are not inferred and are initialized with
zeros/NaNs. Channel order is honored exactly; the
resulting spectra are explicitly symmetrized to be
Hermitian.
Examples
--------
Minimal synthesis with unit power:
>>> ed = EDIFile("site_imp.edi")
>>> sp = spectra_from_Z(ed.Z)
With magnetic PSD and tipper, including HZ:
>>> Pxx = np.full(ed.Z.n_freq, 1e-2)
>>> Pyy = np.full(ed.Z.n_freq, 1e-2)
>>> sp = spectra_from_Z(
... ed.Z,
... H_psd=(Pxx, Pyy, None),
... tipper=ed.Tip,
... include_hz=True,
... chan_order=("HX","HY","HZ","EX","EY"),
... )
Writing synthesized spectra to EDI:
>>> sect, io = sp.to_io()
>>> _ = ed.write_new_edi(
... edi_fn="site_with_synth_spec.edi",
... Spectra=sp,
... )
See Also
--------
Spectra.from_Z
Classmethod wrapper returning a spectra from Z.
pycsamt.seg.ops.synthesize_spectra_from_z
Low–level array constructor used internally.
Spectra.to_Z
Inverse operation (spectra → Z and tipper).
References
----------
.. [1] Chave, A. D., & Jones, A. G. (2012). *The
Magnetotelluric Method: Theory and Practice*.
Cambridge Univ. Press.
.. [2] Bendat, J. S., & Piersol, A. G. (2011).
*Random Data: Analysis and Measurement
Procedures*. Wiley.
.. [3] SEG EDI MT/EMAP standard (1987). MTNet.
"""
if (
getattr(z_obj, "z", None) is None
or getattr(z_obj, "freq", None) is None
):
raise EdIDataError("Z object is incomplete (z or freq missing).")
tip_arr = None
if tipper is not None:
tip_arr = getattr(tipper, "tipper", tipper)
Sfull, order = synthesize_spectra_from_z(
z_obj.z,
S_HH=S_HH,
H_psd=H_psd,
tipper=tip_arr,
include_hz=include_hz,
chan_order=chan_order,
e_noise=e_noise,
h_noise=h_noise,
)
sp = Spectra(
name=(name or getattr(z_obj, "name", None)),
verbose=verbose,
)
sp._freq = np.asarray(z_obj.freq, float)
sp._S = np.asarray(Sfull, complex)
sp.chan_ids = list(order)
nf = sp.n_freq
sp.bw = np.zeros(nf, float)
sp.avgt = np.zeros(nf, float)
sp.avgf = np.full(nf, np.nan, float)
sp.rotspec = np.full(nf, np.nan, float)
sp.segnum = np.zeros(nf, int)
sp.band = [""] * nf
return sp