Source code for pycsamt.geology.structural

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Structural geology — field measurements and fault traces.

Independent structural evidence to check an interpretation against, the
same role :class:`~pycsamt.geology.borehole.Borehole` plays for lithology.
None of this is electromagnetic: positions are profile-relative metres
(``x``), exactly like :class:`~pycsamt.geology.borehole.Borehole` and
:class:`~pycsamt.geology.lithology.StratigraphicLog` -- real-world
placement (lat/lon) is handled separately by :mod:`pycsamt.site` and
:mod:`pycsamt.gis`.

Two kinds of field measurement are distinguished, matching how a
stereonet actually plots them:

* :class:`StructuralMeasurement` -- a **planar** feature (bedding,
  foliation, joint, cleavage, contact, fault plane, unconformity
  surface), recorded as strike/dip/dip-direction.
* :class:`LinearMeasurement` -- a **linear** feature (fold axis,
  lineation, slickenline), recorded as trend/plunge.

:class:`FaultTrace` is a distinct, coarser entity: where a fault crosses
the 2-D profile itself, which side is downthrown, and (if known) the
throw -- the piece that plugs directly into the structural-continuity
review questions in :doc:`/user_guide/interpretation/workflow`
("do apparent boundary offsets align with known structures?").
:class:`StructuralModel` collects all three per profile.

Angle conventions
------------------
This is the one place the rest of :mod:`pycsamt` does not already agree
with itself: MT geoelectric strike (:mod:`pycsamt.emtools.strike`) is
axial and wrapped to ``(-90, 90]``; :mod:`pycsamt.gis`/:mod:`pycsamt.site`
azimuth is a directed compass bearing, ``[0, 360)``; the synthetic
geometry in :mod:`pycsamt.ai.geology.lenses` is axial mod-180 for an
unrelated reason (ellipse symmetry). None of those fit real field data,
so this module uses the convention modern digital field-mapping tools
(FieldMove, StraboSpot) record directly from a compass-clinometer:

* ``strike_deg``, ``trend_deg``, ``dip_direction_deg`` -- compass
  bearings, ``[0, 360)``, degrees clockwise from north.
* ``dip_deg``, ``plunge_deg`` -- angle below horizontal, ``[0, 90]``.

