Source code for pycsamt.interp.export

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""export — write EM interpretation results to industry formats.

Supported formats
-----------------
* **Oasis Montaj XYZ** — point-data profile readable by Geosoft Oasis Montaj
  and any XYZ-aware GIS tool.
* **LAS 2.0** — CWLS well-log ASCII standard; compatible with petrophyscial
  software (Petrel, Kingdom, IHS Markit).
* **CSV** — flat table; station, depth, log10-rho, lithology.
* **VTK** — ASCII rectilinear-grid format readable by Paraview, QGIS, and
  other 3-D viewers.
* **Golden Software Surfer** — ``to_surfer_grid`` (DSAA regular grid,
  opens directly as a finished map/section) and ``to_surfer_xyz``
  (scattered ``X Y Z`` points at the model's real, possibly non-uniform
  cell centres, gridded by Surfer itself). Both accept *any* 2-D
  inversion result — Occam2D, ModEM, the backend-neutral
  :class:`~pycsamt.inversion.results.InversionResult`, an AI agent
  result, or a raw array — via :meth:`~pycsamt.interp.ResistivityModel.from_any`.
  ``to_surfer_bln`` writes the matching topography polygon (Surfer
  blanking file) so the air above real station elevation can be blanked
  out of the grid/map in Surfer directly.

