# 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"]
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,
*,
topo: Any = None,
epsg: int | None = None,
utm_zone: Any | None = None,
latlon: bool = False,
on_mismatch: str = "raise",
) -> tuple[StationTable, dict[str, float]]:
"""Returns the built table plus the *effective* elevation-override
mapping (``station_elevations`` merged with ``topo``'s own
elevation, ``topo`` taking priority) -- the caller uses that merged
mapping 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."""
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([])
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]
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
[docs]
def modem3d_to_pcsf(
result: InversionResult,
*,
model: ModEmModel3D | None = None,
data: ModEmData | None = None,
station_elevations: Mapping[str, 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,
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.
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``), the original natural-log grid
preserved 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.
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
# 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=int(resolved_model.n_air),
)
resolved_data = data or result.data_obs or result.data_pred
stations = None
elevation_overrides: dict[str, float] = {}
if resolved_data is not None:
stations, elevation_overrides = _stations_from_modem_data(
resolved_data,
station_elevations,
topo=topo,
epsg=epsg,
utm_zone=utm_zone,
latlon=latlon,
on_mismatch=on_mismatch,
)
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),
}
return PCSFModel(
geometry=geometry,
resistivity=resolved_model.rho_linear,
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,
)