Source code for PyHydroGeophysX.visualization.axis_units

"""Length units and the vertical axis of a section: metres or feet, elevation or depth.

Every model in the package is computed in metres, and it stays in metres: the
meshes, the contour grids, the clip polygons and every exported file. What this
module changes is only what an axis *shows*. A figure drawn "in feet" keeps its
data in metres and gets a tick locator and formatter that place round numbers
of feet along the axis, so a pyGIMLi mesh drawing, a filled contour and a clip
path all line up exactly as before - nothing is re-projected.

The same mechanism turns the vertical axis of a section into depth. A survey
read without elevations puts every electrode at z = 0 and the model below it at
negative z, and labelling that axis "Elevation" presents a made-up datum as a
measured one. When the ground surface is flat at zero, :func:`set_section_axes`
labels the axis "Depth" and shows the values positive downward, which is what
pyGIMLi itself does for such a mesh. A section with real topography keeps
"Elevation".

The unit is a package-wide preference, like a matplotlib rcParam, so one choice
reaches every figure: :func:`set_length_unit` sets it, :func:`length_unit`
changes it for a block, and every plotting function also takes ``length_unit``
to override it for one call.

Examples
--------
>>> from PyHydroGeophysX.visualization.axis_units import length_label, length_unit
>>> length_label("Distance")
'Distance (m)'
>>> with length_unit("feet"):
...     length_label("Distance")
'Distance (ft)'
"""

from __future__ import annotations

from contextlib import contextmanager
from typing import Any, Iterator, Optional, Tuple

import numpy as np

__all__ = [
    "FEET_PER_METRE",
    "LENGTH_UNITS",
    "normalize_length_unit",
    "get_length_unit",
    "set_length_unit",
    "length_unit",
    "length_factor",
    "to_display_length",
    "length_label",
    "section_has_elevation",
    "resolve_vertical",
    "vertical_axis",
    "set_length_axis",
    "set_section_axes",
    "section_label_pair",
    "pyqtgraph_length_axis",
]

#: International foot: exactly 0.3048 m.
FEET_PER_METRE = 1.0 / 0.3048

#: The units an axis can be shown in.
LENGTH_UNITS: Tuple[str, ...] = ("m", "ft")

_ALIASES = {
    "m": "m", "meter": "m", "meters": "m", "metre": "m", "metres": "m",
    "ft": "ft", "feet": "ft", "foot": "ft",
}

_FACTORS = {"m": 1.0, "ft": FEET_PER_METRE}

_VERTICAL = ("auto", "elevation", "depth")

_default_unit = "m"


