Coverage for src / gwtransport / surfacearea.py: 100%
26 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-04 18:51 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-04 18:51 +0000
1"""
2Surface Area Calculations for Streamline-Based Transport Analysis.
4This module provides geometric utilities for computing surface areas and average
5heights between streamlines in heterogeneous aquifer systems. These calculations
6support direct estimation of pore volume distributions from streamline analysis,
7providing an alternative to gamma distribution approximations.
9Available functions:
11- :func:`compute_average_heights` - Compute average heights of clipped trapezoids formed by
12 streamlines. Trapezoids have vertical sides defined by x_edges and top/bottom edges defined
13 by y_edges (2D array). Clipping bounds (y_lower, y_upper) restrict the integration domain.
14 Returns area/width ratios representing average heights for use in pore volume calculations
15 from streamline geometry.
17This file is part of gwtransport which is released under AGPL-3.0 license.
18See the ./LICENSE file or go to https://github.com/gwtransport/gwtransport/blob/main/LICENSE for full license details.
19"""
21import numpy as np
22import numpy.typing as npt
25def _positive_part_integral(
26 a: npt.NDArray[np.floating], b: npt.NDArray[np.floating], w: npt.NDArray[np.floating]
27) -> npt.NDArray[np.floating]:
28 """
29 Integrate max(f(x), 0) from x=0 to x=w where f is linear from a to b.
31 Parameters
32 ----------
33 a : ndarray
34 Function values at x=0.
35 b : ndarray
36 Function values at x=w.
37 w : ndarray
38 Integration width.
40 Returns
41 -------
42 ndarray
43 Integral values.
44 """
45 both_pos = (a > 0) & (b > 0)
46 only_a_pos = (a > 0) & ~both_pos
47 only_b_pos = (b > 0) & ~both_pos
49 abs_diff = np.abs(a - b)
50 safe_diff = np.where(abs_diff > 0, abs_diff, 1.0)
52 excess = np.where(only_a_pos, a, np.where(only_b_pos, b, 0.0))
54 return np.where(
55 both_pos,
56 w * (a + b) / 2,
57 w * excess**2 / (2 * safe_diff),
58 )
61def _clipped_linear_integral(
62 a: npt.NDArray[np.floating],
63 b: npt.NDArray[np.floating],
64 w: npt.NDArray[np.floating],
65 lo: float,
66 hi: float,
67) -> npt.NDArray[np.floating]:
68 """
69 Integrate clip(f(x), lo, hi) from x=0 to x=w where f is linear from a to b.
71 Uses the identity ``clip(f) = f - max(f - hi, 0) + max(lo - f, 0)`` to
72 compute the exact integral analytically.
74 Parameters
75 ----------
76 a : ndarray
77 Function values at x=0.
78 b : ndarray
79 Function values at x=w.
80 w : ndarray
81 Integration width.
82 lo : float
83 Lower clipping bound.
84 hi : float
85 Upper clipping bound.
87 Returns
88 -------
89 ndarray
90 Integral values.
91 """
92 raw = w * (a + b) / 2
93 excess_above = _positive_part_integral(a - hi, b - hi, w)
94 deficit_below = _positive_part_integral(lo - a, lo - b, w)
95 return raw - excess_above + deficit_below
98def compute_average_heights(
99 *, x_edges: npt.ArrayLike, y_edges: npt.ArrayLike, y_lower: float, y_upper: float
100) -> npt.NDArray[np.floating]:
101 """
102 Compute average heights of clipped trapezoids.
104 Trapezoids have vertical left and right sides, with corners at:
105 - top-left: (y_edges[i, j], x_edges[j])
106 - top-right: (y_edges[i, j+1], x_edges[j+1])
107 - bottom-left: (y_edges[i+1, j], x_edges[j])
108 - bottom-right: (y_edges[i+1, j+1], x_edges[j+1])
110 The area is computed as the exact integral of
111 ``clip(top(x), y_lower, y_upper) - clip(bottom(x), y_lower, y_upper)``
112 where ``top(x)`` and ``bottom(x)`` are linear interpolants of the corner
113 values, and ``clip`` restricts values to ``[y_lower, y_upper]``.
115 Parameters
116 ----------
117 x_edges : numpy.ndarray
118 1D array of x coordinates, shape (n_x,)
119 y_edges : numpy.ndarray
120 2D array of y coordinates, shape (n_y, n_x)
121 y_lower : float
122 Lower horizontal clipping bound
123 y_upper : float
124 Upper horizontal clipping bound
126 Returns
127 -------
128 avg_heights : numpy.ndarray
129 2D array of average heights (area/width) for each clipped trapezoid,
130 shape (n_y-1, n_x-1)
132 See Also
133 --------
134 gwtransport.advection.infiltration_to_extraction : Use computed areas in transport calculations
136 Examples
137 --------
138 >>> import numpy as np
139 >>> from gwtransport.surfacearea import compute_average_heights
140 >>> # Create simple grid
141 >>> x_edges = np.array([0.0, 1.0, 2.0])
142 >>> y_edges = np.array([[0.0, 0.5, 1.0], [1.0, 1.5, 2.0], [2.0, 2.5, 3.0]])
143 >>> # Compute average heights with clipping bounds
144 >>> avg_heights = compute_average_heights(
145 ... x_edges=x_edges, y_edges=y_edges, y_lower=0.5, y_upper=2.5
146 ... )
147 >>> print(f"Shape: {avg_heights.shape}")
148 Shape: (2, 2)
149 """
150 x_edges = np.asarray(x_edges, dtype=float)
151 y_edges = np.asarray(y_edges, dtype=float)
153 y_tl, y_tr = y_edges[:-1, :-1], y_edges[:-1, 1:]
154 y_bl, y_br = y_edges[1:, :-1], y_edges[1:, 1:]
155 widths = np.diff(x_edges)
157 top_integral = _clipped_linear_integral(y_tl, y_tr, widths, y_lower, y_upper)
158 bottom_integral = _clipped_linear_integral(y_bl, y_br, widths, y_lower, y_upper)
160 areas = np.maximum(top_integral - bottom_integral, 0.0)
162 with np.errstate(invalid="ignore"):
163 return areas / widths