Source code for pycsamt.format.schema

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""In-memory schema for the pyCSAMT Common Subsurface Format (PCSF).

PCSF is the backend-neutral container every inversion result (Occam2D,
ModEM, MARE2DEM, DUHI) can be converted to, so :mod:`pycsamt.format.io`,
``app/mapview``, and the web 3-D view share one on-disk representation
instead of five ad hoc in-memory shapes. See
``PYCSAMT-PCSF-INVERSION-FORMAT-PLAN.md`` at the repository root for the
full design rationale.

This module defines the schema only — geometry and container
dataclasses, the frozen list of supported geometry kinds, and shape
validation. Reading/writing ``.pcsf`` files lives in
:mod:`pycsamt.format.io`; per-backend conversion lives in
:mod:`pycsamt.format.adapters` (Phases 2-4).
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, ClassVar

import numpy as np

from ..api.property import MetadataMixin, PyCSAMTObject

__all__ = [
    "PCSF_VERSION",
    "RESISTIVITY_UNIT",
    "GEOMETRY_KINDS",
    "RESISTIVITY_ENCODINGS",
    "TOPOGRAPHY_KINDS",
    "DERIVATION_METHODS",
    "Grid2DGeometry",
    "Grid3DGeometry",
    "UnstructuredMeshGeometry",
    "LineEntry",
    "DerivedVolume",
    "MultilineGeometry",
    "StationTable",
    "TopographyPerStation",
    "TopographyRaster",
    "PCSFModel",
]

PCSF_VERSION = "0.1.0"
RESISTIVITY_UNIT = "ohm.m"
GEOMETRY_KINDS = ("grid2d", "grid3d", "mesh_unstructured", "multiline")
RESISTIVITY_ENCODINGS = ("log10", "ln", "linear")
TOPOGRAPHY_KINDS = ("per_station", "raster")
DERIVATION_METHODS = ("linear_interp", "kriging", "idw")


def _arr(value: Any, *, dtype: Any = float) -> np.ndarray:
    return np.asarray(value, dtype=dtype)


def _opt_arr(value: Any, *, dtype: Any = float) -> np.ndarray | None:
    return None if value is None else np.asarray(value, dtype=dtype)


# ---------------------------------------------------------------------
# Geometry kinds
# ---------------------------------------------------------------------


