fastpgv 0.1.0__tar.gz

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.
@@ -0,0 +1,48 @@
1
+ name: TestPyPI
2
+ on:
3
+ workflow_dispatch:
4
+
5
+ permissions:
6
+ contents: read
7
+
8
+ jobs:
9
+ build:
10
+ uses: ./.github/workflows/wheels.yml
11
+ publish:
12
+ needs: build
13
+ runs-on: ubuntu-22.04
14
+ environment:
15
+ name: testpypi
16
+ url: https://test.pypi.org/p/fastpgv
17
+ permissions:
18
+ id-token: write
19
+ steps:
20
+ - uses: actions/download-artifact@v4
21
+ with:
22
+ path: dist
23
+ merge-multiple: true
24
+ - uses: actions/setup-python@v5
25
+ with:
26
+ python-version: '3.12'
27
+ - run: python -m pip install twine
28
+ - run: python -m twine check dist/*
29
+ - uses: pypa/gh-action-pypi-publish@release/v1
30
+ with:
31
+ repository-url: https://test.pypi.org/legacy/
32
+ verify:
33
+ needs: publish
34
+ runs-on: ubuntu-22.04
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+ - uses: actions/setup-python@v5
38
+ with:
39
+ python-version: '3.12'
40
+ - run: python -m pip install numpy
41
+ - name: Install published package (allow index propagation)
42
+ run: |
43
+ for attempt in 1 2 3 4 5; do
44
+ python -m pip install --index-url https://test.pypi.org/simple/ --only-binary=:all: --no-deps fastpgv==0.1.0 && break
45
+ sleep 20
46
+ done
47
+ python -c 'import fastpgv; assert fastpgv.__version__ == "0.1.0"'
48
+ - run: python examples/two_polygons.py
@@ -0,0 +1,57 @@
1
+ name: Build and Test
2
+ on:
3
+ push:
4
+ pull_request:
5
+ workflow_dispatch:
6
+ workflow_call:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ wheels:
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ include:
17
+ - os: ubuntu-22.04
18
+ arch: x86_64
19
+ - os: macos-14
20
+ arch: arm64
21
+ - os: macos-15-intel
22
+ arch: x86_64
23
+ - os: windows-2022
24
+ arch: AMD64
25
+ runs-on: ${{ matrix.os }}
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ - uses: pypa/cibuildwheel@v3.1
29
+ env:
30
+ CIBW_ARCHS: ${{ matrix.arch }}
31
+ CIBW_ENVIRONMENT_MACOS: MACOSX_DEPLOYMENT_TARGET=11.0
32
+ - uses: actions/upload-artifact@v4
33
+ with:
34
+ name: wheels-${{ matrix.os }}
35
+ path: wheelhouse/*.whl
36
+ source:
37
+ runs-on: ubuntu-22.04
38
+ steps:
39
+ - uses: actions/checkout@v4
40
+ - uses: actions/setup-python@v5
41
+ with:
42
+ python-version: '3.12'
43
+ - run: python -m pip install build twine
44
+ - run: python -m build --sdist
45
+ - run: python -m twine check dist/*
46
+ - name: Build and test outside the repository
47
+ run: |
48
+ mkdir /tmp/fastpgv-source
49
+ tar -xf dist/*.tar.gz -C /tmp/fastpgv-source
50
+ cd /tmp/fastpgv-source/fastpgv-0.1.0
51
+ python -m pip install '.[test]'
52
+ python -m pytest
53
+ python examples/two_polygons.py
54
+ - uses: actions/upload-artifact@v4
55
+ with:
56
+ name: source
57
+ path: dist/*.tar.gz
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ build/
3
+ dist/
4
+ wheelhouse/
5
+ __pycache__/
6
+ .pytest_cache/
7
+ *.egg-info/
@@ -0,0 +1,15 @@
1
+ cmake_minimum_required(VERSION 3.18)
2
+ project(fastpgv LANGUAGES CXX)
3
+ set(PYBIND11_FINDPYTHON ON)
4
+ find_package(Python COMPONENTS Interpreter Development.Module REQUIRED)
5
+ find_package(pybind11 CONFIG REQUIRED)
6
+ find_package(Threads REQUIRED)
7
+ pybind11_add_module(_native cpp/bindings.cpp cpp/sled.cpp)
8
+ target_link_libraries(_native PRIVATE Threads::Threads)
9
+ target_compile_features(_native PRIVATE cxx_std_17)
10
+ if(MSVC)
11
+ target_compile_options(_native PRIVATE /W4 /fp:precise)
12
+ else()
13
+ target_compile_options(_native PRIVATE -Wall -Wextra -Wpedantic)
14
+ endif()
15
+ install(TARGETS _native LIBRARY DESTINATION fastpgv RUNTIME DESTINATION fastpgv)
fastpgv-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SLED contributors
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.
fastpgv-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastpgv
3
+ Version: 0.1.0
4
+ Summary: Weighted polygon-to-grid aggregation with the native SLED algorithm
5
+ Keywords: polygon,raster,geospatial,SLED,aggregation
6
+ Author: SLED contributors
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ License-File: THIRD_PARTY_NOTICES.md
10
+ License-File: licenses/pybind11-LICENSE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: C++
20
+ Classifier: Topic :: Scientific/Engineering :: GIS
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: numpy>=1.23
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest>=8; extra == "test"
25
+ Requires-Dist: shapely>=2; extra == "test"
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8; extra == "dev"
28
+ Requires-Dist: shapely>=2; extra == "dev"
29
+ Requires-Dist: build>=1.2; extra == "dev"
30
+ Requires-Dist: twine>=6; extra == "dev"
31
+ Description-Content-Type: text/markdown
32
+
33
+ # fastpgv
34
+
35
+ **fastpgv** is a lightweight Python wrapper for fast density computation for Polygon-to-Grid Visualization (PGV).
36
+
37
+ This package provides an efficient way to compute density results through a simple Python interface.
38
+ Compute a density value for every pixel from weighted polygons using the C++17 SLED (Sweep-Line Edge-Difference Aggregation) algorithm.
39
+
40
+ ## Features
41
+
42
+ - One Python interface: `fastpgv.run(weighted_polygons, resolution)`.
43
+ - Weighted polygons with explicit vertex coordinates.
44
+ - The polygons should be non-overlapping.
45
+ - C++ computation backend with the Python interface.
46
+
47
+ ## Installation
48
+
49
+ From the `fastpgv/` source directory:
50
+
51
+ ```bash
52
+ python -m pip install .
53
+ ```
54
+
55
+ After version 0.1.0 is successfully published to production PyPI:
56
+
57
+ ```bash
58
+ python -m pip install fastpgv==0.1.0
59
+ ```
60
+
61
+ Publication is pending. The prepared wheel is for CPython 3.12 on macOS 11+
62
+ Apple Silicon (arm64). Other environments need to build the source distribution
63
+ with a C++17 compiler. Cross-platform wheels are not included in this release.
64
+
65
+ ## Requirements
66
+
67
+ - Python 3.10 or newer.
68
+ - NumPy, installed automatically by pip.
69
+ - A C++17 compiler for source builds. pip installs the Python build dependencies.
70
+
71
+ Java, DuckDB, APRIL, Boost, and the original benchmark repository are not required.
72
+
73
+ ## Quick Example
74
+
75
+ ```python
76
+ import fastpgv
77
+
78
+ # Polygons contain ordered vertex coordinates, not just identifiers.
79
+ polygon_a = {
80
+ "type": "Polygon",
81
+ "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]],
82
+ }
83
+ polygon_b = {
84
+ "type": "Polygon",
85
+ "coordinates": [[[1, 0], [2, 0], [2, 1], [1, 1], [1, 0]]],
86
+ }
87
+
88
+ density = fastpgv.run(
89
+ [(polygon_a, 2.0), (polygon_b, 3.0)],
90
+ resolution=(2, 1),
91
+ )
92
+ print(density)
93
+ # [[2. 3.]]
94
+ ```
95
+
96
+ ## Inputs and Output
97
+
98
+ Both arguments are required:
99
+
100
+ | Argument | Meaning |
101
+ |---|---|
102
+ | `weighted_polygons` | Nonempty iterable of `(geometry, weight)` pairs |
103
+ | `resolution` | Positive integer `(X, Y)`: columns and rows |
104
+
105
+ Geometry must contain ordered vertices as a GeoJSON `Polygon` or `MultiPolygon`
106
+ dictionary, or expose `__geo_interface__` (for example, a Shapely polygon).
107
+ Consecutive vertices define edges automatically; open rings are closed internally.
108
+ No separate edge input is required or accepted. The first ring is the exterior;
109
+ additional rings are holes. MultiPolygon components share one weight.
110
+
111
+ Weights must be explicit finite numbers. Negative and zero weights are supported.
112
+ The supported use case is non-overlapping polygonal inputs: interiors must not
113
+ overlap, but shared edges and vertices are allowed. Overlaps are not automatically
114
+ checked; validate this requirement before calling `run`.
115
+ The grid covers the combined minimum
116
+ bounding rectangle of **all** supplied geometries, including zero-weight ones.
117
+
118
+ `run` returns a `numpy.ndarray` directly, with dtype `float64` and shape `(Y, X)`.
119
+ `density[v, u]` is the value of the pixel in row `v` and column `u`. Row zero is
120
+ the top row at `y_max`; columns run from `x_min` to `x_max`.
121
+
122
+ The function returns data directly; it does not write files or render a visualization.
123
+
124
+ ## Notes
125
+
126
+ - Each pixel value is the sum of `weight * polygon_pixel_intersection_area`:
127
+ **weighted area, not area-normalized density**.
128
+ - Coordinates are planar, with no CRS transformation or geodesic calculation.
129
+ Project geographic coordinates first when metric areas are required.
130
+ - Supply valid polygon topology. Ring orientation is corrected, but invalid
131
+ holes and self-intersections are not repaired. Empty input is rejected.
132
+ - Numerical tests use absolute tolerance `1e-9` or relative tolerance `1e-12`.
133
+ Floating-point results can differ across platforms; extreme coordinates or
134
+ weights may require rescaling and application-specific error analysis.
135
+ - The complete output array requires `8 * X * Y` bytes, plus polygon-edge and
136
+ working storage. Choose a resolution that fits available memory.
137
+
138
+ ## License and Further Information
139
+
140
+ MIT licensed, by **SLED contributors**.
141
+
142
+ The source archive includes `PUBLISHING.md` (build and upload instructions),
143
+ `PORTING.md` (implementation provenance), and `THIRD_PARTY_NOTICES.md`
144
+ (dependency notices).
@@ -0,0 +1,26 @@
1
+ # Native engine provenance
2
+
3
+ Version 0.1.0 is a standalone adaptation of the SLED implementation in
4
+ `pgv-c-code/baseline/sled.cpp`, reviewed on 2026-09-07. The source workspace
5
+ had no committed revision to identify. No sibling files are needed to build.
6
+ The reference file's SHA-256 was
7
+ `0bf6fc00ae4a03633bf97e0b85939c1f766de206544b53477d8d0d93b25a4e4d`.
8
+
9
+ Retained algorithm: corrected outer/hole orientation, downward signed edges,
10
+ exact coordinate-pair cancellation, row scheduling, vertical-grid fragment
11
+ splitting, trapezoidal boundary integration, and row difference prefix sums.
12
+ The density-only revision removes candidate-count buffers and instrumentation.
13
+
14
+ Adaptation changes: Python validates weighted geometry pairs with vertices;
15
+ C++ takes coordinate arrays instead of JSON strings. A Builder and one-row
16
+ Sweep replace benchmark context/output dependencies. Translated shoelace
17
+ coordinates reduce orientation cancellation; grid-index conversion clamps
18
+ before integer conversion. Native buffers use long double as in SLED; output
19
+ is float64. Interactive updates, benchmark timers, DuckDB, Boost, and APRIL
20
+ are excluded. The public function is `run(weighted_polygons, resolution)`;
21
+ bounds are inferred from input geometries. File readers and public row streaming
22
+ are excluded. This snapshot is maintained independently, not auto-synchronized.
23
+
24
+ Tests use analytical fixtures and Shapely intersection areas. Repository-local
25
+ validation also compares densities against the original SLED and scan methods;
26
+ their executable is not a package or test dependency.
@@ -0,0 +1,117 @@
1
+ # Build and Publish
2
+
3
+ ## Release Status
4
+
5
+ Package version: 0.1.0. TestPyPI and production PyPI publication have not yet
6
+ occurred. A successful name lookup does not reserve a project name. Do not
7
+ advertise `pip install fastpgv` from production PyPI until that release exists.
8
+
9
+ ## Manual Production Upload
10
+
11
+ No GitHub repository is required. Upload only the freshly validated release
12
+ files, not the older files in `dist/`. From the release directory containing
13
+ the wheel, source archive, and `SHA256SUMS`:
14
+
15
+ ```bash
16
+ python -m pip install --upgrade twine
17
+ shasum -a 256 -c SHA256SUMS
18
+ python -m twine check --strict fastpgv-0.1.0-cp312-cp312-macosx_11_0_arm64.whl fastpgv-0.1.0.tar.gz
19
+ python -m twine upload --repository pypi fastpgv-0.1.0-cp312-cp312-macosx_11_0_arm64.whl fastpgv-0.1.0.tar.gz
20
+ ```
21
+
22
+ Use a production account at pypi.org with a verified email and two-factor
23
+ authentication configured. Create a production PyPI API token locally. For a
24
+ first project upload, an account-scoped token may be needed; after creating the
25
+ project, replace it with a project-scoped token. At Twine's prompts, enter
26
+ `__token__` as the username and paste the token at the hidden password prompt.
27
+ Never include the token in command arguments, files, or chat. TestPyPI accounts
28
+ and tokens are separate and cannot authenticate to production PyPI.
29
+
30
+ Before uploading, check https://pypi.org/project/fastpgv/ again. A missing project
31
+ does not reserve the name or guarantee permission. If the name/version is no
32
+ longer available, stop and resolve it instead of renaming artifacts manually.
33
+ Uploaded filenames cannot be overwritten. Do not upload validation reports or
34
+ checksum files: the command above names only the two distribution files.
35
+
36
+ This release includes only a CPython 3.12 macOS 11+ arm64 wheel and a source
37
+ archive. Other Python/platform combinations require a C++17 source build and
38
+ are not confirmed by the local macOS validation. No cross-platform CI results
39
+ are claimed.
40
+
41
+ After uploading, verify in a new CPython 3.12 environment on macOS Apple Silicon:
42
+
43
+ ```bash
44
+ python3.12 -m venv /tmp/fastpgv-pypi-check
45
+ /tmp/fastpgv-pypi-check/bin/python -m pip install --index-url https://pypi.org/simple/ --only-binary=:all: fastpgv==0.1.0
46
+ /tmp/fastpgv-pypi-check/bin/python -c 'import fastpgv; p={"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,1],[0,0]]]}; assert fastpgv.run([(p,2)],(1,1)).tolist()==[[2.0]]; print("PyPI installation verified")'
47
+ ```
48
+
49
+ Also run `examples/two_polygons.py` from the extracted source archive using that
50
+ environment. Expected output is `[[2. 3.]]`. Publication and this post-upload
51
+ check remain manual steps until the upload succeeds.
52
+
53
+ ## Optional GitHub Builds
54
+
55
+ The workflows in this folder assume **fastpgv is the repository root**. Place
56
+ this standalone folder in its own GitHub repository to activate them; workflows
57
+ nested inside a parent repository are not automatically executed by GitHub.
58
+
59
+ ## Local Validation
60
+
61
+ Use Python 3.10 or newer and a C++17 compiler:
62
+
63
+ ```bash
64
+ python -m pip install build twine
65
+ python -m build
66
+ python -m twine check dist/*
67
+ python -m venv /tmp/fastpgv-wheel-test
68
+ /tmp/fastpgv-wheel-test/bin/python -m pip install dist/*.whl pytest shapely
69
+ /tmp/fastpgv-wheel-test/bin/python -m pytest tests
70
+ /tmp/fastpgv-wheel-test/bin/python examples/two_polygons.py
71
+ ```
72
+
73
+ On Windows use the environment's `Scripts/python.exe` instead of `bin/python`.
74
+ Also extract the source archive to a directory outside the original repository
75
+ and run `python -m build --wheel` there. Check that no sibling source folders
76
+ or installed benchmark binary are needed. Build outputs and validation results
77
+ are local only; cross-platform support is confirmed by CI, not inferred from
78
+ one macOS build.
79
+
80
+ The wheel workflow targets standard CPython 3.10–3.14 on Linux x86_64,
81
+ macOS arm64/x86_64, and Windows x86_64. It runs tests against installed wheels,
82
+ including translated-coordinate and geometry-oracle tests on Windows, where
83
+ `long double` has different precision. Free-threaded CPython and musl Linux
84
+ are not targeted in this release.
85
+
86
+ ## Trusted Publishing to TestPyPI
87
+
88
+ 1. Create a TestPyPI account and enable its required account security settings.
89
+ 2. Create/configure the standalone GitHub repository. No repository URL or
90
+ publisher identity is embedded in this source snapshot.
91
+ 3. On TestPyPI, register a pending Trusted Publisher for `fastpgv`, using your
92
+ GitHub owner/repository, workflow `testpypi.yml`, and environment `testpypi`.
93
+ If the name is unavailable, resolve the name before publishing.
94
+ 4. Create the matching GitHub environment and require approval for publication.
95
+ 5. Run the **TestPyPI** workflow manually. It first builds and tests all wheels
96
+ plus the source archive, checks metadata, then uploads through OIDC.
97
+ 6. Review the post-upload installation check and TestPyPI project page.
98
+
99
+ Never put API tokens in source files, workflow YAML, or chat. The workflow uses
100
+ short-lived Trusted Publishing credentials. An upload is public and versioned;
101
+ an existing filename cannot be overwritten. Fixes require a new version.
102
+
103
+ Manual post-upload verification in a new environment:
104
+
105
+ ```bash
106
+ python -m pip install numpy
107
+ python -m pip install --index-url https://test.pypi.org/simple/ \
108
+ --only-binary=:all: --no-deps fastpgv==0.1.0
109
+ python examples/two_polygons.py
110
+ ```
111
+
112
+ The workflow above targets TestPyPI only. For the manual production release,
113
+ use the explicit production upload command earlier in this guide.
114
+
115
+ Official references: [Trusted Publishing](https://docs.pypi.org/trusted-publishers/),
116
+ [packaging binary extensions](https://packaging.python.org/en/latest/guides/packaging-binary-extensions/),
117
+ [cibuildwheel](https://cibuildwheel.pypa.io/).
@@ -0,0 +1,112 @@
1
+ # fastpgv
2
+
3
+ **fastpgv** is a lightweight Python wrapper for fast density computation for Polygon-to-Grid Visualization (PGV).
4
+
5
+ This package provides an efficient way to compute density results through a simple Python interface.
6
+ Compute a density value for every pixel from weighted polygons using the C++17 SLED (Sweep-Line Edge-Difference Aggregation) algorithm.
7
+
8
+ ## Features
9
+
10
+ - One Python interface: `fastpgv.run(weighted_polygons, resolution)`.
11
+ - Weighted polygons with explicit vertex coordinates.
12
+ - The polygons should be non-overlapping.
13
+ - C++ computation backend with the Python interface.
14
+
15
+ ## Installation
16
+
17
+ From the `fastpgv/` source directory:
18
+
19
+ ```bash
20
+ python -m pip install .
21
+ ```
22
+
23
+ After version 0.1.0 is successfully published to production PyPI:
24
+
25
+ ```bash
26
+ python -m pip install fastpgv==0.1.0
27
+ ```
28
+
29
+ Publication is pending. The prepared wheel is for CPython 3.12 on macOS 11+
30
+ Apple Silicon (arm64). Other environments need to build the source distribution
31
+ with a C++17 compiler. Cross-platform wheels are not included in this release.
32
+
33
+ ## Requirements
34
+
35
+ - Python 3.10 or newer.
36
+ - NumPy, installed automatically by pip.
37
+ - A C++17 compiler for source builds. pip installs the Python build dependencies.
38
+
39
+ Java, DuckDB, APRIL, Boost, and the original benchmark repository are not required.
40
+
41
+ ## Quick Example
42
+
43
+ ```python
44
+ import fastpgv
45
+
46
+ # Polygons contain ordered vertex coordinates, not just identifiers.
47
+ polygon_a = {
48
+ "type": "Polygon",
49
+ "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]],
50
+ }
51
+ polygon_b = {
52
+ "type": "Polygon",
53
+ "coordinates": [[[1, 0], [2, 0], [2, 1], [1, 1], [1, 0]]],
54
+ }
55
+
56
+ density = fastpgv.run(
57
+ [(polygon_a, 2.0), (polygon_b, 3.0)],
58
+ resolution=(2, 1),
59
+ )
60
+ print(density)
61
+ # [[2. 3.]]
62
+ ```
63
+
64
+ ## Inputs and Output
65
+
66
+ Both arguments are required:
67
+
68
+ | Argument | Meaning |
69
+ |---|---|
70
+ | `weighted_polygons` | Nonempty iterable of `(geometry, weight)` pairs |
71
+ | `resolution` | Positive integer `(X, Y)`: columns and rows |
72
+
73
+ Geometry must contain ordered vertices as a GeoJSON `Polygon` or `MultiPolygon`
74
+ dictionary, or expose `__geo_interface__` (for example, a Shapely polygon).
75
+ Consecutive vertices define edges automatically; open rings are closed internally.
76
+ No separate edge input is required or accepted. The first ring is the exterior;
77
+ additional rings are holes. MultiPolygon components share one weight.
78
+
79
+ Weights must be explicit finite numbers. Negative and zero weights are supported.
80
+ The supported use case is non-overlapping polygonal inputs: interiors must not
81
+ overlap, but shared edges and vertices are allowed. Overlaps are not automatically
82
+ checked; validate this requirement before calling `run`.
83
+ The grid covers the combined minimum
84
+ bounding rectangle of **all** supplied geometries, including zero-weight ones.
85
+
86
+ `run` returns a `numpy.ndarray` directly, with dtype `float64` and shape `(Y, X)`.
87
+ `density[v, u]` is the value of the pixel in row `v` and column `u`. Row zero is
88
+ the top row at `y_max`; columns run from `x_min` to `x_max`.
89
+
90
+ The function returns data directly; it does not write files or render a visualization.
91
+
92
+ ## Notes
93
+
94
+ - Each pixel value is the sum of `weight * polygon_pixel_intersection_area`:
95
+ **weighted area, not area-normalized density**.
96
+ - Coordinates are planar, with no CRS transformation or geodesic calculation.
97
+ Project geographic coordinates first when metric areas are required.
98
+ - Supply valid polygon topology. Ring orientation is corrected, but invalid
99
+ holes and self-intersections are not repaired. Empty input is rejected.
100
+ - Numerical tests use absolute tolerance `1e-9` or relative tolerance `1e-12`.
101
+ Floating-point results can differ across platforms; extreme coordinates or
102
+ weights may require rescaling and application-specific error analysis.
103
+ - The complete output array requires `8 * X * Y` bytes, plus polygon-edge and
104
+ working storage. Choose a resolution that fits available memory.
105
+
106
+ ## License and Further Information
107
+
108
+ MIT licensed, by **SLED contributors**.
109
+
110
+ The source archive includes `PUBLISHING.md` (build and upload instructions),
111
+ `PORTING.md` (implementation provenance), and `THIRD_PARTY_NOTICES.md`
112
+ (dependency notices).
@@ -0,0 +1,12 @@
1
+ # Dependencies and notices
2
+
3
+ The SLED implementation is distributed under the MIT license in LICENSE.
4
+ Native bindings use pybind11 (BSD-3-Clause); its notice is reproduced in
5
+ `licenses/pybind11-LICENSE`. NumPy is a separately installed runtime dependency
6
+ under its own BSD license; it is not bundled here. Build tools scikit-build-core,
7
+ CMake, and a C++17 compiler are installed separately. Test tools pytest and
8
+ Shapely are optional and are not runtime dependencies.
9
+
10
+ No DuckDB, APRIL, Boost, private datasets, or benchmark outputs are included.
11
+ CMake does not fetch source files from the network. A source build still needs
12
+ the declared Python build dependencies, normally provisioned by pip.
@@ -0,0 +1,24 @@
1
+ #include "sled.h"
2
+ #include <pybind11/numpy.h>
3
+ #include <pybind11/pybind11.h>
4
+ #include <pybind11/stl.h>
5
+ #include <algorithm>
6
+ #include <limits>
7
+
8
+ namespace py = pybind11;
9
+ PYBIND11_MODULE(_native, module) {
10
+ module.attr("long_double_bits") = std::numeric_limits<long double>::digits;
11
+ py::class_<fastpgv::Sweep>(module, "Sweep")
12
+ .def("next_row", [](fastpgv::Sweep& sweep) {
13
+ fastpgv::Row row;
14
+ { py::gil_scoped_release release; row = sweep.next(); }
15
+ if (row.v < 0) throw py::stop_iteration();
16
+ py::array_t<double> density(row.density.size());
17
+ std::copy(row.density.begin(), row.density.end(), density.mutable_data());
18
+ return density;
19
+ });
20
+ py::class_<fastpgv::Builder>(module, "Builder")
21
+ .def(py::init<>())
22
+ .def("add", &fastpgv::Builder::add, py::call_guard<py::gil_scoped_release>())
23
+ .def("finish", &fastpgv::Builder::finish, py::call_guard<py::gil_scoped_release>());
24
+ }
@@ -0,0 +1,195 @@
1
+ // Adapted from pgv-c-code/baseline/sled.cpp; see PORTING.md.
2
+ #include "sled.h"
3
+
4
+ #include <algorithm>
5
+ #include <cmath>
6
+ #include <limits>
7
+ #include <stdexcept>
8
+ #include <tuple>
9
+
10
+ namespace fastpgv {
11
+ namespace {
12
+ auto key(const Edge& e) { return std::tie(e.xu, e.yu, e.xl, e.yl); }
13
+
14
+ long double snap(long double value) {
15
+ const auto nearest = std::round(value);
16
+ const auto tolerance = 64.0L * std::numeric_limits<long double>::epsilon() *
17
+ std::max(1.0L, std::abs(value));
18
+ return std::abs(value - nearest) <= tolerance ? nearest : value;
19
+ }
20
+
21
+ long double interpolate(const Edge& e, long double y) {
22
+ const auto t = (static_cast<long double>(e.yu) - y) /
23
+ (static_cast<long double>(e.yu) - e.yl);
24
+ return e.xu + (static_cast<long double>(e.xl) - e.xu) * t;
25
+ }
26
+
27
+ // Clamp before integer conversion, including edges far outside a custom bbox.
28
+ int bounded_index(long double position, int maximum) {
29
+ return static_cast<int>(std::clamp(position, 0.0L,
30
+ static_cast<long double>(maximum)));
31
+ }
32
+ } // namespace
33
+
34
+ void Builder::add(const Geometry& geometry, double weight) {
35
+ std::lock_guard<std::mutex> lock(mutex_);
36
+ if (finished_) throw std::logic_error("builder has already been consumed");
37
+ if (!std::isfinite(weight)) throw std::invalid_argument("weight must be finite");
38
+ if (geometry.empty()) throw std::invalid_argument("geometry must not be empty");
39
+ std::vector<Edge> additions;
40
+ Bounds local{};
41
+ bool first = true;
42
+ for (const auto& polygon : geometry) {
43
+ if (polygon.empty()) throw std::invalid_argument("polygon requires an outer ring");
44
+ for (std::size_t r = 0; r < polygon.size(); ++r) {
45
+ std::vector<Point> ring;
46
+ for (const auto& point : polygon[r]) {
47
+ if (!std::isfinite(point[0]) || !std::isfinite(point[1]))
48
+ throw std::invalid_argument("coordinates must be finite");
49
+ if (first) { local = {point[0], point[1], point[0], point[1]}; first = false; }
50
+ local[0] = std::min(local[0], point[0]);
51
+ local[1] = std::min(local[1], point[1]);
52
+ local[2] = std::max(local[2], point[0]);
53
+ local[3] = std::max(local[3], point[1]);
54
+ if (ring.empty() || ring.back() != point) ring.push_back(point);
55
+ }
56
+ if (ring.size() < 3) throw std::invalid_argument("ring requires three distinct vertices");
57
+ if (ring.front() != ring.back()) ring.push_back(ring.front());
58
+ if (ring.size() < 4) throw std::invalid_argument("degenerate ring");
59
+ // Translation reduces cancellation on platforms where long double is double.
60
+ long double area = 0;
61
+ for (std::size_t i = 0; i + 1 < ring.size(); ++i) {
62
+ const long double ax = static_cast<long double>(ring[i][0]) - ring[0][0];
63
+ const long double ay = static_cast<long double>(ring[i][1]) - ring[0][1];
64
+ const long double bx = static_cast<long double>(ring[i+1][0]) - ring[0][0];
65
+ const long double by = static_cast<long double>(ring[i+1][1]) - ring[0][1];
66
+ area += ax * by - bx * ay;
67
+ }
68
+ if (!std::isfinite(area) || area == 0)
69
+ throw std::invalid_argument("ring has zero or non-finite signed area");
70
+ if ((area > 0) != (r == 0)) std::reverse(ring.begin(), ring.end());
71
+ for (std::size_t i = 0; i + 1 < ring.size(); ++i) {
72
+ const auto& a = ring[i]; const auto& b = ring[i+1];
73
+ if (a[1] == b[1]) continue;
74
+ if (a[1] > b[1]) additions.push_back({a[0], a[1], b[0], b[1], weight});
75
+ else additions.push_back({b[0], b[1], a[0], a[1], -static_cast<long double>(weight)});
76
+ }
77
+ }
78
+ }
79
+ edges_.insert(edges_.end(), additions.begin(), additions.end());
80
+ if (!have_bounds_) { bounds_ = local; have_bounds_ = true; }
81
+ else {
82
+ bounds_[0] = std::min(bounds_[0], local[0]); bounds_[1] = std::min(bounds_[1], local[1]);
83
+ bounds_[2] = std::max(bounds_[2], local[2]); bounds_[3] = std::max(bounds_[3], local[3]);
84
+ }
85
+ }
86
+
87
+ std::unique_ptr<Sweep> Builder::finish(int width, int height) {
88
+ std::lock_guard<std::mutex> lock(mutex_);
89
+ if (finished_) throw std::logic_error("builder has already been consumed");
90
+ if (!have_bounds_) throw std::invalid_argument("weighted_polygons must not be empty");
91
+ const Bounds bounds = bounds_;
92
+ for (double value : bounds)
93
+ if (!std::isfinite(value)) throw std::invalid_argument("bounds must be finite");
94
+ if (width <= 0 || height <= 0 || bounds[2] <= bounds[0] || bounds[3] <= bounds[1])
95
+ throw std::invalid_argument("resolution and bounds must have positive extent");
96
+ if (width == std::numeric_limits<int>::max() || height == std::numeric_limits<int>::max())
97
+ throw std::invalid_argument("resolution exceeds native index capacity");
98
+ for (int axis = 0; axis < 2; ++axis) {
99
+ double span = bounds[axis+2] - bounds[axis];
100
+ double step = span / (axis == 0 ? width : height);
101
+ if (!std::isfinite(span) || !(step > 0) ||
102
+ bounds[axis] + step == bounds[axis] || bounds[axis+2] - step == bounds[axis+2])
103
+ throw std::invalid_argument("grid spacing is not representable at these coordinates");
104
+ }
105
+ std::stable_sort(edges_.begin(), edges_.end(), [](const Edge& a, const Edge& b) { return key(a) < key(b); });
106
+ std::size_t write = 0;
107
+ for (std::size_t i = 0; i < edges_.size();) {
108
+ auto edge = edges_[i++];
109
+ while (i < edges_.size() && key(edge) == key(edges_[i])) edge.weight += edges_[i++].weight;
110
+ if (!std::isfinite(edge.weight)) throw std::overflow_error("edge coefficient overflow");
111
+ if (edge.weight != 0) edges_[write++] = edge;
112
+ }
113
+ edges_.resize(write);
114
+ edges_.shrink_to_fit();
115
+ finished_ = true;
116
+ return std::make_unique<Sweep>(std::move(edges_), bounds, width, height);
117
+ }
118
+
119
+ Sweep::Sweep(std::vector<Edge> edges, Bounds bounds, int width, int height)
120
+ : edges_(std::move(edges)), bounds_(bounds), width_(width), height_(height),
121
+ dx_((bounds[2]-bounds[0])/width), dy_((bounds[3]-bounds[1])/height),
122
+ last_rows_(edges_.size(), -1), offsets_(static_cast<std::size_t>(height)+1, 0),
123
+ boundary_(width), difference_(static_cast<std::size_t>(width)+1) {
124
+ std::vector<int> first_rows(edges_.size(), -1);
125
+ for (std::size_t i = 0; i < edges_.size(); ++i) {
126
+ auto top = std::min(static_cast<long double>(edges_[i].yu), static_cast<long double>(bounds[3]));
127
+ auto bottom = std::max(static_cast<long double>(edges_[i].yl), static_cast<long double>(bounds[1]));
128
+ if (!(bottom < top)) continue;
129
+ int first = bounded_index(std::floor(snap((bounds[3]-top)/dy_)), height-1);
130
+ int last = bounded_index(std::ceil(snap((bounds[3]-bottom)/dy_))-1, height-1);
131
+ first_rows[i] = first; last_rows_[i] = last; ++offsets_[first+1];
132
+ }
133
+ for (int row = 1; row <= height; ++row) offsets_[row] += offsets_[row-1];
134
+ entries_.resize(offsets_.back());
135
+ auto cursors = offsets_;
136
+ for (std::size_t i = 0; i < edges_.size(); ++i)
137
+ if (first_rows[i] >= 0) entries_[cursors[first_rows[i]]++] = i;
138
+ }
139
+
140
+ void Sweep::accumulate(const Edge& edge, int row) {
141
+ long double top = std::min(static_cast<long double>(edge.yu),
142
+ static_cast<long double>(bounds_[3] - row * dy_));
143
+ long double bottom = std::max(static_cast<long double>(edge.yl),
144
+ static_cast<long double>(bounds_[3] - (row+1) * dy_));
145
+ if (!(bottom < top)) return;
146
+ auto xt = interpolate(edge, top), xb = interpolate(edge, bottom);
147
+ std::vector<long double> parameters{0, 1};
148
+ if (xt != xb) {
149
+ int first = bounded_index(std::floor(snap((std::min(xt,xb)-bounds_[0])/dx_))+1, width_);
150
+ int last = bounded_index(std::ceil(snap((std::max(xt,xb)-bounds_[0])/dx_))-1, width_);
151
+ for (int line = first; line <= last; ++line) {
152
+ auto t = (static_cast<long double>(bounds_[0]+line*dx_) - xt)/(xb-xt);
153
+ if (t > 0 && t < 1) parameters.push_back(t);
154
+ }
155
+ }
156
+ std::sort(parameters.begin(), parameters.end());
157
+ parameters.erase(std::unique(parameters.begin(), parameters.end()), parameters.end());
158
+ for (std::size_t i = 0; i+1 < parameters.size(); ++i) {
159
+ auto a = xt + (xb-xt)*parameters[i], b = xt + (xb-xt)*parameters[i+1];
160
+ auto h = (top-bottom)*(parameters[i+1]-parameters[i]);
161
+ auto midpoint = (a+b)*0.5L, suffix = edge.weight*h*dx_;
162
+ if (midpoint < bounds_[0]) { difference_[0] += suffix; difference_[width_] -= suffix; continue; }
163
+ if (midpoint >= bounds_[2]) continue;
164
+ int column = bounded_index(std::floor(snap((midpoint-bounds_[0])/dx_)), width_);
165
+ if (column >= width_) continue;
166
+ const long double right = bounds_[0]+(column+1)*dx_;
167
+ boundary_[column] += edge.weight*((right-a)+(right-b))*0.5L*h;
168
+ difference_[column+1] += suffix; difference_[width_] -= suffix;
169
+ }
170
+ }
171
+
172
+ Row Sweep::next() {
173
+ std::lock_guard<std::mutex> lock(mutex_);
174
+ if (next_row_ == height_) return {-1, {}};
175
+ const int row = next_row_;
176
+ std::fill(boundary_.begin(), boundary_.end(), 0);
177
+ std::fill(difference_.begin(), difference_.end(), 0);
178
+ active_.insert(active_.end(), entries_.begin()+offsets_[row], entries_.begin()+offsets_[row+1]);
179
+ std::size_t retained = 0;
180
+ for (auto i : active_) {
181
+ accumulate(edges_[i], row);
182
+ if (last_rows_[i] > row) active_[retained++] = i;
183
+ }
184
+ active_.resize(retained);
185
+ Row result{row, std::vector<double>(width_)};
186
+ long double suffix = 0;
187
+ for (int column = 0; column < width_; ++column) {
188
+ suffix += difference_[column];
189
+ result.density[column] = static_cast<double>(boundary_[column]+suffix);
190
+ if (!std::isfinite(result.density[column])) throw std::overflow_error("density overflow");
191
+ }
192
+ ++next_row_;
193
+ return result;
194
+ }
195
+ } // namespace fastpgv
@@ -0,0 +1,51 @@
1
+ #pragma once
2
+
3
+ #include <array>
4
+ #include <memory>
5
+ #include <mutex>
6
+ #include <vector>
7
+
8
+ namespace fastpgv {
9
+ using Point = std::array<double, 2>;
10
+ using Polygon = std::vector<std::vector<Point>>;
11
+ using Geometry = std::vector<Polygon>;
12
+ using Bounds = std::array<double, 4>;
13
+
14
+ struct Edge {
15
+ double xu, yu, xl, yl;
16
+ long double weight;
17
+ };
18
+
19
+ struct Row {
20
+ int v;
21
+ std::vector<double> density;
22
+ };
23
+
24
+ class Sweep {
25
+ public:
26
+ Sweep(std::vector<Edge> edges, Bounds bounds, int width, int height);
27
+ Row next();
28
+
29
+ private:
30
+ void accumulate(const Edge& edge, int row);
31
+ std::vector<Edge> edges_;
32
+ Bounds bounds_;
33
+ int width_, height_, next_row_ = 0;
34
+ double dx_, dy_;
35
+ std::vector<int> last_rows_;
36
+ std::vector<std::size_t> offsets_, entries_, active_;
37
+ std::vector<long double> boundary_, difference_;
38
+ std::mutex mutex_;
39
+ };
40
+
41
+ class Builder {
42
+ public:
43
+ void add(const Geometry& geometry, double weight);
44
+ std::unique_ptr<Sweep> finish(int width, int height);
45
+ private:
46
+ std::vector<Edge> edges_;
47
+ Bounds bounds_{};
48
+ bool have_bounds_ = false, finished_ = false;
49
+ std::mutex mutex_;
50
+ };
51
+ } // namespace fastpgv
@@ -0,0 +1,8 @@
1
+ import fastpgv
2
+
3
+ polygons = [
4
+ {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]},
5
+ {"type": "Polygon", "coordinates": [[[1, 0], [2, 0], [2, 1], [1, 1], [1, 0]]]},
6
+ ]
7
+ density = fastpgv.run([(polygons[0], 2), (polygons[1], 3)], resolution=(2, 1))
8
+ print(density) # [[2., 3.]]
@@ -0,0 +1,29 @@
1
+ Copyright (c) 2016 Wenzel Jakob <wenzel.jakob@epfl.ch>, All rights reserved.
2
+
3
+ Redistribution and use in source and binary forms, with or without
4
+ modification, are permitted provided that the following conditions are met:
5
+
6
+ 1. Redistributions of source code must retain the above copyright notice, this
7
+ list of conditions and the following disclaimer.
8
+
9
+ 2. Redistributions in binary form must reproduce the above copyright notice,
10
+ this list of conditions and the following disclaimer in the documentation
11
+ and/or other materials provided with the distribution.
12
+
13
+ 3. Neither the name of the copyright holder nor the names of its contributors
14
+ may be used to endorse or promote products derived from this software
15
+ without specific prior written permission.
16
+
17
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
18
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
19
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
20
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
21
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
22
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
23
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
24
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
25
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27
+
28
+ Please also refer to the file .github/CONTRIBUTING.md, which clarifies licensing of
29
+ external contributions to this project including patches, pull requests, etc.
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["scikit-build-core>=0.11,<2", "pybind11>=3.0,<4"]
3
+ build-backend = "scikit_build_core.build"
4
+
5
+ [project]
6
+ name = "fastpgv"
7
+ version = "0.1.0"
8
+ description = "Weighted polygon-to-grid aggregation with the native SLED algorithm"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md", "licenses/*"]
13
+ authors = [{name = "SLED contributors"}]
14
+ dependencies = ["numpy>=1.23"]
15
+ keywords = ["polygon", "raster", "geospatial", "SLED", "aggregation"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Science/Research",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ "Programming Language :: C++",
26
+ "Topic :: Scientific/Engineering :: GIS",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ test = ["pytest>=8", "shapely>=2"]
31
+ dev = ["pytest>=8", "shapely>=2", "build>=1.2", "twine>=6"]
32
+
33
+ [tool.scikit-build]
34
+ cmake.version = ">=3.18"
35
+ wheel.packages = ["src/fastpgv"]
36
+ sdist.include = ["cpp/*.cpp", "cpp/*.h", "src/fastpgv/*.py", "tests/*.py", "examples/*.py", "licenses/*", "/README.md", "/PORTING.md", "/PUBLISHING.md", "/THIRD_PARTY_NOTICES.md", "/LICENSE", "/CMakeLists.txt", "/pyproject.toml", ".github/workflows/*.yml"]
37
+ sdist.exclude = ["dist/**", "build/**", ".venv/**", "**/__pycache__/**", "**/*.pyc", ".pytest_cache/**"]
38
+
39
+ [tool.cibuildwheel]
40
+ build = "cp310-* cp311-* cp312-* cp313-* cp314-*"
41
+ skip = "*-musllinux_* *-win32 *-manylinux_i686"
42
+ test-requires = ["pytest>=8", "shapely>=2"]
43
+ test-command = "python -m pytest {project}/tests"
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
@@ -0,0 +1,75 @@
1
+ """SLED weighted polygon-to-grid aggregation."""
2
+ import math as _math
3
+ from numbers import Integral as _Integral, Real as _Real
4
+
5
+ import numpy as _np
6
+ from . import _native
7
+
8
+ __version__ = "0.1.0"
9
+ __all__ = ["run"]
10
+
11
+
12
+ def _number(value, label):
13
+ if isinstance(value, bool) or not isinstance(value, _Real):
14
+ raise ValueError(f"{label} must be a finite number")
15
+ try:
16
+ result = float(value)
17
+ except (OverflowError, ValueError) as exc:
18
+ raise ValueError(f"{label} must be a finite number") from exc
19
+ if not _math.isfinite(result):
20
+ raise ValueError(f"{label} must be a finite number")
21
+ return result
22
+
23
+
24
+ def _geometry(value):
25
+ value = getattr(value, "__geo_interface__", value)
26
+ if not isinstance(value, dict) or value.get("type") not in ("Polygon", "MultiPolygon"):
27
+ raise ValueError("geometry must be a GeoJSON Polygon or MultiPolygon with vertex coordinates")
28
+ try:
29
+ polygons = [value["coordinates"]] if value["type"] == "Polygon" else value["coordinates"]
30
+ return [
31
+ [[[_number(p[0], "coordinate"), _number(p[1], "coordinate")]
32
+ for p in ring] for ring in polygon] for polygon in polygons]
33
+ except (KeyError, IndexError, TypeError) as exc:
34
+ raise ValueError("invalid polygon vertex coordinates") from exc
35
+
36
+
37
+ def run(weighted_polygons, resolution):
38
+ """Return weighted intersection areas as a float64 array shaped (Y, X).
39
+
40
+ weighted_polygons is an iterable of (geometry, weight) pairs. Geometry
41
+ contains ordered vertices as a GeoJSON Polygon/MultiPolygon dictionary
42
+ or an object exposing __geo_interface__. Weights must be finite.
43
+ resolution is (X, Y); both dimensions are required positive integers.
44
+ Bounds are inferred from all polygons. Row zero is the top/northern row.
45
+ Valid polygon topology is required; coordinates are treated as planar.
46
+ """
47
+ try:
48
+ dimensions = tuple(resolution)
49
+ except TypeError as exc:
50
+ raise ValueError("resolution must be two positive integers (X, Y)") from exc
51
+ if len(dimensions) != 2 or any(isinstance(v, bool) or not isinstance(v, _Integral)
52
+ or not 0 < v < 2147483647 for v in dimensions):
53
+ raise ValueError("resolution must be two positive integers (X, Y)")
54
+ x, y = map(int, dimensions)
55
+ builder = _native.Builder()
56
+ try:
57
+ records = iter(weighted_polygons)
58
+ except TypeError as exc:
59
+ raise ValueError("weighted_polygons must be an iterable of (geometry, weight) pairs") from exc
60
+ for index, record in enumerate(records):
61
+ if isinstance(record, (str, bytes, dict)):
62
+ raise ValueError(f"weighted polygon {index} must be a (geometry, weight) pair")
63
+ try:
64
+ geometry, weight = record
65
+ except (TypeError, ValueError) as exc:
66
+ raise ValueError(f"weighted polygon {index} must be a (geometry, weight) pair") from exc
67
+ try:
68
+ builder.add(_geometry(geometry), _number(weight, "weight"))
69
+ except ValueError as exc:
70
+ raise ValueError(f"weighted polygon {index}: {exc}") from exc
71
+ sweep = builder.finish(x, y)
72
+ density = _np.empty((y, x), dtype=_np.float64)
73
+ for v in range(y):
74
+ density[v] = sweep.next_row()
75
+ return density
@@ -0,0 +1,37 @@
1
+ """Optional snapshot parity check; not required for standalone wheel tests.
2
+
3
+ Set FASTPGV_BASELINE to the absolute pgv_baselines executable path.
4
+ """
5
+ import csv
6
+ import os
7
+ import subprocess
8
+
9
+ import numpy as np
10
+ import pytest
11
+ from shapely.geometry import box
12
+
13
+ import fastpgv
14
+ from test_fastpgv import fixture, write_records
15
+
16
+
17
+ @pytest.mark.skipif(not os.environ.get("FASTPGV_BASELINE"), reason="optional benchmark executable")
18
+ @pytest.mark.parametrize("resolution", [(4, 4), (17, 13)])
19
+ @pytest.mark.parametrize("shared", [False, True])
20
+ def test_benchmark_reference(tmp_path, resolution, shared):
21
+ source = tmp_path / "records.jsonl"
22
+ geometries, weights = ([box(0, 0, 2, 4), box(2, 0, 4, 4)], [2, 2]) if shared else fixture()
23
+ write_records(source, geometries, weights)
24
+ process = subprocess.run([
25
+ os.environ["FASTPGV_BASELINE"], "--records", str(source), "--resolution", f"{resolution[0]}x{resolution[1]}",
26
+ "--method", "scan,SLED", "--threads", "1",
27
+ "--out-dir", str(tmp_path / "reference"), "--run-name", "package_validation",
28
+ "--compare", "--no-progress"], capture_output=True, text=True)
29
+ assert process.returncode == 0, process.stderr + process.stdout
30
+ result = fastpgv.run(zip(geometries, weights), resolution)
31
+ paths = list((tmp_path / "reference").rglob("pixels.csv"))
32
+ assert len(paths) == 2
33
+ for path in paths:
34
+ with path.open() as f:
35
+ rows = list(csv.DictReader(f))
36
+ np.testing.assert_allclose(result.ravel(), [float(r["density"]) for r in rows],
37
+ atol=1e-9, rtol=1e-12)
@@ -0,0 +1,115 @@
1
+ import json
2
+ import inspect
3
+
4
+ import numpy as np
5
+ import pytest
6
+ from shapely.geometry import Polygon, MultiPolygon, box, mapping
7
+
8
+ import fastpgv
9
+
10
+
11
+ def fixture():
12
+ return [box(0, 0, 2, 2), Polygon([(0.25, 0.25), (3.75, 0.75), (2.25, 3.75)]),
13
+ Polygon([(0, 0), (4, 0), (4, 4), (0, 4)],
14
+ [[(1, 1), (3, 1), (3, 3), (1, 3)]]),
15
+ MultiPolygon([box(0, 3, 1, 4), box(3, 0, 4, 1)]), box(1.25, 1.25, 2.75, 2.75)], \
16
+ [1, 2, 0.5, -0.75, 0]
17
+
18
+
19
+ def write_records(path, geometries, weights):
20
+ with path.open("w") as f:
21
+ for geometry, weight in zip(geometries, weights):
22
+ f.write(json.dumps({"cell_id": "duplicate", "geometry": mapping(geometry),
23
+ "weight": {"value": weight}}) + "\n")
24
+
25
+
26
+ @pytest.mark.parametrize("resolution", [(4, 4), (17, 13), (1, 1)])
27
+ def test_geometry_oracle(resolution):
28
+ geometries, weights = fixture()
29
+ result = fastpgv.run(zip(geometries, weights), resolution)
30
+ x, y = resolution
31
+ expected = np.zeros((y, x))
32
+ dx, dy = 4/x, 4/y
33
+ for v in range(y):
34
+ for u in range(x):
35
+ pixel = box(u*dx, 4-(v+1)*dy, (u+1)*dx, 4-v*dy)
36
+ expected[v, u] = sum(w*g.intersection(pixel).area for g, w in zip(geometries, weights))
37
+ difference = np.abs(result - expected)
38
+ assert np.all((difference <= 1e-9) | (difference <= 1e-12*np.abs(expected)))
39
+ assert result.dtype == np.float64 and result.shape == (y, x)
40
+
41
+
42
+ def test_example_and_vertices():
43
+ # Overlaps remain an internal algorithm regression, not a public guarantee.
44
+ a = {"type": "Polygon", "coordinates": [[[0, 0], [2, 0], [2, 2], [0, 2], [0, 0]]]}
45
+ b = {"type": "Polygon", "coordinates": [[[1, 1], [2, 1], [2, 2], [1, 2]]]}
46
+ np.testing.assert_array_equal(fastpgv.run([(a, 2), (b, 3)], (2, 2)), [[2, 5], [2, 2]])
47
+
48
+
49
+ def test_release_example():
50
+ a, b = box(0, 0, 1, 1), box(1, 0, 2, 1)
51
+ assert a.intersection(b).area == 0
52
+ np.testing.assert_array_equal(fastpgv.run([(mapping(a), 2), (mapping(b), 3)], (2, 1)), [[2, 3]])
53
+
54
+
55
+ def test_shared_boundaries_and_zero_weights():
56
+ left, right = box(0, 0, 1, 2), box(1, 0, 2, 2)
57
+ np.testing.assert_array_equal(fastpgv.run([(left, 1), (right, 2)], (2, 2)), [[1, 2], [1, 2]])
58
+ assert not fastpgv.run([(left, 1), (left, -1)], (2, 2)).any()
59
+ # Zero-weight geometry still defines the domain.
60
+ np.testing.assert_array_equal(fastpgv.run([(box(0, 0, 1, 1), 1), (box(0, 0, 2, 2), 0)], (2, 2)),
61
+ [[0, 0], [1, 0]])
62
+ assert not fastpgv.run([(left, 0)], (2, 2)).any()
63
+
64
+
65
+ def test_orientation_and_unsegmented_edges():
66
+ left = Polygon([(0, 0), (0, 2), (1, 2), (1, 1), (1, 0)])
67
+ right = box(1, 0, 2, 2)
68
+ np.testing.assert_array_equal(fastpgv.run([(left, 1), (right, 1)], (2, 2)), np.ones((2, 2)))
69
+
70
+
71
+ def test_translated_coordinates():
72
+ # Also runs on Windows, where long double has 53 significand bits.
73
+ origin = 1e8
74
+ polygon = Polygon([(origin, origin), (origin+2, origin), (origin, origin+2)])
75
+ np.testing.assert_allclose(fastpgv.run([(polygon, 1)], (2, 2)), [[0.5, 0], [1, 0.5]], atol=1e-9)
76
+
77
+
78
+ @pytest.mark.parametrize("resolution", [(0, 2), (2.5, 2), (True, 2), (2,), (2147483647, 2), None])
79
+ def test_invalid_resolution(resolution):
80
+ with pytest.raises(ValueError, match="resolution"):
81
+ fastpgv.run([(box(0, 0, 1, 1), 1)], resolution)
82
+
83
+
84
+ @pytest.mark.parametrize("geometry", [{"type": "Point", "coordinates": [0, 0]},
85
+ {"type": "Polygon", "coordinates": []},
86
+ {"type": "Polygon", "coordinates": [[[0, 0], [1, 1], [2, 2]]]},
87
+ {"type": "Polygon", "coordinates": [[[0, 0], [1, float("nan")], [2, 0]]]},
88
+ {"type": "Polygon", "coordinates": None}, "polygon_id"])
89
+ def test_invalid_geometry(geometry):
90
+ with pytest.raises(ValueError):
91
+ fastpgv.run([(geometry, 1)], (2, 2))
92
+
93
+
94
+ @pytest.mark.parametrize("weight", [float("nan"), float("inf"), "1", True, 10**400])
95
+ def test_invalid_weight(weight):
96
+ with pytest.raises(ValueError, match="weight"):
97
+ fastpgv.run([(box(0, 0, 1, 1), weight)], (2, 2))
98
+
99
+
100
+ @pytest.mark.parametrize("records", [[], None, [()], [(1, 2, 3)], ["ab"], [{"geometry": {}, "weight": 1}]])
101
+ def test_invalid_pairs(records):
102
+ with pytest.raises(ValueError):
103
+ fastpgv.run(records, (2, 2))
104
+
105
+
106
+ def test_public_api():
107
+ assert fastpgv.__all__ == ["run"]
108
+ assert list(inspect.signature(fastpgv.run).parameters) == ["weighted_polygons", "resolution"]
109
+ for name in ("rasterize", "rasterize_jsonl", "iter_rows_jsonl", "GridResult", "GridRow"):
110
+ assert not hasattr(fastpgv, name)
111
+ for keyword in ("bounds", "weights", "streaming"):
112
+ with pytest.raises(TypeError):
113
+ fastpgv.run([(box(0, 0, 1, 1), 1)], (2, 2), **{keyword: None})
114
+ with pytest.raises(TypeError):
115
+ fastpgv.run([(box(0, 0, 1, 1), 1)])