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.

Example
-------
>>> from pycsamt.interp import export
>>> export.to_oasis_montaj_xyz(logs, "profile_K1.xyz")
>>> export.to_las(logs[0], "S17.las")
>>> export.to_csv(logs, "all_stations.csv")
"""

from __future__ import annotations

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

import numpy as np

from ._base import ResistivityModel
from .lithology import StratigraphicLog

__all__ = [
    "to_oasis_montaj_xyz",
    "to_las",
    "to_csv",
    "to_vtk",
]

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