Source code for pycsamt.zonge.property

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
pycsamt.zonge.property
----------------------

Reusable, lightweight property containers for survey-level
metadata that are *not* tied to a particular AVG record.
All classes expose a simple ``set()/get()`` API, plus helpers
to read/write CSAVGW/ASTATIC ``$keyword=value`` style headers.

Design goals
~~~~~~~~~~~~
- Small, explicit data holders (dataclasses).
- Robust set/get with gentle validation.
- Round-trip helpers:
    • ``update_from_keywords(meta)``  ← dict from parser
    • ``to_keywords()``               → dict for writer
- Tolerate legacy aliases (e.g. ``XMTR`` ↔ ``Tx.GdpStn``).
- Keep lines short for readability in code reviews.
"""

from __future__ import annotations

import json
from dataclasses import asdict, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from ..compat.python import dc

__all__ = [
    "SkipFlag",
    "Hardware",
    "Receiver",
    "Transmitter",
    "SurveyConfiguration",
    "SurveyAnnotation",
]


[docs] class SkipFlag: """ Encapsulate Zonge *skip-flag* quality codes. Codes ----- 2 → good quality 1 → kept but *skip* on plots 0 → rejected / bad data * → no data (placeholder) """ _flag_map: dict[str, str] = { "2": "good", "1": "skip", "0": "reject", "*": "nodata", } def __init__(self, value: str | int | None = "2") -> None: self._value = "2" self.set(value)
[docs] def set(self, value: str | int | None = None) -> None: """Set the flag using {0,1,2,'*'}.""" if value is None: return code = str(value) if code not in self._flag_map: valid = ", ".join(self._flag_map) raise ValueError(f"skip-flag must be one of: {valid}") self._value = code
[docs] def get(self) -> str: """Return the *label* ('good', 'skip', ...).""" return self._flag_map[self._value]
[docs] @property def code(self) -> str: """Return the *raw* code as string.""" return self._value
def __str__(self) -> str: return f"SkipFlag({self.code}{self.get()})" __repr__ = __str__
[docs] @dc(slots=True) class Hardware: """ Minimal provenance captured from banner / comment lines. Many of these are informational and may not appear as $keywords. We keep ``to_keywords`` minimal to avoid inventing conventions. """ version: str = "7.76" source_file: Path | None = None dated: str | None = None processed: str | None = None astatic_ver: str = "v3.60" updated: str | None = None tma_points: int | None = None tma_freq: float | None = None _extra: dict[str, Any] = field(default_factory=dict, repr=False) # No formal CSAVGW keymap for banner lines; keep empty by design. KEYMAP: dict[str, str] = field(default_factory=dict, init=False)
[docs] def set(self, **kwargs) -> None: """Set known fields; unknowns land in ``_extra``.""" for key, val in kwargs.items(): if hasattr(self, key): setattr(self, key, val) else: self._extra[key] = val
[docs] def get(self, key: str, default: Any = None) -> Any: """Get known field or fallback to ``_extra``.""" return getattr(self, key, self._extra.get(key, default))
def __str__(self) -> str: src = f", file='{self.source_file.name}'" if self.source_file else "" return f"Hardware(ver={self.version}{src})"
[docs] def to_json(self, *, indent: int = 0) -> str: base = asdict(self) base.update(self._extra) if base["source_file"] is not None: base["source_file"] = str(base["source_file"]) return json.dumps(base, indent=indent, default=str)
[docs] def update_from_keywords(self, meta: dict[str, Any]) -> None: """ Accept a plain dict (keys may or may not start with '$') and coerce a few well-known banner values to proper types. """ m = {_norm_key(k): v for k, v in meta.items()} # source_file handling so tests can round-trip it if "source_file" in m: try: self.source_file = Path(str(m["source_file"]).strip()) except Exception: # Fall back to a plain string if Path chokes self.source_file = Path(str(m["source_file"])) if "version" in m: self.version = str(m["version"]).strip() if "dated" in m: self.dated = str(m["dated"]).strip() if "processed" in m: self.processed = str(m["processed"]).strip() if "astatic_ver" in m: self.astatic_ver = str(m["astatic_ver"]).strip() if "updated" in m: self.updated = str(m["updated"]).strip() if "tma_points" in m: tp = _to_number(m["tma_points"]) self.tma_points = ( int(tp) if isinstance(tp, (int, float)) else None ) if "tma_freq" in m: tf = _to_number(m["tma_freq"]) self.tma_freq = ( float(tf) if isinstance(tf, (int, float)) else None )
[docs] def to_keywords(self) -> dict[str, Any]: """ Provide a small, stable set for round-trip / tests. """ out: dict[str, Any] = {} if self.version is not None: out["version"] = self.version if self.dated is not None: out["dated"] = self.dated if self.processed is not None: out["processed"] = self.processed if self.astatic_ver is not None: out["astatic_ver"] = self.astatic_ver if self.updated is not None: out["updated"] = self.updated if self.tma_points is not None: out["tma_points"] = int(self.tma_points) if self.tma_freq is not None: out["tma_freq"] = float(self.tma_freq) out["source_file"] = ( str(self.source_file) if self.source_file is not None else None ) return out
[docs] @dc(slots=True) class Receiver: """Receiver electrode / coil metadata (``$Rx.*``).""" station: int | None = None # $Rx.Stn (client) length_m: float | None = None # $Rx.Length (units) gdp_station: int | None = None # $Rx.GdpStn hpr: tuple[float, float, float] | None = None # $Rx.HPR comps: str | None = "ExHy" # $Rx.Cmp azimuth_deg: float | None = None # convenience (alias of HPR[0]) latitude: float | None = None # $GPS.Lat longitude: float | None = None # $GPS.Lon elevation: float | None = None # (rare; when present) unit: str | None = "m" # $Unit.Length notes: str | None = None KEYMAP: dict[str, str] = field( default_factory=lambda: { "station": "Rx.Stn", "gdp_station": "Rx.GdpStn", "length_m": "Rx.Length", "hpr": "Rx.HPR", "comps": "Rx.Cmp", "latitude": "GPS.Lat", "longitude": "GPS.Lon", }, init=False, ) ALIASES: dict[str, str] = field(default_factory=dict, init=False)
[docs] def set(self, **kwargs) -> None: """Set any attribute; no hard validation here.""" for k, v in kwargs.items(): setattr(self, k, v)
[docs] def get(self, key: str, default: Any = None) -> Any: """Get attribute by name.""" return getattr(self, key, default)
[docs] def update_from_keywords(self, meta: dict[str, Any]) -> None: """Update from parsed $Rx.* / $GPS.* keys with typing.""" # 1) normalize keys once (“$Rx.Stn” → “Rx.Stn”) norm = {_norm_key(k): v for k, v in meta.items()} # 2) apply mapping using normalized keys _apply_keywords(self, self.KEYMAP, norm, aliases=self.ALIASES) # 3) numeric coercions from normalized keys stn = _to_number(norm.get("Rx.Stn")) if isinstance(stn, (int, float)): self.station = int(stn) gdp = _to_number(norm.get("Rx.GdpStn")) if isinstance(gdp, (int, float)): self.gdp_station = int(gdp) length, unit = _parse_length(norm.get("Rx.Length"), self.unit or "m") if length is not None: self.length_m = float(length) self.unit = unit or self.unit hpr_raw = norm.get("Rx.HPR") if hpr_raw is not None: if isinstance(hpr_raw, (tuple, list)) and len(hpr_raw) == 3: self.hpr = tuple(float(_to_number(v) or 0.0) for v in hpr_raw) # type: ignore else: parts = [ p.strip() for p in str(hpr_raw).replace(";", ",").split(",") ] if len(parts) >= 3: self.hpr = ( float(_to_number(parts[0]) or 0.0), float(_to_number(parts[1]) or 0.0), float(_to_number(parts[2]) or 0.0), ) if self.hpr and self.azimuth_deg is None: self.azimuth_deg = float(self.hpr[0]) lat = _to_number(norm.get("GPS.Lat")) lon = _to_number(norm.get("GPS.Lon")) if isinstance(lat, (int, float)): self.latitude = float(lat) if isinstance(lon, (int, float)): self.longitude = float(lon) if "Rx.Cmp" in norm and norm["Rx.Cmp"] is not None: self.comps = str(norm["Rx.Cmp"])
[docs] def to_keywords(self) -> dict[str, Any]: """Export standardized $Rx.* keys with units preserved.""" out: dict[str, Any] = {} if self.gdp_station is not None: out["Rx.GdpStn"] = int(self.gdp_station) if self.station is not None: out["Rx.Stn"] = int(self.station) if self.length_m is not None: out["Rx.Length"] = f"{self.length_m:g} {self.unit or 'm'}" if self.hpr is not None: h, p, r = self.hpr out["Rx.HPR"] = f"{h:g},{p:g},{r:g}" if self.comps: out["Rx.Cmp"] = self.comps if self.latitude is not None: out["GPS.Lat"] = float(self.latitude) if self.longitude is not None: out["GPS.Lon"] = float(self.longitude) return out
def __str__(self) -> str: unit = self.unit or "m" st = f"{self.station}" if self.station is not None else "?" ln = f"{self.length_m:g} {unit}" if self.length_m else "?" cmp_ = self.comps or "?" return f"Receiver(stn={st}, len={ln}, cmp={cmp_})"
[docs] @dc(slots=True) class Transmitter: """Transmitter loop / bipole metadata (``$Tx.*``).""" station: int | None = None # $Tx.Stn (client) length_m: float | None = None # $Tx.Length gdp_station: int | None = None # $Tx.GdpStn (alias XMTR) tx_type: str | None = None # $Tx.Type center: tuple[float, float, float] | None = None # $Tx.Center hpr: tuple[float, float, float] | None = None # $Tx.HPR current_a: float | None = None frequency_hz: float | None = None latitude: float | None = None # seldom in headers longitude: float | None = None notes: str | None = None KEYMAP: dict[str, str] = field( default_factory=lambda: { "station": "Tx.Stn", "gdp_station": "Tx.GdpStn", "length_m": "Tx.Length", "tx_type": "Tx.Type", "center": "Tx.Center", "hpr": "Tx.HPR", }, init=False, ) ALIASES: dict[str, str] = field( default_factory=lambda: { # legacy alias encountered in AMTAVG headers "XMTR": "gdp_station", }, init=False, )
[docs] def set(self, **kwargs) -> None: """Set any attribute; no hard validation here.""" for k, v in kwargs.items(): setattr(self, k, v)
[docs] def get(self, key: str, default: Any = None) -> Any: """Get attribute by name.""" return getattr(self, key, default)
[docs] def update_from_keywords(self, meta: dict[str, Any]) -> None: """Update from $Tx.* (and legacy XMTR) with typing.""" # 1) normalize keys once norm = {_norm_key(k): v for k, v in meta.items()} # 2) apply mapping with normalized keys _apply_keywords(self, self.KEYMAP, norm) # 3) numeric coercions from normalized keys stn = _to_number(norm.get("Tx.Stn")) if isinstance(stn, (int, float)): self.station = int(stn) gdp = _to_number(norm.get("Tx.GdpStn") or norm.get("XMTR")) if isinstance(gdp, (int, float)): self.gdp_station = int(gdp) elif "XMTR" in norm and self.gdp_station is None: try: self.gdp_station = int(norm["XMTR"]) # type: ignore[arg-type] except Exception: self.gdp_station = norm[ "XMTR" ] # leave as string if truly odd length, _ = _parse_length(norm.get("Tx.Length"), "m") if length is not None: self.length_m = float(length) if "Tx.Type" in norm and norm["Tx.Type"] is not None: self.tx_type = str(norm["Tx.Type"]) cen = norm.get("Tx.Center") if cen is not None: if isinstance(cen, (tuple, list)) and len(cen) == 3: self.center = ( float(_to_number(cen[0]) or 0.0), float(_to_number(cen[1]) or 0.0), float(_to_number(cen[2]) or 0.0), ) else: parts = [ p.strip() for p in str(cen).replace(";", ",").split(",") ] if len(parts) >= 3: self.center = ( float(_to_number(parts[0]) or 0.0), float(_to_number(parts[1]) or 0.0), float(_to_number(parts[2]) or 0.0), ) hpr_raw = norm.get("Tx.HPR") if hpr_raw is not None: if isinstance(hpr_raw, (tuple, list)) and len(hpr_raw) == 3: self.hpr = tuple(float(_to_number(v) or 0.0) for v in hpr_raw) # type: ignore else: parts = [ p.strip() for p in str(hpr_raw).replace(";", ",").split(",") ] if len(parts) >= 3: self.hpr = ( float(_to_number(parts[0]) or 0.0), float(_to_number(parts[1]) or 0.0), float(_to_number(parts[2]) or 0.0), )
[docs] def to_keywords(self) -> dict[str, Any]: """Export standardized $Tx.* keys.""" out: dict[str, Any] = {} if self.gdp_station is not None: out["Tx.GdpStn"] = int(self.gdp_station) if self.station is not None: out["Tx.Stn"] = int(self.station) if self.length_m is not None: out["Tx.Length"] = f"{self.length_m:g} m" if self.tx_type: out["Tx.Type"] = self.tx_type if self.center is not None: e, n, z = self.center out["Tx.Center"] = f"{e:g},{n:g},{z:g}" if self.hpr is not None: h, p, r = self.hpr out["Tx.HPR"] = f"{h:g},{p:g},{r:g}" return out
def __str__(self) -> str: cur = f", I={self.current_a:g} A" if self.current_a else "" typ = f", type={self.tx_type}" if self.tx_type else "" stn = "?" if self.station is None else f"{self.station}" return f"Transmitter(stn={stn}{cur}{typ})"
[docs] @dc(slots=True) class SurveyConfiguration: """Survey-level configuration taken from AVG headers.""" # core identifiers survey_type: str = "CSAMT" # $Survey.Type array_type: str | None = None # $Survey.Array # line information line_name: str | None = None # $Line.Name line_number: float | None = None # $Line.Number line_azim_deg: float | None = None # $Line.Azimuth # GDP / client station numbering stn_gdp_beg: float | None = None # $Stn.GdpBeg stn_gdp_inc: float | None = None # $Stn.GdpInc stn_beg: float | None = None # $Stn.Beg stn_inc: float | None = None # $Stn.Inc stn_left: float | None = None # $Stn.Left stn_right: float | None = None # $Stn.Right # units unit_length: str = "m" # $Unit.Length unit_emag: str = "nV/Am" # $Unit.E unit_hfield: str = "pT/A" # $Unit.B unit_phase: str = "mrad" # $Unit.Phase # misc utm_zone: int | None = None created: str = field( default_factory=lambda: datetime.now().isoformat(timespec="seconds") ) _extra: dict[str, Any] = field(default_factory=dict, repr=False) KEYMAP: dict[str, str] = field( default_factory=lambda: { "survey_type": "Survey.Type", "array_type": "Survey.Array", "line_name": "Line.Name", "line_number": "Line.Number", "line_azim_deg": "Line.Azimuth", "stn_gdp_beg": "Stn.GdpBeg", "stn_gdp_inc": "Stn.GdpInc", "stn_beg": "Stn.Beg", "stn_inc": "Stn.Inc", "stn_left": "Stn.Left", "stn_right": "Stn.Right", "unit_length": "Unit.Length", "unit_emag": "Unit.E", "unit_hfield": "Unit.B", "unit_phase": "Unit.Phase", }, init=False, ) ALIASES: dict[str, str] = field( default_factory=lambda: { # common synonyms observed in real files "Units": "Unit.Length", "JobLine": "Line.Name", "BrgLine": "Line.Azimuth", "StnBeg": "Stn.GdpBeg", "StnDelt": "Stn.GdpInc", "LblFrst": "Stn.Left", "LblDelt": "Stn.Inc", }, init=False, )
[docs] def set(self, **kwargs) -> None: """ Set known fields; unknowns are stored in ``_extra`` so nothing is lost on round-trip. """ for k, v in kwargs.items(): if hasattr(self, k): setattr(self, k, v) else: self._extra[k] = v
[docs] def get(self, key: str, default: Any = None) -> Any: """Get known field or fallback to ``_extra``.""" return getattr(self, key, self._extra.get(key, default))
[docs] def update_from_keywords(self, meta: dict[str, Any]) -> None: """ Populate from $... header dict (keys without '$'). Coerce numeric fields so tests don't see strings. """ # Merge aliases → external canonical keys unified: dict[str, Any] = {} for k, v in meta.items(): key = _norm_key(k) key = self.ALIASES.get(key, key) unified[key] = v # Map into attributes _apply_keywords(self, self.KEYMAP, unified) # Coerce numerics for attr in ( "line_number", "line_azim_deg", "stn_gdp_beg", "stn_gdp_inc", "stn_beg", "stn_inc", "stn_left", "stn_right", ): val = getattr(self, attr) num = _to_number(val) if isinstance(num, (int, float)): setattr(self, attr, float(num))
[docs] def to_keywords(self) -> dict[str, Any]: """Export standardized $Survey.*, $Line.*, $Stn.*.""" base = asdict(self) base.update(self._extra) # prune internals base.pop("_extra", None) base.pop("utm_zone", None) base.pop("created", None) # leave floats as floats (no stringification) return _kv_roundtrip(base, self.KEYMAP)
def __str__(self) -> str: line = self.line_name or "-" return f"Survey(type={self.survey_type}, line={line})"
[docs] def to_json(self, *, indent: int = 0) -> str: data = asdict(self) data.update(self._extra) return json.dumps(data, indent=indent)
[docs] @dc(slots=True) class SurveyAnnotation: """Project-level annotation block (``$Job.*``).""" project_name: str = "CSAMTSurvey" # $Job.Name project_area: str | None = None # $Job.Area customer_name: str = "Zonge Engineering" # $Job.For contractor_name: str = "Zonge" # $Job.By project_label: str = "pyCSAMT" # $Job.Number acq_date: str = field( # $Job.Date default_factory=lambda: datetime.now(tz=timezone.utc).isoformat( timespec="seconds" ) ) _extra: dict[str, Any] = field(default_factory=dict, repr=False) KEYMAP: dict[str, str] = field( default_factory=lambda: { "project_name": "Job.Name", "project_area": "Job.Area", "customer_name": "Job.For", "contractor_name": "Job.By", "project_label": "Job.Number", "acq_date": "Job.Date", }, init=False, ) ALIASES: dict[str, str] = field( default_factory=lambda: { "Project": "Job.Name", "client": "Job.For", "company": "Job.By", "JobNumb": "Job.Number", "JobDate": "Job.Date", }, init=False, )
[docs] def set(self, **kwargs) -> None: """Set known fields; unknowns land in ``_extra``.""" for k, v in kwargs.items(): if hasattr(self, k): setattr(self, k, v) else: self._extra[k] = v
[docs] def get(self, key: str, default: Any = None) -> Any: """Get known field or fallback to ``_extra``.""" return getattr(self, key, self._extra.get(key, default))
[docs] def update_from_keywords(self, meta: dict[str, Any]) -> None: """Update from parsed ``$Job.*`` keys (with alias support).""" unified: dict[str, Any] = {} for k, v in meta.items(): key = _norm_key(k) key = self.ALIASES.get(key, key) unified[key] = v _apply_keywords(self, self.KEYMAP, unified)
[docs] def to_keywords(self) -> dict[str, Any]: """Export standardized ``$Job.*`` keys.""" base = asdict(self) base.update(self._extra) base.pop("_extra", None) return _kv_roundtrip(base, self.KEYMAP)
def __str__(self) -> str: area = self.project_area or "-" return f"Annotation(project={self.project_name}, area={area})"
[docs] def to_json(self, *, indent: int = 0) -> str: data = asdict(self) data.update(self._extra) return json.dumps(data, indent=indent)
def _norm_key(key: str) -> str: """ Normalize header keys: - strips leading '$' - preserves section (e.g., 'Rx.Length') - trims surrounding whitespace """ key = key.lstrip("$").strip() return key def _kv_roundtrip( data: dict[str, Any], keymap: dict[str, str], *, drop_none: bool = True, ) -> dict[str, Any]: """ Map internal attribute names → CSAVGW keys using *keymap*. Parameters ---------- data Internal ``{attr: value}`` mapping (e.g., from dataclass). keymap Mapping of internal attr → "Section.Key" strings. drop_none When *True*, omit entries with ``None`` values. Returns ------- dict ``{"Section.Key": value}`` dictionary suitable for writing back to ``.avg`` headers. """ out: dict[str, Any] = {} for attr, ext in keymap.items(): if attr not in data: continue val = data[attr] if drop_none and val is None: continue out[ext] = val return out def _apply_keywords( obj: Any, keymap: dict[str, str], meta: dict[str, Any], *, aliases: dict[str, str] | None = None, ) -> None: """ Update *obj* attributes from a parsed metadata dict. Notes ----- - Keys may come with or without leading '$'. - *aliases* supports legacy synonyms (e.g., 'XMTR'). """ inv = {v: k for k, v in keymap.items()} if aliases: inv.update({_norm_key(k): v for k, v in aliases.items()}) for k, v in meta.items(): key = _norm_key(k) attr = inv.get(key) if attr is None: continue setattr(obj, attr, v) def _to_number(x): """int if integral, else float, else original.""" if x is None: return None s = str(x).strip() # empty/asterisk/null → None if s in {"", "*", "nan", "NaN", "None", "null"}: return None try: f = float(s) return int(f) if f.is_integer() else f except Exception: return x # leave as string if not numeric def _parse_length(s, default_unit="m"): """ Parse '200 ft' or '50 m' → (value: float, unit: str). If only '200' provided, keep default_unit. """ if s is None: return None, default_unit t = str(s).strip() parts = t.split() if not parts: return None, default_unit value = _to_number(parts[0]) unit = parts[1] if len(parts) > 1 else default_unit # coerce unit spacing/format just a bit unit = unit.replace(".", "") return (float(value) if isinstance(value, (int, float)) else None, unit)