[docs] def normalize_length_unit(unit: Optional[str] = None) -> str: """``'m'`` or ``'ft'`` for any spelling of either; None is the current default. Examples -------- >>> normalize_length_unit("Feet") 'ft' >>> normalize_length_unit(" metres ") 'm' """ if unit is None: return _default_unit key = str(unit).strip().lower() if key not in _ALIASES: raise ValueError( f"Unknown length unit {unit!r}; use one of {', '.join(LENGTH_UNITS)}.") return _ALIASES[key]
[docs] def get_length_unit() -> str: """The unit figures are drawn in when a call does not name one.""" return _default_unit
[docs] def set_length_unit(unit: str) -> str: """Draw every figure in ``unit`` from now on; returns the previous unit.""" global _default_unit previous = _default_unit _default_unit = normalize_length_unit(unit) return previous
[docs] @contextmanager def length_unit(unit: Optional[str]) -> Iterator[str]: """Draw in ``unit`` inside the block, then go back. None leaves it as it is.""" if unit is None: yield _default_unit return previous = set_length_unit(unit) try: yield _default_unit finally: set_length_unit(previous)
[docs] def length_factor(unit: Optional[str] = None) -> float: """Displayed units per metre: 1 for metres, 3.28084 for feet.""" return _FACTORS[normalize_length_unit(unit)]
[docs] def to_display_length(values: Any, unit: Optional[str] = None) -> Any: """A length in metres - scalar or array - converted to ``unit``. For numbers written into titles and annotations, which no axis formatter reaches. Examples -------- >>> round(to_display_length(10.0, "ft"), 3) 32.808 """ factor = length_factor(unit) if np.isscalar(values): return float(values) * factor return np.asarray(values, dtype=float) * factor
[docs] def length_label(name: str, unit: Optional[str] = None) -> str: """``'Distance (ft)'``: an axis name with the unit it is shown in.""" return f"{name} ({normalize_length_unit(unit)})"
# --------------------------------------------------------------------------- # Elevation or depth # --------------------------------------------------------------------------- def _surface_z(mesh: Any = None, surface: Any = None, z: Any = None) -> Optional[np.ndarray]: """Ground-surface elevations from whichever of the three the caller has.""" if surface is not None: arr = np.asarray(surface[1] if isinstance(surface, tuple) else surface, dtype=float) if arr.ndim == 2 and arr.shape[1] >= 2: arr = arr[:, 1] return arr.ravel() if mesh is not None: try: from PyHydroGeophysX.core.section_geometry import surface_line return np.asarray(surface_line(mesh)[1], dtype=float) except Exception: # noqa: BLE001 - an unreadable mesh keeps "Elevation" return None if z is not None: # Only the section's coordinates: the top of them is all that says where # the ground is. arr = np.asarray(z, dtype=float).ravel() arr = arr[np.isfinite(arr)] return arr[[int(np.argmax(arr))]] if arr.size else None return None
[docs] def section_has_elevation(mesh: Any = None, *, surface: Any = None, z: Any = None) -> bool: """Whether a section's vertical coordinate is a real elevation. False when the ground surface is flat at zero - the geometry a survey gets when it was read without elevations. Give the ``mesh``, the ``surface`` (``(x, z)`` tuple, ``(n, 2)`` array or the surface z values) or just the section's ``z`` coordinates, whose top is then taken as the surface. When none of them can be read the answer is True, which keeps the label a section has always had. Examples -------- >>> section_has_elevation(surface=[0.0, 0.0, 0.0]) False >>> section_has_elevation(surface=[312.4, 310.9, 309.0]) True >>> section_has_elevation(z=[0.0, -5.0, -20.0]) False """ return _has_elevation(_surface_z(mesh, surface, z))
def _has_elevation(top: Optional[np.ndarray]) -> bool: if top is None: return True top = top[np.isfinite(top)] if top.size == 0: return True return not bool(np.all(np.abs(top) <= 1.0e-6)) def _check_vertical(vertical: Optional[str]) -> str: mode = str(vertical or "auto").strip().lower() if mode not in _VERTICAL: raise ValueError(f"vertical must be one of {', '.join(_VERTICAL)}, not {vertical!r}.") return mode
[docs] def resolve_vertical(vertical: str = "auto", *, mesh: Any = None, surface: Any = None, z: Any = None) -> str: """``'elevation'`` or ``'depth'``: what the vertical axis of a section shows. ``'auto'`` gives depth when :func:`section_has_elevation` says the section has no elevation, and elevation otherwise. """ return vertical_axis(vertical, mesh=mesh, surface=surface, z=z)[0]
[docs] def vertical_axis(vertical: str = "auto", *, mesh: Any = None, surface: Any = None, z: Any = None) -> Tuple[str, Optional[float]]: """``(mode, depth_reference)`` for a section's vertical axis. ``mode`` is as :func:`resolve_vertical` decides it. For depth, ``depth_reference`` is the level depth is measured down from - the top of the ground surface, 0 when it cannot be read; for elevation it is None. The surface is read once, so a view can keep the pair and pass it back to :func:`set_section_axes` on every redraw. Examples -------- >>> vertical_axis(z=[0.0, -4.0, -12.0]) ('depth', 0.0) >>> vertical_axis(surface=[102.0, 101.5]) ('elevation', None) """ mode = _check_vertical(vertical) top = None if mode != "elevation": top = _surface_z(mesh, surface, z) if mode == "auto": mode = "elevation" if _has_elevation(top) else "depth" if mode == "elevation": return mode, None finite = None if top is None else top[np.isfinite(top)] return mode, float(np.max(finite)) if finite is not None and finite.size else 0.0
# --------------------------------------------------------------------------- # Axis ticks shown in another unit or as depth # --------------------------------------------------------------------------- def _transforms(factor: float, depth_reference: Optional[float]): """``(to_display, from_display)`` between data metres and what the axis shows.""" if depth_reference is None: return (lambda v: np.asarray(v, dtype=float) * factor, lambda d: np.asarray(d, dtype=float) / factor) ref = float(depth_reference) return (lambda v: (ref - np.asarray(v, dtype=float)) * factor, lambda d: ref - np.asarray(d, dtype=float) / factor) def _display_ticker(to_display, from_display): """A matplotlib locator and formatter that tick in displayed units. Built on first use so that importing this module never imports matplotlib. """ from matplotlib.ticker import Formatter, Locator, MaxNLocator class DisplayLocator(Locator): """Round numbers of the displayed unit, placed at their data positions.""" phgx_display = True def __init__(self) -> None: self._base = MaxNLocator(nbins="auto", steps=[1, 2, 2.5, 5, 10]) def set_axis(self, axis) -> None: super().set_axis(axis) self._base.set_axis(axis) def __call__(self): vmin, vmax = self.axis.get_view_interval() return self.tick_values(vmin, vmax) def tick_values(self, vmin, vmax): lo, hi = sorted(float(v) for v in to_display([vmin, vmax])) ticks = np.asarray(self._base.tick_values(lo, hi), dtype=float) return np.sort(from_display(ticks)) class DisplayFormatter(Formatter): """Tick labels in the displayed unit, with as many decimals as they need.""" phgx_display = True def __init__(self) -> None: self._decimals = 0 def set_locs(self, locs) -> None: super().set_locs(locs) shown = np.asarray(to_display(locs), dtype=float) shown = shown[np.isfinite(shown)] self._decimals = 0 if shown.size: scale = max(float(np.max(np.abs(shown))), 1.0) for decimals in range(7): if np.allclose(np.round(shown, decimals), shown, rtol=0.0, atol=scale * 1.0e-9): self._decimals = decimals break else: self._decimals = 6 def _text(self, value: float, decimals: int) -> str: shown = float(to_display(value)) if abs(shown) < 0.5 * 10.0 ** (-decimals): shown = 0.0 # no "-0" on the surface line return self.fix_minus(f"{shown:.{decimals}f}") def __call__(self, x, pos=None) -> str: return self._text(x, self._decimals) def format_data_short(self, value) -> str: # The cursor readout: finer than the ticks, in the same unit. return self._text(value, max(self._decimals, 2)) def format_data(self, value) -> str: return self._text(value, max(self._decimals, 3)) return DisplayLocator(), DisplayFormatter() def _axis(ax: Any, which: str): which = str(which).lower() if which not in ("x", "y", "z") or (which == "z" and not hasattr(ax, "zaxis")): raise ValueError(f"axis must be 'x', 'y' or, on 3-D axes, 'z'; not {which!r}") return getattr(ax, f"{which}axis")
[docs] def set_length_axis(ax: Any, which: str = "x", name: str = "Distance", *, unit: Optional[str] = None, depth_reference: Optional[float] = None, labelled: bool = True, **label_kw: Any) -> str: """Label one axis of ``ax`` as a length and tick it in ``unit``. Parameters ---------- ax : matplotlib.axes.Axes which : ``'x'``, ``'y'`` or, on 3-D axes, ``'z'`` name : str The quantity, e.g. ``'Distance'`` or ``'Depth'``; the unit is appended. unit : str, optional ``'m'`` or ``'ft'`` (any spelling). Defaults to :func:`get_length_unit`. depth_reference : float, optional Show ``depth_reference - value`` instead of the value, positive downward: the data are elevations and the axis reads depth below this level. labelled : bool Set the label. False leaves it alone - an inner panel of a grid, whose ticks still have to be in the right unit. **label_kw Passed to ``set_xlabel``/``set_ylabel`` (font size, family). Returns ------- str The label text. """ resolved = normalize_length_unit(unit) axis = _axis(ax, which) label = length_label(name, resolved) factor = _FACTORS[resolved] if factor != 1.0 or depth_reference is not None: locator, formatter = _display_ticker(*_transforms(factor, depth_reference)) axis.set_major_locator(locator) axis.set_major_formatter(formatter) else: # Plain metres. An axis drawn in feet or as depth before - a view that # reuses its axes - gets matplotlib's own ticks back, and so does one # pyGIMLi relabelled for what it took to be a depth section, so the # numbers agree with the label. from matplotlib.ticker import AutoLocator, FuncFormatter, ScalarFormatter if getattr(axis.get_major_locator(), "phgx_display", False): axis.set_major_locator(AutoLocator()) formatter = axis.get_major_formatter() if getattr(formatter, "phgx_display", False) or isinstance(formatter, FuncFormatter): axis.set_major_formatter(ScalarFormatter()) if labelled: getattr(ax, f"set_{which.lower()}label")(label, **label_kw) return label
[docs] def set_section_axes(ax: Any, *, mesh: Any = None, surface: Any = None, z: Any = None, vertical: str = "auto", unit: Optional[str] = None, xlabel: str = "Distance", depth_reference: Optional[float] = None, label_x: bool = True, label_y: bool = True, elevation_name: str = "Elevation", depth_name: str = "Depth", **label_kw: Any) -> str: """Label and tick both axes of a 2-D section; returns ``'elevation'`` or ``'depth'``. The horizontal axis becomes ``xlabel`` in ``unit``. The vertical one is ``elevation_name`` or, when the section has no elevation (see :func:`resolve_vertical`), ``depth_name`` shown positive downward from ``depth_reference`` - the top of the ground surface unless given. ``label_x``/``label_y`` False keep the ticks in the right unit but leave that label off, for the inner panels of a grid. """ if _check_vertical(vertical) == "depth" and depth_reference is not None: mode = "depth" # nothing left to read off the geometry else: mode, reference = vertical_axis(vertical, mesh=mesh, surface=surface, z=z) if depth_reference is None: depth_reference = reference set_length_axis(ax, "x", xlabel, unit=unit, labelled=label_x, **label_kw) if mode == "depth": set_length_axis(ax, "y", depth_name, unit=unit, depth_reference=depth_reference, labelled=label_y, **label_kw) else: set_length_axis(ax, "y", elevation_name, unit=unit, labelled=label_y, **label_kw) return mode
[docs] def pyqtgraph_length_axis(plot: Any, side: str, name: str, *, unit: Optional[str] = None) -> str: """Label a pyqtgraph ``PlotItem``/``PlotWidget`` axis as a length in ``unit``. pyqtgraph scales an axis's tick values itself (``AxisItem.setScale``) and picks round numbers in the scaled unit, so the data stay in metres here as well. Returns the label text. """ resolved = normalize_length_unit(unit) label = length_label(name, resolved) axis = plot.getAxis(side) axis.setScale(_FACTORS[resolved]) plot.setLabel(side, label) return label
[docs] def section_label_pair(vertical: str = "auto", *, unit: Optional[str] = None, xlabel: str = "Distance", mesh: Any = None, surface: Any = None, z: Any = None) -> Tuple[str, str]: """``(xlabel, ylabel)`` text for a section, for code that only sets labels.""" mode = resolve_vertical(vertical, mesh=mesh, surface=surface, z=z) return (length_label(xlabel, unit), length_label("Depth" if mode == "depth" else "Elevation", unit))