# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Import geophysical inversion results into :class:`~pycsamt.map.MapData`.
A ModEM run inverts one 3-D resistivity volume for the whole survey —
individual lines are not separate model files, they are paths of
stations *through* that one volume. :func:`load_modem_lines` slices
that volume into one 2-D vertical curtain per survey line (see
:mod:`pycsamt.models.modem.section`) and returns a :class:`MapData`
carrying real station coordinates plus the precomputed sections, so
the existing 3-D fence/depth builders in :mod:`pycsamt.map.volume`
render inversion-sourced lines exactly like EDI-sourced ones — same
real-geometry line placement, same UI.
Geo-referencing station coordinates
------------------------------------
Three sources are tried, in order, for each ModEM station:
1. ``known_stations`` — a previously-loaded EDI :class:`MapData`'s
stations, matched by station id. Recommended: works for any
inversion backend and lets a matched station's line/elevation
override the (less complete) values recoverable from the ModEM
files alone.
2. The ModEM ``.dat`` file's own ``GG_Lat``/``GG_Lon`` columns
(see :attr:`pycsamt.models.modem.data.ModEmData.site_lonlat`).
3. Neither — the station keeps ``latitude=longitude=None`` and the
3-D builder falls back to synthetic index-based line spacing for
that line (see :mod:`pycsamt.map.geometry`).
"""
from __future__ import annotations
from collections.abc import Iterable
from pathlib import Path
from typing import Any
import numpy as np
from ._core import (
MapData,
StationRecord,
normalize_station_id,
)
__all__ = ["group_modem_stations", "load_modem_lines", "load_pcsf_lines"]
#: Cell resistivity (ohm.m) above which a ``grid3d`` PCSF cell is
#: treated as ModEM's above-topography "air" / padding fill and dropped
#: from a sliced curtain. A defensive net for ``.pcsf`` files written
#: before :func:`pycsamt.format.adapters.modem3d.modem3d_to_pcsf` began
#: masking that fill itself (mirrors its ``DEFAULT_AIR_THRESHOLD_OHM_M``);
#: harmless on newer files, whose air cells are already ``nan``.
_AIR_FILL_THRESHOLD = 1e8
#: A ModEM 3-D grid grades geometrically to tens of km for the boundary
#: conditions; those deep cells carry no interpretable resolution and,
#: left in a sliced curtain, dominate the vertical extent of every 3-D
#: view. Trim trailing cells once a cell is this many times thicker than
#: the shallowest earth cell (keeping at least ``_MIN_EARTH_CELLS``).
_BC_PADDING_GROWTH = 15.0
_MIN_EARTH_CELLS = 8
def _earth_cell_cut(z_nodes_earth: np.ndarray) -> int:
"""Index of the first boundary-condition padding cell in an earth
depth-node array (``0`` at the earth top). Cells at and beyond it are
dropped from a sliced curtain."""
dz = np.diff(np.asarray(z_nodes_earth, dtype=float))
if dz.size <= _MIN_EARTH_CELLS:
return dz.size
too_thick = dz > _BC_PADDING_GROWTH * dz[0]
if not too_thick.any():
return dz.size
return max(_MIN_EARTH_CELLS, int(np.argmax(too_thick)))
def _model_top_datum(model: Any, elevs: np.ndarray) -> float | None:
"""Elevation (m a.s.l.) of a ``grid3d`` model's own ``z = 0`` plane.
ModEM's model z-axis is measured downward from the *model top*, which
for a run that represents topography through a high-resistivity fill
(rather than explicit air layers) sits well above the real ground --
so a curtain sliced straight off ``geometry.z`` is referenced to the
model top, not each station's own surface, and a "200 m" depth slice
is really ~(200 - (model_top - station_elev)) m below ground.
:func:`pycsamt.format.adapters.modem3d.modem3d_to_pcsf` records the
datum in ``metadata["station_z"]["datum_masl"]`` when the source
``.dat`` file names one (``... m a.s.l.``). Absent that, the highest
station elevation is a safe floor: the model must extend at least to
the highest ground. ``None`` when there is no topography to reference
at all (flat/zero station z).
"""
meta = (getattr(model, "metadata", None) or {}).get("station_z") or {}
datum = meta.get("datum_masl")
if datum is not None:
return float(datum)
finite = elevs[np.isfinite(elevs)]
if finite.size == 0 or not np.any(finite != 0.0):
return None
return float(np.max(finite))
[docs]
def group_modem_stations(
station_names: Iterable[str],
*,
known_stations: Iterable[StationRecord] | None = None,
) -> dict[str, list[str]]:
"""Group ModEM station names into survey lines.
Prefers matching each name against *known_stations* (e.g.
previously loaded EDI stations) and using their ``line`` tag.
Falls back to parsing the line token out of the station-name
convention ``{survey}-{line}-{station}{suffix}`` (e.g.
``23-18-001A`` -> line ``18``) — a heuristic, used only when a
station has no match in *known_stations*.
"""
known_by_id = _index_known_stations(known_stations)
groups: dict[str, list[str]] = {}
for name in station_names:
match = _lookup(known_by_id, name)
line = (
str(match.line)
if match is not None and match.line
else _line_token(name)
)
groups.setdefault(line, []).append(str(name))
return groups
def _line_token(name: str) -> str:
"""Best-effort line id from a ``{survey}-{line}-{station}`` name."""
parts = str(name).split("-")
if len(parts) >= 3:
return parts[1]
if len(parts) == 2:
return parts[0]
return "line"
def _index_known_stations(
known_stations: Iterable[StationRecord] | None,
) -> dict[str, StationRecord]:
index: dict[str, StationRecord] = {}
for station in known_stations or ():
index[str(station.id)] = station
index[str(station.id).strip().lower()] = station
# Normalized fallback tier — matches even when the two
# sources format ids slightly differently (dashes vs
# underscores vs spaces, mixed case, ...).
index.setdefault(normalize_station_id(station.id), station)
return index
def _lookup(
known_by_id: dict[str, StationRecord],
name: str,
) -> StationRecord | None:
exact = known_by_id.get(str(name)) or known_by_id.get(
str(name).strip().lower()
)
if exact is not None:
return exact
return known_by_id.get(normalize_station_id(name))
def _grid3d_offset(
node_extent: tuple[float, float] | None,
centers: np.ndarray,
coords: np.ndarray,
) -> float:
"""Registration offset between a PCSF ``grid3d`` file's own grid
frame and its station coordinate frame.
Same symmetric-padding heuristic
:func:`pycsamt.models.modem.section.station_curtain` uses for a
*live* ModEM folder (there, ``_grid_offset``), reimplemented here
from a PCSF file's plain arrays instead of a live
``ModEmModel3D``/``ModEmData`` pair: ModEM builds its horizontal
grid with symmetric padding around the station footprint, so the
grid's own extent midpoint always coincides with the midpoint of
the station coordinate range -- a closed-form offset that needs no
parsed padding/core-zone boundaries either way. Grid rotation is
not applied here, matching ``station_curtain``'s own scope -- both
are exact for an axis-aligned grid (``rotation_deg == 0``, the
common case) and best-effort otherwise.
"""
finite = coords[np.isfinite(coords)]
if finite.size == 0 or centers.size == 0:
return 0.0
data_mid = (float(finite.min()) + float(finite.max())) / 2.0
if node_extent is not None:
grid_mid = (node_extent[0] + node_extent[1]) / 2.0
else:
grid_mid = (float(centers[0]) + float(centers[-1])) / 2.0
return grid_mid - data_mid
def _nearest_cell(centers: np.ndarray, value: float) -> int:
idx = int(np.searchsorted(centers, value))
if idx <= 0:
return 0
if idx >= centers.size:
return centers.size - 1
return (
idx
if abs(centers[idx] - value) < abs(centers[idx - 1] - value)
else idx - 1
)
def _grid3d_sections(
model: Any,
known_stations: Iterable[StationRecord] | None,
) -> tuple[list[StationRecord], dict[str, dict[str, Any]]]:
"""Slice one 2-D vertical curtain per survey line from a PCSF
``grid3d`` volume.
The same nearest-cell sampling
:func:`pycsamt.models.modem.section.station_curtain` already does
for a *live* ModEM folder — a single ModEM 3-D run inverts one
volume for a whole (possibly multi-line) survey, so a curtain has
to be sliced out per line, not read off a per-line file. Lines are
grouped the same way :func:`load_modem_lines` groups a live
folder's stations (:func:`group_modem_stations`, by
``known_stations`` line tags first, else a station-name-prefix
heuristic) whenever the file carries no explicit
``stations.line_id`` --
:func:`pycsamt.format.adapters.modem3d.modem3d_to_pcsf` does not
currently set one.
"""
geo = model.geometry
st = model.stations
known_by_id = _index_known_stations(known_stations)
if st.line_id:
groups: dict[str, list[str]] = {}
for name, line in zip(st.name, st.line_id):
groups.setdefault(str(line), []).append(str(name))
else:
groups = group_modem_stations(st.name, known_stations=known_stations)
name_to_idx = {str(name): i for i, name in enumerate(st.name)}
x_nodes = geo.x_nodes if geo.x_nodes is not None else geo.x
y_nodes = geo.y_nodes if geo.y_nodes is not None else geo.y
offset_x = _grid3d_offset(
(float(x_nodes[0]), float(x_nodes[-1])), geo.x, st.x
)
offset_y = _grid3d_offset(
(float(y_nodes[0]), float(y_nodes[-1])), geo.y, st.y
)
n_air = int(getattr(geo, "n_air", 0) or 0)
# Re-zero the earth's depth axis so 0 is the *top of the earth
# domain*, not the model top (matches
# :func:`pycsamt.models.modem.section.station_curtain`); the two
# differ by the air-layer thickness for an ``n_air > 0`` model.
cut = None
if geo.z_nodes is not None and len(geo.z_nodes) >= n_air + 2:
z_nodes_earth = np.asarray(geo.z_nodes[n_air:], dtype=float)
z_nodes_earth = z_nodes_earth - z_nodes_earth[0]
cut = _earth_cell_cut(z_nodes_earth)
z_earth = (z_nodes_earth[:-1] + z_nodes_earth[1:]) / 2.0
else:
z_earth = np.asarray(geo.z[n_air:], dtype=float)
z_earth = z_earth - float(z_earth[0]) if z_earth.size else z_earth
rho_earth = np.asarray(model.resistivity[n_air:, :, :], dtype=float)
if cut is not None and cut < z_earth.size:
# Drop the deep boundary-condition padding (no interpretable
# resolution; only bloats every 3-D view's vertical extent).
z_earth = z_earth[:cut]
rho_earth = rho_earth[:cut, :, :]
# Defensive: drop any residual above-topography air fill (very high
# ohm.m) a pre-mask .pcsf file still carries -- newer files already
# have nan here, so this is a no-op for them.
rho_earth = np.where(rho_earth > _AIR_FILL_THRESHOLD, np.nan, rho_earth)
# An ``n_air == 0`` ModEM model represents topography through a
# high-resistivity fill, not explicit air layers, so its ``z = 0`` is
# the flat model top and a station's real ground surface sits
# ``datum - elev`` below it (see :func:`_model_top_datum`).
# Re-reference every column to that station's own surface so the
# returned ``z`` axis is a true depth-below-surface -- otherwise a
# depth slice reads ~100 m shallower than its label and warps with
# topography. An ``n_air > 0`` model already carries topography in its
# own air layers, so the earth-top-relative axis above is already
# correct and no per-station shift is applied.
datum = None
if n_air == 0:
datum = _model_top_datum(
model, np.asarray(getattr(st, "z", []), dtype=float)
)
stations: list[StationRecord] = []
sections: dict[str, dict[str, Any]] = {}
for line_id, names in groups.items():
line_names: list[str] = []
elevs: list[float] = []
columns: list[np.ndarray] = []
for name in names:
i = name_to_idx.get(str(name))
if i is None:
continue
match = _lookup(known_by_id, name)
ix = _nearest_cell(geo.x, float(st.x[i]) + offset_x)
iy = _nearest_cell(geo.y, float(st.y[i]) + offset_y)
elev = float(st.z[i])
if not np.isfinite(elev) and match is not None:
elev = (
match.elevation
if match.elevation is not None
else np.nan
)
lon, lat = _resolve_pcsf_lonlat(st, i, match)
stations.append(
StationRecord(
id=str(name),
latitude=lat,
longitude=lon,
elevation=elev if np.isfinite(elev) else None,
line=str(line_id),
index=len(stations),
)
)
line_names.append(str(name))
elevs.append(elev)
columns.append(
_rezero_column(rho_earth[:, iy, ix], z_earth, datum, elev)
)
if not line_names:
continue
sections[str(line_id)] = {
"z": z_earth,
"rho": np.column_stack(columns),
"stations": np.array(line_names, dtype=object),
"elev": np.array(elevs, dtype=float),
}
return stations, sections
def _rezero_column(
col: np.ndarray,
z_earth: np.ndarray,
datum: float | None,
elev: float,
) -> np.ndarray:
"""Shift one resistivity column from model-top-referenced depth to
depth below *elev*'s own ground surface, resampled onto *z_earth*.
Interpolation is in ``log10`` (resistivity spans decades). Cells the
shift would pull from above the model top, or from deeper than the
model reaches, come back ``nan``. A no-op when there is no datum or
no real elevation.
"""
col = np.asarray(col, dtype=float)
if datum is None or not np.isfinite(elev):
return col
surf_below_top = float(datum) - float(elev)
if abs(surf_below_top) < 1e-6:
return col
# A cell at ``z_earth[k]`` below the earth top sits
# ``z_earth[k] - surf_below_top`` below *this* station's surface.
src_depth = z_earth - surf_below_top
good = np.isfinite(col) & (col > 0) & np.isfinite(src_depth)
if good.sum() < 2:
return np.full(z_earth.shape, np.nan)
log_out = np.interp(
z_earth,
src_depth[good],
np.log10(col[good]),
left=np.nan,
right=np.nan,
)
return np.power(10.0, log_out)
_MESH_DEFAULT_N_Z = 60
def _mesh_unstructured_sections(
model: Any,
known_stations: Iterable[StationRecord] | None,
n_z: int,
) -> tuple[list[StationRecord], dict[str, dict[str, Any]]]:
"""Slice one per-station curtain from a PCSF ``mesh_unstructured``
(MARE2DEM) mesh via point-location on its real triangulation.
Unlike ``grid2d``/``multiline``/``grid3d``, a mesh has no
rectilinear index to look up a station's nearest column by — a
triangle's own connectivity is the only structure there is, so a
curtain needs an actual point-in-triangle query at each
``(x_station, z_sample)`` location. :class:`matplotlib.tri.Triangulation`
(already a hard pycsamt dependency, no new install) builds that
query structure directly from the mesh's own real ``nodes``/
``connectivity`` — no re-triangulation, no approximation of the
mesh's own geometry.
``z_sample`` itself has no natural resolution to reuse (a mesh
carries no separate z axis the way a rectilinear grid does), so
*n_z* evenly spaced samples across the mesh's own node z-range are
used — the same kind of pragmatic, documented default the rest of
this format already makes rather than inventing an unbounded
resolution choice (cf. PCSM's own row-width cap).
A query point outside the mesh (above the mesh's shallowest
triangle at that x, or beyond its lateral/depth extent) returns
``nan`` for that depth, never a fabricated value.
"""
from matplotlib.tri import Triangulation
geo = model.geometry
st = model.stations
known_by_id = _index_known_stations(known_stations)
connectivity = np.asarray(geo.connectivity, dtype=np.int64)
nodes = np.asarray(geo.nodes, dtype=float)
resistivity = np.asarray(model.resistivity, dtype=float)
if resistivity.shape != (connectivity.shape[0],):
raise ValueError(
"load_pcsf_lines needs per-triangle mesh_unstructured "
f"resistivity (shape ({connectivity.shape[0]},)), got "
f"shape {resistivity.shape} -- expand resistivity_by_region "
"onto each triangle's region id first (every "
"pycsamt.format.adapters writer already does this)."
)
triangulation = Triangulation(
nodes[:, 0], nodes[:, 1], triangles=connectivity
)
trifinder = triangulation.get_trifinder()
z_samples = np.linspace(
float(nodes[:, 1].min()), float(nodes[:, 1].max()), n_z
)
if st.line_id:
groups: dict[str, list[str]] = {}
for name, line in zip(st.name, st.line_id):
groups.setdefault(str(line), []).append(str(name))
else:
groups = {"line1": [str(name) for name in st.name]}
name_to_idx = {str(name): i for i, name in enumerate(st.name)}
stations: list[StationRecord] = []
sections: dict[str, dict[str, Any]] = {}
for line_id, names in groups.items():
line_names: list[str] = []
elevs: list[float] = []
columns: list[np.ndarray] = []
for name in names:
i = name_to_idx.get(str(name))
if i is None:
continue
match = _lookup(known_by_id, name)
x_station = float(st.x[i])
tri_idx = trifinder(np.full(n_z, x_station), z_samples)
column = np.full(n_z, np.nan)
inside = tri_idx >= 0
column[inside] = resistivity[tri_idx[inside]]
elev = float(st.z[i])
if not np.isfinite(elev) and match is not None:
elev = (
match.elevation
if match.elevation is not None
else np.nan
)
lon, lat = _resolve_pcsf_lonlat(st, i, match)
stations.append(
StationRecord(
id=str(name),
latitude=lat,
longitude=lon,
elevation=elev if np.isfinite(elev) else None,
line=str(line_id),
index=len(stations),
)
)
line_names.append(str(name))
elevs.append(elev)
columns.append(column)
if not line_names:
continue
sections[str(line_id)] = {
"z": z_samples,
"rho": np.column_stack(columns),
"stations": np.array(line_names, dtype=object),
"elev": np.array(elevs, dtype=float),
}
return stations, sections
[docs]
def load_modem_lines(
folder: str | Path,
*,
known_stations: Iterable[StationRecord] | None = None,
fetch_elevation: bool = True,
verbose: int = 0,
) -> MapData:
"""Load a ModEM 3-D inversion result folder as a multi-line MapData.
Parameters
----------
folder : path-like
A ModEM output directory. The matching final-iteration
``.rho``/``.dat`` pair is auto-detected by
:class:`pycsamt.models.modem.results.InversionResult`.
known_stations : iterable of StationRecord, optional
Previously-loaded EDI stations (e.g. ``existing_map_data.stations``)
used to geo-reference and group ModEM stations by real
coordinates/line name — see the module docstring.
fetch_elevation : bool, default True
ModEM output carries no real elevation (unlike EDI, where
it's already in the file header, so "Drape topography" just
works). When ``True``, any station still missing an
elevation after ``known_stations`` matching gets a
best-effort online lookup (Open-Meteo) so topography isn't
silently flat by default. Failures (offline, API error, …)
are swallowed — elevation simply stays unset, same as
passing ``False``.
verbose : int, default 0
Verbosity forwarded to the ModEM readers.
Returns
-------
MapData
``sites=None`` (no EDI backing); ``stations`` carries one
:class:`StationRecord` per ModEM station with coordinates
resolved where possible; ``metadata["sections"]`` carries the
precomputed per-line ``(x, z, rho)`` curtains consumed
directly by :mod:`pycsamt.map.volume`.
"""
from pycsamt.models.modem.results import InversionResult
from pycsamt.models.modem.section import station_curtain
result = InversionResult(str(folder), verbose=verbose)
model = result.model_final
data = result.data_obs
if model is None or data is None or not data.site_names:
msg = f"No usable ModEM model/data found under {folder!r}"
raise ValueError(msg)
known_by_id = _index_known_stations(known_stations)
groups = group_modem_stations(
data.site_names, known_stations=known_stations
)
stations: list[StationRecord] = []
sections: dict[str, dict[str, Any]] = {}
for idx, (line, names) in enumerate(groups.items()):
curtain = station_curtain(model, data, names)
if not curtain.station_names:
continue
elevs: list[float] = []
for name in curtain.station_names:
match = _lookup(known_by_id, name)
lon, lat = _resolve_lonlat(name, data, match)
elev = match.elevation if match is not None else None
stations.append(
StationRecord(
id=name,
latitude=lat,
longitude=lon,
elevation=elev,
line=str(line),
index=idx,
)
)
elevs.append(elev if elev is not None else np.nan)
sections[str(line)] = {
"z": curtain.z,
"rho": curtain.rho,
"stations": np.array(curtain.station_names, dtype=object),
"elev": np.array(elevs, dtype=float),
}
metadata = {
"source": "modem",
"workdir": str(folder),
"sections": sections,
"rms": result.final_rms,
}
data = MapData(sites=None, stations=tuple(stations), metadata=metadata)
if fetch_elevation:
data = _try_fetch_elevations(data)
return data
[docs]
def load_pcsf_lines(
path: str | Path,
*,
known_stations: Iterable[StationRecord] | None = None,
fetch_elevation: bool = True,
verbose: int = 0,
mesh_z_samples: int = _MESH_DEFAULT_N_Z,
) -> MapData:
"""Load a backend-neutral ``.pcsf``/``.pcsm``/``.pcsm.gz`` file as a
multi-line MapData.
Phase 7 of the PCSF format plan: the same route
:func:`load_modem_lines` already established, but working from any
backend's converted ``grid2d``/``multiline`` PCSF file instead of a
live ModEM folder — the view layer here carries no
Occam2D/ModEM/MARE2DEM-specific logic, only PCSF's own schema.
Both encodings of the format are accepted transparently — see
:func:`pycsamt.format.read_pcsf_or_pcsm` — since PCSM is a
lossless ASCII projection of the exact same in-memory model.
``pycsamt.map.volume``'s 3-D builders need one real ``(x, z, rho)``
curtain *per named station*. For ``grid2d``/``multiline``, a PCSF
file's own dense mesh column at each real station's along-profile
position is extracted (the nearest-column convention
:meth:`pycsamt.interp._base.ResistivityModel.column_nearest` also
uses) — the full mesh resolution between stations is not carried
into ``MapData``, the same simplification :func:`load_modem_lines`
already makes via :func:`pycsamt.models.modem.section.station_curtain`.
For ``grid3d`` (native ModEM 3-D), the same nearest-cell sampling
``station_curtain`` uses for a *live* ModEM folder is applied here
to the PCSF file's own volume instead — see :func:`_grid3d_sections`.
For ``mesh_unstructured`` (MARE2DEM), a station's column instead
comes from point-location on the mesh's real triangulation (which
triangle contains a given ``(x, z)`` query point) — a genuinely
different algorithm from the other three kinds' index lookups,
since a triangular mesh carries no rectilinear index to begin with
— see :func:`_mesh_unstructured_sections`.
Parameters
----------
path : path-like
A ``.pcsf``, ``.pcsm``, or ``.pcsm.gz`` file with
``geometry.kind`` ``"grid2d"``, ``"multiline"``, ``"grid3d"``,
or ``"mesh_unstructured"``, and a populated
:attr:`PCSFModel.stations` table.
known_stations : iterable of StationRecord, optional
Previously-loaded EDI stations, used the same way
:func:`load_modem_lines` uses them: when a station matches one
here, its ``line``/elevation/lon/lat take priority. Real
geo-referencing does not strictly require this, though --
``StationTable``'s own ``lon``/``lat`` (populated by
``occam2d_to_pcsf(station_lonlat=...)``,
``modem3d_to_pcsf``'s ``GG_Lat``/``GG_Lon`` passthrough, or
``build_multiline_pcsf``'s ``sta_lat``/``sta_lon``), when
present, are used as the fallback source -- only an
unmatched station in a file with neither falls back to
synthetic line spacing.
fetch_elevation : bool, default True
Best-effort online elevation lookup for any station still
missing one after ``known_stations`` matching (see
:func:`load_modem_lines`).
verbose : int, default 0
Unused today; accepted for signature parity with
:func:`load_modem_lines`.
mesh_z_samples : int, default 60
``mesh_unstructured`` only: number of evenly spaced depth
samples across the mesh's own node z-range to query per
station. A triangular mesh carries no separate z axis to reuse
the way a rectilinear grid does, so this is a pragmatic,
overridable resolution choice, not a property of the file
itself. Ignored for every other geometry kind.
Returns
-------
MapData
``sites=None``; ``metadata["sections"]`` carries one precomputed
curtain per line, consumed directly by :mod:`pycsamt.map.volume`.
Raises
------
ValueError
If the file has no station table, or (``mesh_unstructured``
only) its resistivity is the compact per-region form rather
than already expanded onto each triangle.
"""
from pycsamt.format import read_pcsf_or_pcsm
model = read_pcsf_or_pcsm(path)
if model.stations is None or not list(model.stations.name):
raise ValueError(
f"{path}: PCSF file has no station table -- MapView needs "
"named stations to build 2-D curtains."
)
if model.kind == "grid3d":
stations, sections = _grid3d_sections(model, known_stations)
elif model.kind == "mesh_unstructured":
stations, sections = _mesh_unstructured_sections(
model, known_stations, mesh_z_samples
)
else:
if model.kind == "grid2d":
line_specs = [
(
"line1",
model.geometry.x,
model.geometry.z,
model.resistivity,
)
]
else:
line_specs = [
(
line.line_id,
line.geometry.x,
line.geometry.z,
line.resistivity,
)
for line in model.geometry.lines
]
st = model.stations
station_by_line: dict[str, list[int]] = {}
for i in range(len(st.name)):
line_id = st.line_id[i] if st.line_id else "line1"
station_by_line.setdefault(str(line_id), []).append(i)
known_by_id = _index_known_stations(known_stations)
stations = []
sections = {}
for line_id, x_geo, z_geo, rho in line_specs:
idxs = station_by_line.get(str(line_id), [])
if not idxs:
continue
names: list[str] = []
elevs: list[float] = []
columns: list[np.ndarray] = []
for i in idxs:
name = str(st.name[i])
match = _lookup(known_by_id, name)
col_idx = int(np.argmin(np.abs(x_geo - st.x[i])))
elev = float(st.z[i])
if not np.isfinite(elev) and match is not None:
elev = (
match.elevation
if match.elevation is not None
else np.nan
)
lon, lat = _resolve_pcsf_lonlat(st, i, match)
stations.append(
StationRecord(
id=name,
latitude=lat,
longitude=lon,
elevation=elev if np.isfinite(elev) else None,
line=str(line_id),
index=len(stations),
)
)
names.append(name)
elevs.append(elev)
columns.append(rho[:, col_idx])
sections[str(line_id)] = {
"z": np.asarray(z_geo, dtype=float),
"rho": np.column_stack(columns),
"stations": np.array(names, dtype=object),
"elev": np.array(elevs, dtype=float),
}
metadata = {
"source": "pcsf",
"path": str(path),
"sections": sections,
"rms": (model.metadata or {}).get("final_rms"),
}
data = MapData(sites=None, stations=tuple(stations), metadata=metadata)
if fetch_elevation:
data = _try_fetch_elevations(data)
return data
def _try_fetch_elevations(data: MapData) -> MapData:
"""Best-effort online elevation fetch for stations missing one.
Only touches stations that don't already have an elevation (e.g.
from ``known_stations`` matching), so a real, previously-resolved
value is never overwritten by a coarser online lookup.
"""
from .topo import apply_elevations, fetch_elevations
missing = tuple(s for s in data.stations if s.elevation is None)
if not missing:
return data
probe = MapData(sites=None, stations=missing)
try:
elev_map = fetch_elevations(probe)
except Exception: # noqa: BLE001 - never let a network hiccup break the import
return data
if not elev_map:
return data
return apply_elevations(data, elev_map)
def _resolve_lonlat(
name: str,
data: Any,
known_match: StationRecord | None,
) -> tuple[float | None, float | None]:
if (
known_match is not None
and known_match.longitude is not None
and known_match.latitude is not None
):
return known_match.longitude, known_match.latitude
lonlat = data.site_lonlat.get(name)
if lonlat is not None:
return float(lonlat[0]), float(lonlat[1])
return None, None
def _resolve_pcsf_lonlat(
st: Any,
i: int,
known_match: StationRecord | None,
) -> tuple[float | None, float | None]:
"""Same priority :func:`_resolve_lonlat` uses for a live ModEM
folder's ``site_lonlat`` -- ``known_stations`` first (lets an
already-geo-located EDI survey override/enrich), else the PCSF
file's own ``StationTable.lon``/``.lat`` (populated by
``occam2d_to_pcsf``'s ``station_lonlat``,
``modem3d_to_pcsf``'s own ``GG_Lat``/``GG_Lon`` passthrough, or
``build_multiline_pcsf``'s ``sta_lat``/``sta_lon``), else unknown.
"""
if (
known_match is not None
and known_match.longitude is not None
and known_match.latitude is not None
):
return known_match.longitude, known_match.latitude
if st.lon is not None and st.lat is not None:
lon, lat = float(st.lon[i]), float(st.lat[i])
if np.isfinite(lon) and np.isfinite(lat):
return lon, lat
return None, None