pad-python 4.0.0__cp311-none-macosx_11_0_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
libpad_python.dylib ADDED
Binary file
@@ -0,0 +1,351 @@
1
+ Metadata-Version: 2.4
2
+ Name: pad_python
3
+ Version: 4.0.0
4
+ Summary: Precipitation Attribution Distance for fields on a planar grid
5
+ Author-email: Gregor Skok <Gregor.Skok@fmf.uni-lj.si>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/skokg/PAD
8
+ Project-URL: Repository, https://github.com/skokg/PAD
9
+ Project-URL: Issues, https://github.com/skokg/PAD/issues
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Operating System :: Microsoft :: Windows
13
+ Classifier: Operating System :: MacOS :: MacOS X
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: C++
17
+ Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: numpy>=1.23
22
+ Provides-Extra: examples
23
+ Requires-Dist: netCDF4; extra == "examples"
24
+ Requires-Dist: matplotlib>=3.5; extra == "examples"
25
+ Requires-Dist: cartopy; extra == "examples"
26
+ Dynamic: license-file
27
+
28
+ # PAD Python: Precipitation Attribution Distance
29
+
30
+ Precipitation Attribution Distance (PAD) measures spatial displacement between
31
+ precipitation fields on a planar grid. Its results describe how much
32
+ precipitation is attributed between locations, the distances between those
33
+ locations, and the amounts left unattributed in each field.
34
+
35
+ This Python library provides a NumPy interface to the C++ implementation.
36
+ Attribution uses randomized nearest-neighbor searches with k-d trees.
37
+ Normalization, overlap removal and distance limits are controlled from Python.
38
+
39
+ The package is distributed and imported as `pad_python`.
40
+
41
+ GitHub repository: [skokg/PAD](https://github.com/skokg/PAD).
42
+
43
+ ## Installation
44
+
45
+ Python 3.10 or newer is required. Install the package with:
46
+
47
+ ```sh
48
+ pip install pad_python
49
+ ```
50
+
51
+ To install from source:
52
+
53
+ ```sh
54
+ pip install --no-binary=pad_python pad_python
55
+ ```
56
+
57
+ Compiling from source requires a C++ compiler: GCC or Clang on Linux, MSVC Build
58
+ Tools on Windows, or Xcode Command Line Tools on macOS.
59
+
60
+ The package can also be installed from conda-forge:
61
+
62
+ ```sh
63
+ conda install -c conda-forge pad_python
64
+ ```
65
+
66
+ Alternatively, download the source from the
67
+ [GitHub repository](https://github.com/skokg/PAD) and run `make` in the
68
+ project's root directory on Linux. This requires GNU Make and a C++ compiler.
69
+ Running `make` creates `libpad_python.so` in the `src` folder.
70
+ Copy `pad_python.py` and `libpad_python.so` from `src` into the folder
71
+ containing your Python script. NumPy must also be installed.
72
+ No compiled library is included in this source checkout.
73
+
74
+ ## Usage
75
+
76
+ Import the library with:
77
+
78
+ ```python
79
+ import pad_python as pad
80
+ ```
81
+
82
+ Inputs are precipitation amounts in two equally shaped, nonempty 2D NumPy
83
+ arrays. A field has shape `(y, x)` and represents a rectangular grid with equal
84
+ spacing along both axes. Values must be finite and nonnegative, with at least
85
+ one positive value in each field. Masked arrays are not supported.
86
+
87
+ The two main calculation functions are:
88
+
89
+ - `pad.calculate_PAD_attributions()`: attribute precipitation between fields
90
+ and return the attribution records and the two residual fields.
91
+ - `pad.calculate_PAD_distance_from_attributions()`: calculate the amount-weighted
92
+ mean attribution distance from those records.
93
+
94
+ By default, `normalize=True` independently scales each field to sum to one,
95
+ `remove_overlap=True` attributes pointwise overlap at zero distance first,
96
+ and `distance_cutoff=None` imposes no distance limit.
97
+ A fixed `random_seed` makes repeated calculations
98
+ reproducible for identical inputs and the same native-library build.
99
+ The selected seed is printed when nearest-neighbor work is needed.
100
+
101
+ Distances and cutoffs are in grid cells. Multiply a distance by the common
102
+ grid spacing to express it in physical units. With `normalize=False`, amounts
103
+ and residuals retain the input units. For equal-area cells, rainfall depths
104
+ give the same weighting as water volumes; the library does not calculate
105
+ cell areas.
106
+
107
+ PAD averages only attributed amounts. A cutoff can leave precipitation
108
+ unmatched; if there are no attributions, the mean distance is undefined and
109
+ the distance function raises `ValueError`. Detailed parameters and returned
110
+ columns are described in **Function descriptions** below.
111
+
112
+ This interface supports equally shaped planar fields. For latitude/longitude
113
+ coordinates and spherical distances, see
114
+ [PAD on Sphere](https://github.com/skokg/PAD_on_Sphere).
115
+
116
+ ### Idealized planar field example
117
+
118
+ This example uses a grid with 120 rows and 200 columns. A rectangular region
119
+ has 10 mm of observed rainfall; the forecast contains the same region shifted
120
+ 60 grid cells to the right, leaving 20 dry columns between the rectangles.
121
+ An attribution cutoff of 50 grid cells leaves the outer edges unmatched.
122
+
123
+ The grid cells are assumed to have equal areas. Setting `normalize=False`
124
+ retains rainfall depths in mm, so the residual fields can be plotted directly.
125
+ Weighting by depth is equivalent to weighting by volume on this grid.
126
+
127
+ ```python
128
+ import numpy as np
129
+ import pad_python as pad
130
+
131
+ # Rainfall on a planar grid with equal spacing in both directions.
132
+ cutoff_cells = 50
133
+ observed = np.zeros((120, 200))
134
+ observed[40:80, 30:70] = 10.0
135
+ forecast = np.roll(observed, shift=60, axis=1) # No rainfall wraps at the boundary.
136
+
137
+ # Keep original amounts: attribution weights and residuals are in mm.
138
+ attributions, remaining_forecast, remaining_observed = pad.calculate_PAD_attributions(
139
+ forecast, observed, normalize=False, distance_cutoff=cutoff_cells, random_seed=42,
140
+ )
141
+
142
+ distance_cells = pad.calculate_PAD_distance_from_attributions(attributions)
143
+ print(f"PAD: {distance_cells:.2f} grid cells")
144
+ ```
145
+
146
+ To display the fields, attributions and unattributed precipitation, append:
147
+
148
+ ```python
149
+ import matplotlib.pyplot as plt
150
+
151
+ fig, axes = plt.subplots(2, 2, figsize=(14, 10), constrained_layout=True)
152
+ axes = axes.ravel()
153
+ ax_pdf = axes[2]
154
+
155
+ for ax, fields, title in zip(axes[:2],
156
+ [(observed, forecast), (remaining_observed, remaining_forecast)],
157
+ ["Rainfall and sampled attributions", "Unattributed precipitation"]):
158
+ for rain, cmap, label in zip(fields, ["Blues", "Reds"], ["Observed", "Forecast"]):
159
+ image = ax.imshow(np.ma.masked_equal(rain, 0), cmap=cmap,
160
+ vmin=0, vmax=10, alpha=0.6, origin="lower",
161
+ interpolation="nearest")
162
+ fig.colorbar(image, ax=ax, location="right", label=f"{label} (mm)", shrink=0.7)
163
+ ax.set(xlabel="Column (grid cells)", ylabel="Row (grid cells)",
164
+ title=f"{title} (cutoff: {cutoff_cells} grid cells)")
165
+
166
+ # Plot at most 100 attributions; all are still used to calculate PAD.
167
+ indices = np.random.default_rng(42).choice(len(attributions),
168
+ min(100, len(attributions)), replace=False)
169
+ for x1, y1, x2, y2 in attributions[indices, 2:]:
170
+ axes[0].plot([x1, x2], [y1, y2], "k.-", linewidth=0.8, markersize=3)
171
+
172
+ # Amount-weighted PDF using all attributions (also volume-weighted here).
173
+ ax_pdf.hist(attributions[:, 0], bins=25,
174
+ weights=attributions[:, 1], density=True, color="steelblue")
175
+ ax_pdf.axvline(distance_cells, color="black", linestyle="--",
176
+ label=f"PAD distance: {distance_cells:.1f} grid cells")
177
+ ax_pdf.legend()
178
+ ax_pdf.set(xlabel="Attribution distance (grid cells)",
179
+ ylabel="Probability density (per grid cell)",
180
+ title=f"Attribution PDF (cutoff: {cutoff_cells} grid cells)")
181
+
182
+ # Amount-weighted two-dimensional displacement PDF.
183
+ displacement = attributions[:, 4:6] - attributions[:, 2:4]
184
+ limit = max(1., np.abs(displacement).max())
185
+ ax_displacement = axes[3]
186
+ hist = ax_displacement.hist2d(*displacement.T, bins=25, range=[(-limit, limit)] * 2,
187
+ weights=attributions[:, 1], density=True, cmap="Blues")
188
+ fig.colorbar(hist[3], ax=ax_displacement, label="Probability density (per grid cell²)")
189
+ ax_displacement.axhline(0, color="grey", linewidth=0.5)
190
+ ax_displacement.axvline(0, color="grey", linewidth=0.5)
191
+ ax_displacement.set(xlabel="Δx (grid cells)", ylabel="Δy (grid cells)",
192
+ title="2D attribution PDF", aspect="equal")
193
+ plt.show()
194
+ ```
195
+
196
+ Observed rainfall is blue and forecast rainfall is red. Dry cells are
197
+ transparent. The top-left panel shows a reproducible random sample of up to
198
+ 100 attributions in black; zero-distance matches would appear as points.
199
+ The top-right panel shows observed and forecast residuals, both as
200
+ nonnegative rainfall depths in mm. Each field panel has two colourbars on
201
+ the right. The bottom-left panel is an amount-weighted PDF histogram of all
202
+ attribution distances; the bottom-right panel shows their two-dimensional
203
+ displacement PDF. Displacements are `Δx = x2 - x1` and `Δy = y2 - y1`, from
204
+ the first input field to the second. Here the forecast is the first input,
205
+ so attribution lines point toward the observed rectangle on its left.
206
+ Unattributed precipitation is excluded from both histograms.
207
+ This planar example needs NumPy and Matplotlib, without geographic map data.
208
+
209
+ PAD is the amount-weighted mean attribution distance, equivalent to the
210
+ volume-weighted mean for these equal-area cells. It is not simply the
211
+ 60-cell shift of the rectangle. Observed columns 30–39 and forecast columns
212
+ 120–129 are more than 50 grid cells from any counterpart and remain
213
+ unattributed. Other residuals depend on the attribution. Returned residuals
214
+ are already in mm because normalization is disabled; with the default
215
+ `normalize=True`, they would instead be in normalized amount units.
216
+
217
+ ## Further examples
218
+
219
+ The source distribution and GitHub repository include two scripts and their
220
+ sample NetCDF data in `examples/`. Examples and data are not installed into
221
+ `site-packages` by a wheel. Download and extract the source to use them.
222
+ The examples additionally require Matplotlib, netCDF4 and Cartopy.
223
+
224
+ Then run the examples from the downloaded source root:
225
+
226
+ ```sh
227
+ python examples/example_PAD_01.py
228
+ python examples/example_PAD_02.py
229
+ ```
230
+
231
+ | Script | Input | Output |
232
+ | --- | --- | --- |
233
+ | `example_PAD_01.py` | Two synthetic fields, shape `(300, 500)`, with displaced rainy rectangles. | Printed attributions and PAD; a 2×2 figure with fields and sampled attribution lines, residuals, distance PDF and displacement PDF. |
234
+ | `example_PAD_02.py` | `sample_data/sample_field_A.nc` and `sample_data/sample_field_B.nc`, relative to the script. | Printed attributions and PAD; the same 2×2 layout with maps in the top row. |
235
+
236
+ Both scripts display figures rather than save output files. They can also be
237
+ run from another working directory because the second resolves its data paths
238
+ relative to its own file. Longitude/latitude in the second example are used
239
+ only for map display; the PAD calculation still uses grid-cell distances.
240
+ The second script disables normalization and retains the original amount units;
241
+ it applies no distance cutoff by default.
242
+ Its precipitation colours use a square-root stretch, and the 2D attribution
243
+ PDF uses logarithmic colours to reveal weaker features.
244
+ Cartopy may download coastline data the first time the map example is used.
245
+
246
+ ## Function descriptions
247
+
248
+ The following describes the two calculation functions used above. Attribution
249
+ records and residual fields are NumPy arrays. Input fields are never modified.
250
+
251
+ ### `pad.calculate_PAD_attributions(fa, fb, remove_overlap=True, distance_cutoff=None, random_seed=None, normalize=True)`
252
+
253
+ **Inputs:** `fa` and `fb` are real numeric NumPy arrays shaped `(y, x)`, with
254
+ identical shapes and nonempty dimensions. Each field must contain only finite,
255
+ nonnegative values and at least one positive value. Masked arrays are rejected.
256
+ The fields represent the same rectangular grid, with equal spacing along x
257
+ and y; no separate coordinate arrays are supplied. Values are converted to
258
+ `float64` for calculation and must remain finite, with a positive amount in
259
+ each field after conversion.
260
+
261
+ | Parameter | Meaning |
262
+ | --- | --- |
263
+ | `remove_overlap` | Boolean, default `True`. Attribute the pointwise minimum of the two prepared fields at zero distance before nearest-neighbor attribution. `False` skips this preprocessing, but zero-distance matches can still occur. |
264
+ | `distance_cutoff` | `None` for no limit, or a finite nonnegative Euclidean distance in grid cells. Matches exactly on the cutoff are permitted. Zero allows only zero-distance matches. |
265
+ | `random_seed` | `None` or `-1` chooses a random seed. An integer from `0` to `4294967295` selects a seed explicitly; Boolean values are rejected. A fixed seed reproduces results for identical inputs and the same native-library build. |
266
+ | `normalize` | Boolean, default `True`. Independently scale each field to sum to one before overlap removal and attribution. `False` retains the original amounts. |
267
+
268
+ **Output:** A list `[attributions, remaining1, remaining2]`:
269
+
270
+ | Array | Contents |
271
+ | --- | --- |
272
+ | `attributions` | A `float64` array shaped `(N, 6)`, with one row per attributed amount. It has shape `(0, 6)` when no matches are possible under the cutoff. |
273
+ | `remaining1` | A `float64` array with the shape of `fa`, containing its remaining unattributed amounts. |
274
+ | `remaining2` | A `float64` array with the shape of `fb`, containing its remaining unattributed amounts. |
275
+
276
+ The six attribution columns are:
277
+
278
+ | Column | Contents |
279
+ | --- | --- |
280
+ | `0`: distance | Euclidean distance between the endpoints, in grid cells. |
281
+ | `1`: amount | Amount attributed between the endpoints. |
282
+ | `2`: x1 | Zero-based column of the endpoint in `fa`. |
283
+ | `3`: y1 | Zero-based row of the endpoint in `fa`. |
284
+ | `4`: x2 | Zero-based column of the endpoint in `fb`. |
285
+ | `5`: y2 | Zero-based row of the endpoint in `fb`. |
286
+
287
+ Endpoint coordinates represent integer grid indices stored as `float64` values.
288
+ For array indexing, convert them to integers and use `[y, x]` order.
289
+ Attribution amounts and both residual fields use normalized amount units when
290
+ `normalize=True`, and the original input units when `normalize=False`.
291
+ Returned arrays do not share storage with the inputs and remain valid after
292
+ later calculation calls.
293
+
294
+ **Functionality:** Validates the fields and options, optionally normalizes each
295
+ field, and optionally attributes their pointwise overlap. It then alternates
296
+ between fields, selecting points in randomized order and attributing amounts
297
+ to the nearest remaining precipitation in the opposite field. Each match
298
+ transfers the smaller available amount, consuming at least one endpoint.
299
+ Points without an acceptable neighbor under the cutoff retain their amounts
300
+ in the residual output. Attribution stops when one field has no points left
301
+ for further searches.
302
+
303
+ The returned rows describe the attribution, rather than the scalar PAD value;
304
+ pass them to `calculate_PAD_distance_from_attributions` for that value.
305
+ Nearest-neighbor work prints the selected random seed, including when a seed
306
+ was supplied explicitly. No seed is printed when overlap removal leaves no
307
+ nearest-neighbor work. Invalid inputs or options raise `TypeError` or
308
+ `ValueError`; native calculation failures are reported as `RuntimeError`.
309
+
310
+ ### `pad.calculate_PAD_distance_from_attributions(PAD_attributions)`
311
+
312
+ **Input:** A nonempty six-column attribution array, normally returned by
313
+ `calculate_PAD_attributions`. Array-like input convertible to `float64` is
314
+ also accepted. Column 0 contains distances and column 1 contains attribution
315
+ amounts. Both columns must be finite and nonnegative, with at least one
316
+ strictly positive amount. The four endpoint columns are not used in the
317
+ distance calculation.
318
+
319
+ **Output:** A Python `float` containing the amount-weighted mean attribution
320
+ distance. For attributions produced by this library, the unit is grid cells.
321
+ The result is `0.0` when all positively weighted distances are zero.
322
+
323
+ **Functionality:** Computes `sum(distance * amount) / sum(amount)` using scaled
324
+ values to reduce overflow risk. Zero-weight rows contribute nothing and are
325
+ excluded before choosing the distance scale; their distances must still pass
326
+ input validation. The input array is not modified.
327
+
328
+ Only attributed amounts enter the mean. Residual precipitation is not included,
329
+ and the cutoff used to construct the attributions therefore affects which
330
+ amounts contribute. An empty array, incorrect number of columns, invalid
331
+ distances or amounts, or entirely zero amounts raises `ValueError`; PAD is
332
+ undefined without a positive attributed amount.
333
+
334
+ ## Author and license
335
+
336
+ Gregor Skok, Faculty of Mathematics and Physics, University of Ljubljana,
337
+ Slovenia.
338
+
339
+ Email: [Gregor.Skok@fmf.uni-lj.si](mailto:Gregor.Skok@fmf.uni-lj.si).
340
+ Please contact the author if you have questions or trouble using the package.
341
+
342
+ Licensed under the MIT License. See LICENSE, which also includes
343
+ the modified third-party k-d tree notice.
344
+
345
+ ## References
346
+
347
+ The paper below describes precipitation attribution distance; please cite it
348
+ when publishing work using PAD:
349
+
350
+ - Skok, G. (2023). Precipitation attribution distance. Atmospheric Research, 295.
351
+ [https://doi.org/10.1016/j.atmosres.2023.106998](https://doi.org/10.1016/j.atmosres.2023.106998)
@@ -0,0 +1,7 @@
1
+ pad_python.py,sha256=E5pdWZj8XHttHZ2Owo1y04iI6-eSm1e7i9iAUcJTevo,10465
2
+ libpad_python.dylib,sha256=z_waSU21YXbNA8FG6VKGqkAb_NkAspCoOUTSj7tKiIs,81136
3
+ pad_python-4.0.0.dist-info/RECORD,,
4
+ pad_python-4.0.0.dist-info/WHEEL,sha256=gBMB7oEL8gTQevV8keLQBHpwXlFO3-tERwO-0eHBkNU,135
5
+ pad_python-4.0.0.dist-info/top_level.txt,sha256=LFLTG4mxINgSV4zgzGlM30ByO8SH38cxl1hqckIrQxQ,25
6
+ pad_python-4.0.0.dist-info/METADATA,sha256=uCN8l_IUJX1_ZNMNahvKzItrbKzciRGW7baS5uXT5c4,17126
7
+ pad_python-4.0.0.dist-info/licenses/LICENSE,sha256=s_K7B8QvZfGhJR6XU7EJ9AWWXfCZf8mE63ErYbLzHho,2415
@@ -0,0 +1,6 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: false
4
+ Tag: cp311-none-macosx_11_0_arm64
5
+ Generator: delocate 0.13.0
6
+
@@ -0,0 +1,49 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Gregor Skok
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+ KD-tree implementation
23
+ ======================
24
+
25
+ src/cpp/CU_kdtree_with_index.cc contains an implementation
26
+ originally by SuYuxi, subsequently modified by Gregor Skok. The original notice
27
+ is preserved in that file and reproduced here for installed distributions.
28
+
29
+ MIT License
30
+
31
+ Copyright (c) 2019 SuYuxi
32
+
33
+ Permission is hereby granted, free of charge, to any person obtaining a copy
34
+ of this software and associated documentation files (the "Software"), to deal
35
+ in the Software without restriction, including without limitation the rights
36
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
37
+ copies of the Software, and to permit persons to whom the Software is
38
+ furnished to do so, subject to the following conditions:
39
+
40
+ The above copyright notice and this permission notice shall be included in all
41
+ copies or substantial portions of the Software.
42
+
43
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
44
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
45
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
46
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
47
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
48
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
49
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ libpad_python
2
+ pad_python
pad_python.py ADDED
@@ -0,0 +1,244 @@
1
+ """Planar PAD using the C++ wrapper (planar ABI 2; rebuild the shared library).
2
+
3
+ All results have six columns: Euclidean distance, amount, x1, y1, x2, y2.
4
+ The 2D-field API optionally normalizes each field (enabled by default) and always
5
+ return [attributions, remaining1, remaining2]. No cutoff is used by default.
6
+ """
7
+ import ctypes as ct
8
+ import operator
9
+ from pathlib import Path
10
+ import sys
11
+
12
+ import numpy as np
13
+
14
+ # Windows wheel repair inserts the bundled-DLL search path here.
15
+ # delvewheel: patch
16
+
17
+ __all__ = [
18
+ "check_input_fields",
19
+ "calculate_PAD_attributions",
20
+ "calculate_PAD_distance_from_attributions",
21
+ ]
22
+
23
+ _native_library_names = {
24
+ "linux": "libpad_python.so",
25
+ "win32": "pad_python.dll",
26
+ "darwin": "libpad_python.dylib",
27
+ }
28
+ if sys.platform not in _native_library_names:
29
+ raise ImportError("pad_python supports Linux, Windows and macOS.")
30
+ _library_path = Path(__file__).resolve().with_name(_native_library_names[sys.platform])
31
+ _compile_advice = (
32
+ "Reinstall pad_python using a compatible wheel, or build from source with "
33
+ "a C++ compiler. For manual Linux builds, run make in the repository root "
34
+ "to create src/libpad_python.so beside src/pad_python.py."
35
+ )
36
+ if not _library_path.is_file():
37
+ raise ImportError(
38
+ f"PAD shared library not found: {_library_path}. {_compile_advice}"
39
+ )
40
+
41
+ try:
42
+ libc = ct.CDLL(str(_library_path))
43
+ except OSError as exc:
44
+ raise ImportError(
45
+ f"Cannot load PAD shared library: {_library_path}. {_compile_advice}"
46
+ ) from exc
47
+ try:
48
+ libc.PAD_planar_wrapper_abi_version.argtypes = []
49
+ libc.PAD_planar_wrapper_abi_version.restype = ct.c_int
50
+ except AttributeError as exc:
51
+ raise ImportError(
52
+ "Rebuild pad_python's native library: planar wrapper ABI 2 is required. "
53
+ + _compile_advice
54
+ ) from exc
55
+ if libc.PAD_planar_wrapper_abi_version() != 2:
56
+ raise ImportError(
57
+ "Incompatible PAD shared library: planar wrapper ABI 2 is required. "
58
+ + _compile_advice
59
+ )
60
+
61
+ ND_POINTER_1D = np.ctypeslib.ndpointer(
62
+ dtype=np.float64, ndim=1, flags=("C_CONTIGUOUS", "ALIGNED")
63
+ )
64
+ libc.free_mem_double_array.argtypes = [ct.POINTER(ct.c_double)]
65
+ libc.free_mem_double_array.restype = None
66
+ libc.PAD_last_error.argtypes = []
67
+ libc.PAD_last_error.restype = ct.c_char_p
68
+ libc.calculate_PAD_results_assume_same_grid_ctypes.argtypes = [
69
+ ND_POINTER_1D, ND_POINTER_1D, ND_POINTER_1D, ND_POINTER_1D,
70
+ ct.c_size_t, ct.POINTER(ct.c_size_t), ct.c_double, ct.c_int64,
71
+ ]
72
+ libc.calculate_PAD_results_assume_same_grid_ctypes.restype = ct.POINTER(ct.c_double)
73
+ libc.calculate_PAD_results_assume_different_grid_ctypes.argtypes = [
74
+ ND_POINTER_1D, ND_POINTER_1D, ND_POINTER_1D, ct.c_size_t,
75
+ ND_POINTER_1D, ND_POINTER_1D, ND_POINTER_1D, ct.c_size_t,
76
+ ct.POINTER(ct.c_size_t), ct.c_double, ct.c_int64,
77
+ ]
78
+ libc.calculate_PAD_results_assume_different_grid_ctypes.restype = ct.POINTER(ct.c_double)
79
+
80
+
81
+ def _check_array(array, name, ndim):
82
+ if not isinstance(array, np.ndarray) or isinstance(array, np.ma.MaskedArray):
83
+ raise TypeError(f"{name} must be an unmasked NumPy array.")
84
+ if array.ndim != ndim or array.size == 0:
85
+ raise ValueError(f"{name} must be a nonempty {ndim}-dimensional array.")
86
+ if not np.issubdtype(array.dtype, np.number) or np.iscomplexobj(array):
87
+ raise TypeError(f"{name} must contain real numeric values.")
88
+ if not np.all(np.isfinite(array)):
89
+ raise ValueError(f"{name} must contain only finite values.")
90
+
91
+
92
+ def _check_amounts(values, name):
93
+ if np.any(values < 0) or not np.any(values > 0):
94
+ raise ValueError(f"{name} must be nonnegative with at least one positive amount.")
95
+ if values.size > np.iinfo(np.int32).max:
96
+ raise ValueError("Too many grid points for the C++ tree.")
97
+
98
+
99
+ def _array(array, name):
100
+ _check_array(array, name, 1)
101
+ converted = np.require(array, dtype=np.float64, requirements=["C", "A"])
102
+ if not np.all(np.isfinite(converted)):
103
+ raise ValueError(f"{name} must be representable as finite float64 values.")
104
+ return converted
105
+
106
+
107
+ def check_input_fields(fa, fb):
108
+ """Validate equally shaped, nonnegative 2D fields; raise on invalid input."""
109
+ for field, name in ((fa, "fa"), (fb, "fb")):
110
+ _check_array(field, name, 2)
111
+ _check_amounts(field, name)
112
+ if fa.shape != fb.shape:
113
+ raise ValueError("fa and fb must have identical shapes.")
114
+ return True
115
+
116
+
117
+ def _seed(random_seed):
118
+ if random_seed is None:
119
+ return -1
120
+ if isinstance(random_seed, (bool, np.bool_)):
121
+ raise TypeError("random_seed must be an integer, not a boolean.")
122
+ seed = operator.index(random_seed)
123
+ if seed < -1 or seed > 0xFFFFFFFF:
124
+ raise ValueError("random_seed must be None, -1, or an unsigned 32-bit integer.")
125
+ return seed
126
+
127
+
128
+ def _cutoff(distance_cutoff):
129
+ if distance_cutoff is None:
130
+ return np.finfo(np.float64).max
131
+ value = float(distance_cutoff)
132
+ if not np.isfinite(value) or value < 0:
133
+ raise ValueError("Euclidean distance cutoff must be finite and nonnegative, or None.")
134
+ return value
135
+
136
+
137
+ def _normalized_values(values):
138
+ """Normalize already validated float64 amounts without modifying inputs."""
139
+ scaled = values / values.max()
140
+ return np.ascontiguousarray(scaled / scaled.sum(), dtype=np.float64)
141
+
142
+
143
+ def calculate_PAD_attributions(
144
+ fa, fb, remove_overlap=True, distance_cutoff=None, random_seed=None,
145
+ normalize=True,
146
+ ):
147
+ """Calculate planar PAD from two equally shaped, equidistant 2D fields.
148
+
149
+ Coordinates are generated from columns (x) and rows (y), shared by both
150
+ fields. Separate coordinate arrays, 1D inputs and different grids are not
151
+ supported by this Python API.
152
+
153
+ remove_overlap=True attributes pointwise overlap at distance zero first.
154
+ False skips preprocessing; zero-distance NN matches can still occur.
155
+ normalize=True independently scales each field to sum to one before
156
+ attribution; False keeps original amounts. Inputs are never modified.
157
+
158
+ distance_cutoff=None means no cutoff. A finite nonnegative Euclidean cutoff
159
+ is in grid-cell units, as are returned distances. Zero is valid.
160
+ random_seed=None or -1 chooses and prints a seed when NN work is needed;
161
+ uint32 seeds reproduce runs for identical inputs and library versions.
162
+
163
+ Always returns [attributions, remaining1, remaining2]. Attributions is
164
+ float64 with shape (N, 6), including (0, 6) when no matches are allowed:
165
+ distance, amount, x1, y1, x2, y2. Endpoint coordinates are zero-based
166
+ column (x) and row (y) positions, as in the original planar Python API.
167
+ Residuals have the original 2D field shapes, in normalized units when
168
+ normalize=True and original amount units otherwise.
169
+ """
170
+ check_input_fields(fa, fb)
171
+ if not isinstance(remove_overlap, (bool, np.bool_)):
172
+ raise TypeError("remove_overlap must be a boolean.")
173
+ if not isinstance(normalize, (bool, np.bool_)):
174
+ raise TypeError("normalize must be a boolean.")
175
+ seed, cutoff = _seed(random_seed), _cutoff(distance_cutoff)
176
+ values1 = _array(fa.ravel(order="C"), "fa")
177
+ values2 = _array(fb.ravel(order="C"), "fb")
178
+ # Check again after conversion, which may underflow extended-precision data.
179
+ _check_amounts(values1, "fa")
180
+ _check_amounts(values2, "fb")
181
+ if normalize:
182
+ values1 = _normalized_values(values1)
183
+ values2 = _normalized_values(values2)
184
+ x = np.tile(np.arange(fa.shape[1], dtype=np.float64), fa.shape[0])
185
+ y = np.repeat(np.arange(fa.shape[0], dtype=np.float64), fa.shape[1])
186
+ count = ct.c_size_t()
187
+ if remove_overlap:
188
+ result = libc.calculate_PAD_results_assume_same_grid_ctypes(
189
+ x, y, values1, values2, values1.size, ct.byref(count), cutoff, seed,
190
+ )
191
+ else:
192
+ # Use the general C++ engine on the SAME generated grid to skip overlap.
193
+ result = libc.calculate_PAD_results_assume_different_grid_ctypes(
194
+ x, y, values1, values1.size, x, y, values2, values2.size,
195
+ ct.byref(count), cutoff, seed,
196
+ )
197
+ if not result:
198
+ message = libc.PAD_last_error()
199
+ raise RuntimeError(
200
+ message.decode("utf-8", errors="replace") if message else "Planar PAD calculation failed."
201
+ )
202
+ try:
203
+ n = count.value
204
+ if n > values1.size + values2.size:
205
+ raise RuntimeError("Invalid attribution count returned by PAD.")
206
+ total = 4 * n + values1.size + values2.size
207
+ # One owned copy instead of a list of Python float objects.
208
+ packed = np.ctypeslib.as_array(result, shape=(total,)).copy()
209
+ # The internal C++ buffer retains indices; expose original Python XY output.
210
+ records = packed[:4 * n].reshape(n, 4)
211
+ index1 = records[:, 2].astype(np.intp)
212
+ index2 = records[:, 3].astype(np.intp)
213
+ attributions = np.empty((n, 6), dtype=np.float64)
214
+ attributions[:, :2] = records[:, :2]
215
+ attributions[:, 2] = x[index1]
216
+ attributions[:, 3] = y[index1]
217
+ attributions[:, 4] = x[index2]
218
+ attributions[:, 5] = y[index2]
219
+ remaining1 = packed[4 * n:4 * n + values1.size]
220
+ remaining2 = packed[4 * n + values1.size:]
221
+ return [attributions, remaining1.reshape(fa.shape), remaining2.reshape(fb.shape)]
222
+ finally:
223
+ libc.free_mem_double_array(result)
224
+
225
+
226
+ def calculate_PAD_distance_from_attributions(PAD_attributions):
227
+ """Return the amount-weighted mean distance; empty results are undefined."""
228
+ rows = np.asarray(PAD_attributions, dtype=np.float64)
229
+ if rows.ndim != 2 or rows.shape[1] != 6 or rows.shape[0] == 0:
230
+ raise ValueError("PAD requires a nonempty six-column attribution array.")
231
+ distances, weights = rows[:, 0], rows[:, 1]
232
+ if (not np.all(np.isfinite(distances)) or not np.all(np.isfinite(weights))
233
+ or np.any(distances < 0) or np.any(weights < 0) or not np.any(weights > 0)):
234
+ raise ValueError("PAD requires finite nonnegative distances and positive total weight.")
235
+ # Zero-weight rows contribute nothing and must not set the distance scale:
236
+ # an irrelevant huge distance could otherwise underflow all useful ratios.
237
+ positive = weights > 0
238
+ distances, weights = distances[positive], weights[positive]
239
+ weights = weights / weights.max()
240
+ scale = distances.max()
241
+ if scale == 0:
242
+ return 0.0
243
+ fraction = np.sum((distances / scale) * weights) / np.sum(weights)
244
+ return float(min(fraction, 1.0) * scale)