Source code for pycsamt.format.adapters.occam2d

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Occam2D -> PCSF adapter (Phase 2 of the PCSF format plan).

Converts a completed :class:`pycsamt.models.occam2d.results.InversionResult`
into a backend-neutral :class:`~pycsamt.format.schema.PCSFModel` with a
``grid2d`` geometry. Built directly on
:meth:`pycsamt.interp._base.ResistivityModel.from_occam2d`, which already
recovers real station chainage from Occam2D's mesh-local coordinate frame
(see that method's docstring for the padding-symmetry argument) — this
module reuses that correction rather than re-deriving it, and only adds
the cell-edge (node) coordinates and history/station bookkeeping PCSF
needs on top.

DUHI (:mod:`pycsamt.ai.inversion`) has no separate adapter: its output
only becomes a real, final resistivity model once folded back into an
Occam2D run (see ``mapping2d.map_ai_grid_to_occam``), so a DUHI-produced
:class:`InversionResult` converts through this same function.
"""

from __future__ import annotations

import warnings
from typing import TYPE_CHECKING, Any, Mapping, Sequence

import numpy as np

from ...interp._base import ResistivityModel
from ..schema import Grid2DGeometry, PCSFModel, StationTable, TopographyPerStation
from ..topo_source import resolve_topo

if TYPE_CHECKING:
    from pycsamt.models.occam2d.results import InversionResult

__all__ = ["occam2d_to_pcsf"]


def _station_elevation_array(
    names: Sequence[str], station_elevations: Mapping[str, float] | None
) -> np.ndarray:
    if not station_elevations:
        return np.full(len(names), np.nan)
    return np.array(
        [float(station_elevations.get(name, np.nan)) for name in names]
    )


def _station_lonlat_arrays(
    names: Sequence[str],
    station_lonlat: Mapping[str, tuple[float, float]] | None,
) -> tuple[np.ndarray, np.ndarray] | tuple[None, None]:
    if not station_lonlat:
        return None, None
    lonlat = np.array(
        [station_lonlat.get(name, (np.nan, np.nan)) for name in names],
        dtype=float,
    )
    if np.all(np.isnan(lonlat)):
        return None, None
    return lonlat[:, 0], lonlat[:, 1]


def _survey_to_dict(survey: Any | Mapping[str, Any] | None) -> dict[str, Any]:
    if survey is None:
        return {}
    to_dict = getattr(survey, "to_dict", None)
    if callable(to_dict):
        return dict(to_dict())
    return dict(survey)


[docs] def occam2d_to_pcsf( result: InversionResult, *, station_elevations: Mapping[str, float] | None = None, station_lonlat: Mapping[str, tuple[float, float]] | None = None, topo: Any = None, epsg: int | None = None, utm_zone: Any | None = None, latlon: bool = False, on_mismatch: str = "raise", survey: Any | Mapping[str, Any] | None = None, origin: np.ndarray | Sequence[float] | None = None, azimuth_deg: float | None = None, created_by: str = "", crs: str | None = None, description: str = "", ) -> PCSFModel: r"""Convert an Occam2D :class:`InversionResult` to a :class:`PCSFModel`. Parameters ---------- result : InversionResult A loaded Occam2D post-inversion result (``workdir`` scanned, ``rho_2d`` and ``mesh`` populated). station_elevations : mapping of str to float, optional ``station_name -> elevation (m)``, e.g. from :func:`pycsamt.map.topo.fetch_elevations` or a survey's own EDI-derived topography. Occam2D itself carries no elevation, so this is the only way :attr:`PCSFModel.topography` gets populated. Stations without a known elevation are recorded as ``nan`` rather than a fabricated flat value. station_lonlat : mapping of str to (lon, lat), optional ``station_name -> (longitude, latitude)``, WGS84 decimal degrees -- e.g. from the same EDI headers a :class:`pycsamt.metadata.SurveyMeta` or :class:`pycsamt.map._core.StationRecord` would carry. Occam2D itself has no real-world coordinate concept (only mesh-local chainage, recovered into :attr:`Grid2DGeometry.x`/ :class:`StationTable`'s own ``x``), so this is the only way a *single* Occam2D line's PCSF file becomes self-sufficiently geo-referenced -- without it, placing this line on a real basemap needs a separate ``known_stations`` match at :func:`pycsamt.map.MapView.from_pcsf` load time instead. Superseded per-station by *topo* when both are given (see below); a station *topo* has no data for still falls back to this mapping. topo : path-like, TopoTable, Sites/MapData-like, or mapping, optional A "smart" real-coordinate source resolved via :func:`pycsamt.format.topo_source.resolve_topo` — a ``.bln``/``.csv``/``.stn`` topo file, an already-geo-located ``Sites``/``MapData`` object (e.g. ``pycsamt.map.load_lines(edi_folder)``), or a plain ``{station_name: (lon, lat[, elevation])}`` mapping. A name-less source (a bare ``.bln``, or a ``.csv`` without a station column) is matched *positionally*, in :attr:`ResistivityModel.station_names`'s own along-profile order, and therefore requires exactly one point per station (see *on_mismatch*). When given, *topo*'s own lon/lat/ elevation take precedence over *station_lonlat*/ *station_elevations* for every station it resolves — with a :class:`UserWarning` if both were supplied, so the override is never silent; a station *topo* has no data for keeps whatever *station_lonlat*/*station_elevations* already gave it. Passing ``None`` (the default) leaves this adapter's behaviour exactly as it was before *topo* existed. epsg, utm_zone : optional Forwarded to :func:`pycsamt.format.topo_source.resolve_topo` for a *topo* file storing projected easting/northing rather than lon/lat (``.stn`` files always do; a ``.csv``/``.bln`` does when its columns/units are projected). Ignored unless *topo* is given and actually needs conversion. latlon : bool, default False ``.bln`` *topo* files only: set ``True`` when the file's own ``x, y`` columns are already ``lon, lat`` (a ``.bln`` carries no CRS metadata to detect this from). on_mismatch : {"raise", "warn"}, default "raise" How a *topo* station-count mismatch is handled for a name-less (positional) source — see :func:`pycsamt.format.topo_source.attribute_topo`. survey : SurveyMeta or mapping, optional Survey-level metadata (e.g. a :class:`pycsamt.metadata.SurveyMeta`). Stored via its own ``to_dict()`` when available, otherwise copied as a plain mapping. origin : ndarray or sequence of float, optional Real-world ``(x, y)`` offset for the profile, when known. azimuth_deg : float, optional Profile bearing, when known. created_by, crs, description : str, optional Passed straight through to :class:`PCSFModel`. Returns ------- PCSFModel ``geometry.kind == "grid2d"``, canonical linear-ohm.m resistivity in :attr:`PCSFModel.resistivity`, the original log10 grid preserved in :attr:`PCSFModel.resistivity_native`, and iteration history (RMS, roughness, Lagrange multiplier, step size) from :attr:`InversionResult.log` when available. Raises ------ ValueError If *result* has no ``rho_2d``/``mesh`` (mirrors :meth:`ResistivityModel.from_occam2d`'s own check). Examples -------- >>> from pycsamt.models.occam2d.results import InversionResult >>> from pycsamt.format.adapters.occam2d import occam2d_to_pcsf >>> from pycsamt.format import write_pcsf >>> result = InversionResult(workdir="data/occam2D") # doctest: +SKIP >>> model = occam2d_to_pcsf(result) # doctest: +SKIP >>> write_pcsf(model, "occam2d_run.pcsf") # doctest: +SKIP """ if result.rho_2d is None or result.mesh is None: raise ValueError( "InversionResult has no rho_2d or mesh — ensure the workdir " "contains mesh, model, and iter files." ) rm = ResistivityModel.from_occam2d(result) mesh = result.mesh # Cell centres are already corrected for the mesh-local -> real # station-chainage shift (see ResistivityModel.from_occam2d's # docstring). Recover that same shift for the node (cell-edge) # coordinates, which the neutral ResistivityModel does not carry. x_centers_raw = (mesh.x_nodes[:-1] + mesh.x_nodes[1:]) / 2.0 shift = ( float(rm.x_centers[0] - x_centers_raw[0]) if rm.x_centers.size and x_centers_raw.size else 0.0 ) x_nodes = mesh.x_nodes + shift z_nodes = mesh.z_nodes.copy() geometry = Grid2DGeometry( x=rm.x_centers, z=rm.z_centers, x_nodes=x_nodes, z_nodes=z_nodes, origin=None if origin is None else np.asarray(origin, dtype=float), azimuth_deg=azimuth_deg, ) resistivity = 10.0**rm.rho_2d stations = None topography = None if rm.station_names: elevation = _station_elevation_array( rm.station_names, station_elevations ) lon, lat = _station_lonlat_arrays(rm.station_names, station_lonlat) if topo is not None: if station_lonlat or station_elevations: warnings.warn( "occam2d_to_pcsf: 'topo' takes precedence over " "'station_lonlat'/'station_elevations' for every " "station it resolves.", UserWarning, stacklevel=2, ) attr = resolve_topo( topo, list(rm.station_names), epsg=epsg, utm_zone=utm_zone, latlon=latlon, on_mismatch=on_mismatch, ) lon = np.array( [attr.lon.get(n, lon[i] if lon is not None else np.nan) for i, n in enumerate(rm.station_names)] ) lat = np.array( [attr.lat.get(n, lat[i] if lat is not None else np.nan) for i, n in enumerate(rm.station_names)] ) if np.all(np.isnan(lon)): lon = lat = None elevation = np.array( [attr.elevation.get(n, elevation[i]) for i, n in enumerate(rm.station_names)] ) stations = StationTable( name=list(rm.station_names), x=rm.station_x, y=np.zeros_like(rm.station_x), z=elevation, lon=lon, lat=lat, ) if station_elevations or (topo is not None and not np.all(np.isnan(elevation))): topography = TopographyPerStation( station_id=list(rm.station_names), elevation=elevation ) history: dict[str, np.ndarray] = {} if result.log is not None: log = result.log history = { "iteration": np.asarray(log.iterations, dtype=float), "rms": np.asarray(log.rms, dtype=float), "roughness": np.asarray(log.roughness, dtype=float), "lagrange": np.asarray(log.lagrange, dtype=float), "stepsize": np.asarray(log.stepsize, dtype=float), } metadata: dict[str, Any] = { "workdir": str(result.workdir), "final_rms": rm.rms, "n_iterations": result.n_iterations, } if result.log is not None: metadata["converged"] = bool(result.log.converged) return PCSFModel( geometry=geometry, resistivity=resistivity, resistivity_native=rm.rho_2d, resistivity_native_encoding="log10", stations=stations, topography=topography, survey=_survey_to_dict(survey), history=history, source_backend="occam2d", created_by=created_by, crs=crs, description=description, metadata=metadata, )