Example
-------
>>> from pycsamt.interp import export
>>> export.to_oasis_montaj_xyz(logs, "profile_K1.xyz")  # doctest: +SKIP
>>> export.to_las(logs[0], "S17.las")  # doctest: +SKIP
>>> export.to_csv(logs, "all_stations.csv")  # doctest: +SKIP
>>> export.to_surfer_grid(model, "section.grd")  # doctest: +SKIP
"""

from __future__ import annotations

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

import numpy as np

from ..geology.lithology import StratigraphicLog
from ._base import ResistivityModel

__all__ = [
    "to_oasis_montaj_xyz",
    "to_las",
    "to_csv",
    "to_vtk",
    "to_surfer_grid",
    "to_surfer_dsaa",
    "to_surfer_xyz",
    "to_surfer_bln",
]

PathLike = Union[str, Path]


# ---------------------------------------------------------------------------
# Oasis Montaj XYZ
# ---------------------------------------------------------------------------


[docs] def to_oasis_montaj_xyz( logs: Sequence[StratigraphicLog], path: PathLike, *, y: float = 0.0, elevation: np.ndarray | None = None, log_rho: bool = True, channels: list[str] | None = None, ) -> Path: """Write pseudo-stratigraphic logs to Oasis Montaj XYZ format. Each station is written as a separate ``/ Line`` block. The depth axis is encoded as a negative Z value (depth below surface). Parameters ---------- logs : list of StratigraphicLog path : path-like Output file path (extension ``.xyz`` recommended). y : float Northing to assign to all points (single-profile surveys). elevation : ndarray (n_stations,), optional Surface elevation for each station in metres a.s.l. If provided, Z = elevation − depth. Otherwise Z = −depth. log_rho : bool Write :math:`\\log_{10}(\\rho)` (default) or linear ρ (Ω·m). channels : list of str, optional Override column headers. Defaults to ``['X', 'Y', 'Z', 'RESD', 'LITH']``. Returns ------- Path Written file path. """ out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) hdr = channels if channels else ["X", "Y", "Z", "RESD", "LITH"] with out.open("w") as fh: fh.write("/ pycsamt.interp — Oasis Montaj XYZ\n") fh.write(f"/ Generated: {datetime.utcnow().isoformat()}\n") fh.write("/ " + " ".join(hdr) + "\n") for k, log in enumerate(logs): elev = float(elevation[k]) if elevation is not None else 0.0 fh.write(f"/ Line {log.station_name}\n") for _iz, (z, rho) in enumerate(zip(log.z_centers, log.rho_log10)): if np.isnan(rho): continue x_val = log.station_x z_val = elev - float(z) rho_val = float(rho) if log_rho else float(10.0**rho) # find lithology for this depth cell lith = "" for layer in log.layers: if layer.top <= float(z) < layer.bottom: lith = layer.lithology break lith_safe = lith.replace(" ", "_") fh.write( f"{x_val:>14.3f} {y:>14.3f} {z_val:>12.3f} " f"{rho_val:>10.5f} {lith_safe}\n" ) fh.write("\n") return out
# --------------------------------------------------------------------------- # LAS 2.0 # ---------------------------------------------------------------------------
[docs] def to_las( log: StratigraphicLog, path: PathLike, *, well_name: str = "", company: str = "pycsamt", null_value: float = -9999.25, log_rho: bool = True, ) -> Path: """Write a single station log to LAS 2.0 format. Parameters ---------- log : StratigraphicLog path : path-like Output ``.las`` file. well_name : str Well / station identifier; defaults to ``log.station_name``. company : str null_value : float LAS null sentinel for missing values. log_rho : bool Write :math:`\\log_{10}(\\rho)` (default) or linear ρ. Returns ------- Path """ out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) w_name = well_name or log.station_name z = log.z_centers rho = log.rho_log10.copy() if not log_rho: rho = 10.0**rho # build lithology-code column lith_codes = np.full(len(z), 0, dtype=int) for i, zi in enumerate(z): for layer in log.layers: if layer.top <= float(zi) < layer.bottom: lith_codes[i] = hash(layer.lithology) % 1000 break step = float(np.median(np.diff(z))) if len(z) > 1 else 1.0 rho_unit = "LOG10.OHMM" if log_rho else "OHMM" lines = [ "~VERSION INFORMATION", " VERS. 2.0: CWLS log ASCII Standard - VERSION 2.0", " WRAP. NO: ONE LINE PER DEPTH STEP", "~WELL INFORMATION", f" STRT.M {z[0]:.4f}: START DEPTH", f" STOP.M {z[-1]:.4f}: STOP DEPTH", f" STEP.M {step:.4f}: STEP", f" NULL. {null_value}: NULL VALUE", f" COMP. {company}: COMPANY", f" WELL. {w_name}: WELL", f" DATE. {datetime.utcnow().strftime('%Y-%m-%d')}: DATE", "~CURVE INFORMATION", " DEPT.M : DEPTH", f" RESD.{rho_unit} : RESISTIVITY (EM-derived)", " LITH. : LITHOLOGY CODE (hash % 1000)", "~A DEPT RESD LITH", ] with out.open("w") as fh: fh.write("\n".join(lines) + "\n") for zi, ri, li in zip(z, rho, lith_codes): ri_out = ri if not np.isnan(ri) else null_value fh.write(f" {zi:10.4f} {ri_out:12.5f} {li:6d}\n") return out
# --------------------------------------------------------------------------- # CSV # ---------------------------------------------------------------------------
[docs] def to_csv( logs: Sequence[StratigraphicLog], path: PathLike, *, log_rho: bool = True, ) -> Path: """Write all station logs to a flat CSV file. Columns: ``station, x_m, depth_m, rho_log10, rho_ohm_m, lithology`` Parameters ---------- logs : list of StratigraphicLog path : path-like log_rho : bool Include the ``rho_log10`` column (always written); if ``False`` only ``rho_ohm_m`` is added. Returns ------- Path """ out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) fieldnames = [ "station", "x_m", "depth_m", "rho_log10", "rho_ohm_m", "lithology", ] with out.open("w", newline="") as fh: writer = csv.DictWriter(fh, fieldnames=fieldnames) writer.writeheader() for log in logs: for zi, rho_l in zip(log.z_centers, log.rho_log10): if np.isnan(rho_l): continue lith = "" for layer in log.layers: if layer.top <= float(zi) < layer.bottom: lith = layer.lithology break writer.writerow( { "station": log.station_name, "x_m": f"{log.station_x:.3f}", "depth_m": f"{float(zi):.3f}", "rho_log10": f"{float(rho_l):.5f}", "rho_ohm_m": f"{10.0 ** float(rho_l):.3f}", "lithology": lith, } ) return out
# --------------------------------------------------------------------------- # VTK rectilinear grid # ---------------------------------------------------------------------------
[docs] def to_vtk( model: ResistivityModel, path: PathLike, *, log_rho: bool = True, field_name: str = "log10_rho", ) -> Path: """Write a 2-D resistivity model as a VTK rectilinear grid. The output is plain ASCII VTK (``RECTILINEAR_GRID``) readable by Paraview, QGIS (via the SimpleVTK plugin), and most 3-D viewers. Parameters ---------- model : ResistivityModel path : path-like Output ``.vtk`` file. log_rho : bool Write :math:`\\log_{10}(\\rho)` (default) or linear ρ. field_name : str VTK scalar name. Returns ------- Path """ out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) x = model.x_centers z = model.z_centers rho = model.rho_2d if log_rho else 10.0**model.rho_2d nx, nz = len(x), len(z) with out.open("w") as fh: fh.write("# vtk DataFile Version 3.0\n") fh.write("pycsamt.interp resistivity model\n") fh.write("ASCII\n") fh.write("DATASET RECTILINEAR_GRID\n") fh.write(f"DIMENSIONS {nx} {nz} 1\n") fh.write(f"X_COORDINATES {nx} float\n") fh.write(" ".join(f"{v:.3f}" for v in x) + "\n") fh.write(f"Y_COORDINATES {nz} float\n") fh.write(" ".join(f"{v:.3f}" for v in z) + "\n") fh.write("Z_COORDINATES 1 float\n0.0\n") n_cells = nx * nz fh.write(f"POINT_DATA {n_cells}\n") fh.write(f"SCALARS {field_name} float 1\n") fh.write("LOOKUP_TABLE default\n") for iz in range(nz): for ix in range(nx): val = rho[iz, ix] fh.write(f"{val if not np.isnan(val) else -9999.0:.5f}\n") return out
# --------------------------------------------------------------------------- # Golden Software Surfer # --------------------------------------------------------------------------- _SURFER_BLANK = 1.70141e38 # Surfer's own default blanking sentinel
[docs] def to_surfer_dsaa( x: np.ndarray, y: np.ndarray, values: np.ndarray, path: PathLike, *, blank_value: float = _SURFER_BLANK, ) -> Path: """Write a regular scalar grid as a Golden Software Surfer DSAA file. Unlike :func:`to_surfer_grid`, this generic writer applies no resistivity, logarithm, depth, or elevation convention. ``x`` and ``y`` are increasing one-dimensional grid axes and ``values`` has shape ``(len(y), len(x))``. Non-finite cells are written with Surfer's blanking sentinel. """ x = np.asarray(x, dtype=float) y = np.asarray(y, dtype=float) grid = np.asarray(values, dtype=float) if x.ndim != 1 or y.ndim != 1 or x.size < 2 or y.size < 2: raise ValueError( "x and y must be one-dimensional with at least 2 values." ) if grid.shape != (y.size, x.size): raise ValueError("values shape must be (len(y), len(x)).") if not np.isfinite(x).all() or not np.isfinite(y).all(): raise ValueError("x and y must contain only finite values.") if np.any(np.diff(x) <= 0.0) or np.any(np.diff(y) <= 0.0): raise ValueError("x and y must be strictly increasing.") written = grid.copy() written[~np.isfinite(written)] = float(blank_value) finite = grid[np.isfinite(grid)] z_min = float(finite.min()) if finite.size else 0.0 z_max = float(finite.max()) if finite.size else 0.0 out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) with out.open("w") as fh: fh.write("DSAA\n") fh.write(f"{x.size} {y.size}\n") fh.write(f"{x.min():.12g} {x.max():.12g}\n") fh.write(f"{y.min():.12g} {y.max():.12g}\n") fh.write(f"{z_min:.12g} {z_max:.12g}\n") for row in written: fh.write(" ".join(f"{value:.9g}" for value in row) + "\n") return out
def _elevation_at_x( x_centers: np.ndarray, elevation: np.ndarray | None, chainage: np.ndarray | None, fallback_chainage: np.ndarray, ) -> np.ndarray: """Interpolate station elevation onto *x_centers*; ``0.0`` everywhere when no elevation was supplied (so "Y = elevation − depth" collapses to plain "Y = −depth" and every Surfer writer needs only one code path).""" if elevation is None: return np.zeros_like(x_centers) chain = np.asarray( chainage if chainage is not None else fallback_chainage, dtype=float ) elev = np.asarray(elevation, dtype=float) order = np.argsort(chain) return np.interp(x_centers, chain[order], elev[order])
[docs] def to_surfer_grid( model: Any, path: PathLike, *, log_rho: bool = True, nx: int | None = None, ny: int | None = None, elevation: np.ndarray | None = None, station_x: np.ndarray | None = None, chainage: np.ndarray | None = None, blank_value: float = _SURFER_BLANK, ) -> Path: """Write a 2-D resistivity model as a Golden Software Surfer DSAA grid. Accepts *any* 2-D inversion result via :meth:`~pycsamt.interp.ResistivityModel.from_any` (Occam2D, ModEM, the backend-neutral ``InversionResult``, an AI agent result, a raw ``(x_centers, z_centers, rho_2d)`` triple, or an existing :class:`ResistivityModel`) — not just resistivity models already in hand. The written file opens directly in Surfer (``File > Open``) as a finished grid, no further gridding step needed. DSAA is a *regular* grid format: X and Y must each be evenly spaced. The source model's cell centres generally are not (Occam2D depth cells expand geometrically, mesh padding is uneven, ...), so this resamples onto a new ``nx`` x ``ny`` regular grid first. The resample is exact and separable when *elevation* is omitted (a straightforward 1-D linear interpolation along each axis, since a ``ResistivityModel``'s ``rho_2d`` already sits on a true ``x_centers`` x ``z_centers`` tensor grid). With *elevation* given, each x-column's local depth-to-elevation mapping differs (real terrain), so the grid becomes column-sheared before resampling; this handles that with one linear interpolation per column rather than a full 2-D scattered regrid, still exact along each axis and scipy-free. Grid cells outside a column's real elevation coverage (above local terrain, or below the deepest column-local sample) are written as *blank_value*, Surfer's own missing-data sentinel. Parameters ---------- model : object Anything :meth:`ResistivityModel.from_any` accepts. Native MARE2DEM results (an unstructured triangular mesh, not a regular grid) are not supported — regrid onto a ``(x_centers, z_centers, rho_2d)`` triple first. A native Occam2D/ModEM result is not auto-cropped to the station-carrying core — pass ``model.clip_to_stations()`` (after ``ResistivityModel.from_any``) instead of the raw result if Occam2D's own wide boundary-padding columns should not appear in the output. path : path-like Output ``.grd`` file. log_rho : bool Write :math:`\\log_{10}(\\rho)` (default) or linear ρ (Ω·m). nx, ny : int, optional Output grid resolution. Default to the source model's own ``n_x``/``n_z`` (a reasonable resolution match; raise for a finer resample). elevation : ndarray, optional Real terrain elevation (m a.s.l.) — pairs with *chainage* (or *station_x* when *chainage* is omitted). When given, Y becomes real elevation (``elevation − depth``); otherwise Y is ``−depth`` (flat datum at 0, matching :func:`to_oasis_montaj_xyz`'s convention). station_x : ndarray, optional Overrides the station positions used to resolve *model* via :meth:`ResistivityModel.from_any` (only meaningful for input forms that need it, e.g. a raw array triple). chainage : ndarray, optional Along-profile positions matching *elevation*, metres. Defaults to the resolved model's own ``station_x``. blank_value : float Surfer's missing-data sentinel. Default matches Surfer's own default (``1.70141e38``); change only if a project's Surfer installation uses a different one. Returns ------- Path Examples -------- >>> import numpy as np >>> from pycsamt.interp import export >>> x = np.linspace(0.0, 900.0, 10) >>> z = np.array([10.0, 50.0, 120.0]) >>> rho_log10 = np.tile(np.array([[2.0], [2.5], [3.0]]), (1, 10)) >>> path = export.to_surfer_grid( ... (x, z, rho_log10), ... "surfer_grid_doctest.grd", ... nx=5, ... ny=3, ... ) >>> lines = path.read_text().splitlines() >>> lines[0] 'DSAA' >>> lines[1] '5 3' >>> path.unlink() """ rm = ResistivityModel.from_any(model, station_x=station_x) values_src = rm.rho_2d if log_rho else 10.0**rm.rho_2d x_c = rm.x_centers z_c = rm.z_centers elev_at_x = _elevation_at_x(x_c, elevation, chainage, rm.station_x) n_x = int(nx) if nx else rm.n_x n_y = int(ny) if ny else rm.n_z x_uniform = np.linspace(float(x_c.min()), float(x_c.max()), n_x) # Pass 1: interpolate every original depth row along x onto x_uniform. values_x = np.empty((rm.n_z, n_x)) for iz in range(rm.n_z): values_x[iz, :] = np.interp(x_uniform, x_c, values_src[iz, :]) elev_uniform = np.interp(x_uniform, x_c, elev_at_x) y_min = float(np.min(elev_at_x - z_c.max())) y_max = float(np.max(elev_at_x - z_c.min())) y_uniform = np.linspace(y_min, y_max, n_y) # Pass 2: for each x_uniform column, interpolate along that column's # own local (elevation − depth) axis onto the shared y_uniform axis, # blanking outside its real coverage. grid = np.full((n_y, n_x), blank_value) for ix in range(n_x): y_local = elev_uniform[ix] - z_c order = np.argsort(y_local) y_sorted = y_local[order] col_sorted = values_x[order, ix] in_range = (y_uniform >= y_sorted[0]) & (y_uniform <= y_sorted[-1]) grid[in_range, ix] = np.interp( y_uniform[in_range], y_sorted, col_sorted ) # A NaN in the source model (a masked/unresolved cell) propagates # through linear interpolation as NaN, not as blank_value -- and # Surfer's DSAA reader treats "nan" as an unparsable number, not as # its blanking sentinel, so this must be caught explicitly rather # than relying on the blank_value fill above. grid[np.isnan(grid)] = blank_value out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) finite = grid[grid != blank_value] z_min = float(finite.min()) if finite.size else 0.0 z_max = float(finite.max()) if finite.size else 0.0 with out.open("w") as fh: fh.write("DSAA\n") fh.write(f"{n_x} {n_y}\n") fh.write(f"{x_uniform.min():.6g} {x_uniform.max():.6g}\n") fh.write(f"{y_uniform.min():.6g} {y_uniform.max():.6g}\n") fh.write(f"{z_min:.6g} {z_max:.6g}\n") for iy in range(n_y): # row 1 = y_min, matching the DSAA spec fh.write(" ".join(f"{v:.6g}" for v in grid[iy, :]) + "\n") return out
[docs] def to_surfer_xyz( model: Any, path: PathLike, *, log_rho: bool = True, elevation: np.ndarray | None = None, station_x: np.ndarray | None = None, chainage: np.ndarray | None = None, delimiter: str = "\t", header: bool = True, ) -> Path: """Write a 2-D resistivity model as scattered Surfer ``X Y Z`` points. Unlike :func:`to_surfer_grid`, this writes the model's *actual* cell centres exactly as they are — no resampling, no regularity requirement, nothing blanked — and lets Surfer's own gridding (``Grid > Data``) turn it into a map with whatever method and blanking the user chooses. Accepts the same input forms as :func:`to_surfer_grid` via :meth:`~pycsamt.interp.ResistivityModel.from_any`. Parameters ---------- model : object Anything :meth:`ResistivityModel.from_any` accepts. path : path-like Output text file (``.xyz`` / ``.dat`` recommended). log_rho : bool Write :math:`\\log_{10}(\\rho)` (default) or linear ρ (Ω·m). elevation, station_x, chainage : Same meaning as :func:`to_surfer_grid` — with *elevation*, Y is real elevation (``elevation − depth``); without it, Y is ``−depth``. delimiter : str Column separator (default tab, Surfer's own default import delimiter). header : bool Write an ``X<delim>Y<delim>Z`` header row. Returns ------- Path Examples -------- >>> import numpy as np >>> from pycsamt.interp import export >>> x = np.array([0.0, 100.0]) >>> z = np.array([10.0, 50.0]) >>> rho_log10 = np.array([[2.0, 2.1], [2.5, 2.6]]) >>> path = export.to_surfer_xyz( ... (x, z, rho_log10), "surfer_xyz_doctest.dat" ... ) >>> path.read_text().splitlines() ['X\\tY\\tZ', '0.000\\t-10.000\\t2.00000', '0.000\\t-50.000\\t2.50000', '100.000\\t-10.000\\t2.10000', '100.000\\t-50.000\\t2.60000'] >>> path.unlink() """ rm = ResistivityModel.from_any(model, station_x=station_x) values = rm.rho_2d if log_rho else 10.0**rm.rho_2d x_c = rm.x_centers z_c = rm.z_centers elev_at_x = _elevation_at_x(x_c, elevation, chainage, rm.station_x) out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) with out.open("w") as fh: if header: fh.write(delimiter.join(["X", "Y", "Z"]) + "\n") for ix in range(rm.n_x): y_col = elev_at_x[ix] - z_c for iz in range(rm.n_z): val = values[iz, ix] if np.isnan(val): continue fh.write( delimiter.join( [f"{x_c[ix]:.3f}", f"{y_col[iz]:.3f}", f"{val:.5f}"] ) + "\n" ) return out
[docs] def to_surfer_bln( elevation: np.ndarray, chainage: np.ndarray, path: PathLike, *, sky_margin: float = 100.0, ) -> Path: """Write a Golden Software Surfer blanking (``.bln``) polygon for topography. Traces the real, along-profile station elevation as one edge of a closed polygon covering everything *above* the terrain (the "sky"), with the blanking flag set so Surfer blanks that polygon's interior (``Map``/``Grid > Blank`` in Surfer, using this file). Loaded together with a terrain-draped grid from :func:`to_surfer_grid` (pass the same *elevation*/*chainage*), this removes the flat rectangular "no data" band above real terrain that :func:`to_surfer_grid` otherwise leaves filled with its blanking sentinel but does not clip out of the plot. Parameters ---------- elevation : ndarray Real terrain elevation at each profile position, metres a.s.l. (e.g. real station elevation from EDI headers via :func:`pycsamt.topo.extract.extract_elevation`). chainage : ndarray Along-profile position matching *elevation*, metres, in the same coordinate frame as the grid written by :func:`to_surfer_grid` (its *chainage*/*station_x* argument). path : path-like Output ``.bln`` file. sky_margin : float Extra height, in metres, added above the highest station so the polygon's top edge clears the grid's own upper bound with room to spare (default ``100.0`` m). Returns ------- Path Notes ----- The Surfer ``.bln`` format's first line is ``n_points,blank_flag``, where ``blank_flag=1`` blanks the polygon's *interior* (used here) and ``0`` would blank everything *outside* it. Each following line is one ``x,y`` vertex; Surfer closes the polygon back to the first vertex automatically. Examples -------- >>> import numpy as np >>> from pycsamt.interp import export >>> elev = np.array([100.0, 120.0, 90.0]) >>> chain = np.array([0.0, 50.0, 100.0]) >>> path = export.to_surfer_bln(elev, chain, "topo_doctest.bln") >>> path.read_text().splitlines()[0] '5,1' >>> path.unlink() See Also -------- to_surfer_grid Write the matching terrain-draped resistivity grid. """ elevation = np.asarray(elevation, dtype=float) chainage = np.asarray(chainage, dtype=float) order = np.argsort(chainage) x = chainage[order] y = elevation[order] y_sky = float(np.nanmax(y)) + float(sky_margin) xs = np.concatenate([[x[0]], x, [x[-1]]]) ys = np.concatenate([[y_sky], y, [y_sky]]) n = xs.size out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) with out.open("w") as fh: fh.write(f"{n},1\n") for xi, yi in zip(xs, ys): fh.write(f"{xi:.6g},{yi:.6g}\n") return out