Source code for pycsamt.jones.property

"""
Site‑level properties parsed from the J‑format information block.

This module defines a lightweight container for the metadata
exposed in lines beginning with ``>KEY=VALUE`` in A.G. Jones J files.
It focuses on robust parsing and normalization (e.g., azimuth
wrapping, latitude/longitude hemispheres, and missing sentinels).
"""

from __future__ import annotations

import io
import math
import warnings
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from pathlib import Path

from ..exceptions import JParseError
from ..gis.utils import (
    assert_lat_value as _assert_lat,
)
from ..gis.utils import (
    assert_lon_value as _assert_lon,
)
from ..gis.utils import (
    convert_position_str2float as _to_deg,
)
from .config import ENCODING_DEFAULT, MISSING_FLOAT
from .utils import iter_info, iter_lines

__all__ = ["JSiteProperty"]


[docs] @dataclass class JSiteProperty: r""" Container for site properties parsed from J-format info. This class stores site-level metadata such as latitude, longitude, azimuth, and elevation that are declared in the J-format *information block* as ``>KEY=VALUE`` lines. Parameters ---------- azimuth : float or None, optional Site X-axis azimuth in degrees, referenced to true north. Values are wrapped to ``[0, 360)`` when parsed. latitude : float or None, optional Latitude in decimal degrees. Inputs may include DMS or hemisphere suffixes and are normalized to ``[-90, 90]``. longitude : float or None, optional Longitude in decimal degrees. Inputs may include DMS or hemisphere suffixes and are normalized to ``[-180, 180)``. elevation : float or None, optional Elevation in metres. Missing sentinels are mapped to ``None``. extra : dict of (str -> str), optional Unrecognized ``>KEY=VALUE`` pairs preserved verbatim. verbose : int or bool, default 0 Verbosity for non-fatal parsing warnings. strict : bool, default False If ``True``, out-of-range coordinates raise errors instead of being coerced to valid bounds. Attributes ---------- location : tuple of (float, float) or None Convenience property returning ``(lat, lon)`` when both are available, else ``None``. azimuth_rad : float or None Azimuth converted to radians when available. Notes ----- Parsing leverages robust coordinate helpers that accept DMS, hemisphere letters, and free whitespace. Longitudes are normalized into the half-open interval ``[-180, 180)``. Examples -------- >>> lines = [\">LATITUDE = 41.9782\", \">LONGITUDE = 140.8958\"]\n >>> prop = JSiteProperty.from_lines(lines)\n >>> prop.location\n (41.9782, 140.8958) See Also -------- Info High-level container of the information block which can be used to build a :class:`JSiteProperty`. References ---------- .. [1] A. G. Jones (1994). Magnetotelluric data file J-format, version 2.0. """ azimuth: float | None = None latitude: float | None = None longitude: float | None = None elevation: float | None = None extra: dict[str, str] = field(default_factory=dict) verbose: int | bool = 0 strict: bool = False
[docs] @classmethod def from_file( cls, obj: io.TextIOBase | str | Path, *, encoding: str = ENCODING_DEFAULT, strict: bool = False, verbose: int | bool = 0, ) -> JSiteProperty: r""" Build a :class:`JSiteProperty` from a J file path or file-like object. Only the initial *information block* is consumed. Later station or data sections are ignored by this method. Parameters ---------- obj : str or pathlib.Path or TextIO Path to a J file or an open text stream positioned at the beginning of the file. encoding : str, default 'utf-8' Text encoding used when reading from a file path. strict : bool, default False If ``True``, invalid coordinates raise. If ``False``, out-of-range values are coerced with a warning. verbose : int or bool, default 0 Verbosity for non-fatal parsing warnings. Returns ------- JSiteProperty Parsed site properties. Notes ----- This method stops at the first line that is not a comment, not blank, and not a ``>KEY=VALUE`` record. Examples -------- >>> prop = JSiteProperty.from_file(\"data/j/kb0-s001.txt\")\n >>> prop.azimuth is None or 0.0 <= prop.azimuth < 360.0\n True See Also -------- from_lines Parse from an in-memory sequence of lines. from_mapping Quick constructor from a dict of keys and values. References ---------- .. [1] A. G. Jones (1994). Magnetotelluric data file J-format, version 2.0. """ lines = iter_lines(obj, encoding=encoding) return cls.from_lines(lines, strict=strict, verbose=verbose)
[docs] @classmethod def from_lines( cls, lines: Iterable[str], *, strict: bool = False, verbose: int | bool = 0, ) -> JSiteProperty: r""" Build a :class:`JSiteProperty` from an iterable of lines. The parser reads sequentially and collects ``>KEY=VALUE`` pairs as long as lines are comments, blanks, or info records. It stops at the first non-conforming line. Parameters ---------- lines : iterable of str Lines that include the information block content. strict : bool, default False If ``True``, invalid coordinates raise. If ``False``, out-of-range values are coerced with a warning. verbose : int or bool, default 0 Verbosity for non-fatal parsing warnings. Returns ------- JSiteProperty Parsed site properties. Notes ----- Latitude and longitude accept flexible text including DMS and hemisphere tokens. Longitudes are normalized into ``[-180, 180)``. Examples -------- >>> from pycsamt. >>> lines = [\n ... \">AZIMUTH = -30.0\", \">LATITUDE = 41.9782\",\n ... \">LONGITUDE = 140.8958\", \">ELEVATION = 0.0\",\n ... ]\n >>> prop = JSiteProperty.from_lines(lines)\n >>> prop.location[0] == 41.9782\n True See Also -------- from_file Parse from a file path or open stream. from_mapping Quick constructor from a dict of keys and values. References ---------- .. [1] A. G. Jones (1994). Magnetotelluric data file J-format, version 2.0. """ props: dict[str, float | None] = {} extra: dict[str, str] = {} for key, val in iter_info(lines): key_up = key.upper() try: if key_up == "AZIMUTH": az = _to_float_safe(val) props["azimuth"] = _norm_azimuth(az) elif key_up == "LATITUDE": lat = _parse_latitude(val, strict=strict, verbose=verbose) props["latitude"] = lat elif key_up == "LONGITUDE": lon = _parse_longitude( val, strict=strict, verbose=verbose ) props["longitude"] = lon elif key_up == "ELEVATION": el = _maybe_missing_float(val) props["elevation"] = float(el) if el is not None else None else: extra[key_up] = str(val).strip() except ValueError as exc: msg = f"Failed to parse {key_up}='{val}': {exc}" if strict: raise JParseError(msg) from exc _vwarn(msg, verbose) return cls( azimuth=props.get("azimuth"), latitude=props.get("latitude"), longitude=props.get("longitude"), elevation=props.get("elevation"), extra=extra, verbose=verbose, strict=strict, )
[docs] @classmethod def from_mapping( cls, mapping: Mapping[str, str | float | int | None], *, strict: bool = False, verbose: int | bool = 0, ) -> JSiteProperty: r""" Build a :class:`JSiteProperty` from a dict of key-value pairs. Keys can be any case and will be normalized to the upper case tokens used by J files. Values can be strings or numbers. Unknown keys are preserved in ``extra``. Parameters ---------- mapping : Mapping[str, Any] Pairs like ``{\"latitude\": 41.9, \"longitude\": 140.8}``. strict : bool, default False If ``True``, invalid coordinates raise. If ``False``, out-of-range values are coerced with a warning. verbose : int or bool, default 0 Verbosity for non-fatal parsing warnings. Returns ------- JSiteProperty Parsed site properties. Notes ----- This is a convenience front-end that converts the mapping to synthetic ``>KEY=VALUE`` lines and forwards the work to :meth:`from_lines`. Examples -------- >>> prop = JSiteProperty.from_mapping({\n ... 'azimuth': 370, 'latitude': '41:58N',\n ... 'longitude': '140:54E', 'elevation': 0,\n ... })\n >>> 0.0 <= (prop.azimuth or 0.0) < 360.0\n True See Also -------- from_lines Parse from an in-memory sequence of lines. from_file Parse from a file path or open stream. References ---------- .. [1] A. G. Jones (1994). Magnetotelluric data file J-format, version 2.0. """ lines = (f">{k}={v}" for k, v in mapping.items()) return cls.from_lines(lines, strict=strict, verbose=verbose)
[docs] def asdict(self) -> dict[str, float | None]: return { "azimuth": self.azimuth, "latitude": self.latitude, "longitude": self.longitude, "elevation": self.elevation, }
[docs] @property def location(self) -> tuple[float, float] | None: if self.latitude is None or self.longitude is None: return None return (self.latitude, self.longitude)
[docs] @property def azimuth_rad(self) -> float | None: if self.azimuth is None: return None return math.radians(self.azimuth)
def __repr__(self) -> str: # pragma: no cover - cosmetic parts = [] for k, v in self.asdict().items(): if v is not None: parts.append(f"{k}={v!r}") s = ", ".join(parts) if self.extra: s = ( f"{s}, extra={len(self.extra)} keys" if s else (f"extra={len(self.extra)} keys") ) return f"JSiteProperty({s})"
def _maybe_missing_float(value: str) -> float | None: try: v = _to_float_safe(value) except Exception: return None if math.isclose(v, MISSING_FLOAT, rel_tol=0.0, abs_tol=0.0): return None return v def _parse_latitude( value: str, *, strict: bool, verbose: int | bool ) -> float | None: """Parse latitude using GIS utilities. - In ``strict`` mode, delegate to :func:`assert_lat_value` which raises for out‑of‑range inputs. - Otherwise, parse with :func:`convert_position_str2float` and coerce into ``[-90, 90]`` with a warning if needed. """ if strict: return _assert_lat(value) lat = _to_deg(value) if lat is None: # plain decimal fallback try: lat = float(str(value).strip()) except Exception: return None if not (-90.0 <= lat <= 90.0): _vwarn(f"Latitude {lat:.6f} coerced to range", verbose) lat = _coerce_lat(lat) return lat def _parse_longitude( value: str, *, strict: bool, verbose: int | bool ) -> float | None: """Parse longitude using GIS utilities. - In ``strict`` mode, delegate to :func:`assert_lon_value` which raises for out‑of‑range inputs. - Otherwise, parse with :func:`convert_position_str2float` and normalize to ``[-180, 180)``; warn if a wrap was applied. """ if strict: return _assert_lon(value) lon = _to_deg(value) if lon is None: # plain decimal fallback try: lon = float(str(value).strip()) except Exception: return None fixed = _coerce_lon(lon) if abs(fixed - lon) > 1e-12: _vwarn(f"Longitude {lon:.6f} normalized to {fixed:.6f}", verbose) return fixed def _vwarn(msg: str, verbose: int | bool) -> None: if verbose: warnings.warn(msg, RuntimeWarning, stacklevel=2) def _to_float_safe(s: str) -> float: """Best‑effort float parser for simple numeric fields (e.g., azimuth). Unlike :func:`_to_deg`, this does *not* parse DMS — it's meant for scalar numeric tokens where non‑numeric input should surface quickly. """ s = str(s).strip() if s == "" or s == "None": raise ValueError("empty string") return float(s) def _norm_azimuth(a: float) -> float: a = a % 360.0 return a if a >= 0.0 else a + 360.0 def _coerce_lat(v: float) -> float: return 90.0 if v > 90.0 else (-90.0 if v < -90.0 else v) def _coerce_lon(v: float) -> float: return ((v + 180.0) % 360.0) - 180.0