[docs] @dataclass(repr=False) class Grid2DGeometry(PyCSAMTObject): """Single-profile rectilinear geometry (Occam2D / DUHI-via-Occam2D). Parameters ---------- x : ndarray (n_x,) Real station chainage, metres — never a solver's mesh-local frame (see the Occam2D coordinate-frame note in :meth:`pycsamt.interp._base.ResistivityModel.from_occam2d`). z : ndarray (n_z,) Depth cell centres, metres, positive downward. x_nodes, z_nodes : ndarray, optional Cell-edge coordinates, one longer than *x*/*z*. origin : ndarray (2,), optional Real-world offset when *x* is locally referenced. azimuth_deg : float, optional Profile bearing, for georeferencing back to the survey line. """ kind: ClassVar[str] = "grid2d" x: np.ndarray z: np.ndarray x_nodes: np.ndarray | None = None z_nodes: np.ndarray | None = None origin: np.ndarray | None = None azimuth_deg: float | None = None def __post_init__(self) -> None: self.x = _arr(self.x) self.z = _arr(self.z) self.x_nodes = _opt_arr(self.x_nodes) self.z_nodes = _opt_arr(self.z_nodes) self.origin = _opt_arr(self.origin)
[docs] def validate(self) -> None: if self.x.ndim != 1 or self.z.ndim != 1: raise ValueError("Grid2DGeometry.x and .z must be 1-D") if self.x_nodes is not None and self.x_nodes.shape != ( self.x.shape[0] + 1, ): raise ValueError("x_nodes must have one more element than x") if self.z_nodes is not None and self.z_nodes.shape != ( self.z.shape[0] + 1, ): raise ValueError("z_nodes must have one more element than z") if self.origin is not None and self.origin.shape != (2,): raise ValueError("origin must have shape (2,)")
[docs] @property def resistivity_shape(self) -> tuple[int, int]: return (self.z.shape[0], self.x.shape[0])
[docs] @dataclass(repr=False) class Grid3DGeometry(PyCSAMTObject): """Native 3-D tensor volume geometry (ModEM). Parameters ---------- x, y, z : ndarray Cell-centre coordinates, metres. x_nodes, y_nodes, z_nodes : ndarray, optional Cell-edge coordinates. origin : ndarray (3,), optional Real-world grid origin. rotation_deg : float, default 0.0 Grid rotation about the vertical axis. n_air : int, default 0 Explicit air-layer count (unlike Occam2D's inferred count). """ kind: ClassVar[str] = "grid3d" x: np.ndarray y: np.ndarray z: np.ndarray x_nodes: np.ndarray | None = None y_nodes: np.ndarray | None = None z_nodes: np.ndarray | None = None origin: np.ndarray | None = None rotation_deg: float = 0.0 n_air: int = 0 def __post_init__(self) -> None: self.x = _arr(self.x) self.y = _arr(self.y) self.z = _arr(self.z) self.x_nodes = _opt_arr(self.x_nodes) self.y_nodes = _opt_arr(self.y_nodes) self.z_nodes = _opt_arr(self.z_nodes) self.origin = _opt_arr(self.origin)
[docs] def validate(self) -> None: if self.x.ndim != 1 or self.y.ndim != 1 or self.z.ndim != 1: raise ValueError("Grid3DGeometry.x, .y, .z must be 1-D") if self.origin is not None and self.origin.shape != (3,): raise ValueError("origin must have shape (3,)") if self.n_air < 0: raise ValueError("n_air must be >= 0")
[docs] @property def resistivity_shape(self) -> tuple[int, int, int]: # Deliberately (n_z, n_y, n_x) — matches ModEM's own native # order, to avoid introducing a second axis-order transpose bug. return (self.z.shape[0], self.y.shape[0], self.x.shape[0])
[docs] @dataclass(repr=False) class UnstructuredMeshGeometry(PyCSAMTObject): """Native unstructured triangular mesh geometry (MARE2DEM). Preserves the mesh as-is (no forced regrid onto a tensor grid), so a MARE2DEM result keeps its real element resolution. Parameters ---------- nodes : ndarray (n, 2) or (n, 3) Node coordinates, metres. connectivity : ndarray (m, 3), int Triangle node indices. region_ids : ndarray (m,), int Region id per triangle. plane : {"xz", "xy", "3d"}, default "xz" Physical plane the mesh lives in. MARE2DEM profiles are conventionally in ``(y, z)`` but stored generically as ``plane="xz"`` with *x* holding the profile's own along-line coordinate. """ kind: ClassVar[str] = "mesh_unstructured" nodes: np.ndarray connectivity: np.ndarray region_ids: np.ndarray plane: str = "xz" def __post_init__(self) -> None: self.nodes = _arr(self.nodes) self.connectivity = _arr(self.connectivity, dtype=np.int64) self.region_ids = _arr(self.region_ids, dtype=np.int32)
[docs] def validate(self) -> None: if self.nodes.ndim != 2 or self.nodes.shape[1] not in (2, 3): raise ValueError("nodes must have shape (n, 2) or (n, 3)") if self.connectivity.ndim != 2 or self.connectivity.shape[1] != 3: raise ValueError("connectivity must have shape (m, 3)") if self.region_ids.shape != (self.connectivity.shape[0],): raise ValueError( "region_ids must have one entry per triangle" ) if self.plane not in ("xz", "xy", "3d"): raise ValueError( f"plane must be one of 'xz'/'xy'/'3d', got {self.plane!r}" ) n_nodes = self.nodes.shape[0] if self.connectivity.size and ( self.connectivity.min() < 0 or self.connectivity.max() >= n_nodes ): raise ValueError("connectivity references an out-of-range node")
[docs] @property def n_regions(self) -> int: return int(np.unique(self.region_ids).size) if self.region_ids.size else 0
[docs] @dataclass(repr=False) class LineEntry(PyCSAMTObject): """One profile within a :class:`MultilineGeometry`. Parameters ---------- line_id : str Unique identifier for this line. geometry : Grid2DGeometry The line's own 2-D section geometry. resistivity : ndarray (n_z, n_x) Canonical linear ohm.m resistivity for this line. offset_y : float Cross-line position, metres. offset_kind : {"real", "synthetic"}, default "synthetic" Whether *offset_y* comes from real survey geometry or is a placeholder spacing for display only. azimuth_deg : float, optional Line bearing. """ line_id: str geometry: Grid2DGeometry resistivity: np.ndarray offset_y: float = 0.0 offset_kind: str = "synthetic" azimuth_deg: float | None = None def __post_init__(self) -> None: self.resistivity = _arr(self.resistivity)
[docs] def validate(self) -> None: self.geometry.validate() if self.resistivity.shape != self.geometry.resistivity_shape: raise ValueError( f"line {self.line_id!r}: resistivity shape " f"{self.resistivity.shape} does not match geometry " f"{self.geometry.resistivity_shape}" ) if self.offset_kind not in ("real", "synthetic"): raise ValueError("offset_kind must be 'real' or 'synthetic'")
[docs] @dataclass(repr=False) class DerivedVolume(PyCSAMTObject): """Optional cached 3-D volume synthesized from stacked lines. Kept explicitly tagged as synthesized so a reader never mistakes a stack-interpolated volume for a native 3-D inversion (see ``derivation_method``/``synthesized`` in the design plan's §2). """ grid: Grid3DGeometry resistivity: np.ndarray derivation_method: str = "linear_interp" derived_from: list[str] = field(default_factory=list) synthesized: bool = True def __post_init__(self) -> None: self.resistivity = _arr(self.resistivity)
[docs] def validate(self) -> None: self.grid.validate() if self.resistivity.shape != self.grid.resistivity_shape: raise ValueError( f"derived_volume resistivity shape {self.resistivity.shape} " f"does not match grid {self.grid.resistivity_shape}" ) if self.derivation_method not in DERIVATION_METHODS: raise ValueError( "derivation_method must be one of " f"{DERIVATION_METHODS}, got {self.derivation_method!r}" )
[docs] @dataclass(repr=False) class MultilineGeometry(PyCSAMTObject): """A set of profiles plus real line geometry (fence/block views). Formalizes what ``pycsamt/app/web/callbacks/map3d.py`` currently reconstructs at render time from a stack of independent 2-D sections. Each line carries its own resistivity; the optional :attr:`derived_volume` is a documented, reproducible synthesis rather than a render-time-only side effect. """ kind: ClassVar[str] = "multiline" lines: list[LineEntry] = field(default_factory=list) derived_volume: DerivedVolume | None = None
[docs] def validate(self) -> None: if not self.lines: raise ValueError("MultilineGeometry needs at least one line") ids = [line.line_id for line in self.lines] if len(set(ids)) != len(ids): raise ValueError(f"duplicate line_id values: {ids}") for line in self.lines: line.validate() if self.derived_volume is not None: self.derived_volume.validate()
# --------------------------------------------------------------------- # Container-level tables # ---------------------------------------------------------------------
[docs] @dataclass(repr=False) class StationTable(PyCSAMTObject): """Survey station positions, shared across geometry kinds. ``x``/``y``/``z`` are geometry-local (along-profile chainage for ``grid2d``, the model grid's own frame for ``grid3d``, whatever frame the caller supplied for ``mesh_unstructured``) — never assumed to be real-world geographic coordinates, per SPEC.md's own ``load_pcsf_lines`` convention. ``lon``/``lat``, when present, are the one explicit, unambiguous carrier of real-world position: WGS84 decimal degrees, the same convention every other real-coordinate source in this codebase already uses (EDI headers, a ModEM ``.dat`` file's ``GG_Lat``/``GG_Lon`` columns, :class:`pycsamt.map._core.StationRecord`). A single-line ``grid2d`` (or any other kind's) PCSF file that sets these needs no separate ``known_stations`` match to place its stations on a real basemap. """ name: list[str] = field(default_factory=list) x: np.ndarray = field(default_factory=lambda: np.array([])) y: np.ndarray = field(default_factory=lambda: np.array([])) z: np.ndarray = field(default_factory=lambda: np.array([])) line_id: list[str] | None = None lon: np.ndarray | None = None lat: np.ndarray | None = None def __post_init__(self) -> None: self.x = _arr(self.x) self.y = _arr(self.y) self.z = _arr(self.z) self.lon = _opt_arr(self.lon) self.lat = _opt_arr(self.lat)
[docs] def validate(self) -> None: n = len(self.name) for label, arr in (("x", self.x), ("y", self.y), ("z", self.z)): if arr.shape != (n,): raise ValueError( f"StationTable.{label} must have shape ({n},) to " f"match {n} station names, got {arr.shape}" ) if self.line_id is not None and len(self.line_id) != n: raise ValueError("StationTable.line_id must match name length") for label, arr in (("lon", self.lon), ("lat", self.lat)): if arr is not None and arr.shape != (n,): raise ValueError( f"StationTable.{label} must have shape ({n},) to " f"match {n} station names, got {arr.shape}" ) if (self.lon is None) != (self.lat is None): raise ValueError( "StationTable.lon and .lat must be set together (or " "both left unset), never only one" )
[docs] @dataclass(repr=False) class TopographyPerStation(PyCSAMTObject): """Scalar-per-station topography (matches the existing convention in :mod:`pycsamt.map.topo`). """ kind: ClassVar[str] = "per_station" station_id: list[str] = field(default_factory=list) elevation: np.ndarray = field(default_factory=lambda: np.array([])) def __post_init__(self) -> None: self.elevation = _arr(self.elevation)
[docs] def validate(self) -> None: if self.elevation.shape != (len(self.station_id),): raise ValueError( "TopographyPerStation.elevation must match station_id length" )
[docs] @dataclass(repr=False) class TopographyRaster(PyCSAMTObject): """Gridded-DEM topography — a regular elevation surface independent of any station table. Unlike :class:`TopographyPerStation`, this carries no station identifiers at all: it is a standalone terrain surface a consumer can sample at any coordinate, not a per-station lookup table. It introduces no GDAL/rasterio-class dependency — construction is via plain ``x``/``y``/``elevation`` arrays a caller has already obtained by whatever means it likes (see :func:`pycsamt.format.topography.topography_from_grid`); PCSF itself never parses a georeferenced raster file format. Parameters ---------- x : ndarray (n_x,) Grid x-coordinates (or longitude), increasing. y : ndarray (n_y,) Grid y-coordinates (or latitude), increasing. elevation : ndarray (n_y, n_x) Elevation surface, metres, sampled on the ``(y, x)`` meshgrid implied by *x*/*y* — the same row-major convention ``numpy.meshgrid(x, y)`` (default ``indexing="xy"``) produces. """ kind: ClassVar[str] = "raster" x: np.ndarray y: np.ndarray elevation: np.ndarray def __post_init__(self) -> None: self.x = _arr(self.x) self.y = _arr(self.y) self.elevation = _arr(self.elevation)
[docs] def validate(self) -> None: if self.x.ndim != 1 or self.y.ndim != 1: raise ValueError("TopographyRaster.x and .y must be 1-D") expected = (self.y.shape[0], self.x.shape[0]) if self.elevation.shape != expected: raise ValueError( f"TopographyRaster.elevation shape {self.elevation.shape} " f"does not match (len(y), len(x)) = {expected}" )
# --------------------------------------------------------------------- # Root container # ---------------------------------------------------------------------
[docs] @dataclass(repr=False) class PCSFModel(PyCSAMTObject, MetadataMixin): """Backend-neutral inversion-result container (one PCSF file). Parameters ---------- geometry : Grid2DGeometry | Grid3DGeometry | UnstructuredMeshGeometry | MultilineGeometry The model's geometry, discriminated by ``geometry.kind``. resistivity : ndarray, optional Canonical **linear ohm.m** resistivity. Required for ``grid2d``/``grid3d``/``mesh_unstructured`` geometries; must be ``None`` for ``multiline`` (each line carries its own resistivity — see :class:`LineEntry`). resistivity_native : ndarray, optional Passthrough of the source backend's own array, for provenance. resistivity_native_encoding : {"log10", "ln", "linear"}, optional Encoding of *resistivity_native*. Required whenever *resistivity_native* is given — never assumed. uncertainty, sensitivity : ndarray, optional Same shape as *resistivity*, when available from the source. resistivity_by_region : ndarray, optional Per-region resistivity table (``mesh_unstructured`` only), alongside the per-cell *resistivity* expanded from it. resistivity_by_node : ndarray, optional Per-node resistivity table (``mesh_unstructured`` only), shape ``(n_nodes,)`` -- the natural output shape of a graph-based model (e.g. a GCN) that predicts one value per mesh vertex rather than per cell. Kept alongside the per-cell *resistivity* expanded from it (see :func:`pycsamt.format.adapters.generic.mesh_to_pcsf`), the same provenance relationship *resistivity_by_region* has to its own per-cell expansion. stations : StationTable, optional topography : TopographyPerStation | TopographyRaster, optional survey : dict Free-form survey metadata. Adapters populate this from :mod:`pycsamt.metadata` objects (``SurveyMeta``, ``BBox``, ``ProvenanceMeta``) via their own dict conversion; PCSF itself does not require a specific metadata class here. history : dict of ndarray Optional per-iteration series (e.g. ``{"rms": ..., "lambda": ...}``) from an ``InversionHistory``-like source. source_backend : str, default "generic" ``"occam2d"`` | ``"modem3d"`` | ``"mare2dem"`` | ``"duhi"`` | ``"generic"``. crs : str, optional A pyproj-compatible CRS string. """ geometry: ( Grid2DGeometry | Grid3DGeometry | UnstructuredMeshGeometry | MultilineGeometry ) resistivity: np.ndarray | None = None resistivity_native: np.ndarray | None = None resistivity_native_encoding: str | None = None uncertainty: np.ndarray | None = None sensitivity: np.ndarray | None = None resistivity_by_region: np.ndarray | None = None resistivity_by_node: np.ndarray | None = None stations: StationTable | None = None topography: TopographyPerStation | TopographyRaster | None = None survey: dict[str, Any] = field(default_factory=dict) history: dict[str, np.ndarray] = field(default_factory=dict) source_backend: str = "generic" created_by: str = "" created_at: str = "" crs: str | None = None description: str = "" metadata: dict[str, Any] = field(default_factory=dict) boreholes: Any | None = None def __post_init__(self) -> None: self.resistivity = _opt_arr(self.resistivity) self.resistivity_native = _opt_arr(self.resistivity_native) self.uncertainty = _opt_arr(self.uncertainty) self.sensitivity = _opt_arr(self.sensitivity) self.resistivity_by_region = _opt_arr(self.resistivity_by_region) self.resistivity_by_node = _opt_arr(self.resistivity_by_node) self.history = { str(key): _arr(value) for key, value in dict(self.history).items() }
[docs] @property def kind(self) -> str: return self.geometry.kind
[docs] def validate(self) -> None: kind = self.geometry.kind if kind not in GEOMETRY_KINDS: raise ValueError( f"unknown geometry kind {kind!r}; expected one of " f"{GEOMETRY_KINDS}" ) self.geometry.validate() if kind == "multiline": if self.resistivity is not None: raise ValueError( "multiline geometry carries resistivity per line " "(PCSFModel.resistivity must be None); set it on " "each LineEntry instead" ) else: if self.resistivity is None: raise ValueError( f"resistivity is required for geometry kind {kind!r}" ) if kind == "mesh_unstructured": # Per-cell (m,) or the region-collapsed (n_regions,) form. valid_shapes = { (self.geometry.connectivity.shape[0],), (self.geometry.n_regions,), } if self.resistivity.shape not in valid_shapes: raise ValueError( f"resistivity shape {self.resistivity.shape} does " f"not match mesh cells or region count {valid_shapes}" ) else: expected = self.geometry.resistivity_shape if self.resistivity.shape != expected: raise ValueError( f"resistivity shape {self.resistivity.shape} does " f"not match geometry {expected}" ) for label, arr in ( ("uncertainty", self.uncertainty), ("sensitivity", self.sensitivity), ): if arr is not None and arr.shape != self.resistivity.shape: raise ValueError( f"{label} shape {arr.shape} does not match " f"resistivity shape {self.resistivity.shape}" ) if self.resistivity_by_node is not None: if kind != "mesh_unstructured": raise ValueError( "resistivity_by_node is only valid for geometry kind " f"'mesh_unstructured', got {kind!r}" ) n_nodes = self.geometry.nodes.shape[0] if self.resistivity_by_node.shape != (n_nodes,): raise ValueError( "resistivity_by_node shape " f"{self.resistivity_by_node.shape} does not match " f"the mesh's node count ({n_nodes},)" ) if self.resistivity_native is not None and ( self.resistivity_native_encoding not in RESISTIVITY_ENCODINGS ): raise ValueError( "resistivity_native_encoding must be one of " f"{RESISTIVITY_ENCODINGS} when resistivity_native is set, " f"got {self.resistivity_native_encoding!r}" ) if self.stations is not None: self.stations.validate() if self.topography is not None: self.topography.validate() if self.boreholes is not None: from .borehole.pcsf import PCBHAssociation if not isinstance(self.boreholes, PCBHAssociation): raise TypeError("boreholes must be a PCBHAssociation") self.boreholes.validate()