# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
from __future__ import annotations
import math
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
from ..log.logger import get_logger
from .base import JComponentBase
from .config import (
DTYPE_SPECS,
ENCODING_DEFAULT,
KIND_COMPLEX_TF,
KIND_RHO_PHI,
MISSING_FLOAT,
RE_BLANK,
RE_COMMENT,
RE_DATATYPE_UNITS,
RE_INFO,
RE_NPOINTS,
RE_STATION,
)
from .heads import Head
from .utils import (
JParseError,
iter_lines,
parse_datatype_units,
)
logger = get_logger(__name__)
__all__ = [
"RRow",
"TFRow",
"JBlock",
"RBlock",
"TFBlock",
"JBlocks",
]
[docs]
@dataclass
class RRow:
period: float
rho: float
pha: float
rhomax: float
rhomin: float
phamax: float
phamin: float
wrho: float
wpha: float
rej: bool = False
[docs]
@dataclass
class TFRow:
period: float
real: float
imag: float
error: float
weight: float
rej: bool = False
[docs]
class JBlock(JComponentBase):
r"""
Abstract base for J-format data blocks.
A block ties a parsed header (:class:`Head`) to a homogeneous
sequence of rows (:class:`RRow` or :class:`TFRow`). Subclasses
implement the row parser, normalization, serialization, and
simple QA summaries.
Parameters
----------
head : Head
Parsed header triple (station, data-type, count).
rows : sequence of rows
Concrete row records for this block family.
verbose : int, optional
Verbosity for warnings during parsing and writing.
Attributes
----------
head : Head
Block header with ``station``, ``kind``, ``comp``, ``n``.
nrows : int
Number of parsed data rows.
station, kind, comp, units : str or None
Convenience views onto ``head.dtype``.
columns : tuple of str
Column names for the structured numeric view.
shape : (int, int)
``(nrows, ncols)`` derived from the current rows.
Methods
-------
from_file(j_fn, *, verbose=0)
Build a block from a file slice. The concrete subclass
is chosen from the header's ``kind``.
from_lines(lines, *, verbose=0)
Parse from in-memory lines (header + body).
read((head, body_lines))
Populate the block from an existing :class:`Head` and
the subsequent body lines. Marks the instance as read.
write()
Serialize header and all body rows into text.
to_numpy()
Structured array view with numeric normalization
(period sign, missing sentinels, rejection flags).
to_dataframe()
Optional ``pandas`` representation (if available).
qa_summary()
Small dict of quick QA metrics for this block.
Notes
-----
The base class is intentionally minimal to keep the parser
light. Subclasses define the row regex, column names, and
row-level normalization logic.
Examples
--------
>>> # Pseudocode; subclass chosen from header
>>> blk = JBlock.from_lines(['S01', 'RXY', '1', '...'])
>>> blk.station, blk.nrows
('S01', 1)
See Also
--------
RBlock, TFBlock : Concrete block families.
Heads : Header-only convenience container.
References
----------
.. [1] A. G. Jones (1994). Magnetotelluric data file
J-format, version 2.0.
"""
def __init__(
self,
head: Head | None = None,
*,
rows: Sequence[Any] | None = None,
verbose: int = 0,
) -> None:
super().__init__(verbose=verbose)
self.head: Head | None = head
self.rows: list[Any] = list(rows or [])
[docs]
@classmethod
def from_file(
cls,
j_fn: str | Path,
*,
verbose: int = 0,
) -> JBlock:
lines = list(iter_lines(j_fn, encoding=ENCODING_DEFAULT))
return cls.from_lines(lines, verbose=verbose)
[docs]
@classmethod
def from_lines(
cls,
lines: Sequence[str],
*,
verbose: int = 0,
) -> JBlock:
head, body = _extract_first_head_and_body(lines)
blk = _block_factory(head, verbose=verbose)
return blk.read((head, body))
[docs]
def read(
self,
data: tuple[Head, Sequence[str]] | None = None,
) -> JBlock:
raise NotImplementedError("override in subclass")
[docs]
def write(self) -> list[str]:
raise NotImplementedError("override in subclass")
# ---- normalization & QA (common facade) ----
[docs]
def normalize(self) -> JBlock:
"""
Apply row-level normalization:
- negative period => frequency (Hz) to period (s)
- missing sentinel (-999) => NaN
- set rejection flags per rules
"""
return self # implemented by subclasses
[docs]
def qa_summary(self) -> dict[str, Any]:
"""
Quick QA metrics (counts and basic stats).
"""
return {}
# ---- exports ----
[docs]
def to_numpy(self) -> dict[str, np.ndarray]:
raise NotImplementedError("override in subclass")
[docs]
def to_dataframe(self):
try:
import pandas as pd # type: ignore
except Exception as exc: # pragma: no cover
raise ImportError(
"pandas is required for to_dataframe()"
) from exc
data = self.to_numpy()
return pd.DataFrame(data)
[docs]
@property
def nrows(self) -> int:
return len(self.rows)
[docs]
@property
def station(self) -> str | None:
return None if self.head is None else self.head.station
[docs]
@property
def kind(self) -> str | None:
return None if self.head is None else self.head.kind
[docs]
@property
def comp(self) -> str | None:
return None if self.head is None else self.head.comp
[docs]
@property
def units(self) -> str | None:
return None if self.head is None else self.head.units
[docs]
@property
def has_units(self) -> bool:
return bool(self.units)
[docs]
@property
def columns(self) -> tuple[str, ...]:
# default; subclasses override if needed
return tuple([])
[docs]
@property
def shape(self) -> tuple[int, int]:
return (self.nrows, len(self.columns))
[docs]
@property
def periods(self):
# cheap view; avoids pandas dep
a = self.to_numpy()
return a["period"]
def __str__(self) -> str:
st = self.head.station if self.head else None
dt = getattr(self.head, "dtype", None)
k = getattr(dt, "kind", None)
c = getattr(dt, "comp", None)
return (
f"{self.__class__.__name__}(station={st!r}, "
f"dtype={k}{c}, nrows={self.nrows})"
)
[docs]
class RBlock(JBlock):
r"""
Resistivity/phase (*R/S*) block implementation.
Parses rows of the form ``period rho pha rhomax rhomin phamax
phamin wrho wpha``. Applies J-format rules to normalize
period sign and to mark rejected estimates.
Parameters
----------
head : Head
Header whose kind is ``'R'`` or ``'S'``. The component
encodes the tensor entry or average (e.g. ``RXY``, ``RDE``).
rows : sequence of RRow, optional
Prebuilt rows. Usually left to the parser.
verbose : int, optional
Verbosity for warnings during parsing.
Attributes
----------
columns : tuple of str
``('period', 'rho', 'pha', 'rhomax', 'rhomin',
'phamax', 'phamin', 'wrho', 'wpha', 'rej')``.
Notes
-----
Normalization rules:
- If input period is negative, the value stores frequency in
Hz and is converted to period (s).
- ``rho < 0`` or ``wrho < 0`` marks the row as rejected.
- ``-999.0`` sentinels are converted to ``NaN`` in numeric
views.
Examples
--------
>>> lines = [
... 'S01', 'RXY', '2',
... '-1.0 100 45 110 90 50 40 1 1',
... ' 2.0 -5 30 35 25 40 20 1 1',
... ]
>>> blk = RBlock.from_lines(lines)
>>> a = blk.to_numpy()
>>> a['period'][0] # 1/Hz -> s
1.0
>>> a['rej'][1] # rho < 0
True
See Also
--------
TFBlock : Transfer-function block family.
RRow : Single row record used by this block.
References
----------
.. [1] A. G. Jones (1994). Magnetotelluric data file
J-format, version 2.0.
"""
[docs]
def read(
self,
data: tuple[Head, Sequence[str]] | None = None,
) -> RBlock:
if data is None:
raise ValueError("data is required")
head, body = data
spec = DTYPE_SPECS[head.dtype.kind]
rows: list[RRow] = []
n = head.n or 0
i = 0
for ln in body:
if RE_BLANK.match(ln):
continue
m = spec.regex.match(ln)
if not m:
break
d = m.groupdict()
rows.append(
RRow(
period=float(d["p"]),
rho=float(d["rho"]),
pha=float(d["pha"]),
rhomax=float(d["rhomax"]),
rhomin=float(d["rhomin"]),
phamax=float(d["phamax"]),
phamin=float(d["phamin"]),
wrho=float(d["wrho"]),
wpha=float(d["wpha"]),
)
)
i += 1
if n and i >= n:
break
self.head = head
self.rows = rows
self._mark_read(True)
# apply default normalization
return self.normalize()
[docs]
def normalize(self) -> RBlock:
for r in self.rows:
# period: if negative => frequency (Hz)
if r.period < 0:
f = abs(r.period)
r.period = _safe_div(1.0, f)
# missing sentinels => NaN
r.rho = _nanz(r.rho)
r.pha = _nanz(r.pha)
r.rhomax = _nanz(r.rhomax)
r.rhomin = _nanz(r.rhomin)
r.phamax = _nanz(r.phamax)
r.phamin = _nanz(r.phamin)
r.wrho = _nanz(r.wrho)
r.wpha = _nanz(r.wpha)
# rejection (rho<0 or wrho<0)
rej = False
if isinstance(r.rho, float) and not math.isnan(r.rho):
rej = rej or (r.rho < 0.0)
if isinstance(r.wrho, float) and not math.isnan(r.wrho):
rej = rej or (r.wrho < 0.0)
r.rej = bool(rej)
return self
[docs]
def to_numpy(self) -> dict[str, np.ndarray]:
n = self.nrows
arr = {
"period": np.empty(n, float),
"rho": np.empty(n, float),
"pha": np.empty(n, float),
"rhomax": np.empty(n, float),
"rhomin": np.empty(n, float),
"phamax": np.empty(n, float),
"phamin": np.empty(n, float),
"wrho": np.empty(n, float),
"wpha": np.empty(n, float),
"rej": np.empty(n, bool),
}
for i, r in enumerate(self.rows):
arr["period"][i] = r.period
arr["rho"][i] = r.rho
arr["pha"][i] = r.pha
arr["rhomax"][i] = r.rhomax
arr["rhomin"][i] = r.rhomin
arr["phamax"][i] = r.phamax
arr["phamin"][i] = r.phamin
arr["wrho"][i] = r.wrho
arr["wpha"][i] = r.wpha
arr["rej"][i] = r.rej
return arr
[docs]
def qa_summary(self) -> dict[str, Any]:
a = self.to_numpy()
n = self.nrows
rej = int(a["rej"].sum())
keep = n - rej
finite = np.isfinite(a["rho"]).sum()
pmin = np.nanmin(a["period"]) if n else np.nan
pmax = np.nanmax(a["period"]) if n else np.nan
return {
"kind": "R/S",
"nrows": n,
"rejected": rej,
"kept": keep,
"finite_rho": int(finite),
"period_min": float(pmin),
"period_max": float(pmax),
}
[docs]
def write(self) -> list[str]:
if not self.head or self.head.n is None:
raise ValueError("missing head")
out: list[str] = []
out.extend(self.head.write())
for r in self.rows:
out.append(
(
f"{r.period: .6e} {r.rho: .6e} {r.pha: .6e} {r.rhomax: .6e} {r.rhomin: .6e} "
f"{r.phamax: .6e} {r.phamin: .6e} {r.wrho: .6e} {r.wpha: .6e}"
).strip()
)
return out
[docs]
@property
def columns(self) -> tuple[str, ...]:
return (
"period",
"rho",
"pha",
"rhomax",
"rhomin",
"phamax",
"phamin",
"wrho",
"wpha",
"rej",
)
[docs]
class TFBlock(JBlock):
r"""
Transfer-function (*Z/Q/C/T*) block implementation.
Parses rows of the form ``period real imag error weight``.
Applies J-format rules to normalize period sign and to mark
rejected estimates.
Parameters
----------
head : Head
Header whose kind is one of ``'Z'``, ``'Q'``, ``'C'``,
or ``'T'``.
rows : sequence of TFRow, optional
Prebuilt rows. Usually left to the parser.
verbose : int, optional
Verbosity for warnings during parsing.
Attributes
----------
columns : tuple of str
``('period', 'real', 'imag', 'error', 'weight', 'rej')``.
Notes
-----
Normalization rules:
- If input period is negative, the value stores frequency in
Hz and is converted to period (s).
- ``weight < 0`` marks the row as rejected.
- ``-999.0`` sentinels are converted to ``NaN`` in numeric
views.
Examples
--------
>>> lines = [
... 'S02', 'ZXY SI', '2',
... '-1.0e+1 1.0 -2.0 0.1 1.0',
... ' 5.0 -999 3.0 -999 -1.0',
... ]
>>> blk = TFBlock.from_lines(lines)
>>> a = blk.to_numpy()
>>> round(a['period'][0], 3)
0.1
>>> a['rej'][1] # weight < 0
True
See Also
--------
RBlock : Resistivity/phase block family.
TFRow : Single row record used by this block.
References
----------
.. [1] A. G. Jones (1994). Magnetotelluric data file
J-format, version 2.0.
"""
[docs]
def read(
self,
data: tuple[Head, Sequence[str]] | None = None,
) -> TFBlock:
if data is None:
raise ValueError("data is required")
head, body = data
spec = DTYPE_SPECS[head.dtype.kind]
rows: list[TFRow] = []
n = head.n or 0
i = 0
for ln in body:
if RE_BLANK.match(ln):
continue
m = spec.regex.match(ln)
if not m:
break
d = m.groupdict()
rows.append(
TFRow(
period=float(d["p"]),
real=float(d["real"]),
imag=float(d["imag"]),
error=float(d["error"]),
weight=float(d["weight"]),
)
)
i += 1
if n and i >= n:
break
self.head = head
self.rows = rows
self._mark_read(True)
return self.normalize()
[docs]
def normalize(self) -> TFBlock:
for r in self.rows:
if r.period < 0:
f = abs(r.period)
r.period = _safe_div(1.0, f)
r.real = _nanz(r.real)
r.imag = _nanz(r.imag)
r.error = _nanz(r.error)
r.weight = _nanz(r.weight)
rej = False
if isinstance(r.weight, float) and not math.isnan(r.weight):
rej = rej or (r.weight < 0.0)
r.rej = bool(rej)
return self
[docs]
def to_numpy(self) -> dict[str, np.ndarray]:
n = self.nrows
arr = {
"period": np.empty(n, float),
"real": np.empty(n, float),
"imag": np.empty(n, float),
"error": np.empty(n, float),
"weight": np.empty(n, float),
"rej": np.empty(n, bool),
}
for i, r in enumerate(self.rows):
arr["period"][i] = r.period
arr["real"][i] = r.real
arr["imag"][i] = r.imag
arr["error"][i] = r.error
arr["weight"][i] = r.weight
arr["rej"][i] = r.rej
return arr
[docs]
def qa_summary(self) -> dict[str, Any]:
a = self.to_numpy()
n = self.nrows
rej = int(a["rej"].sum())
keep = n - rej
pmin = np.nanmin(a["period"]) if n else np.nan
pmax = np.nanmax(a["period"]) if n else np.nan
rms = np.sqrt(np.nanmean(np.square(a["error"]))) if n else np.nan
return {
"kind": "TF",
"nrows": n,
"rejected": rej,
"kept": keep,
"period_min": float(pmin),
"period_max": float(pmax),
"error_rms": float(rms),
}
[docs]
def write(self) -> list[str]:
if not self.head or self.head.n is None:
raise ValueError("missing head")
out: list[str] = []
out.extend(self.head.write())
for r in self.rows:
out.append(
(
f"{r.period: .6e} {r.real: .6e} {r.imag: .6e} {r.error: .6e} {r.weight: .6e}"
).strip()
)
return out
[docs]
@property
def columns(self) -> tuple[str, ...]:
return (
"period",
"real",
"imag",
"error",
"weight",
"rej",
)
[docs]
class JBlocks(JComponentBase):
r"""
Container for a sequence of parsed J data blocks.
Provides discovery from raw text, iteration, selection by
family or component, serialization, and quick QA roll-ups
across blocks.
Parameters
----------
blocks : sequence of JBlock, optional
Prebuilt blocks. Usually created by the parser.
verbose : int, optional
Verbosity for warnings during parsing.
Attributes
----------
blocks : list of JBlock
Parsed blocks in file order.
n : int
Number of blocks.
stations : list of str
All station identifiers seen across blocks.
kinds : list of str
The ``kind`` tokens across all blocks (e.g. ``'R'``, ``'Z'``).
Methods
-------
from_file(j_fn, *, verbose=0)
Parse all blocks found in a file.
from_lines(lines, *, verbose=0)
Parse blocks from an in-memory sequence of lines.
read(lines)
Replace current content with blocks parsed from lines.
write()
Serialize every block back to text.
select(kind=None, comp=None)
Return blocks filtered by family and/or component.
period_range()
Global period min/max across blocks (ignores ``NaN``).
qa_summary()
List of QA dicts, one per block.
Notes
-----
Header discovery is tolerant to real-world quirks, including
optional azimuth on the station line and the
count-before-dtype pattern seen in some files.
Examples
--------
>>> col = JBlocks.from_file('data/j/kb0-s001.txt')
>>> col.n >= 1
True
>>> [b.station for b in col.select(kind='R')]
['KB0001', ...]
See Also
--------
Head, Heads : Header parsers used during discovery.
RBlock, TFBlock : Concrete block families contained here.
References
----------
.. [1] A. G. Jones (1994). Magnetotelluric data file
J-format, version 2.0.
"""
def __init__(
self,
blocks: Sequence[JBlock] | None = None,
*,
verbose: int = 0,
) -> None:
super().__init__(verbose=verbose)
self.blocks: list[JBlock] = list(blocks or [])
[docs]
@property
def n(self) -> int:
return len(self.blocks)
[docs]
@classmethod
def from_file(
cls,
j_fn: str | Path,
*,
verbose: int = 0,
) -> JBlocks:
lines = list(iter_lines(j_fn, encoding=ENCODING_DEFAULT))
inst = cls.from_lines(lines, verbose=verbose)
# inst is already marked by from_lines (see below)
return inst
[docs]
@classmethod
def from_lines(
cls,
lines: Sequence[str],
*,
verbose: int = 0,
) -> JBlocks:
blocks = _extract_all_blocks(lines, verbose=verbose)
inst = cls(blocks=blocks, verbose=verbose)
inst._mark_read(True) # ← important
return inst
[docs]
def read(
self,
lines: Sequence[str] | None = None,
) -> JBlocks:
if lines is None:
raise ValueError("lines are required")
blk = self.from_lines(lines, verbose=self.verbose)
self.blocks = blk.blocks
self._mark_read(True)
return self
[docs]
def write(self) -> list[str]:
out: list[str] = []
for b in self.blocks:
out.extend(b.write())
return out
# convenience exports
[docs]
def to_numpy(self) -> list[dict[str, np.ndarray]]:
return [b.to_numpy() for b in self.blocks]
[docs]
def to_dataframe(self):
try:
import pandas as pd # type: ignore
except Exception as exc: # pragma: no cover
raise ImportError(
"pandas is required for to_dataframe()"
) from exc
dfs = [b.to_dataframe() for b in self.blocks]
if not dfs:
return pd.DataFrame()
return pd.concat(dfs, ignore_index=True)
[docs]
def qa_summary(self) -> list[dict[str, Any]]:
return [b.qa_summary() for b in self.blocks]
[docs]
@property
def stations(self) -> list[str]:
out: list[str] = []
for b in self.blocks:
if b.station is not None:
out.append(b.station)
return out
[docs]
@property
def station(self) -> str | None:
"""
Return the single, unique station name for
the block collection.
Returns None if no blocks are present.
"""
if not self.blocks:
return None
# All blocks should have the same
# station name after correct parsing
return self.blocks[0].station
[docs]
@property
def kinds(self) -> list[str]:
out: list[str] = []
for b in self.blocks:
if b.kind is not None:
out.append(b.kind)
return out
[docs]
def select(
self, *, kind: str | None = None, comp: str | None = None
) -> list[JBlock]:
out: list[JBlock] = []
for b in self.blocks:
if kind is not None and b.kind != kind:
continue
if comp is not None and b.comp != comp:
continue
out.append(b)
return out
[docs]
def period_range(self) -> tuple[float, float] | None:
lo, hi = None, None
for b in self.blocks:
a = b.to_numpy()
if len(a["period"]) == 0:
continue
pmin = float(np.nanmin(a["period"]))
pmax = float(np.nanmax(a["period"]))
lo = pmin if lo is None else min(lo, pmin)
hi = pmax if hi is None else max(hi, pmax)
if lo is None or hi is None:
return None
return (lo, hi)
def _is_miss(x: float) -> bool:
return x == MISSING_FLOAT
def _nanz(x: float) -> float:
return np.nan if _is_miss(x) else x
def _safe_div(a: float, b: float) -> float:
try:
return a / b
except Exception:
return np.nan
def _index_of_subsequence(hay: Sequence[str], needle: Sequence[str]) -> int:
if not needle:
return -1
n = len(needle)
for i in range(0, len(hay) - n + 1):
ok = True
for j in range(n):
if hay[i + j].strip() != needle[j].strip():
ok = False
break
if ok:
return i
return -1
def _block_factory(head: Head, *, verbose: int) -> JBlock:
k = head.dtype.kind
if k in KIND_RHO_PHI:
return RBlock(head=head, verbose=verbose)
if k in KIND_COMPLEX_TF:
return TFBlock(head=head, verbose=verbose)
raise ValueError(f"unsupported kind: {k!r}")
def _extract_first_head_and_body(
lines: Sequence[str],
) -> tuple[Head, list[str]]:
seq = list(lines)
h = Head.from_lines(seq)
trip = h.write()
idx = _index_of_subsequence(seq, trip)
if idx < 0:
idx = 0
while idx < len(seq) and trip[-1] != seq[idx].strip():
idx += 1
start = idx + len(trip)
return h, seq[start:]
def _extract_all_blocks(
lines: Sequence[str], *, verbose: int = 0
) -> list[JBlock]:
"""
Robustly parse all data blocks from a J-file for
a single station.
This parser finds the single station name for the
file and then iteratively finds all subsequent data blocks,
which are identified by a [data_type, count] pair.
"""
seq = list(lines)
blocks: list[JBlock] = []
# 1. Find the first station line in the entire file. This will be the
# station for ALL subsequent blocks.
station_line = None
first_data_line_index = 0
for i, line in enumerate(seq):
# Skip over the initial comment and info blocks
if (
RE_COMMENT.match(line)
or RE_INFO.match(line)
or RE_BLANK.match(line)
):
continue
# The first non-header line must be the station
if RE_STATION.match(line):
station_line = line
first_data_line_index = i + 1
break
if station_line is None:
# If no station is found in the file, there are no blocks to parse.
if verbose:
logger.warning("No station line found. Cannot parse data blocks.")
return []
# 2. Now, loop through the rest of the
# file from where we found the station.
i = first_data_line_index
while i < len(seq):
# Find the next data type line, skipping any blank lines
dtype_line = None
dtype_idx = -1
while i < len(seq):
line = seq[i].strip()
if RE_BLANK.match(line):
i += 1
continue
try:
parse_datatype_units(line)
dtype_line = line
dtype_idx = i
break
except JParseError:
# This line is not a dtype, probably
# leftover data or garbage. Skip it.
i += 1
if not dtype_line:
break # No more data type lines found, we are done.
# 3. Find the next count line, skipping blank lines
count_line = None
count_idx = -1
j = dtype_idx + 1
while j < len(seq):
line = seq[j].strip()
if RE_BLANK.match(line):
j += 1
continue
if RE_NPOINTS.match(line):
count_line = line
count_idx = j
break
# If we find something that is not
# a count, the header is malformed.
break
if not count_line:
# Could not find a count for the detected
# data type, stop parsing.
if verbose:
logger.warning(
"Found data type '{dtype_line}'"
" but no following count. "
"Stopping parse."
)
break
# 4. We have the full header. Create the block.
header_lines = [station_line, dtype_line, count_line]
head = Head(verbose=verbose).read(header_lines)
body_start_index = count_idx + 1
body_lines = seq[body_start_index:]
# Create the block and let its `read` method consume the data rows
blk = _block_factory(head, verbose=verbose).read((head, body_lines))
blocks.append(blk)
# 5. Advance the main loop index past the data we just consumed.
rows_consumed = max(head.n or 0, 0)
i = body_start_index + rows_consumed
return blocks
def _locate_header_indices(
seq: Sequence[str], start: int
) -> tuple[int, int, int] | None:
"""
Return (s_idx, d_idx, n_idx) for the next header triple starting
at 'start', or None if none is found.
Accepts both orders:
station -> dtype -> count
station -> count -> dtype (before next station/EOF)
"""
n = len(seq)
i = start
# skip comments/info/blank first
while i < n and (
RE_COMMENT.match(seq[i])
or RE_INFO.match(seq[i])
or RE_BLANK.match(seq[i])
):
i += 1
# find station (must not be dtype/info/comment/blank)
while i < n:
if RE_STATION.match(seq[i]):
if not RE_DATATYPE_UNITS.match(seq[i]):
s = i
break
i += 1
else:
return None # no station
# first non-blank after station
j = s + 1
while j < n and RE_BLANK.match(seq[j]):
j += 1
if j >= n:
return None
# case A: dtype then count
try:
parse_datatype_units(seq[j])
d = j
k = d + 1
while k < n and RE_BLANK.match(seq[k]):
k += 1
if k < n and RE_NPOINTS.match(seq[k]):
return (s, d, k)
except JParseError:
pass
# case B: count then dtype later (before next station/EOF)
if RE_NPOINTS.match(seq[j]):
k = j
t = k + 1
while t < n:
if RE_STATION.match(seq[t]):
break # next header starts
if RE_BLANK.match(seq[t]):
t += 1
continue
try:
parse_datatype_units(seq[t])
d = t
return (s, d, k)
except JParseError:
t += 1
continue
return None
# unknown header after station; advance caller
return None
TFRow.__doc__ = r"""
One parsed transfer-function row of a J *Z/Q/C/T* block.
The row stores period (s), real/imag parts, a standard error,
a row weight, and a derived ``rej`` flag based on J-format
rules.
Parameters
----------
period : float
Period in seconds. If input stored frequency (Hz) as a
negative number, it is normalized to a positive period.
real, imag : float
Real and imaginary parts of the transfer function.
error : float
Standard error (format-specific). May be missing.
weight : float
Row weight. Negative values mark the row as rejected.
rej : bool
Convenience rejection flag derived from the rules above.
Notes
-----
The J specification allows several TF families (Z/Q/C/T). The
row schema stays the same; only the physical meaning differs.
Examples
--------
>>> tf = TFRow(
... period=0.1, real=1.0, imag=-2.0,
... error=0.1, weight=1.0, rej=False,
... )
>>> tf.period, tf.rej
(0.1, False)
See Also
--------
TFBlock : Sequence of :class:`TFRow` with I/O helpers.
RRow : Row model for resistivity/phase blocks.
References
----------
.. [1] A. G. Jones (1994). Magnetotelluric data file
J-format, version 2.0.
"""
RRow.__doc__ = r"""
One parsed resistivity/phase row of a J *R/S* block.
The row stores period (s), apparent resistivity (Ω·m), phase
(deg), their 1σ bounds, and per-column weights. A derived
boolean flag ``rej`` marks a row as rejected according to the
J-format rules.
Parameters
----------
period : float
Period in seconds. If input stored frequency (Hz) as a
negative number, it is normalized to a positive period.
rho : float
Apparent resistivity. Values ``< 0`` mark the estimate as
rejected.
pha : float
Phase in degrees.
rhomax, rhomin : float
Upper and lower 1σ bounds of resistivity. May be missing.
phamax, phamin : float
Upper and lower 1σ bounds of phase. May be missing.
wrho, wpha : float
Weights. A negative weight marks the estimate as rejected.
rej : bool
Convenience rejection flag derived from the rules above.
Notes
-----
Missing numeric values are often written as ``-999.0`` in J
files. The higher-level APIs convert them to ``NaN`` for
numeric workflows.
Examples
--------
>>> row = RRow(
... period=1.0, rho=100.0, pha=45.0,
... rhomax=110.0, rhomin=90.0,
... phamax=50.0, phamin=40.0,
... wrho=1.0, wpha=1.0, rej=False,
... )
>>> row.rho, row.rej
(100.0, False)
See Also
--------
RBlock : Sequence of :class:`RRow` with I/O helpers.
TFRow : Row model for transfer-function blocks.
References
----------
.. [1] A. G. Jones (1994). Magnetotelluric data file
J-format, version 2.0.
"""