Source code for pycsamt.inversion.export

# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Export helpers for :mod:`pycsamt.inversion` results."""

from __future__ import annotations

import csv
import json
import tempfile
import zipfile
from pathlib import Path
from typing import Any, Union

import numpy as np

from .results import InversionResult

PathLike = Union[str, Path]

__all__ = [
    "to_archive",
    "to_csv",
    "to_geojson",
    "to_geotiff",
    "to_npz",
    "to_vtk",
]


[docs] def to_csv( result: InversionResult, path: PathLike, *, log_rho: bool = True ) -> Path: """Export a recovered resistivity model as long-form CSV. The CSV writer stores one row per model cell with profile position, depth, resistivity value, and station label. It is the simplest exchange format for spreadsheets, quick inspection, and downstream scripts that do not need mesh topology. Parameters ---------- result : InversionResult Inversion result convertible through :meth:`pycsamt.inversion.results.InversionResult.to_resistivity_model`. path : path-like Output CSV path. Parent directories are created automatically. log_rho : bool, default True If ``True``, write ``log10(rho / ohm m)`` values. If ``False``, write linear resistivity in ohm metres. Returns ------- pathlib.Path Path to the written CSV file. Examples -------- >>> from pycsamt.inversion.export import to_csv >>> to_csv(result, "profile.csv") # doctest: +SKIP >>> to_csv(result, "profile_ohm_m.csv", log_rho=False) # doctest: +SKIP References ---------- .. [1] Shafranovich, Y. (2005). Common Format and MIME Type for Comma-Separated Values (CSV) Files. RFC 4180. """ model = result.to_resistivity_model() rho = np.asarray(model.rho_2d, dtype=float) values = rho if log_rho else 10.0**rho out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) with out.open("w", newline="") as fh: writer = csv.writer(fh) unit = "log10_ohm_m" if log_rho else "ohm_m" writer.writerow(["x_m", "z_m", f"rho_{unit}", "station"]) for ix, x in enumerate(model.x_centers): station = "" if ix < len(model.station_names): station = model.station_names[ix] for iz, z in enumerate(model.z_centers): writer.writerow( [float(x), float(z), float(values[iz, ix]), station] ) return out
[docs] def to_npz(result: InversionResult, path: PathLike) -> Path: """Export common result arrays to a compressed NumPy archive. The NPZ writer preserves numerical arrays used by the inversion API: resistivity grid, coordinates, station metadata, RMS, and optional uncertainty/history arrays. It is the preferred lightweight format for Python workflows because arrays are stored without text parsing. Parameters ---------- result : InversionResult Inversion result convertible to a resistivity model. Uncertainty and convergence-history arrays are exported when present. path : path-like Output ``.npz`` path. Parent directories are created automatically. Returns ------- pathlib.Path Path to the written compressed NumPy archive. Examples -------- >>> import numpy as np >>> from pycsamt.inversion.export import to_npz >>> path = to_npz(result, "profile.npz") # doctest: +SKIP >>> arrays = np.load(path) # doctest: +SKIP >>> arrays["rho_2d"].shape # doctest: +SKIP (10, 20) References ---------- .. [1] NumPy Developers. ``numpy.savez_compressed`` documentation. """ model = result.to_resistivity_model() out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) payload = { "rho_2d": np.asarray(model.rho_2d, dtype=float), "x_centers": np.asarray(model.x_centers, dtype=float), "z_centers": np.asarray(model.z_centers, dtype=float), "station_x": np.asarray(model.station_x, dtype=float), "station_names": np.asarray(model.station_names, dtype=str), "method": result.method, "dimension": result.dimension, "backend": result.backend, "rms": result.rms, } if result.uncertainty is not None: for key in ( "model_std", "covariance_diag", "sensitivity", "confidence", "station_confidence", "depth_confidence", ): value = getattr(result.uncertainty, key, None) if value is not None: payload[f"uncertainty_{key}"] = np.asarray(value, dtype=float) if result.history is not None: for key, value in result.history.arrays().items(): payload[f"history_{key}"] = np.asarray(value, dtype=float) np.savez_compressed(out, **payload) return out
[docs] def to_geojson( result: InversionResult, path: PathLike, *, log_rho: bool = True ) -> Path: """Export a 2-D inversion section as GeoJSON cell polygons. Coordinates are profile-distance/depth pairs in metres. Depth is positive downward, matching :class:`pycsamt.interp.ResistivityModel`. Parameters ---------- result : InversionResult Inversion result convertible to a 2-D resistivity model. path : path-like Output GeoJSON path. Parent directories are created automatically. log_rho : bool, default True If ``True``, each feature contains ``rho_log10_ohm_m``. If ``False``, each feature contains ``rho_ohm_m``. Returns ------- pathlib.Path Path to the written GeoJSON file. Notes ----- Each model cell is written as one polygon feature. Cell properties include ``ix``, ``iz``, cell-center coordinates, resistivity, and matching uncertainty maps such as ``uncertainty_confidence`` when available. Examples -------- >>> from pycsamt.inversion.export import to_geojson >>> to_geojson(result, "profile.geojson") # doctest: +SKIP References ---------- .. [1] Butler, H. et al. (2016). The GeoJSON Format. RFC 7946. """ model = result.to_resistivity_model() rho = np.asarray(model.rho_2d, dtype=float) values = rho if log_rho else 10.0**rho x_edges = _cell_edges(model.x_centers) z_edges = _cell_edges(model.z_centers) value_key = _rho_key(log_rho) uncertainty = _uncertainty_arrays(result, rho.shape) features = [] for iz in range(rho.shape[0]): z0, z1 = float(z_edges[iz]), float(z_edges[iz + 1]) for ix in range(rho.shape[1]): x0, x1 = float(x_edges[ix]), float(x_edges[ix + 1]) properties = { "ix": ix, "iz": iz, "x_center_m": float(model.x_centers[ix]), "z_center_m": float(model.z_centers[iz]), value_key: float(values[iz, ix]), } properties.update( { key: float(array[iz, ix]) for key, array in uncertainty.items() } ) features.append( { "type": "Feature", "properties": properties, "geometry": { "type": "Polygon", "coordinates": [ [ [x0, z0], [x1, z0], [x1, z1], [x0, z1], [x0, z0], ] ], }, } ) out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) collection = { "type": "FeatureCollection", "features": features, "properties": _metadata(result), } with out.open("w", encoding="utf-8") as fh: json.dump(collection, fh, indent=2, sort_keys=True) return out
[docs] def to_vtk( result: InversionResult, path: PathLike, *, log_rho: bool = True ) -> Path: """Export a 2-D inversion section as legacy ASCII VTK. The file is a ``RECTILINEAR_GRID`` with one cell in the cross-line direction, suitable for ParaView and other lightweight model viewers. Parameters ---------- result : InversionResult Inversion result convertible to a 2-D resistivity model. path : path-like Output ``.vtk`` path. Parent directories are created automatically. log_rho : bool, default True If ``True``, write ``rho_log10_ohm_m`` as the primary cell scalar. If ``False``, write ``rho_ohm_m``. Returns ------- pathlib.Path Path to the written legacy VTK file. Notes ----- The profile coordinate is written on the VTK X axis, a dummy cross-line axis is written on Y, and positive-down depth is written on Z. Matching 2-D uncertainty arrays are included as additional cell scalars. Examples -------- >>> from pycsamt.inversion.export import to_vtk >>> to_vtk(result, "profile.vtk") # doctest: +SKIP References ---------- .. [1] Schroeder, W., Martin, K. and Lorensen, B. (2006). *The Visualization Toolkit*, 4th edition. """ model = result.to_resistivity_model() rho = np.asarray(model.rho_2d, dtype=float) values = rho if log_rho else 10.0**rho x_edges = _cell_edges(model.x_centers) z_edges = _cell_edges(model.z_centers) uncertainty = _uncertainty_arrays(result, rho.shape) out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) with out.open("w", encoding="utf-8") as fh: fh.write("# vtk DataFile Version 3.0\n") fh.write("pycsamt inversion result\n") fh.write("ASCII\n") fh.write("DATASET RECTILINEAR_GRID\n") fh.write(f"DIMENSIONS {len(x_edges)} 2 {len(z_edges)}\n") _write_vtk_coords(fh, "X_COORDINATES", x_edges) _write_vtk_coords(fh, "Y_COORDINATES", np.array([0.0, 1.0])) _write_vtk_coords(fh, "Z_COORDINATES", z_edges) fh.write(f"CELL_DATA {rho.size}\n") _write_vtk_scalar(fh, _rho_key(log_rho), values) for key, array in uncertainty.items(): _write_vtk_scalar(fh, key, array) return out
[docs] def to_geotiff( result: InversionResult, path: PathLike, *, log_rho: bool = True, crs: Any = None, ) -> Path: """Export a 2-D inversion section as a single-band GeoTIFF. This writer requires :mod:`rasterio`. The raster axes are profile distance and depth in metres; pass *crs* only when those coordinates are already tied to a projected coordinate reference system. Parameters ---------- result : InversionResult Inversion result convertible to a 2-D resistivity model. path : path-like Output GeoTIFF path. Parent directories are created automatically. log_rho : bool, default True If ``True``, write log10 resistivity. If ``False``, write linear resistivity in ohm metres. crs : object, optional Coordinate reference system passed to :mod:`rasterio`. Use this only when profile/depth coordinates are already in a projected CRS. Returns ------- pathlib.Path Path to the written GeoTIFF. Raises ------ ImportError If :mod:`rasterio` is not installed. Examples -------- >>> from pycsamt.inversion.export import to_geotiff >>> to_geotiff(result, "profile.tif") # doctest: +SKIP References ---------- .. [1] Ritter, N. and Ruth, M. (1997). GeoTIFF Format Specification. .. [2] Gillies, S. et al. Rasterio documentation. """ try: import rasterio from rasterio.transform import Affine except ImportError as exc: raise ImportError("GeoTIFF export requires rasterio.") from exc model = result.to_resistivity_model() rho = np.asarray(model.rho_2d, dtype=float) values = rho if log_rho else 10.0**rho x_edges = _cell_edges(model.x_centers) z_edges = _cell_edges(model.z_centers) dx = _mean_spacing(x_edges) dz = _mean_spacing(z_edges) transform = Affine(dx, 0.0, float(x_edges[0]), 0.0, dz, float(z_edges[0])) out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) with rasterio.open( out, "w", driver="GTiff", height=values.shape[0], width=values.shape[1], count=1, dtype="float32", crs=crs, transform=transform, ) as dst: dst.write(values.astype("float32"), 1) dst.update_tags( backend=result.backend, method=result.method, dimension=result.dimension, unit=_rho_key(log_rho), ) return out
[docs] def to_archive( result: InversionResult, path: PathLike, *, include_native: bool = True, log_rho: bool = True, ) -> Path: """Write a portable ZIP snapshot of an inversion result. The archive always contains common products (metadata, NPZ, CSV). Existing backend-native files referenced by ``result.files`` are included under ``native_files/`` when *include_native* is true. Parameters ---------- result : InversionResult Inversion result to snapshot. path : path-like Output ``.zip`` path. Parent directories are created automatically. include_native : bool, default True Include existing backend-native files listed in ``result.files`` under ``native_files/``. log_rho : bool, default True Passed to the CSV export stored inside the archive. Returns ------- pathlib.Path Path to the written ZIP archive. Notes ----- The archive contains: * ``metadata.json`` with backend, RMS, warnings, file references, and history/uncertainty metadata. * ``result.npz`` with numerical arrays. * ``model.csv`` with the long-form resistivity model. * ``native_files/`` entries when available and requested. Examples -------- >>> from pycsamt.inversion.export import to_archive >>> to_archive(result, "run_snapshot.zip") # doctest: +SKIP References ---------- .. [1] PKWARE Inc. ZIP File Format Specification. """ out = Path(path) out.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory( prefix="pycsamt-inv-export-" ) as tmp_name: tmp = Path(tmp_name) metadata_path = tmp / "metadata.json" with metadata_path.open("w", encoding="utf-8") as fh: json.dump(_metadata(result), fh, indent=2, sort_keys=True) npz_path = to_npz(result, tmp / "result.npz") csv_path = to_csv(result, tmp / "model.csv", log_rho=log_rho) with zipfile.ZipFile( out, "w", compression=zipfile.ZIP_DEFLATED ) as zf: zf.write(metadata_path, "metadata.json") zf.write(npz_path, "result.npz") zf.write(csv_path, "model.csv") if include_native: for key, value in sorted(result.files.items()): source = Path(value) if source.exists() and source.is_file(): zf.write(source, f"native_files/{key}_{source.name}") return out
def _cell_edges(centers: Any) -> np.ndarray: centers = np.asarray(centers, dtype=float).reshape(-1) if centers.size == 0: raise ValueError("cannot export model with empty cell coordinates.") if centers.size == 1: half_width = max(abs(float(centers[0])) * 0.05, 0.5) return np.array( [centers[0] - half_width, centers[0] + half_width], dtype=float ) mids = 0.5 * (centers[:-1] + centers[1:]) first = centers[0] - (mids[0] - centers[0]) last = centers[-1] + (centers[-1] - mids[-1]) return np.r_[first, mids, last].astype(float) def _mean_spacing(edges: np.ndarray) -> float: spacing = np.diff(np.asarray(edges, dtype=float)) if spacing.size == 0 or not np.all(np.isfinite(spacing)): return 1.0 return float(np.nanmean(np.abs(spacing))) or 1.0 def _rho_key(log_rho: bool) -> str: return "rho_log10_ohm_m" if log_rho else "rho_ohm_m" def _uncertainty_arrays( result: InversionResult, shape: tuple[int, int] ) -> dict[str, np.ndarray]: if result.uncertainty is None: return {} out: dict[str, np.ndarray] = {} for attr in ("model_std", "covariance_diag", "sensitivity", "confidence"): value = getattr(result.uncertainty, attr, None) if value is None: continue array = np.asarray(value, dtype=float) if array.shape == shape: out[f"uncertainty_{attr}"] = array return out def _write_vtk_coords(fh: Any, name: str, values: np.ndarray) -> None: values = np.asarray(values, dtype=float).reshape(-1) fh.write(f"{name} {values.size} float\n") fh.write(" ".join(f"{float(value):.12g}" for value in values)) fh.write("\n") def _write_vtk_scalar(fh: Any, name: str, values: np.ndarray) -> None: array = np.asarray(values, dtype=float) fh.write(f"SCALARS {name} float 1\n") fh.write("LOOKUP_TABLE default\n") for value in array.reshape(-1): fh.write(f"{float(value):.12g}\n") def _metadata(result: InversionResult) -> dict[str, Any]: payload = { "method": result.method, "dimension": result.dimension, "backend": result.backend, "status": result.status, "rms": result.rms, "objective": result.objective, "n_iter": result.n_iter, "workdir": result.workdir, "files": dict(result.files), "warnings": list(result.warnings), "metadata": dict(result.metadata), } if result.uncertainty is not None: payload["uncertainty"] = { "metadata": dict(result.uncertainty.metadata) } if result.history is not None: payload["history"] = { "n_records": result.history.n_records, "metadata": dict(result.history.metadata), } return _json_ready(payload) def _json_ready(value: Any) -> Any: if isinstance(value, dict): return {str(key): _json_ready(item) for key, item in value.items()} if isinstance(value, (list, tuple)): return [_json_ready(item) for item in value] if isinstance(value, np.ndarray): return _json_ready(value.tolist()) if isinstance(value, np.generic): return value.item() if isinstance(value, Path): return str(value) try: json.dumps(value) except TypeError: return str(value) return value