# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""ModEM 3-D -> PCSF adapter (Phase 3 of the PCSF format plan).
Converts a completed :class:`pycsamt.models.modem.results.InversionResult`
(3-D mode) into a backend-neutral :class:`~pycsamt.format.schema.PCSFModel`
with a ``grid3d`` geometry — the first genuinely new persisted 3-D volume
artifact in the project (see ``PYCSAMT-PCSF-INVERSION-FORMAT-PLAN.md``,
§1: nothing else in the codebase currently writes one to disk; the web
3-D view only ever *synthesizes* a volume at render time from stacked
2-D sections).
Unlike Occam2D, ModEM's model already carries its own real-world grid
centre and rotation when a genuine ModEM run wrote the file — see the
``origin``/``rotation_deg`` attributes added to
:class:`pycsamt.models.modem.model3d.ModEmModel3D` alongside this
adapter, which previously parsed and then silently discarded that
trailing line.
"""
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING, Any, Mapping
import numpy as np
from ..schema import Grid3DGeometry, PCSFModel, StationTable, TopographyPerStation
from ..topo_source import resolve_topo
if TYPE_CHECKING:
from pycsamt.models.modem.data import ModEmData
from pycsamt.models.modem.model3d import ModEmModel3D
from pycsamt.models.modem.results import InversionResult
__all__ = ["modem3d_to_pcsf"]
#: Default cell resistivity (ohm.m) above which a ModEM 3-D cell is
#: treated as the model's above-topography "air" / padding fill rather
#: than resolved earth. A genuine rock cell is at most a few times
#: 1e4 ohm.m even for a dry, unweathered resistor; ModEM's air fill is
#: 1e8-1e13 ohm.m, so any reasonable threshold in that gap works. Used
#: only when ``modem3d_to_pcsf(air_threshold_ohm_m=...)`` is left at its
#: default -- pass ``None`` there for an exact, unmasked passthrough.
DEFAULT_AIR_THRESHOLD_OHM_M = 1e8
def _mask_air_fill(
rho_linear: np.ndarray, threshold: float
) -> tuple[np.ndarray, int]:
"""Return ``(masked_rho, n_air)`` for a ModEM 3-D linear-ohm.m volume.
``masked_rho`` is a copy of *rho_linear* with every cell exceeding
*threshold* set to ``nan`` -- ModEM leaves the model cells above the
real ground surface filled with air-like resistivity (1e8-1e13
ohm.m) even when the run declares no dedicated air layers, and those
cells otherwise dominate any auto colour scale and crush the real
earth into a featureless band (see the module docstring / the PCSF
format plan).
``n_air`` counts leading *complete* air layers -- consecutive top
z-layers in which **every** cell is above the threshold. Uneven
topography usually means no layer is 100% air (some stations sit
higher than others), so this is commonly ``0`` even when many
individual cells were masked; the per-cell ``nan`` mask is the
operative fix either way. ``rho_linear`` axis order is ModEM's own
``(nz, ny, nx)``.
"""
rho = np.asarray(rho_linear, dtype=float)
air = rho > threshold # nan > x is False -> genuine gaps stay nan
masked = np.where(air, np.nan, rho)
n_air = 0
if rho.ndim == 3 and rho.shape[0]:
for is_air_layer in air.all(axis=(1, 2)):
if not is_air_layer:
break
n_air += 1
return masked, n_air
#: Phrases in a ModEM ``.dat`` file's own ``#`` comment lines that mark
#: its ``Z(m)`` station column as a *positive-downward depth* below a
#: datum rather than a positive-up elevation (ModEM's model z-axis
#: points down, so a populated station Z is a depth by the format's own
#: convention -- but pycsamt's own writer, and some third-party ones,
#: put a plain a.s.l. elevation there instead, so the file has to say).
_DEPTH_DOWN_MARKERS = (
"depth below",
"positive down",
"positive-down",
"z(m) is depth",
"z is depth",
"z down",
"z-down",
)
def _comment_says_depth_down(comment: str | None) -> bool:
text = (comment or "").lower()
return any(marker in text for marker in _DEPTH_DOWN_MARKERS)
def _parse_masl_datum(comment: str | None) -> float | None:
"""Pull an ``a.s.l.`` datum elevation out of a ``.dat`` comment.
Matches e.g. ``top = 224 m a.s.l.`` / ``datum 1035m asl`` -- the
reference the positive-down ``Z`` column is measured from, so a real
metres-a.s.l. station elevation can be recovered as ``datum - Z``.
Returns ``None`` when no such hint is present (a *relative*
elevation is used instead).
"""
import re
m = re.search(
r"(-?\d+(?:\.\d+)?)\s*m\s*a\.?\s*s\.?\s*l", (comment or "").lower()
)
return float(m.group(1)) if m else None
def _modem_z_to_elevation(
z: np.ndarray, comment: str | None, convention: str
) -> tuple[np.ndarray, dict[str, Any]]:
"""Return ``(elevation, info)`` -- ModEM station ``Z`` as a positive-up
elevation.
*convention* is ``"elevation"`` (trust the column as-is),
``"depth_down"`` (force ``datum - Z``), or ``"auto"`` (flip only
when the ``.dat`` comment says the column is a positive-down depth,
otherwise leave it -- the safe default, since a pycsamt-written
ModEM file already stores a plain elevation there). A flat all-zero
column is always left untouched (it is ModEM's "no topography"
placeholder).
"""
z = np.asarray(z, dtype=float)
info: dict[str, Any] = {"convention": convention, "flipped": False}
if z.size == 0 or not np.any(np.isfinite(z) & (z != 0.0)):
return z, info
if convention == "elevation":
return z, info
depth_down = convention == "depth_down" or (
convention == "auto" and _comment_says_depth_down(comment)
)
if not depth_down:
return z, info
datum = _parse_masl_datum(comment)
if datum is not None:
elevation = datum - z
info["datum_masl"] = datum
else:
# No a.s.l. reference in the file -- keep the *shape* (deepest
# station at 0, higher ground positive) without inventing an
# absolute datum.
elevation = float(np.nanmax(z)) - z
info["datum_masl"] = None
info["flipped"] = True
return elevation, info
def _warn_if_lonlat_quantized(lonlat: np.ndarray) -> None:
"""Warn when the ``.dat`` file's ``GG_Lat``/``GG_Lon`` are truncated
to 3 decimals -- ModEM's fixed ``f9.3`` output format, ~100 m at mid
latitudes. Common when the data file is a ModEM ``-R`` rewrite echo
rather than the real input; on a survey with ~100 m station spacing
it makes profile lines zig-zag and collapses nearby stations onto
one point.
"""
vals = np.asarray(lonlat, dtype=float).ravel()
vals = vals[np.isfinite(vals)]
if vals.size < 6: # < 3 stations
return
if np.all(np.abs(vals - np.round(vals, 3)) < 1e-9):
warnings.warn(
"modem3d_to_pcsf: the '.dat' file's GG_Lat/GG_Lon are "
"quantized to 3 decimal places (~100 m) -- likely a ModEM "
"'-R' rewrite echo, not the real input. Profile geometry "
"will look ragged and nearby stations may collapse onto one "
"point. Pass a full-precision 'data=' file, or 'topo=' / "
"'known_stations' with real coordinates.",
UserWarning,
stacklevel=3,
)
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)
def _stations_from_modem_data(
data: ModEmData,
station_elevations: Mapping[str, float] | None,
*,
station_z_convention: str = "auto",
topo: Any = None,
epsg: int | None = None,
utm_zone: Any | None = None,
latlon: bool = False,
on_mismatch: str = "raise",
) -> tuple[StationTable, dict[str, float], dict[str, Any]]:
"""Returns the built table, the *effective* elevation-override
mapping (``station_elevations`` merged with ``topo``'s own
elevation, ``topo`` taking priority -- the caller uses it to decide
which stations get a :class:`TopographyPerStation` entry, since
ModEM's own z is a real recorded value, typically a flat ``0.0``
placeholder, not distinguishable from "known" by itself), and a
small ``station_z`` info dict describing whether the ModEM ``Z``
column was flipped from a positive-down depth to an elevation."""
names = list(data.site_names)
coords = data.site_coords
xyz = np.array(
[coords.get(name, (np.nan, np.nan, np.nan)) for name in names],
dtype=float,
)
z = xyz[:, 2] if xyz.size else np.array([])
# ModEM's model z-axis points *down*, so a populated station Z is a
# depth below a datum, not an elevation -- MapView's topography
# drape (and TopographyPerStation) need a positive-up elevation, or
# hills render as hollows. Convert here, before any override merge.
z, station_z_info = _modem_z_to_elevation(
z, getattr(data, "comment", None), station_z_convention
)
elevation_overrides: dict[str, float] = dict(station_elevations or {})
if elevation_overrides:
# ModEM's own z is commonly a flat 0.0 placeholder (no real
# topography in the .dat file); an explicit override replaces
# it for matched stations only, real unmatched values (0.0 is
# a real recorded value here, not "unknown") are left as-is.
z = z.copy()
for i, name in enumerate(names):
if name in elevation_overrides:
z[i] = float(elevation_overrides[name])
# A ModEM .dat file's own GG_Lat/GG_Lon columns, when present, are
# the only real-world spatial reference this backend carries --
# populate StationTable.lon/lat from them directly, the same way
# pycsamt.map.inversion.load_modem_lines already does for a *live*
# folder import (see _resolve_lonlat there); no separate
# known_stations match should be required just to place a grid3d
# PCSF file's stations on a real basemap.
lon = lat = None
if data.site_lonlat:
lonlat = np.array(
[data.site_lonlat.get(name, (np.nan, np.nan)) for name in names],
dtype=float,
)
if not np.all(np.isnan(lonlat)):
lon, lat = lonlat[:, 0], lonlat[:, 1]
_warn_if_lonlat_quantized(lonlat[np.isfinite(lonlat).all(axis=1)])
if topo is not None:
if data.site_lonlat or station_elevations:
warnings.warn(
"modem3d_to_pcsf: 'topo' takes precedence over the "
"'.dat' file's own GG_Lat/GG_Lon and over "
"'station_elevations' for every station it resolves.",
UserWarning,
stacklevel=3,
)
attr = resolve_topo(
topo, 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(names)]
)
lat = np.array(
[attr.lat.get(n, lat[i] if lat is not None else np.nan) for i, n in enumerate(names)]
)
if np.all(np.isnan(lon)):
lon = lat = None
z = np.array([attr.elevation.get(n, z[i]) for i, n in enumerate(names)])
elevation_overrides.update(attr.elevation)
station_table = StationTable(
name=names,
x=xyz[:, 0] if xyz.size else np.array([]),
y=xyz[:, 1] if xyz.size else np.array([]),
z=z,
lon=lon,
lat=lat,
)
return station_table, elevation_overrides, station_z_info
[docs]
def modem3d_to_pcsf(
result: InversionResult,
*,
model: ModEmModel3D | None = None,
data: ModEmData | None = None,
station_elevations: Mapping[str, float] | None = None,
air_threshold_ohm_m: float | None = DEFAULT_AIR_THRESHOLD_OHM_M,
station_z_convention: str = "auto",
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,
created_by: str = "",
crs: str | None = None,
description: str = "",
) -> PCSFModel:
r"""Convert a ModEM 3-D :class:`InversionResult` to a :class:`PCSFModel`.
Parameters
----------
result : InversionResult
A loaded ModEM working directory (``result.mode == "3d"``).
model : ModEmModel3D, optional
Model to convert. Defaults to ``result.model_final``, falling
back to ``result.model_initial`` when no final model was
parsed.
data : ModEmData, optional
Source of station coordinates. Defaults to
``result.data_obs``, falling back to ``result.data_pred``.
``None`` when neither is available (``PCSFModel.stations``
stays ``None`` rather than fabricating positions).
station_elevations : mapping of str to float, optional
``station_name -> elevation (m)``, overriding ModEM's own
station z (commonly a flat ``0.0`` placeholder — a real ModEM
``.dat`` file carries no topography). Matched stations also
populate :attr:`PCSFModel.topography`; unmatched stations keep
their real recorded z as-is (``0.0`` is a genuine value here,
not "unknown", unlike Occam2D's equivalent parameter).
Superseded per-station by *topo* when both are given.
air_threshold_ohm_m : float or None, default 1e8
Cell resistivity (ohm.m) above which a cell is treated as
ModEM's above-topography "air" / padding fill rather than
resolved earth, and set to ``nan`` in the canonical
:attr:`PCSFModel.resistivity`. ModEM leaves the model cells
above the real ground surface filled with air-like resistivity
(1e8-1e13 ohm.m) even when the run declares **no** dedicated air
layers (``n_air == 0``), and those cells otherwise hijack any
auto colour scale (e.g. MapView's fence view), crushing the real
earth into a featureless band.
:attr:`PCSFModel.resistivity_native` keeps the source model's
raw natural-log array **unchanged** for provenance. Leading
*complete* air layers detected this way are also written to
:attr:`Grid3DGeometry.n_air` (when the source model reported
``0``); ``metadata["air_mask"]`` records what was done. Pass
``None`` for an exact, unmasked passthrough of every cell.
station_z_convention : {"auto", "elevation", "depth_down"}, default "auto"
How to read a ModEM ``.dat`` file's own ``Z(m)`` station
column. ModEM's model z-axis points **downward**, so a
populated station ``Z`` is a *depth below a datum*, not an
elevation -- copied verbatim into :attr:`StationTable.z` it
makes MapView's topography drape render hills as hollows.
``"depth_down"`` forces the flip to an elevation
(``datum - Z``; the datum is read from an ``... m a.s.l.`` hint
in the file's ``#`` comments when present, else the deepest
station is taken as ``0`` and the result is a *relative*
elevation). ``"elevation"`` trusts the column as-is (what
pycsamt's own ModEM writer produces). ``"auto"`` (default)
flips only when the ``.dat`` comment says the column is a
positive-down depth; a flat all-zero column is always left
untouched. ``metadata["station_z"]`` records the outcome.
topo : path-like, TopoTable, Sites/MapData-like, or mapping, optional
A "smart" real-coordinate source resolved via
:func:`pycsamt.format.topo_source.resolve_topo` -- see
:func:`pycsamt.format.adapters.occam2d.occam2d_to_pcsf`'s
identical parameter for the full description. When given, it
takes precedence over both *station_elevations* and the
``.dat`` file's own ``GG_Lat``/``GG_Lon`` for every station it
resolves (with a :class:`UserWarning` if either was also
supplied); a station it has no data for keeps its existing
value. ``None`` (the default) leaves this adapter's behaviour
unaffected.
epsg, utm_zone, latlon, on_mismatch
Forwarded to :func:`pycsamt.format.topo_source.resolve_topo`;
see *occam2d_to_pcsf*'s identical parameters.
survey : SurveyMeta or mapping, optional
Survey-level metadata, stored the same way as in
:func:`pycsamt.format.adapters.occam2d.occam2d_to_pcsf`.
created_by, crs, description : str, optional
Passed straight through to :class:`PCSFModel`.
Returns
-------
PCSFModel
``geometry.kind == "grid3d"``, canonical linear-ohm.m
resistivity in :attr:`PCSFModel.resistivity`
(``model.rho_linear``, with above-topography air fill set to
``nan`` -- see *air_threshold_ohm_m*), the original natural-log
grid preserved unchanged in
:attr:`PCSFModel.resistivity_native`, and
iteration history (RMS, objective, model norm, Lagrange
multiplier, step-size scaling ``alpha``) from
:attr:`InversionResult.log` when available.
:attr:`PCSFModel.stations`'s ``lon``/``lat`` are populated
from *data*'s own ``GG_Lat``/``GG_Lon`` columns
(:attr:`ModEmData.site_lonlat`) when present, so the file is
self-sufficiently geo-referenced without needing a separate
``known_stations`` match at load time.
:attr:`StationTable.z` is a positive-up elevation -- ModEM's
positive-down ``Z`` column is flipped when needed (see
*station_z_convention*), so MapView's topography drape shows
hills as hills.
Raises
------
ValueError
If *result* is not a 3-D ModEM result, or no model
(explicit or resolved from *result*) is available.
Examples
--------
>>> from pycsamt.models.modem.results import InversionResult
>>> from pycsamt.format.adapters.modem3d import modem3d_to_pcsf
>>> from pycsamt.format import write_pcsf
>>> result = InversionResult("modem_run") # doctest: +SKIP
>>> model = modem3d_to_pcsf(result) # doctest: +SKIP
>>> write_pcsf(model, "modem3d_run.pcsf") # doctest: +SKIP
"""
if result.mode != "3d":
raise ValueError(
f"modem3d_to_pcsf needs a 3-D ModEM result, got mode={result.mode!r}"
)
resolved_model = model or result.model_final or result.model_initial
if resolved_model is None:
raise ValueError(
"InversionResult has no model_final/model_initial and no "
"explicit model was passed — ensure the workdir contains a "
"readable ModEM 3-D model file (.ws or Modular_NLCG_NNN.rho)."
)
x_nodes = resolved_model.x_nodes
y_nodes = resolved_model.y_nodes
z_nodes = resolved_model.z_nodes
x_c = (x_nodes[:-1] + x_nodes[1:]) / 2.0
y_c = (y_nodes[:-1] + y_nodes[1:]) / 2.0
z_c = (z_nodes[:-1] + z_nodes[1:]) / 2.0
source_n_air = int(resolved_model.n_air)
resistivity = resolved_model.rho_linear
n_air = source_n_air
air_mask_meta: dict[str, Any] | None = None
if air_threshold_ohm_m is not None:
masked, detected_n_air = _mask_air_fill(
resolved_model.rho_linear, float(air_threshold_ohm_m)
)
newly_nan = np.isnan(masked) & ~np.isnan(resistivity)
n_masked = int(np.count_nonzero(newly_nan))
if n_masked:
resistivity = masked
# Only *raise* n_air from a source that reported none -- never
# override a real, larger air-layer count a proper ModEM run
# already declared.
if source_n_air == 0 and detected_n_air > 0:
n_air = detected_n_air
air_mask_meta = {
"threshold_ohm_m": float(air_threshold_ohm_m),
"n_cells_masked": n_masked,
"n_air_detected": detected_n_air,
}
# ModEmModel3D.origin/.rotation default to zeros/0.0 (same
# convention as read_mackie3d) rather than None, so there is no
# reliable way to tell "really at the origin" from "unknown" —
# passed through as-is either way.
geometry = Grid3DGeometry(
x=x_c,
y=y_c,
z=z_c,
x_nodes=x_nodes,
y_nodes=y_nodes,
z_nodes=z_nodes,
origin=np.asarray(resolved_model.origin, dtype=float),
rotation_deg=float(resolved_model.rotation),
n_air=n_air,
)
resolved_data = data or result.data_obs or result.data_pred
stations = None
elevation_overrides: dict[str, float] = {}
station_z_info: dict[str, Any] = {}
if resolved_data is not None:
stations, elevation_overrides, station_z_info = (
_stations_from_modem_data(
resolved_data,
station_elevations,
station_z_convention=station_z_convention,
topo=topo,
epsg=epsg,
utm_zone=utm_zone,
latlon=latlon,
on_mismatch=on_mismatch,
)
)
if station_z_info.get("flipped"):
datum = station_z_info.get("datum_masl")
warnings.warn(
"modem3d_to_pcsf: the ModEM '.dat' Z column reads as a "
"positive-down depth; flipped it to a positive-up "
+ (
f"elevation (datum {datum} m a.s.l.)."
if datum is not None
else "relative elevation (deepest station = 0; no "
"a.s.l. datum in the file)."
)
+ " Pass station_z_convention='elevation' to keep it "
"verbatim.",
UserWarning,
stacklevel=2,
)
topography = None
if stations is not None and elevation_overrides:
matched = [
(name, elevation_overrides[name])
for name in stations.name
if name in elevation_overrides
]
if matched:
topography = TopographyPerStation(
station_id=[name for name, _ in matched],
elevation=np.array([elev for _, elev in matched], dtype=float),
)
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),
"objective": np.asarray(log.objective, dtype=float),
"model_norm": np.asarray(log.model_norm, dtype=float),
"lagrange": np.asarray(log.lagrange, dtype=float),
"alpha": np.asarray(log.alpha, dtype=float),
}
metadata: dict[str, Any] = {
"workdir": str(result.workdir),
"mode": result.mode,
"final_rms": float(result.final_rms),
"n_iter": int(result.n_iter),
"model_keys": list(result.models),
}
if air_mask_meta is not None:
metadata["air_mask"] = air_mask_meta
if station_z_info:
metadata["station_z"] = station_z_info
return PCSFModel(
geometry=geometry,
resistivity=resistivity,
resistivity_native=resolved_model.rho_loge,
resistivity_native_encoding="ln",
stations=stations,
topography=topography,
survey=_survey_to_dict(survey),
history=history,
source_backend="modem3d",
created_by=created_by,
crs=crs,
description=description,
metadata=metadata,
)