Storing ``dip_direction_deg`` alongside ``strike_deg`` (rather than only
a right-hand-rule pair) is deliberate: the two are cross-validated against
each other in :meth:`StructuralMeasurement.validate` (dip direction must
be within tolerance of strike +/- 90 degrees), which catches a transposed
field-notebook entry that a bare right-hand-rule number would not.
:meth:`StructuralMeasurement.from_right_hand_rule` builds one from just
dip direction and dip, for anyone who prefers recording that way.
"""

from __future__ import annotations

import csv
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Union

from ..api.property import MetadataMixin, PyCSAMTObject

__all__ = [
    "StructuralMeasurement",
    "LinearMeasurement",
    "FaultTrace",
    "StructuralModel",
]

PathLike = Union[str, Path]

_DIP_DIRECTION_TOLERANCE_DEG = 20.0


def _norm360(angle_deg: float) -> float:
    """Normalise an angle to ``[0, 360)``."""
    return float(angle_deg) % 360.0


def _angular_diff(a_deg: float, b_deg: float) -> float:
    """Smallest absolute difference between two compass bearings, in [0, 180]."""
    d = abs(_norm360(a_deg) - _norm360(b_deg)) % 360.0
    return d if d <= 180.0 else 360.0 - d


# ---------------------------------------------------------------------------
# StructuralMeasurement (planar)
# ---------------------------------------------------------------------------


[docs] @dataclass class StructuralMeasurement(PyCSAMTObject): """A planar structural field measurement. Parameters ---------- x : float Position along the survey profile, metres. kind : str Feature type, e.g. ``'bedding'``, ``'foliation'``, ``'joint'``, ``'cleavage'``, ``'contact'``, ``'fault_plane'``, ``'unconformity'``. Free text -- not an enforced enumeration, matching :attr:`~pycsamt.geology.borehole.Interval.lithology`. strike_deg : float Compass strike, degrees clockwise from north, ``[0, 360)`` as measured. Normalised on construction; not reduced modulo 180, so the raw field reading is preserved. dip_deg : float Dip angle below horizontal, degrees, ``[0, 90]``. dip_direction_deg : float Compass direction the surface dips toward, ``[0, 360)``. Must be within *dip_direction_tolerance_deg* of ``strike_deg + 90`` or ``strike_deg - 90`` (mod 360); raises :class:`ValueError` otherwise, since a wider mismatch usually means one of the two readings was transposed in the field notebook. z : float, optional Depth (positive downward) or elevation of the measurement, metres. ``None`` for a surface outcrop reading with no associated depth. station : str, optional Field station or outcrop label. confidence : float Subjective reading confidence, ``[0, 1]`` (default 1.0). notes : str Free-text field note. Examples -------- >>> m = StructuralMeasurement( ... x=500.0, kind="bedding", strike_deg=45.0, dip_deg=30.0, ... dip_direction_deg=135.0, ... ) >>> m.dip_azimuth_ok True """ x: float kind: str strike_deg: float dip_deg: float dip_direction_deg: float z: float | None = None station: str | None = None confidence: float = 1.0 notes: str = "" dip_direction_tolerance_deg: float = _DIP_DIRECTION_TOLERANCE_DEG def __post_init__(self) -> None: self.validate()
[docs] def validate(self) -> None: """Re-check and re-normalise this measurement's fields. Called automatically by ``__post_init__``, and by :meth:`~pycsamt.api.property.PyCSAMTObject.update`/:meth:`~pycsamt.api.property.PyCSAMTObject.clone` after they set new attribute values -- both go through this method rather than ``__post_init__`` (which only runs once, at construction), so a ``clone(dip_direction_deg=...)`` that breaks the strike/dip-direction consistency check is caught rather than silently accepted. """ if not (0.0 <= self.dip_deg <= 90.0): raise ValueError(f"dip_deg ({self.dip_deg}) must be in [0, 90].") if not (0.0 <= self.confidence <= 1.0): raise ValueError( f"confidence ({self.confidence}) must be in [0, 1]." ) self.strike_deg = _norm360(self.strike_deg) self.dip_direction_deg = _norm360(self.dip_direction_deg) if not self.dip_azimuth_ok: raise ValueError( f"dip_direction_deg ({self.dip_direction_deg}) is not " f"within {self.dip_direction_tolerance_deg} deg of " f"strike_deg ({self.strike_deg}) +/- 90 -- check for a " "transposed strike/dip-direction reading." )
[docs] @property def dip_azimuth_ok(self) -> bool: """Whether ``dip_direction_deg`` is consistent with ``strike_deg``.""" tol = self.dip_direction_tolerance_deg return ( _angular_diff(self.dip_direction_deg, self.strike_deg + 90.0) <= tol or _angular_diff(self.dip_direction_deg, self.strike_deg - 90.0) <= tol )
[docs] @classmethod def from_right_hand_rule( cls, x: float, kind: str, dip_direction_deg: float, dip_deg: float, **kwargs: Any, ) -> StructuralMeasurement: """Build from a dip-direction/dip pair, deriving strike. Strike is set to ``dip_direction_deg - 90`` (mod 360), the right-hand-rule convention: facing along strike with the dip direction to your right. """ strike_deg = _norm360(float(dip_direction_deg) - 90.0) return cls( x=x, kind=kind, strike_deg=strike_deg, dip_deg=dip_deg, dip_direction_deg=dip_direction_deg, **kwargs, )
def __repr__(self) -> str: return ( f"StructuralMeasurement(x={self.x:.1f} m, {self.kind!r}, " f"{self.strike_deg:.0f}/{self.dip_deg:.0f}" f"->{self.dip_direction_deg:.0f})" )
# --------------------------------------------------------------------------- # LinearMeasurement # ---------------------------------------------------------------------------
[docs] @dataclass class LinearMeasurement(PyCSAMTObject): """A linear structural field measurement. Parameters ---------- x : float Position along the survey profile, metres. kind : str Feature type, e.g. ``'fold_axis'``, ``'lineation'``, ``'slickenline'``, ``'fold_hinge'``, ``'intersection_lineation'``. Free text, as with :class:`StructuralMeasurement`. trend_deg : float Compass direction the line plunges toward, degrees clockwise from north, ``[0, 360)``. plunge_deg : float Angle below horizontal, degrees, ``[0, 90]``. z : float, optional station : str, optional confidence : float notes : str Examples -------- >>> LinearMeasurement(x=500.0, kind="fold_axis", trend_deg=210.0, plunge_deg=15.0) LinearMeasurement(x=500.0 m, 'fold_axis', 210/15) """ x: float kind: str trend_deg: float plunge_deg: float z: float | None = None station: str | None = None confidence: float = 1.0 notes: str = "" def __post_init__(self) -> None: self.validate()
[docs] def validate(self) -> None: """Re-check and re-normalise this measurement's fields. Called by ``__post_init__`` and by ``update``/``clone``; see :meth:`StructuralMeasurement.validate`. """ if not (0.0 <= self.plunge_deg <= 90.0): raise ValueError( f"plunge_deg ({self.plunge_deg}) must be in [0, 90]." ) if not (0.0 <= self.confidence <= 1.0): raise ValueError( f"confidence ({self.confidence}) must be in [0, 1]." ) self.trend_deg = _norm360(self.trend_deg)
def __repr__(self) -> str: return ( f"LinearMeasurement(x={self.x:.1f} m, {self.kind!r}, " f"{self.trend_deg:.0f}/{self.plunge_deg:.0f})" )
# --------------------------------------------------------------------------- # FaultTrace # --------------------------------------------------------------------------- _SENSES = ("normal", "reverse", "strike_slip", "unknown") _SIDES = ("left", "right")
[docs] @dataclass class FaultTrace(PyCSAMTObject): """Where a fault crosses the 2-D profile. Parameters ---------- x : float Profile position where the fault is picked, metres. dip_deg : float Apparent dip of the fault plane *in the section*, degrees, ``[0, 90]`` (0 = flat detachment, 90 = vertical). This is the angle a 2-D EM section can actually constrain; the true 3-D dip differs unless the profile happens to run perpendicular to strike. Pass *strike_deg* separately when the true attitude is independently known (surface mapping, borehole). downthrown_side : {'left', 'right'} Which side of ``x`` -- toward decreasing or increasing profile distance -- is downthrown. sense : {'normal', 'reverse', 'strike_slip', 'unknown'} Kinematic sense, where known (default ``'unknown'``). throw_m : float, optional Vertical displacement, metres (magnitude; direction is carried by *downthrown_side*). ``None`` when unknown or unmeasured. strike_deg : float, optional True compass strike, when independently known. z_top : float, optional Depth to the top of the picked trace, metres. ``None`` for a surface trace or when unconstrained. confidence : float evidence : str Free-text source, e.g. ``'resistivity offset'``, ``'borehole'``, ``'surface mapping'``. notes : str Examples -------- >>> FaultTrace(x=500.0, dip_deg=70.0, downthrown_side="right", throw_m=12.0) FaultTrace(x=500.0 m, dip=70 deg, down=right, throw=12.0 m) """ x: float dip_deg: float downthrown_side: str sense: str = "unknown" throw_m: float | None = None strike_deg: float | None = None z_top: float | None = None confidence: float = 1.0 evidence: str = "" notes: str = "" def __post_init__(self) -> None: self.validate()
[docs] def validate(self) -> None: """Re-check and re-normalise this trace's fields. Called by ``__post_init__`` and by ``update``/``clone``; see :meth:`StructuralMeasurement.validate`. """ if not (0.0 <= self.dip_deg <= 90.0): raise ValueError(f"dip_deg ({self.dip_deg}) must be in [0, 90].") if self.downthrown_side not in _SIDES: raise ValueError( f"downthrown_side must be one of {_SIDES}, " f"got {self.downthrown_side!r}." ) if self.sense not in _SENSES: raise ValueError( f"sense must be one of {_SENSES}, got {self.sense!r}." ) if self.throw_m is not None and self.throw_m < 0.0: raise ValueError( f"throw_m ({self.throw_m}) must be >= 0; direction is " "carried by downthrown_side, not the sign of throw_m." ) if not (0.0 <= self.confidence <= 1.0): raise ValueError( f"confidence ({self.confidence}) must be in [0, 1]." ) if self.strike_deg is not None: self.strike_deg = _norm360(self.strike_deg)
def __repr__(self) -> str: throw = f"{self.throw_m:.1f} m" if self.throw_m is not None else "?" return ( f"FaultTrace(x={self.x:.1f} m, dip={self.dip_deg:.0f} deg, " f"down={self.downthrown_side}, throw={throw})" )
# --------------------------------------------------------------------------- # StructuralModel # ---------------------------------------------------------------------------
[docs] class StructuralModel(PyCSAMTObject, MetadataMixin): """Collection of structural evidence along one survey profile. Parameters ---------- planar : list of StructuralMeasurement, optional linear : list of LinearMeasurement, optional faults : list of FaultTrace, optional metadata : dict, optional Free-form provenance, e.g. survey name or source file paths. Examples -------- >>> model = StructuralModel( ... faults=[FaultTrace(x=500.0, dip_deg=70.0, downthrown_side="right")], ... ) >>> len(model.faults) 1 """ def __init__( self, *, planar: Sequence[StructuralMeasurement] | None = None, linear: Sequence[LinearMeasurement] | None = None, faults: Sequence[FaultTrace] | None = None, metadata: dict | None = None, ) -> None: self.planar: list[StructuralMeasurement] = list(planar or []) self.linear: list[LinearMeasurement] = list(linear or []) self.faults: list[FaultTrace] = list(faults or []) self.metadata: dict = dict(metadata) if metadata else {} # ------------------------------------------------------------------ # Mutation # ------------------------------------------------------------------
[docs] def add_planar(self, measurement: StructuralMeasurement) -> None: self.planar.append(measurement)
[docs] def add_linear(self, measurement: LinearMeasurement) -> None: self.linear.append(measurement)
[docs] def add_fault(self, fault: FaultTrace) -> None: self.faults.append(fault)
# ------------------------------------------------------------------ # Queries # ------------------------------------------------------------------
[docs] def within(self, x_min: float, x_max: float) -> StructuralModel: """Return a new model restricted to ``x_min <= x <= x_max``.""" return StructuralModel( planar=[m for m in self.planar if x_min <= m.x <= x_max], linear=[m for m in self.linear if x_min <= m.x <= x_max], faults=[f for f in self.faults if x_min <= f.x <= x_max], metadata=dict(self.metadata), )
[docs] def nearest( self, x: float, *, kind: str = "faults", max_distance: float | None = None, ) -> StructuralMeasurement | LinearMeasurement | FaultTrace | None: """Return the item of *kind* nearest to profile position *x*. Parameters ---------- x : float kind : {'faults', 'planar', 'linear'} max_distance : float, optional Return ``None`` if the nearest item is farther than this (metres), instead of returning a distant match silently. """ items = getattr(self, kind, None) if items is None: raise ValueError( "kind must be one of 'faults', 'planar', 'linear', " f"got {kind!r}." ) if not items: return None best = min(items, key=lambda item: abs(item.x - x)) if max_distance is not None and abs(best.x - x) > max_distance: return None return best
# ------------------------------------------------------------------ # I/O # ------------------------------------------------------------------
[docs] @classmethod def from_csv( cls, *, planar_path: PathLike | None = None, linear_path: PathLike | None = None, faults_path: PathLike | None = None, delimiter: str = ",", ) -> StructuralModel: """Load a model from up to three CSV files, one per evidence type. Expected columns (case-insensitive header, optional columns may be omitted): * *planar_path* -- ``x, kind, strike_deg, dip_deg, dip_direction_deg[, z, station, confidence, notes]`` * *linear_path* -- ``x, kind, trend_deg, plunge_deg[, z, station, confidence, notes]`` * *faults_path* -- ``x, dip_deg, downthrown_side[, sense, throw_m, strike_deg, z_top, confidence, evidence, notes]`` Any path left as ``None`` yields an empty list for that evidence type. """ planar = ( _read_planar_csv(planar_path, delimiter) if planar_path is not None else [] ) linear = ( _read_linear_csv(linear_path, delimiter) if linear_path is not None else [] ) faults = ( _read_faults_csv(faults_path, delimiter) if faults_path is not None else [] ) return cls(planar=planar, linear=linear, faults=faults)
[docs] def to_dict(self) -> dict: return { "planar": [vars(m).copy() for m in self.planar], "linear": [vars(m).copy() for m in self.linear], "faults": [vars(f).copy() for f in self.faults], }
def __repr__(self) -> str: return ( f"StructuralModel({len(self.planar)} planar, " f"{len(self.linear)} linear, {len(self.faults)} faults)" )
# --------------------------------------------------------------------------- # CSV helpers # --------------------------------------------------------------------------- def _header_lookup(fieldnames: Sequence[str]) -> dict: return {h.lower(): h for h in fieldnames} def _optional_float(row: dict, key: str | None) -> float | None: if key is None: return None raw = row.get(key, "") if raw is None or raw.strip() in ("", "nan", "None", "NA"): return None try: return float(raw) except ValueError: return None def _optional_str(row: dict, key: str | None) -> str | None: if key is None: return None raw = row.get(key) return raw.strip() if raw is not None and raw.strip() else None def _read_planar_csv( path: PathLike, delimiter: str ) -> list[StructuralMeasurement]: p = Path(path) out: list[StructuralMeasurement] = [] with p.open(newline="") as fh: reader = csv.DictReader(fh, delimiter=delimiter) if reader.fieldnames is None: raise ValueError(f"CSV file has no header: {p}") h = _header_lookup(reader.fieldnames) for row in reader: kwargs: dict[str, Any] = {} z = _optional_float(row, h.get("z")) if z is not None: kwargs["z"] = z station = _optional_str(row, h.get("station")) if station is not None: kwargs["station"] = station conf = _optional_float(row, h.get("confidence")) if conf is not None: kwargs["confidence"] = conf notes = _optional_str(row, h.get("notes")) if notes is not None: kwargs["notes"] = notes out.append( StructuralMeasurement( x=float(row[h["x"]]), kind=row[h["kind"]].strip(), strike_deg=float(row[h["strike_deg"]]), dip_deg=float(row[h["dip_deg"]]), dip_direction_deg=float(row[h["dip_direction_deg"]]), **kwargs, ) ) return out def _read_linear_csv( path: PathLike, delimiter: str ) -> list[LinearMeasurement]: p = Path(path) out: list[LinearMeasurement] = [] with p.open(newline="") as fh: reader = csv.DictReader(fh, delimiter=delimiter) if reader.fieldnames is None: raise ValueError(f"CSV file has no header: {p}") h = _header_lookup(reader.fieldnames) for row in reader: kwargs: dict[str, Any] = {} z = _optional_float(row, h.get("z")) if z is not None: kwargs["z"] = z station = _optional_str(row, h.get("station")) if station is not None: kwargs["station"] = station conf = _optional_float(row, h.get("confidence")) if conf is not None: kwargs["confidence"] = conf notes = _optional_str(row, h.get("notes")) if notes is not None: kwargs["notes"] = notes out.append( LinearMeasurement( x=float(row[h["x"]]), kind=row[h["kind"]].strip(), trend_deg=float(row[h["trend_deg"]]), plunge_deg=float(row[h["plunge_deg"]]), **kwargs, ) ) return out def _read_faults_csv(path: PathLike, delimiter: str) -> list[FaultTrace]: p = Path(path) out: list[FaultTrace] = [] with p.open(newline="") as fh: reader = csv.DictReader(fh, delimiter=delimiter) if reader.fieldnames is None: raise ValueError(f"CSV file has no header: {p}") h = _header_lookup(reader.fieldnames) for row in reader: kwargs: dict[str, Any] = {} sense = _optional_str(row, h.get("sense")) if sense is not None: kwargs["sense"] = sense throw_m = _optional_float(row, h.get("throw_m")) if throw_m is not None: kwargs["throw_m"] = throw_m strike_deg = _optional_float(row, h.get("strike_deg")) if strike_deg is not None: kwargs["strike_deg"] = strike_deg z_top = _optional_float(row, h.get("z_top")) if z_top is not None: kwargs["z_top"] = z_top conf = _optional_float(row, h.get("confidence")) if conf is not None: kwargs["confidence"] = conf evidence = _optional_str(row, h.get("evidence")) if evidence is not None: kwargs["evidence"] = evidence notes = _optional_str(row, h.get("notes")) if notes is not None: kwargs["notes"] = notes out.append( FaultTrace( x=float(row[h["x"]]), dip_deg=float(row[h["dip_deg"]]), downthrown_side=row[h["downthrown_side"]].strip(), **kwargs, ) ) return out