# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""Generic point-cloud extraction from any :class:`PCSFModel` geometry kind.
Phase 7 of the PCSF format plan needs a 3-D view that works the same
way regardless of which backend produced the file — "no backend-
specific glue code in the view layer" is the phase's own definition of
done. :func:`pcsf_to_point_cloud` is that one shared extraction: it
knows how to turn each of the four geometry kinds
(:data:`~pycsamt.format.schema.GEOMETRY_KINDS`) into a flat
``(x, y, z, log10_rho)`` point cloud, so a view (the desktop 3-D panel
in this phase, potentially others later) only has to know how to
scatter-plot points, never how Occam2D/ModEM/MARE2DEM/a multiline
stack each store their own geometry.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from .schema import PCSFModel
__all__ = ["PointCloud", "pcsf_to_point_cloud"]
[docs]
@dataclass(frozen=True)
class PointCloud:
"""Flat point cloud ready for a 3-D scatter view.
Attributes
----------
x, y, z : ndarray, shape (n,)
Position, metres. ``z`` is elevation-like (positive up) —
callers plotting depth sections see negative values below the
surface, matching the sign convention already used by
``pycsamt.app.web.callbacks.map3d``'s own 3-D views.
value : ndarray, shape (n,)
:math:`\\log_{10}(\\rho / \\Omega\\mathrm{m})`.
label : str
Short description of what was plotted (geometry kind + any
subsampling applied), for a status bar / axis title.
"""
x: np.ndarray
y: np.ndarray
z: np.ndarray
value: np.ndarray
label: str
def _log10_rho(resistivity: np.ndarray) -> np.ndarray:
return np.log10(np.clip(np.asarray(resistivity, dtype=float), 1e-3, None))
def _finite_mask(*arrays: np.ndarray) -> np.ndarray:
mask = np.ones(arrays[0].shape, dtype=bool)
for arr in arrays:
mask &= np.isfinite(arr)
return mask
def _subsample(
x: np.ndarray,
y: np.ndarray,
z: np.ndarray,
value: np.ndarray,
max_points: int,
seed: int,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, bool]:
n = x.size
if n <= max_points:
return x, y, z, value, False
rng = np.random.default_rng(seed)
idx = rng.choice(n, size=max_points, replace=False)
idx.sort()
return x[idx], y[idx], z[idx], value[idx], True
def _grid2d_points(model: PCSFModel) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
geo = model.geometry
x2d, z2d = np.meshgrid(geo.x, geo.z) # both (n_z, n_x)
y2d = np.zeros_like(x2d)
value2d = _log10_rho(model.resistivity)
return x2d.ravel(), y2d.ravel(), -z2d.ravel(), value2d.ravel()
def _grid3d_points(model: PCSFModel) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
geo = model.geometry
# resistivity is (n_z, n_y, n_x); build matching (n_z, n_y, n_x) coord
# grids directly rather than transposing, so no axis-order mistake
# can creep in between the two.
z3d, y3d, x3d = np.meshgrid(geo.z, geo.y, geo.x, indexing="ij")
value3d = _log10_rho(model.resistivity)
return x3d.ravel(), y3d.ravel(), -z3d.ravel(), value3d.ravel()
def _mesh_unstructured_points(
model: PCSFModel,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
geo = model.geometry
centroids = geo.nodes[geo.connectivity].mean(axis=1) # (n_tri, 2)
if model.resistivity.shape[0] != geo.connectivity.shape[0]:
# PCSFModel.validate() also accepts the compact, region-count
# form of resistivity for mesh_unstructured (shape (n_regions,));
# a point per triangle needs the per-cell expansion instead —
# every adapter in this package already produces that (e.g.
# mare2dem_to_pcsf's resistivity_by_region field carries the
# compact table separately), so this only fires for a
# hand-built model that skipped the expansion.
raise ValueError(
"pcsf_to_point_cloud needs per-cell mesh_unstructured "
f"resistivity (shape ({geo.connectivity.shape[0]},)), got "
f"shape {model.resistivity.shape} — expand "
"resistivity_by_region onto each triangle's region id first."
)
value = _log10_rho(model.resistivity)
# A MARE2DEM-style mesh lives in one vertical (x, z) plane; y is a
# single cross-strike position (0.0 -- there is no second line to
# offset against for a lone unstructured mesh).
x = centroids[:, 0]
z = centroids[:, 1]
y = np.zeros_like(x)
return x, y, -z, value
def _multiline_points(
model: PCSFModel,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
xs, ys, zs, values = [], [], [], []
for line in model.geometry.lines:
x2d, z2d = np.meshgrid(line.geometry.x, line.geometry.z)
y2d = np.full_like(x2d, line.offset_y)
value2d = _log10_rho(line.resistivity)
xs.append(x2d.ravel())
ys.append(y2d.ravel())
zs.append(-z2d.ravel())
values.append(value2d.ravel())
return (
np.concatenate(xs),
np.concatenate(ys),
np.concatenate(zs),
np.concatenate(values),
)
_EXTRACTORS = {
"grid2d": _grid2d_points,
"grid3d": _grid3d_points,
"mesh_unstructured": _mesh_unstructured_points,
"multiline": _multiline_points,
}
[docs]
def pcsf_to_point_cloud(
model: PCSFModel,
*,
max_points: int = 200_000,
seed: int = 0,
) -> PointCloud:
"""Flatten any :class:`PCSFModel` geometry into one 3-D point cloud.
Parameters
----------
model : PCSFModel
Any geometry kind.
max_points : int, default 200_000
Random (seeded, reproducible) subsample cap — a native
``grid3d``/``mesh_unstructured`` model can carry hundreds of
thousands of cells, too many for an interactive scatter plot.
seed : int, default 0
Subsampling RNG seed, for a reproducible view across renders.
Returns
-------
PointCloud
Non-finite values (masked cells, log of non-positive
resistivity) are dropped, not zeroed.
Raises
------
ValueError
If ``model.geometry.kind`` is not one of
:data:`~pycsamt.format.schema.GEOMETRY_KINDS`.
Examples
--------
>>> import numpy as np
>>> from pycsamt.format import Grid2DGeometry, PCSFModel
>>> from pycsamt.format.pointcloud import pcsf_to_point_cloud
>>> geometry = Grid2DGeometry(x=np.array([0.0, 100.0]), z=np.array([10.0, 50.0]))
>>> model = PCSFModel(geometry=geometry, resistivity=np.array([[100.0, 110.0], [50.0, 55.0]]))
>>> cloud = pcsf_to_point_cloud(model)
>>> cloud.x.shape
(4,)
"""
kind = model.geometry.kind
extractor = _EXTRACTORS.get(kind)
if extractor is None:
raise ValueError(
f"unsupported geometry kind {kind!r}; expected one of "
f"{tuple(_EXTRACTORS)}"
)
x, y, z, value = extractor(model)
keep = _finite_mask(x, y, z, value)
x, y, z, value = x[keep], y[keep], z[keep], value[keep]
x, y, z, value, subsampled = _subsample(x, y, z, value, max_points, seed)
label = f"{kind} ({x.size} points{', subsampled' if subsampled else ''})"
return PointCloud(x=x, y=y, z=z, value=value, label=label)