"""
Surface abstractions for domain topography and derived vertical supports.
This module intentionally keeps all surface-level operations in one place:
- store one 2D raster-like array,
- carry its optional ``RasterSupport``,
- derive new surfaces from an existing one,
- validate vertical ordering between surfaces.
The goal is to keep ``Domain`` focused on orchestration while all array-level
transformations that conceptually belong to a surface remain implemented here.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import numpy as np
from hydromodpy.spatial.raster_support import RasterSupport
[docs]
@dataclass
class Surface:
"""
One raster-like surface plus its optional spatial support.
Parameters
----------
name : str
Human-readable surface name (for example ``"surface_topo"`` or
``"substratum"``).
values : Any
2D array-like values of the surface.
support : RasterSupport | None
Optional spatial support describing where this 2D array is located in
space. The values remain usable without it, but any georeferenced use
case should attach one.
"""
name: str
values: Any
support: RasterSupport | None = None
[docs]
@classmethod
def from_geographic_dem(
cls,
dem_values: Any,
*,
support: RasterSupport | None = None,
name: str = "surface_topo",
) -> Surface:
"""
Build one surface from explicit DEM values.
This constructor does not read from ``CatchmentDelineation`` directly. The caller
must provide:
- the already-extracted DEM values,
- and, when available, the matching ``RasterSupport``.
"""
values = np.asarray(dem_values, dtype=float)
return cls(name=name, values=values, support=support)
[docs]
def as_array(self) -> np.ndarray:
"""
Return the surface values as a float NumPy array.
This is the canonical internal representation used by all numerical
operations in this module.
"""
return np.asarray(self.values, dtype=float)
[docs]
def assert_support_matches_values(self) -> None:
"""
Ensure the internal array shape is consistent with attached support.
This is intentionally lightweight and only validates local coherence.
Pairwise domain consistency between two surfaces is handled by
``assert_same_geographic_domain``.
"""
if self.support is None:
return
arr = self.as_array()
if self.support.nrows is None or self.support.ncols is None:
return
expected = (int(self.support.nrows), int(self.support.ncols))
if arr.shape != expected:
raise ValueError(
f"Surface '{self.name}' shape {arr.shape} does not match support shape {expected}."
)
[docs]
def assert_same_geographic_domain(
self,
other: Surface,
*,
atol: float = 1.0e-9,
) -> None:
"""
Validate that two surfaces are defined on the exact same geographic domain.
The check is delegated to ``RasterSupport.assert_same_geographic_domain``
and also verifies that each surface values-array is coherent with its own
support dimensions.
"""
if not isinstance(other, Surface):
raise TypeError(f"Expected Surface, got {type(other)!r}.")
if self.support is None or other.support is None:
raise ValueError("Both surfaces must carry a RasterSupport to compare domains.")
self.assert_support_matches_values()
other.assert_support_matches_values()
self.support.assert_same_geographic_domain(other.support, atol=atol)
[docs]
def resample_to_shape(
self,
nrows: int,
ncols: int,
*,
resampling: str = "bilinear",
nodata: float | None = None,
name: str | None = None,
) -> Surface:
"""
Return one new surface re-discretized to ``(nrows, ncols)``.
Geographic extent and CRS are preserved; only raster resolution changes.
"""
if self.support is None:
raise ValueError(f"Surface '{self.name}' has no RasterSupport and cannot be resampled.")
self.support.assert_complete_domain()
self.assert_support_matches_values()
nrows_int = int(nrows)
ncols_int = int(ncols)
if nrows_int < 1 or ncols_int < 1:
raise ValueError("nrows and ncols must be >= 1.")
return self._resample_to_support(
target_nrows=nrows_int,
target_ncols=ncols_int,
resampling=resampling,
nodata=nodata,
name=name,
)
[docs]
def shifted_down_by(
self,
offset: float,
*,
name: str = "substratum",
) -> Surface:
"""
Return a new surface shifted downward by one constant offset.
The returned surface:
- keeps the same raster support as the current one,
- uses a new 2D array equal to ``self - offset``,
- leaves a NODATA cell AT the sentinel instead of shifting it.
The sentinel must not move. The support is shared by reference, so it keeps
advertising the same ``nodata`` value, and every consumer recognises a
no-data cell by exact equality with it (``surface_sampling``, the
discretization guard, the zonal statistics). Shifting ``-9999`` to
``-9999 - offset`` silently turns the sentinel into an ordinary elevation:
it is then interpolated as if it were terrain, and a mesh cell whose stencil
straddles the mask edge inherits a bottom dragged toward it. Such a cell has
no idomain signature either, because the top of that same cell is clean: it
stays active and carries a column kilometres thick, with the transmissivity
and the storage that go with it.
Example
-------
If ``self`` stores the topography and ``offset=50``, the returned
surface is ``topography - 50`` on every cell carrying data.
"""
top = self.as_array()
bottom = top - float(offset)
nodata = getattr(self.support, "nodata", None)
if nodata is not None:
bottom = np.where(top == float(nodata), float(nodata), bottom)
return Surface(name=name, values=bottom, support=self.support)
[docs]
def flat_like(
self,
value: float,
*,
name: str = "substratum",
min_gap: float = 1.0,
) -> Surface:
"""
Return a flat (constant-elevation) substratum below this surface.
The returned surface is ``value`` on every cell whose top sits strictly
above ``value + min_gap``. Where the top drops to or below that level the
cell has no aquifer (basement outcrop, or a NODATA sentinel left by a
watershed-masked / out-of-DEM cell): the bottom is clamped to
``top - min_gap`` so the column stays a thin degenerate cell that the
mesh masks out, instead of inverting (bottom above top). This mirrors
``shifted_down_by`` on those cells, so a flat substratum behaves like a
constant thickness there and never trips the strict-ordering guard.
Example
-------
If ``value=20`` and the terrain is 56..83 m, every cell gets a flat
bottom at 20 m. A NODATA cell (top ``-9999``) is clamped to ``-10000``.
"""
top = self.as_array()
flat = float(value)
gap = float(min_gap)
finite = np.isfinite(top)
if np.any(finite) and not np.any(top[finite] > flat + gap):
raise ValueError(
f"Flat substratum {flat} m sits at or above the top on every finite "
"cell; no aquifer would remain. Lower substratum_elevation."
)
bottom = np.where(top > flat + gap, flat, top - gap)
return Surface(name=name, values=bottom, support=self.support)
[docs]
def assert_strictly_below(self, upper_surface: Surface) -> None:
"""
Validate that this surface is strictly lower than ``upper_surface``.
Validation rules
----------------
- both surfaces must have the same 2D shape,
- only finite overlapping cells are checked,
- at every checked cell, ``self < upper_surface`` must hold.
A ``ValueError`` is raised when:
- shapes are incompatible,
- there is no finite overlap,
- or at least one cell violates the strict ordering.
"""
lower = self.as_array()
upper = upper_surface.as_array()
if lower.shape != upper.shape:
raise ValueError(
f"Cannot compare surfaces with different shapes: "
f"{self.name}={lower.shape}, {upper_surface.name}={upper.shape}"
)
finite_mask = np.isfinite(lower) & np.isfinite(upper)
if not np.any(finite_mask):
raise ValueError(
f"Cannot compare '{self.name}' and '{upper_surface.name}': "
"no finite overlapping DEM cells."
)
violations = lower[finite_mask] >= upper[finite_mask]
if np.any(violations):
n_bad = int(np.count_nonzero(violations))
total = int(violations.size)
max_delta = float(np.max(lower[finite_mask] - upper[finite_mask]))
raise ValueError(
f"Surface '{self.name}' must be strictly below '{upper_surface.name}' "
f"on all cells ({n_bad}/{total} violations, max(delta)={max_delta:.6g})."
)
def _resample_to_support(
self,
*,
target_nrows: int,
target_ncols: int,
resampling: str,
nodata: float | None,
name: str | None,
) -> Surface:
"""
Internal raster resampling helper on the same geographic domain.
Only raster shape changes. Geographic domain (CRS + extent) remains the
one carried by ``self.support``.
"""
from rasterio.enums import Resampling
from rasterio.transform import from_bounds
from rasterio.warp import reproject
if self.support is None:
raise ValueError(f"Surface '{self.name}' has no RasterSupport.")
self.support.assert_complete_domain()
source = np.asarray(self.as_array(), dtype=float)
src = self.support
src_transform = from_bounds(
float(src.xmin),
float(src.ymin),
float(src.xmax),
float(src.ymax),
int(src.ncols),
int(src.nrows),
)
target_nrows_int = int(target_nrows)
target_ncols_int = int(target_ncols)
if target_nrows_int < 1 or target_ncols_int < 1:
raise ValueError("target_nrows and target_ncols must be >= 1.")
dst_transform = from_bounds(
float(src.xmin),
float(src.ymin),
float(src.xmax),
float(src.ymax),
target_ncols_int,
target_nrows_int,
)
method = str(resampling).strip().lower()
if method == "bilinear":
rs = Resampling.bilinear
elif method == "average":
rs = Resampling.average
elif method == "nearest":
rs = Resampling.nearest
else:
raise ValueError(
f"Unsupported resampling='{resampling}'. Allowed: bilinear, average, nearest."
)
nodata_value: float
if nodata is not None:
nodata_value = float(nodata)
elif src.nodata is not None:
nodata_value = float(src.nodata)
else:
nodata_value = -9999.0
destination = np.full(
(target_nrows_int, target_ncols_int),
nodata_value,
dtype=float,
)
reproject(
source=source,
destination=destination,
src_transform=src_transform,
src_crs=src.crs,
dst_transform=dst_transform,
dst_crs=src.crs,
src_nodata=nodata_value,
dst_nodata=nodata_value,
resampling=rs,
)
xmin = float(src.xmin)
xmax = float(src.xmax)
ymin = float(src.ymin)
ymax = float(src.ymax)
out_support = RasterSupport(
crs=src.crs,
dx=(xmax - xmin) / target_ncols_int,
dy=(ymax - ymin) / target_nrows_int,
xmin=xmin,
xmax=xmax,
ymin=ymin,
ymax=ymax,
nrows=target_nrows_int,
ncols=target_ncols_int,
nodata=nodata_value,
)
return Surface(
name=(self.name if name is None else name),
values=destination,
support=out_support,
)