# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
# ruff: noqa: E501
"""High-level :class:`MapView` session façade for pyCSAMT maps.
``MapView`` is the single object scripts, notebooks, and the map-view
platform share. It holds one combined :class:`~pycsamt.map.MapData`
(possibly spanning many lines) and renders every view through the
existing builders, so the GUI carries no plotting logic of its own.
Examples
--------
>>> from pycsamt.map import MapView
>>> mv = MapView.from_folder("data/survey", detect="folder")
>>> mv.lines # doctest: +SKIP
('L18PLT', 'L22PLT')
>>> fig = mv.station(overlay="rho", frequency=1024) # doctest: +SKIP
>>> mv.export("out/station.html") # doctest: +SKIP
"""
from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from typing import Any
from ._core import MapData, ensure_map_data, station_dataframe
from .config import (
ExportOptions,
ProfileMapOptions,
StationMapOptions,
VolumeMapOptions,
)
from .export import export_figure
from .loader import load_lines
from .profile import build_profile_map, build_pseudosection
from .station import build_station_map
from .volume import build_3d_map
__all__ = [
"MapView",
"launch_app",
"launch_mapview",
"open_app",
]
_VIEW_BUILDERS = {
"station": "station",
"profile": "profile",
"pseudosection": "pseudosection",
"map3d": "map3d",
}
[docs]
class MapView:
"""A code-first session bound to one (multi-line) survey."""
def __init__(
self,
data: Any,
*,
theme: str = "light",
backend: str = "plotly",
) -> None:
self.data: MapData = ensure_map_data(data)
self.theme = theme
self.backend = backend
# ── constructors ───────────────────────────────────
[docs]
@classmethod
def from_folder(
cls,
path: str | Path,
*,
detect: str = "folder",
recursive: bool = True,
theme: str = "light",
backend: str = "plotly",
**load_kwargs: Any,
) -> MapView:
"""Build a view from a directory of one or more lines."""
data = load_lines(
path,
detect=detect,
recursive=recursive,
**load_kwargs,
)
return cls(data, theme=theme, backend=backend)
[docs]
@classmethod
def from_lines(
cls,
lines: Any,
*,
theme: str = "light",
backend: str = "plotly",
**load_kwargs: Any,
) -> MapView:
"""Build a view from an explicit ``{line: source}`` mapping."""
data = load_lines(lines, **load_kwargs)
return cls(data, theme=theme, backend=backend)
[docs]
@classmethod
def from_inversion_results(
cls,
folder: str | Path,
*,
known_stations: Any = None,
fetch_elevation: bool = True,
theme: str = "light",
backend: str = "plotly",
verbose: int = 0,
) -> MapView:
"""Build a view from a ModEM 3-D inversion result folder.
See :func:`pycsamt.map.inversion.load_modem_lines`. Pass
``known_stations=existing_view.data.stations`` to geo-reference
and group ModEM stations using a previously-loaded EDI survey.
Pass ``fetch_elevation=False`` to skip the best-effort online
elevation lookup (e.g. offline use, or large surveys where the
extra round-trip isn't wanted).
"""
from .inversion import load_modem_lines
data = load_modem_lines(
folder,
known_stations=known_stations,
fetch_elevation=fetch_elevation,
verbose=verbose,
)
return cls(data, theme=theme, backend=backend)
# ── survey introspection ───────────────────────────
[docs]
@property
def lines(self) -> tuple[str, ...]:
"""Return the profile/line names in this survey."""
return self.data.lines
[docs]
@property
def stations(self) -> tuple[str, ...]:
"""Return the station IDs in this survey."""
return self.data.station_ids
[docs]
@property
def n_stations(self) -> int:
"""Return the number of stations across all lines."""
return len(self.data.stations)
[docs]
@property
def has_geo(self) -> bool:
"""Whether stations carry finite map coordinates."""
return self.data.has_geo
[docs]
def table(self):
"""Return survey stations as a :class:`pandas.DataFrame`."""
return station_dataframe(self.data)
# ── topography / elevation ─────────────────────────
[docs]
def with_elevations(self, elev_map: dict[str, float]) -> MapView:
"""Return a copy with station elevations overridden by *elev_map*."""
from .topo import apply_elevations
new = object.__new__(MapView)
new.data = apply_elevations(self.data, elev_map)
new.theme = self.theme
new.backend = self.backend
return new
[docs]
def fetch_elevations(
self,
*,
api_name: str = "open_meteo",
) -> dict[str, float]:
"""Fetch station elevations online (see :func:`pycsamt.map.fetch_elevations`)."""
from .topo import fetch_elevations
return fetch_elevations(self.data, api_name=api_name)
[docs]
def export_topography(
self,
path: str | Path,
*,
fmt: str | None = None,
):
"""Export this survey's station id/elevation/coordinates.
Useful for an EDI-sourced view: a ModEM (or Occam2D/MARE2DEM)
inversion result carries no real elevation of its own, so
exporting it here produces a small, portable file that can
be re-applied later — even in a session that never reloads
these EDIs — via the "Upload file" elevation source (see
:func:`pycsamt.map.topo.export_elevations`).
"""
from .topo import export_elevations
return export_elevations(self.data, path, fmt=fmt)
# ── view renderers ─────────────────────────────────
[docs]
def station(
self,
*,
options: StationMapOptions | None = None,
**overrides: Any,
) -> Any:
"""Build a 2-D station map figure."""
opts = self._station_options(options, overrides)
return build_station_map(self.data, opts)
[docs]
def profile(
self,
*,
options: ProfileMapOptions | None = None,
**overrides: Any,
) -> Any:
"""Build a profile-view figure."""
opts = self._profile_options(options, overrides)
return build_profile_map(self.data, opts)
[docs]
def pseudosection(
self,
*,
options: ProfileMapOptions | None = None,
**overrides: Any,
) -> Any:
"""Build a resistivity/phase pseudosection figure."""
opts = self._profile_options(options, overrides)
return build_pseudosection(self.data, opts)
[docs]
def map3d(
self,
*,
mode: str = "fence",
options: VolumeMapOptions | None = None,
**overrides: Any,
) -> Any:
"""Build a 3-D fence/block/depth/surface figure."""
opts = self._volume_options(mode, options, overrides)
return build_3d_map(self.data, opts)
# ── export ─────────────────────────────────────────
[docs]
def export(
self,
path: str | Path,
*,
view: str = "station",
fmt: str | None = None,
scale: float = 2.0,
width: int | None = None,
height: int | None = None,
**overrides: Any,
) -> Path:
"""Render *view* and write it to *path*.
The format is inferred from the suffix unless *fmt* is given;
``.html``, ``.png``, ``.json`` and Plotly static images are
supported (see :func:`pycsamt.map.export_figure`).
"""
fig = self.figure(view, **overrides)
options = ExportOptions(
path=path,
format=fmt,
width=width,
height=height,
scale=scale,
)
return export_figure(fig, options)
[docs]
def export_all(
self,
directory: str | Path,
*,
fmt: str = "html",
views: tuple[str, ...] = (
"station",
"pseudosection",
"map3d",
),
**overrides: Any,
) -> dict[str, Path]:
"""Export several views into *directory*, keyed by view name."""
out_dir = Path(directory)
out_dir.mkdir(parents=True, exist_ok=True)
written: dict[str, Path] = {}
for view in views:
target = out_dir / f"{view}.{fmt.lstrip('.')}"
written[view] = self.export(
target,
view=view,
fmt=fmt,
**overrides,
)
return written
# ── platform launch ────────────────────────────────
[docs]
def launch(self, **kwargs: Any) -> None:
"""Open this survey in the map-view platform.
Requires the optional Dash GUI extra
(``pycsamt.app.mapview``).
"""
try:
from pycsamt.app.mapview import launch as _launch
except ImportError as exc: # pragma: no cover - GUI extra
raise ImportError(
"The map-view platform requires the GUI "
"dependencies (dash, dash-bootstrap-components). "
"Install pycsamt with its app extras."
) from exc
_launch(view=self, **kwargs)
# ── option assembly ────────────────────────────────
def _station_options(
self,
options: StationMapOptions | None,
overrides: dict[str, Any],
) -> StationMapOptions:
base = options or StationMapOptions(
theme=self.theme,
backend=self.backend,
)
return replace(base, **overrides) if overrides else base
def _profile_options(
self,
options: ProfileMapOptions | None,
overrides: dict[str, Any],
) -> ProfileMapOptions:
base = options or ProfileMapOptions(
theme=self.theme,
backend=self.backend,
)
return replace(base, **overrides) if overrides else base
def _volume_options(
self,
mode: str,
options: VolumeMapOptions | None,
overrides: dict[str, Any],
) -> VolumeMapOptions:
base = options or VolumeMapOptions(
mode=mode, # type: ignore[arg-type]
theme=self.theme,
)
if options is not None and "mode" not in overrides:
overrides = {**overrides, "mode": mode}
return replace(base, **overrides) if overrides else base
def __repr__(self) -> str:
return (
f"MapView(lines={len(self.lines)}, "
f"stations={self.n_stations}, "
f"geo={self.has_geo})"
)
# Imported at module end to avoid a circular import with ._app.
from ._app import launch_app, launch_mapview, open_app # noqa: E402