├── .clang-format ├── .github ├── codecov.yml ├── dependabot.yml ├── release.yml └── workflows │ ├── ci-additional.yml │ ├── release.yml │ └── run-tests.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CMakeLists.txt ├── LICENSE ├── README.md ├── ci ├── LICENSE_absl.txt ├── LICENSE_openssl.txt ├── LICENSE_s2geography.md ├── LICENSE_s2geometry.txt ├── environment-dev.yml ├── environment.yml ├── install_3rdparty.cmd ├── install_3rdparty.sh ├── s2geography-add-include-dir.patch └── s2geography-add-openssl-as-requirement.patch ├── docs ├── Makefile ├── _static │ ├── favicon.ico │ ├── spherely_logo.svg │ ├── spherely_logo_large.png │ ├── spherely_logo_medium.png │ ├── spherely_logo_noline.svg │ ├── spherely_logo_noline_medium.png │ ├── spherely_logo_noline_notext.svg │ └── spherely_logo_noline_notext_square.svg ├── api.rst ├── api_hidden.rst ├── conf.py ├── environment.yml ├── index.md ├── install.md └── make.bat ├── pixi.lock ├── pyproject.toml ├── src ├── accessors-geog.cpp ├── arrow_abi.h ├── boolean-operations.cpp ├── constants.hpp ├── creation.cpp ├── creation.hpp ├── generate_spherely_vfunc_types.py ├── geoarrow.cpp ├── geography.cpp ├── geography.hpp ├── io.cpp ├── predicates.cpp ├── projections.cpp ├── projections.hpp ├── pybind11.hpp ├── spherely.cpp └── spherely.pyi └── tests ├── __init__.py ├── test_accessors.py ├── test_boolean_operations.py ├── test_creation.py ├── test_geoarrow.py ├── test_geography.py ├── test_io.py └── test_predicates.py /.clang-format: -------------------------------------------------------------------------------- 1 | --- 2 | Language: Cpp 3 | BasedOnStyle: Google 4 | ColumnLimit: 100 5 | TabWidth: 4 6 | IndentWidth: 4 7 | PointerAlignment: Left 8 | ReferenceAlignment: Pointer 9 | IndentAccessModifiers: false 10 | AccessModifierOffset: -4 11 | BinPackArguments: false 12 | BinPackParameters: false 13 | ExperimentalAutoDetectBinPacking: false 14 | AllowAllParametersOfDeclarationOnNextLine: false 15 | AllowShortFunctionsOnASingleLine: Empty 16 | AllowShortLambdasOnASingleLine: Inline 17 | ... 18 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | notify: 3 | after_n_builds: 3 4 | coverage: 5 | status: 6 | project: 7 | default: 8 | target: auto 9 | threshold: 5% 10 | patch: 11 | default: 12 | informational: true 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | dependencies: 9 | patterns: 10 | - "*" 11 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot 5 | - pre-commit-ci 6 | -------------------------------------------------------------------------------- /.github/workflows/ci-additional.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main] 4 | pull_request: 5 | branches: [main] 6 | 7 | name: CI Additional 8 | 9 | jobs: 10 | check-links: 11 | name: Check markdown hyperlinks 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v4 16 | 17 | - name: Markdown link check 18 | uses: gaurav-nelson/github-action-markdown-link-check@v1 19 | 20 | mypy: 21 | name: Mypy 22 | runs-on: ubuntu-latest 23 | defaults: 24 | run: 25 | shell: bash -l {0} 26 | 27 | steps: 28 | - name: Checkout repo 29 | uses: actions/checkout@v4 30 | 31 | - name: Setup micromamba 32 | uses: mamba-org/setup-micromamba@v2 33 | with: 34 | environment-file: ci/environment.yml 35 | environment-name: spherely-dev 36 | create-args: >- 37 | python=3.11 38 | 39 | - name: Build and install spherely 40 | run: | 41 | python -m pip install . -v --no-build-isolation 42 | 43 | - name: Install mypy 44 | run: | 45 | python -m pip install 'mypy' 46 | 47 | - name: Run mypy 48 | run: | 49 | python -m mypy --install-types --non-interactive 50 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish 2 | 3 | on: 4 | # trigger action from GitHub GUI (testing, no publish) 5 | workflow_dispatch: 6 | release: 7 | types: 8 | - published 9 | pull_request: # also build on PRs touching any file below 10 | paths: 11 | - ".github/workflows/release.yml" 12 | - "ci/*" 13 | - "MANIFEST.in" 14 | - "pyproject.toml" 15 | 16 | jobs: 17 | build_sdist: 18 | name: Build sdist 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: Build SDist 26 | run: pipx run build --sdist 27 | 28 | - name: Upload artifacts 29 | uses: actions/upload-artifact@v4 30 | with: 31 | name: release-sdist 32 | path: ./dist/*.tar.gz 33 | retention-days: 30 34 | 35 | - name: Check metadata 36 | run: pipx run twine check dist/* 37 | 38 | build_wheels: 39 | name: Build binary wheel on ${{ matrix.os }} 40 | runs-on: ${{ matrix.os }} 41 | env: 42 | ABSL_VERSION: "20240722.0" 43 | S2GEOMETRY_VERSION: "0.11.1" 44 | S2GEOGRAPHY_VERSION: "0.2.0" 45 | CXX_STANDARD: 17 46 | strategy: 47 | fail-fast: false 48 | matrix: 49 | include: 50 | - os: ubuntu-latest 51 | arch: x86_64 52 | - os: windows-2019 53 | arch: AMD64 54 | msvc_arch: x64 55 | - os: macos-13 56 | arch: x86_64 57 | cmake_osx_architectures: x86_64 58 | macosx_deployment_target: 13.0 59 | - os: macos-14 60 | arch: arm64 61 | cmake_osx_architectures: arm64 62 | macosx_deployment_target: 14.0 63 | 64 | steps: 65 | - uses: actions/checkout@v4 66 | with: 67 | fetch-depth: 0 68 | 69 | - name: Cache 3rd-party install directory 70 | id: cache-build 71 | uses: actions/cache@v4 72 | with: 73 | path: ${{ runner.temp }}/3rd-party/dist 74 | key: ${{ matrix.os }}-${{ matrix.arch }}-${{ env.ABSL_VERSION }}-${{ env.S2GEOMETRY_VERSION }}-${{ env.S2GEOGRAPHY_VERSION }}-${{ hashFiles('ci/*') }} 75 | 76 | - name: Copy 3rd-party license files 77 | run: | 78 | cp ci/LICENSE_* . 79 | ls -all . 80 | shell: bash 81 | 82 | # for some reason mingw is selected by cmake within cibuildwheel before_all 83 | - name: Prepare compiler environment for Windows 84 | if: runner.os == 'Windows' 85 | uses: ilammy/msvc-dev-cmd@v1 86 | with: 87 | arch: x64 88 | 89 | - name: Cache vcpkg install directory (Windows) 90 | if: runner.os == 'Windows' 91 | uses: actions/cache@v4 92 | with: 93 | path: "c:\\vcpkg\\installed" 94 | key: vcpkg-${{ runner.os }} 95 | 96 | - name: Install abseil openssl and s2geometry (Windows) 97 | if: runner.os == 'Windows' 98 | shell: bash 99 | run: | 100 | vcpkg install s2geometry:x64-windows --x-install-root=$VCPKG_INSTALLATION_ROOT/installed 101 | vcpkg list 102 | ls /c/vcpkg/installed 103 | ls /c/vcpkg/installed/x64-windows 104 | ls /c/vcpkg/installed/x64-windows/bin 105 | 106 | - name: Build wheels 107 | uses: pypa/cibuildwheel@v2.22.0 108 | env: 109 | CIBW_ARCHS: ${{ matrix.arch }} 110 | CIBW_SKIP: cp36-* pp* *musllinux* *-manylinux_i686 111 | CIBW_TEST_SKIP: "cp38-macosx_arm64" 112 | CIBW_ENVIRONMENT_LINUX: 113 | DEPENDENCIES_DIR=/host${{ runner.temp }}/3rd-party 114 | CMAKE_PREFIX_PATH=/host${{ runner.temp }}/3rd-party/dist 115 | ABSL_VERSION=${{ env.ABSL_VERSION }} 116 | S2GEOMETRY_VERSION=${{ env.S2GEOMETRY_VERSION }} 117 | S2GEOGRAPHY_VERSION=${{ env.S2GEOGRAPHY_VERSION }} 118 | CXX_STANDARD=${{ env.CXX_STANDARD }} 119 | CIBW_ENVIRONMENT_MACOS: 120 | PROJECT_DIR=${{ github.workspace }} 121 | DEPENDENCIES_DIR=${{ runner.temp }}/3rd-party 122 | CMAKE_PREFIX_PATH=${{ runner.temp }}/3rd-party/dist 123 | ABSL_VERSION=${{ env.ABSL_VERSION }} 124 | S2GEOMETRY_VERSION=${{ env.S2GEOMETRY_VERSION }} 125 | S2GEOGRAPHY_VERSION=${{ env.S2GEOGRAPHY_VERSION }} 126 | CXX_STANDARD=${{ env.CXX_STANDARD }} 127 | MACOSX_DEPLOYMENT_TARGET=${{ matrix.macosx_deployment_target }} 128 | CMAKE_OSX_ARCHITECTURES='${{ matrix.cmake_osx_architectures }}' 129 | CIBW_ENVIRONMENT_WINDOWS: 130 | DEPENDENCIES_DIR='${{ runner.temp }}\3rd-party' 131 | CMAKE_PREFIX_PATH='c:\vcpkg\installed\x64-windows;${{ runner.temp }}\3rd-party\dist' 132 | ABSL_VERSION=${{ env.ABSL_VERSION }} 133 | S2GEOMETRY_VERSION=${{ env.S2GEOMETRY_VERSION }} 134 | S2GEOGRAPHY_VERSION=${{ env.S2GEOGRAPHY_VERSION }} 135 | CXX_STANDARD=${{ env.CXX_STANDARD }} 136 | PROJECT_DIR='${{ runner.workspace }}\spherely' 137 | CIBW_BEFORE_ALL: ./ci/install_3rdparty.sh 138 | CIBW_BEFORE_ALL_WINDOWS: ci\install_3rdparty.cmd 139 | CIBW_BEFORE_BUILD_WINDOWS: pip install delvewheel 140 | CIBW_REPAIR_WHEEL_COMMAND_LINUX: 'LD_LIBRARY_PATH=/host${{ runner.temp }}/3rd-party/dist/lib64 auditwheel repair -w {dest_dir} {wheel}' 141 | CIBW_REPAIR_WHEEL_COMMAND_MACOS: 'DYLD_LIBRARY_PATH=${{ runner.temp }}/3rd-party/dist/lib delocate-wheel --require-archs=${{ matrix.arch }} -w {dest_dir} -v {wheel}' 142 | CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: 'delvewheel repair --add-path ${{ runner.temp }}\3rd-party\dist\bin --add-path c:\vcpkg\installed\x64-windows\bin -w {dest_dir} {wheel}' 143 | CIBW_TEST_REQUIRES: pytest 144 | CIBW_TEST_COMMAND: pytest {project}/tests 145 | 146 | - name: Upload artifacts 147 | uses: actions/upload-artifact@v4 148 | with: 149 | name: release-${{ matrix.os }}-${{ matrix.arch }} 150 | path: ./wheelhouse/*.whl 151 | retention-days: 5 152 | 153 | upload_all: 154 | needs: [build_sdist, build_wheels] 155 | environment: pypi 156 | permissions: 157 | id-token: write 158 | runs-on: ubuntu-latest 159 | if: github.event_name == 'release' && github.event.action == 'published' 160 | steps: 161 | - name: Get dist files 162 | uses: actions/download-artifact@v4 163 | with: 164 | pattern: release-* 165 | merge-multiple: true 166 | path: dist 167 | 168 | - name: Publish on PyPI 169 | uses: pypa/gh-action-pypi-publish@release/v1 170 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main] 4 | pull_request: 5 | branches: [main] 6 | 7 | name: Run tests 8 | 9 | jobs: 10 | test: 11 | name: ${{ matrix.os }}, ${{ matrix.python-version }}, ${{ matrix.env }} 12 | runs-on: ${{ matrix.os }} 13 | defaults: 14 | run: 15 | shell: bash -l {0} 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: ["ubuntu-latest", "macos-latest", "windows-latest"] 20 | python-version: ["3.10", "3.11", "3.12"] 21 | dev: [false] 22 | env: 23 | - ci/environment.yml 24 | include: 25 | - env: ci/environment-dev.yml 26 | os: ubuntu-latest 27 | python-version: "3.12" 28 | dev: true 29 | - env: ci/environment-dev.yml 30 | os: macos-latest 31 | python-version: "3.12" 32 | dev: true 33 | - env: ci/environment-dev.yml 34 | os: windows-latest 35 | python-version: "3.12" 36 | dev: true 37 | 38 | steps: 39 | - name: Checkout repo 40 | uses: actions/checkout@v4 41 | 42 | - name: Get Date 43 | id: get-date 44 | # cache will last one day 45 | run: echo "::set-output name=today::$(/bin/date -u '+%Y%m%d')" 46 | 47 | - name: Setup micromamba 48 | uses: mamba-org/setup-micromamba@v2 49 | with: 50 | environment-file: ${{ matrix.env }} 51 | environment-name: spherely-dev 52 | cache-environment: true 53 | cache-environment-key: "${{ runner.os }}-${{ runner.arch }}-py${{ matrix.python-version }}-${{ steps.get-date.outputs.today }}-${{ hashFiles( matrix.env) }}" 54 | create-args: >- 55 | python=${{ matrix.python-version }} 56 | 57 | - name: Fetch s2geography 58 | uses: actions/checkout@v4 59 | with: 60 | repository: paleolimbot/s2geography 61 | ref: main 62 | path: deps/s2geography 63 | fetch-depth: 0 64 | if: | 65 | matrix.dev == true 66 | 67 | - name: Configure, build & install s2geography (unix) 68 | run: | 69 | cd deps/s2geography 70 | cmake -S . -B build \ 71 | -DCMAKE_CXX_STANDARD=17 \ 72 | -DCMAKE_BUILD_TYPE=Release \ 73 | -DS2GEOGRAPHY_S2_SOURCE=CONDA \ 74 | -DCMAKE_INSTALL_PREFIX=$CONDA_PREFIX 75 | cmake --build build 76 | cmake --install build 77 | if: | 78 | matrix.dev == true && 79 | (runner.os == 'Linux' || runner.os == 'macOS') 80 | 81 | - name: Configure, build & install s2geography (win) 82 | run: | 83 | cd deps/s2geography 84 | cmake -S . -B build \ 85 | -DCMAKE_CXX_STANDARD=17 \ 86 | -DS2GEOGRAPHY_S2_SOURCE=CONDA \ 87 | -DCMAKE_INSTALL_PREFIX=$CONDA_PREFIX/Library \ 88 | -DCMAKE_WINDOWS_EXPORT_ALL_SYMBOLS=TRUE 89 | cmake --build build --config Release 90 | cmake --install build 91 | if: | 92 | matrix.dev == true && runner.os == 'Windows' 93 | 94 | - name: Build and install spherely 95 | run: | 96 | python -m pip install . -v --no-build-isolation --config-settings cmake.define.SPHERELY_CODE_COVERAGE=ON --config-settings build-dir=_skbuild 97 | 98 | - name: Run tests 99 | run: | 100 | pytest . -vv 101 | 102 | - name: Generate and upload coverage report 103 | uses: codecov/codecov-action@v5 104 | with: 105 | gcov: true 106 | gcov_include: src 107 | verbose: true 108 | if: | 109 | runner.os == 'Linux' && matrix.python-version == '3.11' && matrix.dev == false 110 | 111 | test_with_pixi: 112 | name: Tests via pixi 113 | runs-on: ubuntu-latest 114 | steps: 115 | - name: Checkout repo 116 | uses: actions/checkout@v4 117 | 118 | - uses: prefix-dev/setup-pixi@v0.8.1 119 | with: 120 | pixi-version: v0.40.1 121 | cache: true 122 | 123 | - run: pixi run --environment test tests 124 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # IDE 132 | .vscode 133 | .ccls 134 | .ccls-cache/ 135 | compile_commands.json 136 | .dir-locals.el 137 | 138 | # docs 139 | docs/build/ 140 | docs/_api_generated 141 | 142 | # pixi 143 | .pixi 144 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: debug-statements 9 | - id: mixed-line-ending 10 | - repo: https://github.com/psf/black 11 | rev: 24.10.0 12 | hooks: 13 | - id: black 14 | args: [--safe, --quiet] 15 | - repo: https://github.com/pre-commit/mirrors-clang-format 16 | rev: v19.1.6 17 | hooks: 18 | - id: clang-format 19 | args: [--style=file] 20 | 21 | ci: 22 | autofix_prs: false 23 | autoupdate_schedule: quarterly 24 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | sphinx: 4 | configuration: docs/conf.py 5 | 6 | build: 7 | os: "ubuntu-20.04" 8 | tools: 9 | python: "mambaforge-4.10" 10 | 11 | conda: 12 | environment: docs/environment.yml 13 | 14 | python: 15 | install: 16 | - method: pip 17 | path: . 18 | 19 | formats: [] 20 | -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.15...3.25) 2 | project( 3 | "${SKBUILD_PROJECT_NAME}" 4 | LANGUAGES CXX 5 | VERSION "${SKBUILD_PROJECT_VERSION}") 6 | 7 | set(CMAKE_CXX_STANDARD 17 CACHE STRING "The C++ standard to build with") 8 | set(CMAKE_CXX_STANDARD_REQUIRED ON) 9 | 10 | option(SPHERELY_CODE_COVERAGE "Enable coverage reporting" OFF) 11 | 12 | # Dependencies 13 | 14 | list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/third_party/cmake") 15 | 16 | find_package( 17 | Python 18 | COMPONENTS Interpreter Development.Module 19 | REQUIRED) 20 | 21 | find_package(pybind11 CONFIG REQUIRED) 22 | 23 | find_package(s2 CONFIG REQUIRED) 24 | if(s2_FOUND) 25 | get_target_property(s2_INCLUDE_DIRS s2::s2 INTERFACE_INCLUDE_DIRECTORIES) 26 | message(STATUS "Found s2: ${s2_INCLUDE_DIRS}") 27 | else() 28 | message(FATAL_ERROR "Couldn't find s2") 29 | endif() 30 | 31 | # this is needed so that openssl headers included from s2geometry headers are found. 32 | find_package(OpenSSL REQUIRED) 33 | target_include_directories(s2::s2 INTERFACE ${OPENSSL_INCLUDE_DIR}) 34 | 35 | find_package(s2geography CONFIG REQUIRED) 36 | if(${s2geography_FOUND}) 37 | get_target_property(s2geography_INCLUDE_DIRS s2geography INTERFACE_INCLUDE_DIRECTORIES) 38 | message(STATUS "Found s2geography v${s2geography_VERSION}: ${s2geography_INCLUDE_DIRS}") 39 | else() 40 | message(FATAL_ERROR "Couldn't find s2geography") 41 | endif() 42 | 43 | if(SPHERELY_CODE_COVERAGE) 44 | message(STATUS "Building spherely with coverage enabled") 45 | add_library(coverage_config INTERFACE) 46 | endif() 47 | 48 | # Compile definitions and flags 49 | 50 | if (MSVC) 51 | # used in s2geometry's CMakeLists.txt but not defined in target 52 | # TODO: move this in FindS2.cmake? 53 | target_compile_definitions(s2::s2 INTERFACE _USE_MATH_DEFINES) 54 | target_compile_definitions(s2::s2 INTERFACE NOMINMAX) 55 | target_compile_options(s2::s2 INTERFACE /J) 56 | endif() 57 | 58 | if(CMAKE_CXX_COMPILER_ID MATCHES "GNU" OR 59 | CMAKE_CXX_COMPILER_ID MATCHES "Clang" OR 60 | (CMAKE_CXX_COMPILER_ID MATCHES "Intel" AND NOT WIN32)) 61 | set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wextra -Wreorder") 62 | set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wunused-variable -Wunused-parameter") 63 | set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wconversion -Wold-style-cast -Wsign-conversion") 64 | elseif(CMAKE_CXX_COMPILER_ID MATCHES "MSVC") 65 | add_definitions(-D_CRT_SECURE_NO_WARNINGS) 66 | add_definitions(-D_SILENCE_TR1_NAMESPACE_DEPRECATION_WARNING) 67 | set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /EHsc /MP /bigobj /J") 68 | set(CMAKE_EXE_LINKER_FLAGS /MANIFEST:NO) 69 | endif() 70 | 71 | # Build 72 | 73 | set(CPP_SOURCES 74 | src/accessors-geog.cpp 75 | src/boolean-operations.cpp 76 | src/creation.cpp 77 | src/geography.cpp 78 | src/io.cpp 79 | src/predicates.cpp 80 | src/spherely.cpp) 81 | 82 | if(${s2geography_VERSION} VERSION_GREATER_EQUAL "0.2.0") 83 | set(CPP_SOURCES ${CPP_SOURCES} src/geoarrow.cpp src/projections.cpp) 84 | endif() 85 | 86 | add_library(spherely MODULE ${CPP_SOURCES}) 87 | 88 | target_compile_definitions( 89 | spherely 90 | PRIVATE 91 | VERSION_INFO=${PROJECT_VERSION} 92 | S2GEOGRAPHY_VERSION=${s2geography_VERSION} 93 | S2GEOGRAPHY_VERSION_MAJOR=${s2geography_VERSION_MAJOR} 94 | S2GEOGRAPHY_VERSION_MINOR=${s2geography_VERSION_MINOR}) 95 | 96 | target_link_libraries(spherely 97 | PRIVATE pybind11::module pybind11::lto pybind11::windows_extras 98 | PUBLIC s2::s2 s2geography 99 | ) 100 | 101 | pybind11_extension(spherely) 102 | if(NOT MSVC AND NOT ${CMAKE_BUILD_TYPE} MATCHES Debug|RelWithDebInfo) 103 | # Strip unnecessary sections of the binary on Linux/macOS 104 | pybind11_strip(spherely) 105 | endif() 106 | 107 | set_target_properties(spherely PROPERTIES CXX_VISIBILITY_PRESET "hidden") 108 | 109 | if (SPHERELY_CODE_COVERAGE) 110 | target_compile_options(coverage_config INTERFACE -O0 -g --coverage) 111 | target_link_options(coverage_config INTERFACE --coverage) 112 | target_link_libraries(spherely PUBLIC coverage_config) 113 | endif() 114 | 115 | # Install 116 | 117 | install(TARGETS spherely LIBRARY DESTINATION .) 118 | 119 | # install type annotations 120 | install(FILES src/spherely.pyi DESTINATION .) 121 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Benoit Bovy 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![spherely](docs/_static/spherely_logo.svg) 2 | 3 | ![Tests](https://github.com/benbovy/spherely/actions/workflows/run-tests.yaml/badge.svg) 4 | [![Docs](https://readthedocs.org/projects/spherely/badge/?version=latest)](https://spherely.readthedocs.io) 5 | [![Coverage](https://codecov.io/gh/benbovy/spherely/branch/main/graph/badge.svg)](https://app.codecov.io/gh/benbovy/spherely?branch=main) 6 | 7 | *Python library for manipulation and analysis of geometric objects on the sphere.* 8 | 9 | Spherely is the counterpart of [Shapely](https://github.com/shapely/shapely) 10 | (2.0+) for manipulation and analysis of spherical geometric objects. It is using 11 | the widely deployed open-source geometry library 12 | [s2geometry](https://github.com/google/s2geometry) via the library 13 | [s2geography](https://github.com/paleolimbot/s2geography) which provides a 14 | [GEOS](https://libgeos.org) compatibility layer on top of s2geometry. 15 | 16 | **This library is at an early stage of development.** 17 | 18 | ## Installation 19 | 20 | The easiest way to install Spherely is via its binary packages available for 21 | Linux, MacOS, and Windows platforms on [conda-forge](https://conda-forge.org/) 22 | and [PyPI](https://pypi.org/project/spherely/). 23 | 24 | Install the binary wheel using [pip](https://pip.pypa.io/): 25 | 26 | ``` sh 27 | $ pip install spherely 28 | ``` 29 | 30 | Install the conda-forge package using 31 | [conda](https://docs.conda.io/projects/conda/en/stable/): 32 | 33 | ``` sh 34 | $ conda install spherely --channel conda-forge 35 | ``` 36 | 37 | To compile and install Spherely from source, see detailed instructions in the 38 | [documentation](https://spherely.readthedocs.io/en/latest/install.html). 39 | 40 | ## Documentation 41 | 42 | https://spherely.readthedocs.io 43 | 44 | ## License 45 | 46 | Spherely is licensed under BSD 3-Clause license. See the LICENSE file for more 47 | details. 48 | 49 | ## Acknowledgment 50 | 51 | The development of this project has been supported by two 52 | [NumFOCUS](https://numfocus.org) Small Development Grants (GeoPandas 2022 round 53 | 1 and GeoPandas 2023 round 3). 54 | -------------------------------------------------------------------------------- /ci/LICENSE_absl.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | https://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | https://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /ci/LICENSE_openssl.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | https://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /ci/LICENSE_s2geography.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | ============== 3 | 4 | _Version 2.0, January 2004_ 5 | _<>_ 6 | 7 | ### Terms and Conditions for use, reproduction, and distribution 8 | 9 | #### 1. Definitions 10 | 11 | “License” shall mean the terms and conditions for use, reproduction, and 12 | distribution as defined by Sections 1 through 9 of this document. 13 | 14 | “Licensor” shall mean the copyright owner or entity authorized by the copyright 15 | owner that is granting the License. 16 | 17 | “Legal Entity” shall mean the union of the acting entity and all other entities 18 | that control, are controlled by, or are under common control with that entity. 19 | For the purposes of this definition, “control” means **(i)** the power, direct or 20 | indirect, to cause the direction or management of such entity, whether by 21 | contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the 22 | outstanding shares, or **(iii)** beneficial ownership of such entity. 23 | 24 | “You” (or “Your”) shall mean an individual or Legal Entity exercising 25 | permissions granted by this License. 26 | 27 | “Source” form shall mean the preferred form for making modifications, including 28 | but not limited to software source code, documentation source, and configuration 29 | files. 30 | 31 | “Object” form shall mean any form resulting from mechanical transformation or 32 | translation of a Source form, including but not limited to compiled object code, 33 | generated documentation, and conversions to other media types. 34 | 35 | “Work” shall mean the work of authorship, whether in Source or Object form, made 36 | available under the License, as indicated by a copyright notice that is included 37 | in or attached to the work (an example is provided in the Appendix below). 38 | 39 | “Derivative Works” shall mean any work, whether in Source or Object form, that 40 | is based on (or derived from) the Work and for which the editorial revisions, 41 | annotations, elaborations, or other modifications represent, as a whole, an 42 | original work of authorship. For the purposes of this License, Derivative Works 43 | shall not include works that remain separable from, or merely link (or bind by 44 | name) to the interfaces of, the Work and Derivative Works thereof. 45 | 46 | “Contribution” shall mean any work of authorship, including the original version 47 | of the Work and any modifications or additions to that Work or Derivative Works 48 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 49 | by the copyright owner or by an individual or Legal Entity authorized to submit 50 | on behalf of the copyright owner. For the purposes of this definition, 51 | “submitted” means any form of electronic, verbal, or written communication sent 52 | to the Licensor or its representatives, including but not limited to 53 | communication on electronic mailing lists, source code control systems, and 54 | issue tracking systems that are managed by, or on behalf of, the Licensor for 55 | the purpose of discussing and improving the Work, but excluding communication 56 | that is conspicuously marked or otherwise designated in writing by the copyright 57 | owner as “Not a Contribution.” 58 | 59 | “Contributor” shall mean Licensor and any individual or Legal Entity on behalf 60 | of whom a Contribution has been received by Licensor and subsequently 61 | incorporated within the Work. 62 | 63 | #### 2. Grant of Copyright License 64 | 65 | Subject to the terms and conditions of this License, each Contributor hereby 66 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 67 | irrevocable copyright license to reproduce, prepare Derivative Works of, 68 | publicly display, publicly perform, sublicense, and distribute the Work and such 69 | Derivative Works in Source or Object form. 70 | 71 | #### 3. Grant of Patent License 72 | 73 | Subject to the terms and conditions of this License, each Contributor hereby 74 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 75 | irrevocable (except as stated in this section) patent license to make, have 76 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where 77 | such license applies only to those patent claims licensable by such Contributor 78 | that are necessarily infringed by their Contribution(s) alone or by combination 79 | of their Contribution(s) with the Work to which such Contribution(s) was 80 | submitted. If You institute patent litigation against any entity (including a 81 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 82 | Contribution incorporated within the Work constitutes direct or contributory 83 | patent infringement, then any patent licenses granted to You under this License 84 | for that Work shall terminate as of the date such litigation is filed. 85 | 86 | #### 4. Redistribution 87 | 88 | You may reproduce and distribute copies of the Work or Derivative Works thereof 89 | in any medium, with or without modifications, and in Source or Object form, 90 | provided that You meet the following conditions: 91 | 92 | * **(a)** You must give any other recipients of the Work or Derivative Works a copy of 93 | this License; and 94 | * **(b)** You must cause any modified files to carry prominent notices stating that You 95 | changed the files; and 96 | * **(c)** You must retain, in the Source form of any Derivative Works that You distribute, 97 | all copyright, patent, trademark, and attribution notices from the Source form 98 | of the Work, excluding those notices that do not pertain to any part of the 99 | Derivative Works; and 100 | * **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any 101 | Derivative Works that You distribute must include a readable copy of the 102 | attribution notices contained within such NOTICE file, excluding those notices 103 | that do not pertain to any part of the Derivative Works, in at least one of the 104 | following places: within a NOTICE text file distributed as part of the 105 | Derivative Works; within the Source form or documentation, if provided along 106 | with the Derivative Works; or, within a display generated by the Derivative 107 | Works, if and wherever such third-party notices normally appear. The contents of 108 | the NOTICE file are for informational purposes only and do not modify the 109 | License. You may add Your own attribution notices within Derivative Works that 110 | You distribute, alongside or as an addendum to the NOTICE text from the Work, 111 | provided that such additional attribution notices cannot be construed as 112 | modifying the License. 113 | 114 | You may add Your own copyright statement to Your modifications and may provide 115 | additional or different license terms and conditions for use, reproduction, or 116 | distribution of Your modifications, or for any such Derivative Works as a whole, 117 | provided Your use, reproduction, and distribution of the Work otherwise complies 118 | with the conditions stated in this License. 119 | 120 | #### 5. Submission of Contributions 121 | 122 | Unless You explicitly state otherwise, any Contribution intentionally submitted 123 | for inclusion in the Work by You to the Licensor shall be under the terms and 124 | conditions of this License, without any additional terms or conditions. 125 | Notwithstanding the above, nothing herein shall supersede or modify the terms of 126 | any separate license agreement you may have executed with Licensor regarding 127 | such Contributions. 128 | 129 | #### 6. Trademarks 130 | 131 | This License does not grant permission to use the trade names, trademarks, 132 | service marks, or product names of the Licensor, except as required for 133 | reasonable and customary use in describing the origin of the Work and 134 | reproducing the content of the NOTICE file. 135 | 136 | #### 7. Disclaimer of Warranty 137 | 138 | Unless required by applicable law or agreed to in writing, Licensor provides the 139 | Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, 140 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, 141 | including, without limitation, any warranties or conditions of TITLE, 142 | NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are 143 | solely responsible for determining the appropriateness of using or 144 | redistributing the Work and assume any risks associated with Your exercise of 145 | permissions under this License. 146 | 147 | #### 8. Limitation of Liability 148 | 149 | In no event and under no legal theory, whether in tort (including negligence), 150 | contract, or otherwise, unless required by applicable law (such as deliberate 151 | and grossly negligent acts) or agreed to in writing, shall any Contributor be 152 | liable to You for damages, including any direct, indirect, special, incidental, 153 | or consequential damages of any character arising as a result of this License or 154 | out of the use or inability to use the Work (including but not limited to 155 | damages for loss of goodwill, work stoppage, computer failure or malfunction, or 156 | any and all other commercial damages or losses), even if such Contributor has 157 | been advised of the possibility of such damages. 158 | 159 | #### 9. Accepting Warranty or Additional Liability 160 | 161 | While redistributing the Work or Derivative Works thereof, You may choose to 162 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or 163 | other liability obligations and/or rights consistent with this License. However, 164 | in accepting such obligations, You may act only on Your own behalf and on Your 165 | sole responsibility, not on behalf of any other Contributor, and only if You 166 | agree to indemnify, defend, and hold each Contributor harmless for any liability 167 | incurred by, or claims asserted against, such Contributor by reason of your 168 | accepting any such warranty or additional liability. 169 | 170 | _END OF TERMS AND CONDITIONS_ 171 | 172 | ### APPENDIX: How to apply the Apache License to your work 173 | 174 | To apply the Apache License to your work, attach the following boilerplate 175 | notice, with the fields enclosed by brackets `[]` replaced with your own 176 | identifying information. (Don't include the brackets!) The text should be 177 | enclosed in the appropriate comment syntax for the file format. We also 178 | recommend that a file or class name and description of purpose be included on 179 | the same “printed page” as the copyright notice for easier identification within 180 | third-party archives. 181 | 182 | Copyright [yyyy] [name of copyright owner] 183 | 184 | Licensed under the Apache License, Version 2.0 (the "License"); 185 | you may not use this file except in compliance with the License. 186 | You may obtain a copy of the License at 187 | 188 | http://www.apache.org/licenses/LICENSE-2.0 189 | 190 | Unless required by applicable law or agreed to in writing, software 191 | distributed under the License is distributed on an "AS IS" BASIS, 192 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 193 | See the License for the specific language governing permissions and 194 | limitations under the License. 195 | -------------------------------------------------------------------------------- /ci/environment-dev.yml: -------------------------------------------------------------------------------- 1 | name: spherely-dev 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - cxx-compiler 6 | - s2geometry>=0.11.1 7 | - libabseil 8 | - cmake 9 | - python 10 | - numpy 11 | - pybind11>=2.11.0 12 | - scikit-build-core 13 | - ninja 14 | - pytest 15 | - pip 16 | - geoarrow-pyarrow 17 | -------------------------------------------------------------------------------- /ci/environment.yml: -------------------------------------------------------------------------------- 1 | name: spherely-dev 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - cxx-compiler 6 | - s2geometry>=0.11.1 7 | - s2geography>=0.2.0 8 | - libabseil 9 | - cmake 10 | - python 11 | - numpy 12 | - pybind11>=2.11.0 13 | - scikit-build-core 14 | - ninja 15 | - pytest 16 | - pip 17 | - geoarrow-pyarrow 18 | -------------------------------------------------------------------------------- /ci/install_3rdparty.cmd: -------------------------------------------------------------------------------- 1 | :: Build and install absl, s2 and s2geography on Windows. 2 | :: 3 | :: This script requires environment variables to be set 4 | :: - DEPENDENCIES_DIR=/path/to/cached/prefix -- to build or use as cache 5 | :: - ABSL_VERSION 6 | :: - S2GEOMETRY_VERSION 7 | :: - S2GEOGRAPHY_VERSION 8 | :: - CXX_STANDARD 9 | :: 10 | :: This script assumes that library sources have been downloaded or copied in 11 | :: DEPENDENCIES_DIR (e.g., %DEPENDENCIES_DIR%/absl-src-%ABSL_VERSION%). 12 | 13 | set SRC_DIR=%DEPENDENCIES_DIR%\src 14 | set BUILD_DIR=%DEPENDENCIES_DIR%\build 15 | set INSTALL_DIR=%DEPENDENCIES_DIR%\dist 16 | 17 | if exist %INSTALL_DIR%\include\s2geography ( 18 | echo Using cached install directory %INSTALL_DIR% 19 | exit /B 0 20 | ) 21 | 22 | mkdir %SRC_DIR% 23 | mkdir %BUILD_DIR% 24 | mkdir %INSTALL_DIR% 25 | 26 | rem set ABSL_SRC_DIR=%DEPENDENCIES_DIR%\absl-src-%ABSL_VERSION% 27 | rem set S2GEOMETRY_SRC_DIR=%DEPENDENCIES_DIR%\s2geometry-src-%S2GEOMETRY_VERSION% 28 | set S2GEOGRAPHY_SRC_DIR=%DEPENDENCIES_DIR%\s2geography-src-%S2GEOGRAPHY_VERSION% 29 | 30 | rem set ABSL_BUILD_DIR=%BUILD_DIR%\absl-src-%ABSL_VERSION% 31 | rem set S2GEOMETRY_BUILD_DIR=%BUILD_DIR%\s2geometry-src-%S2GEOMETRY_VERSION% 32 | set S2GEOGRAPHY_BUILD_DIR=%BUILD_DIR%\s2geography-src-%S2GEOGRAPHY_VERSION% 33 | 34 | echo %CMAKE_PREFIX_PATH% 35 | 36 | echo "----- Installing cmake" 37 | pip install ninja cmake 38 | 39 | rem echo "----- Downloading, building and installing absl-%ABSL_VERSION%" 40 | 41 | rem cd %DEPENDENCIES_DIR% 42 | rem curl -o absl.tar.gz -L https://github.com/abseil/abseil-cpp/archive/refs/tags/%ABSL_VERSION%.tar.gz 43 | rem tar -xf absl.tar.gz -C %SRC_DIR% 44 | 45 | rem cmake -GNinja ^ 46 | rem -S %SRC_DIR%/abseil-cpp-%ABSL_VERSION% ^ 47 | rem -B %ABSL_BUILD_DIR% ^ 48 | rem -DCMAKE_INSTALL_PREFIX=%INSTALL_DIR% ^ 49 | rem -DCMAKE_POSITION_INDEPENDENT_CODE=ON ^ 50 | rem -DCMAKE_CXX_STANDARD=%CXX_STANDARD% ^ 51 | rem -DCMAKE_BUILD_TYPE=Release ^ 52 | rem -DABSL_ENABLE_INSTALL=ON 53 | 54 | rem IF %ERRORLEVEL% NEQ 0 exit /B 1 55 | rem cmake --build %ABSL_BUILD_DIR% 56 | rem IF %ERRORLEVEL% NEQ 0 exit /B 2 57 | rem cmake --install %ABSL_BUILD_DIR% 58 | 59 | rem echo "----- Downloading, building and installing s2geometry-%S2GEOMETRY_VERSION%" 60 | 61 | rem echo %OPENSSL_ROOT_DIR% 62 | 63 | rem cd %DEPENDENCIES_DIR% 64 | rem curl -o s2geometry.tar.gz -L https://github.com/google/s2geometry/archive/refs/tags/v%S2GEOMETRY_VERSION%.tar.gz 65 | rem tar -xf s2geometry.tar.gz -C %SRC_DIR% 66 | 67 | rem cmake -GNinja ^ 68 | rem -S %SRC_DIR%/s2geometry-%S2GEOMETRY_VERSION% ^ 69 | rem -B %S2GEOMETRY_BUILD_DIR% ^ 70 | rem -DCMAKE_INSTALL_PREFIX=%INSTALL_DIR% ^ 71 | rem -DOPENSSL_ROOT_DIR=%OPENSSL_ROOT_DIR% ^ 72 | rem -DBUILD_TESTS=OFF ^ 73 | rem -DBUILD_EXAMPLES=OFF ^ 74 | rem -UGOOGLETEST_ROOT ^ 75 | rem -DCMAKE_CXX_STANDARD=%CXX_STANDARD% ^ 76 | rem -DCMAKE_BUILD_TYPE=Release ^ 77 | rem -DCMAKE_WINDOWS_EXPORT_ALL_SYMBOLS=TRUE ^ 78 | rem -DBUILD_SHARED_LIBS=ON 79 | 80 | rem IF %ERRORLEVEL% NEQ 0 exit /B 3 81 | rem cmake --build %S2GEOMETRY_BUILD_DIR% 82 | rem IF %ERRORLEVEL% NEQ 0 exit /B 4 83 | rem cmake --install %S2GEOMETRY_BUILD_DIR% 84 | 85 | echo "----- Downloading, building and installing s2geography-%S2GEOGRAPHY_VERSION%" 86 | 87 | cd %DEPENDENCIES_DIR% 88 | curl -o s2geography.tar.gz -L https://github.com/paleolimbot/s2geography/archive/refs/tags/%S2GEOGRAPHY_VERSION%.tar.gz 89 | tar -xf s2geography.tar.gz -C %SRC_DIR% 90 | 91 | rem TODO: remove when fixed in s2geography 92 | rem (https://github.com/paleolimbot/s2geography/pull/53) 93 | cd %SRC_DIR%/s2geography-%S2GEOGRAPHY_VERSION% 94 | patch -i %PROJECT_DIR%\ci\s2geography-add-openssl-as-requirement.patch 95 | 96 | cmake -GNinja ^ 97 | -S %SRC_DIR%/s2geography-%S2GEOGRAPHY_VERSION% ^ 98 | -B %S2GEOGRAPHY_BUILD_DIR% ^ 99 | -DCMAKE_INSTALL_PREFIX=%INSTALL_DIR% ^ 100 | -DOPENSSL_ROOT_DIR=%OPENSSL_ROOT_DIR% ^ 101 | -DS2GEOGRAPHY_BUILD_TESTS=OFF ^ 102 | -DS2GEOGRAPHY_S2_SOURCE=AUTO ^ 103 | -DS2GEOGRAPHY_BUILD_EXAMPLES=OFF ^ 104 | -DCMAKE_CXX_STANDARD=%CXX_STANDARD% ^ 105 | -DCMAKE_BUILD_TYPE=Release ^ 106 | -DCMAKE_WINDOWS_EXPORT_ALL_SYMBOLS=TRUE ^ 107 | -DBUILD_SHARED_LIBS=ON 108 | 109 | IF %ERRORLEVEL% NEQ 0 exit /B 5 110 | cmake --build %S2GEOGRAPHY_BUILD_DIR% 111 | IF %ERRORLEVEL% NEQ 0 exit /B 6 112 | cmake --install %S2GEOGRAPHY_BUILD_DIR% 113 | -------------------------------------------------------------------------------- /ci/install_3rdparty.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Build and install absl, s2 and s2geography on posix systems. 4 | # 5 | # This script requires environment variables to be set 6 | # - DEPENDENCIES_DIR=/path/to/cached/prefix -- to build or use as cache 7 | # - ABSL_VERSION 8 | # - S2GEOMETRY_VERSION 9 | # - S2GEOGRAPHY_VERSION 10 | # - CXX_STANDARD 11 | # 12 | # This script assumes that library sources have been downloaded or copied in 13 | # DEPENDENCIES_DIR (e.g., $DEPENDENCIES_DIR/absl-src-$ABSL_VERSION). 14 | pushd . 15 | 16 | set -e 17 | 18 | if [ -z "$DEPENDENCIES_DIR" ]; then 19 | echo "DEPENDENCIES_DIR must be set" 20 | exit 1 21 | elif [ -z "$ABSL_VERSION" ]; then 22 | echo "ABSL_VERSION must be set" 23 | exit 1 24 | elif [ -z "$S2GEOMETRY_VERSION" ]; then 25 | echo "S2GEOMETRY_VERSION must be set" 26 | exit 1 27 | elif [ -z "$S2GEOGRAPHY_VERSION" ]; then 28 | echo "S2GEOGRAPHY_VERSION must be set" 29 | exit 1 30 | elif [ -z "$CXX_STANDARD" ]; then 31 | echo "CXX_STANDARD must be set" 32 | exit 1 33 | fi 34 | 35 | SRC_DIR=$DEPENDENCIES_DIR/src 36 | BUILD_DIR=$DEPENDENCIES_DIR/build 37 | INSTALL_DIR=$DEPENDENCIES_DIR/dist 38 | 39 | mkdir -p $SRC_DIR 40 | mkdir -p $BUILD_DIR 41 | mkdir -p $INSTALL_DIR 42 | 43 | ABSL_SRC_DIR=$DEPENDENCIES_DIR/absl-src-$ABSL_VERSION 44 | S2GEOMETRY_SRC_DIR=$DEPENDENCIES_DIR/s2geometry-src-$S2GEOMETRY_VERSION 45 | S2GEOGRAPHY_SRC_DIR=$DEPENDENCIES_DIR/s2geography-src-$S2GEOGRAPHY_VERSION 46 | 47 | ABSL_BUILD_DIR=$BUILD_DIR/absl-src-$ABSL_VERSION 48 | S2GEOMETRY_BUILD_DIR=$BUILD_DIR/s2geometry-src-$S2GEOMETRY_VERSION 49 | S2GEOGRAPHY_BUILD_DIR=$BUILD_DIR/s2geography-src-$S2GEOGRAPHY_VERSION 50 | 51 | build_install_dependencies(){ 52 | echo "----- Installing cmake" 53 | pip install cmake 54 | 55 | echo "------ Clean build and install directories" 56 | 57 | rm -rf $BUILD_DIR/* 58 | rm -rf $INSTALL_DIR/* 59 | 60 | echo "----- Downloading, building and installing absl-$ABSL_VERSION" 61 | 62 | cd $DEPENDENCIES_DIR 63 | curl -o absl.tar.gz -L https://github.com/abseil/abseil-cpp/archive/refs/tags/$ABSL_VERSION.tar.gz 64 | tar -xf absl.tar.gz -C $SRC_DIR 65 | rm -f absl.tar.gz 66 | 67 | cmake -S $SRC_DIR/abseil-cpp-$ABSL_VERSION -B $ABSL_BUILD_DIR \ 68 | -DCMAKE_INSTALL_PREFIX=$INSTALL_DIR \ 69 | -DCMAKE_POSITION_INDEPENDENT_CODE=ON \ 70 | -DCMAKE_CXX_STANDARD=$CXX_STANDARD \ 71 | -DCMAKE_BUILD_TYPE=Release \ 72 | -DABSL_ENABLE_INSTALL=ON 73 | 74 | cmake --build $ABSL_BUILD_DIR 75 | cmake --install $ABSL_BUILD_DIR 76 | 77 | echo "----- Downloading, building and installing s2geometry-$S2GEOMETRY_VERSION" 78 | 79 | cd $DEPENDENCIES_DIR 80 | curl -o s2geometry.tar.gz -L https://github.com/google/s2geometry/archive/refs/tags/v$S2GEOMETRY_VERSION.tar.gz 81 | tar -xf s2geometry.tar.gz -C $SRC_DIR 82 | rm -f s2geometry.tar.gz 83 | 84 | cmake -S $SRC_DIR/s2geometry-$S2GEOMETRY_VERSION -B $S2GEOMETRY_BUILD_DIR \ 85 | -DCMAKE_INSTALL_PREFIX=$INSTALL_DIR \ 86 | -DBUILD_TESTS=OFF \ 87 | -DBUILD_EXAMPLES=OFF \ 88 | -UGOOGLETEST_ROOT \ 89 | -DCMAKE_CXX_STANDARD=$CXX_STANDARD \ 90 | -DCMAKE_BUILD_TYPE=Release \ 91 | -DBUILD_SHARED_LIBS=ON 92 | 93 | cmake --build $S2GEOMETRY_BUILD_DIR 94 | cmake --install $S2GEOMETRY_BUILD_DIR 95 | 96 | echo "----- Downloading, building and installing s2geography-$S2GEOGRAPHY_VERSION" 97 | 98 | cd $DEPENDENCIES_DIR 99 | curl -o s2geography.tar.gz -L https://github.com/paleolimbot/s2geography/archive/refs/tags/$S2GEOGRAPHY_VERSION.tar.gz 100 | tar -xf s2geography.tar.gz -C $SRC_DIR 101 | rm -f s2geography.tar.gz 102 | 103 | # TODO: remove when fixed in s2geography 104 | # (https://github.com/paleolimbot/s2geography/pull/53) 105 | cd $SRC_DIR/s2geography-$S2GEOGRAPHY_VERSION 106 | if [ "$(uname)" == "Darwin" ]; then 107 | patch -p1 < $PROJECT_DIR/ci/s2geography-add-openssl-as-requirement.patch 108 | else 109 | patch -p1 < /project/ci/s2geography-add-openssl-as-requirement.patch 110 | fi 111 | 112 | cmake -S $SRC_DIR/s2geography-$S2GEOGRAPHY_VERSION -B $S2GEOGRAPHY_BUILD_DIR \ 113 | -DCMAKE_INSTALL_PREFIX=$INSTALL_DIR \ 114 | -DS2GEOGRAPHY_BUILD_TESTS=OFF \ 115 | -DS2GEOGRAPHY_S2_SOURCE=AUTO \ 116 | -DS2GEOGRAPHY_BUILD_EXAMPLES=OFF \ 117 | -DCMAKE_CXX_STANDARD=$CXX_STANDARD \ 118 | -DCMAKE_BUILD_TYPE=Release \ 119 | -DBUILD_SHARED_LIBS=ON 120 | 121 | cmake --build $S2GEOGRAPHY_BUILD_DIR 122 | cmake --install $S2GEOGRAPHY_BUILD_DIR 123 | } 124 | 125 | 126 | echo "----- Installing OpenSSL in Linux container" 127 | 128 | if [ "$(uname)" != "Darwin" ]; then 129 | # assume manylinux2014 https://cibuildwheel.pypa.io/en/stable/faq/ 130 | # TODO: this is done outside of build_install_dependencies so it can 131 | # work with a cached install directory, but it doesn't prevent 132 | # installing an openssl version greater than the one used to build 133 | # libraries in build_install_dependencies (shoudn't be likely, though). 134 | yum install -y openssl-devel 135 | fi 136 | 137 | 138 | if [ -d "$INSTALL_DIR/include/s2geography" ]; then 139 | echo "----- Using cached install directory $INSTALL_DIR" 140 | else 141 | build_install_dependencies 142 | fi 143 | 144 | popd 145 | -------------------------------------------------------------------------------- /ci/s2geography-add-include-dir.patch: -------------------------------------------------------------------------------- 1 | diff --git a/CMakeLists.txt b/CMakeLists.txt 2 | index d0d5e56..a520a1f 100644 3 | --- a/CMakeLists.txt 4 | +++ b/CMakeLists.txt 5 | @@ -212,6 +212,10 @@ add_library(s2geography 6 | set_target_properties(s2geography PROPERTIES 7 | POSITION_INDEPENDENT_CODE ${BUILD_SHARED_LIBS}) 8 | 9 | +target_include_directories(s2geography PUBLIC 10 | + $ 11 | + $) 12 | + 13 | target_compile_definitions( 14 | s2geography 15 | PUBLIC 16 | -------------------------------------------------------------------------------- /ci/s2geography-add-openssl-as-requirement.patch: -------------------------------------------------------------------------------- 1 | From 7287508b986e65403b3352889c94249ee11a231a Mon Sep 17 00:00:00 2001 2 | From: Benoit Bovy 3 | Date: Wed, 11 Dec 2024 09:09:47 +0100 4 | Subject: [PATCH] another approach for openssl 5 | 6 | --- 7 | CMakeLists.txt | 6 +++++- 8 | 1 file changed, 5 insertions(+), 1 deletion(-) 9 | 10 | diff --git a/CMakeLists.txt b/CMakeLists.txt 11 | index 5fb3e93..0028c86 100644 12 | --- a/CMakeLists.txt 13 | +++ b/CMakeLists.txt 14 | @@ -173,6 +173,9 @@ elseif(${S2_SOURCE} STREQUAL "SYSTEM") 15 | endif() 16 | endif() 17 | 18 | +# --- OpenSSL 19 | +find_package(OpenSSL REQUIRED) 20 | + 21 | # --- Abseil (bundled build not supported) 22 | 23 | find_package(absl REQUIRED) 24 | @@ -271,7 +274,8 @@ if(MSVC) 25 | target_compile_options(s2geography PUBLIC /J) 26 | endif() 27 | 28 | -target_link_libraries(s2geography PUBLIC s2::s2 absl::memory absl::str_format) 29 | +target_link_libraries(s2geography PUBLIC s2::s2 absl::memory absl::str_format 30 | + OpenSSL::SSL OpenSSL::Crypto) 31 | 32 | # Set somewhat aggressive compiler warning flags 33 | if(S2GEOGRAPHY_EXTRA_WARNINGS) 34 | -- 35 | 2.36.0 36 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = spherely 8 | SOURCEDIR = . 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benbovy/spherely/4f527296b3539865c37e7e477ec6ccba9cd5861e/docs/_static/favicon.ico -------------------------------------------------------------------------------- /docs/_static/spherely_logo_large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benbovy/spherely/4f527296b3539865c37e7e477ec6ccba9cd5861e/docs/_static/spherely_logo_large.png -------------------------------------------------------------------------------- /docs/_static/spherely_logo_medium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benbovy/spherely/4f527296b3539865c37e7e477ec6ccba9cd5861e/docs/_static/spherely_logo_medium.png -------------------------------------------------------------------------------- /docs/_static/spherely_logo_noline.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 2022-12-19T20:48:53.425922 12 | image/svg+xml 13 | 14 | 15 | Matplotlib v3.6.2, https://matplotlib.org/ 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /docs/_static/spherely_logo_noline_medium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benbovy/spherely/4f527296b3539865c37e7e477ec6ccba9cd5861e/docs/_static/spherely_logo_noline_medium.png -------------------------------------------------------------------------------- /docs/_static/spherely_logo_noline_notext.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 2022-12-19T20:48:53.425922 15 | image/svg+xml 16 | 17 | 18 | Matplotlib v3.6.2, https://matplotlib.org/ 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /docs/_static/spherely_logo_noline_notext_square.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 2022-12-19T20:48:53.425922 15 | image/svg+xml 16 | 17 | 18 | Matplotlib v3.6.2, https://matplotlib.org/ 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | API reference 4 | ============= 5 | 6 | .. currentmodule:: spherely 7 | 8 | .. _api_properties: 9 | 10 | Geography properties 11 | -------------------- 12 | 13 | Functions that provide access to properties of :py:class:`~spherely.Geography` 14 | objects without side-effects (except for ``prepare`` and ``destroy_prepared``). 15 | 16 | .. autosummary:: 17 | :toctree: _api_generated/ 18 | 19 | GeographyType 20 | is_geography 21 | get_dimension 22 | get_type_id 23 | is_empty 24 | get_x 25 | get_y 26 | is_prepared 27 | prepare 28 | destroy_prepared 29 | 30 | .. _api_creation: 31 | 32 | Geography creation 33 | ------------------ 34 | 35 | Functions that build new :py:class:`~spherely.Geography` objects from 36 | coordinates or existing geographies. 37 | 38 | .. autosummary:: 39 | :toctree: _api_generated/ 40 | 41 | create_point 42 | create_multipoint 43 | create_linestring 44 | create_multilinestring 45 | create_polygon 46 | create_multipolygon 47 | create_collection 48 | 49 | .. _api_io: 50 | 51 | Input/Output 52 | ------------ 53 | 54 | Functions that convert :py:class:`~spherely.Geography` objects to/from an 55 | external format such as `WKT `_. 56 | 57 | .. autosummary:: 58 | :toctree: _api_generated/ 59 | 60 | from_wkt 61 | to_wkt 62 | from_wkb 63 | to_wkb 64 | from_geoarrow 65 | to_geoarrow 66 | 67 | .. _api_measurement: 68 | 69 | Measurement 70 | ----------- 71 | 72 | Functions that compute measurements of one or more geographies. 73 | 74 | .. autosummary:: 75 | :toctree: _api_generated/ 76 | 77 | area 78 | distance 79 | length 80 | perimeter 81 | 82 | .. _api_predicates: 83 | 84 | Predicates 85 | ---------- 86 | 87 | Functions that return ``True`` or ``False`` for some spatial relationship 88 | between two geographies. 89 | 90 | .. autosummary:: 91 | :toctree: _api_generated/ 92 | 93 | equals 94 | intersects 95 | touches 96 | contains 97 | within 98 | disjoint 99 | covers 100 | covered_by 101 | 102 | .. _api_overlays: 103 | 104 | Overlays (boolean operations) 105 | ----------------------------- 106 | 107 | Functions that generate a new geography based on the combination of two 108 | geographies. 109 | 110 | .. autosummary:: 111 | :toctree: _api_generated/ 112 | 113 | union 114 | intersection 115 | difference 116 | symmetric_difference 117 | 118 | .. _api_constructive_ops: 119 | 120 | Constructive operations 121 | ----------------------- 122 | 123 | Functions that generate a new geography based on input. 124 | 125 | .. autosummary:: 126 | :toctree: _api_generated/ 127 | 128 | centroid 129 | boundary 130 | convex_hull 131 | -------------------------------------------------------------------------------- /docs/api_hidden.rst: -------------------------------------------------------------------------------- 1 | .. currentmodule:: spherely 2 | 3 | :orphan: 4 | 5 | .. autosummary:: 6 | :toctree: _api_generated/ 7 | 8 | Geography 9 | Projection 10 | Projection.lnglat 11 | Projection.pseudo_mercator 12 | Projection.orthographic 13 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import spherely 4 | 5 | project = "spherely" 6 | author = "Spherely developers" 7 | copyright = "2022, Spherely Developers" 8 | # The short X.Y version. 9 | version = spherely.__version__.split("+")[0] 10 | # The full version, including alpha/beta/rc tags. 11 | release = spherely.__version__ 12 | 13 | # -- General configuration ---------------------------------------------- 14 | 15 | extensions = [ 16 | "sphinx.ext.autodoc", 17 | "sphinx.ext.autosummary", 18 | "sphinx.ext.intersphinx", 19 | "sphinx.ext.napoleon", 20 | "myst_nb", 21 | ] 22 | 23 | intersphinx_mapping = { 24 | "python": ("https://docs.python.org/3", None), 25 | "numpy": ("https://numpy.org/doc/stable", None), 26 | "shapely": ("https://shapely.readthedocs.io/en/latest/", None), 27 | "pyarrow": ("https://arrow.apache.org/docs/", None), 28 | } 29 | 30 | napoleon_google_docstring = False 31 | napoleon_numpy_docstring = True 32 | napoleon_use_param = False 33 | napoleon_use_rtype = False 34 | napoleon_preprocess_types = True 35 | napoleon_type_aliases = { 36 | # general terms 37 | "sequence": ":term:`sequence`", 38 | "iterable": ":term:`iterable`", 39 | # numpy terms 40 | "array_like": ":term:`array_like`", 41 | "array-like": ":term:`array-like `", 42 | # objects without namespace: spherely 43 | "EARTH_RADIUS_METERS": "spherely.EARTH_RADIUS_METERS", 44 | # objects without namespace: numpy 45 | "ndarray": "~numpy.ndarray", 46 | "array": ":term:`array`", 47 | } 48 | 49 | source_suffix = [".rst", ".md"] 50 | 51 | master_doc = "index" 52 | 53 | language = "en" 54 | 55 | exclude_patterns = [ 56 | "**.ipynb_checkpoints", 57 | "build/**.ipynb", 58 | ] 59 | 60 | templates_path = ["_templates"] 61 | 62 | highlight_language = "python" 63 | 64 | pygments_style = "sphinx" 65 | 66 | # -- Options for HTML output ---------------------------------------------- 67 | 68 | html_theme = "sphinx_book_theme" 69 | html_title = "" 70 | 71 | html_theme_options = dict( 72 | repository_url="https://github.com/benbovy/spherely", 73 | repository_branch="main", 74 | path_to_docs="docs", 75 | use_edit_page_button=True, 76 | use_repository_button=True, 77 | use_issues_button=True, 78 | home_page_in_toc=False, 79 | ) 80 | 81 | html_static_path = ["_static"] 82 | html_logo = "_static/spherely_logo_noline.svg" 83 | html_favicon = "_static/favicon.ico" 84 | htmlhelp_basename = "spherelydoc" 85 | -------------------------------------------------------------------------------- /docs/environment.yml: -------------------------------------------------------------------------------- 1 | 2 | name: spherely-docs 3 | channels: 4 | - conda-forge 5 | dependencies: 6 | - python=3.11 7 | - numpy 8 | - cxx-compiler 9 | - cmake 10 | - make 11 | - s2geometry>=0.11.1 12 | - s2geography>=0.2.0 13 | - libabseil 14 | - sphinx 15 | - pydata-sphinx-theme=0.15.4 16 | - sphinx-book-theme=1.1.3 17 | - myst-nb 18 | - pip 19 | # TODO: install the library here when s2geography is packaged 20 | #- pip: 21 | # - .. 22 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Spherely Documentation 2 | 3 | Manipulation and analysis of geometric objects on the sphere. 4 | 5 | Spherely is the counterpart of [Shapely] (2.0+) for manipulation and analysis 6 | of spherical geometric objects. It is using the widely deployed open-source 7 | geometry library [s2geometry] via the library [s2geography] which provides a 8 | [GEOS] compatibility layer on top of s2geometry. 9 | 10 | **This library is at an early stage of development.** 11 | 12 | **Useful links**: 13 | [Home](http://spherely.readthedocs.io/) | 14 | [Code Repository](https://github.com/benbovy/spherely) | 15 | [Issues](https://github.com/benbovy/spherely/issues) | 16 | [Discussions](https://github.com/benbovy/spherely/discussions) | 17 | [Releases](https://github.com/benbovy/spherely/releases) 18 | 19 | ## Contents 20 | 21 | ```{toctree} 22 | :maxdepth: 1 23 | 24 | install 25 | api 26 | ``` 27 | 28 | ## Acknowledgment 29 | 30 | The development of this project has been supported by two 31 | [NumFOCUS] Small Development Grants (GeoPandas 2022 round 32 | 1 and GeoPandas 2023 round 3). 33 | 34 | [Shapely]: https://shapely.readthedocs.io 35 | [s2geometry]: https://s2geometry.io 36 | [s2geography]: https://github.com/paleolimbot/s2geography 37 | [GEOS]: https://libgeos.org 38 | [NumFOCUS]: https://numfocus.org 39 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | (install)= 2 | 3 | # Installation 4 | 5 | ## Built distributions 6 | 7 | The easiest way to install Spherely is via its binary packages available for 8 | Linux, MacOS, and Windows platforms on [conda-forge](https://conda-forge.org/) 9 | and [PyPI](https://pypi.org/project/spherely/). 10 | 11 | ### Installation of Python binary wheels (PyPI) 12 | 13 | Install the last released binary wheel, e.g., using [pip](https://pip.pypa.io/): 14 | 15 | ``` sh 16 | $ pip install spherely 17 | ``` 18 | 19 | ### Installation of Conda packages (conda-forge) 20 | 21 | Install the last released conda-forge package using 22 | [conda](https://docs.conda.io/projects/conda/en/stable/): 23 | 24 | ``` sh 25 | $ conda install spherely --channel conda-forge 26 | ``` 27 | 28 | ## Installation from source 29 | 30 | Compiling and installing Spherely from source may be useful for development 31 | purpose and/or for building it against a specific version of S2Geography and/or 32 | S2Geometry. 33 | 34 | ### Requirements 35 | 36 | - Python 37 | - Numpy 38 | - [s2geography](https://github.com/paleolimbot/s2geography) v0.2.0 or higher 39 | - [s2geometry](https://github.com/google/s2geometry) v0.11.1 or higher 40 | 41 | Additional build dependencies: 42 | 43 | - C++ compiler supporting C++17 standard 44 | - CMake 45 | - [scikit-build-core](https://github.com/scikit-build/scikit-build-core) 46 | 47 | ### Cloning the source repository 48 | 49 | Spherely's source code can be downloaded by cloning its [source 50 | repository](https://github.com/benbovy/spherely): 51 | 52 | ```sh 53 | $ git clone https://github.com/benbovy/spherely 54 | $ cd spherely 55 | ``` 56 | 57 | ### Setting up a development environment using pixi 58 | 59 | Spherely provides everything needed to manage its dependencies and run common 60 | tasks via [pixi](https://pixi.sh). 61 | 62 | If you have `pixi` installed, you can install a complete development environment 63 | for your platform simply by executing the following command from Spherely's 64 | project root directory: 65 | 66 | ```sh 67 | $ pixi install --environment all 68 | ``` 69 | 70 | Running the command below from Spherely's root directory will install all 71 | required tools and dependencies (if not installed yet) in a local environment, 72 | build and install Spherely (if needed) and run the tests: 73 | 74 | ```sh 75 | $ pixi run tests 76 | ``` 77 | 78 | All available tasks are detailed in the ``pyproject.toml`` file or listed via 79 | the following command: 80 | 81 | ```sh 82 | $ pixi task list 83 | ``` 84 | 85 | ### Setting up a development environment using conda 86 | 87 | If you don't have `pixi` installed, you can follow the steps below to manually 88 | setup a conda environment for developing Spherely. 89 | 90 | After cloning Spherely's source repository, create a conda environment 91 | with the required (and development) dependencies using the 92 | `ci/environment-dev.yml` file: 93 | 94 | ```sh 95 | $ conda env create -f ci/environment-dev.yml 96 | $ conda activate spherely-dev 97 | ``` 98 | 99 | Build and install Spherely: 100 | 101 | ```sh 102 | $ python -m pip install . -v --no-build-isolation 103 | ``` 104 | 105 | Note that you can specify a build directory in order to avoid rebuilding the 106 | whole library from scratch each time after editing the code: 107 | 108 | ```sh 109 | $ python -m pip install . -v --no-build-isolation --config-settings build-dir=build/skbuild 110 | ``` 111 | 112 | Run the tests: 113 | 114 | ```sh 115 | $ pytest . -v 116 | ``` 117 | 118 | Spherely also uses [pre-commit](https://pre-commit.com/) for code 119 | auto-formatting and linting at every commit. After installing it, you can enable 120 | pre-commit hooks with the following command: 121 | 122 | ```sh 123 | $ pre-commit install 124 | ``` 125 | 126 | (Note: you can skip the pre-commit checks with `git commit --no-verify`) 127 | 128 | ### Using the latest S2Geography version 129 | 130 | If you want to compile Spherely against the latest version of S2Geography, use: 131 | 132 | ```sh 133 | $ git clone https://github.com/paleolimbot/s2geography 134 | $ cmake \ 135 | $ -S s2geography \ 136 | $ -B s2geography/build \ 137 | $ -DCMAKE_CXX_STANDARD=17 \ 138 | $ -DCMAKE_INSTALL_PREFIX=$CONDA_PREFIX 139 | $ cmake --build s2geography/build 140 | $ cmake --install s2geography/build 141 | ``` 142 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | set SPHINXPROJ=spherely 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "scikit_build_core[rich]", 4 | "pybind11>=2.11", 5 | ] 6 | build-backend = "scikit_build_core.build" 7 | 8 | [project] 9 | name = "spherely" 10 | version = "0.1.0" 11 | description = "Manipulation and analysis of geometric objects on the sphere" 12 | keywords = ["gis", "geometry", "s2geometry", "shapely"] 13 | readme = "README.md" 14 | license = {text = "BSD 3-Clause"} 15 | authors = [ 16 | {name = "Benoît Bovy"}, 17 | ] 18 | maintainers = [ 19 | {name = "Spherely contributors"}, 20 | ] 21 | requires-python = ">=3.10" 22 | dependencies = ["numpy"] 23 | 24 | [project.urls] 25 | Home = "https://spherely.readthedocs.io" 26 | Repository = "https://github.com/benbovy/spherely" 27 | 28 | [project.optional-dependencies] 29 | test = ["pytest>=6.0"] 30 | 31 | [tool.scikit-build] 32 | sdist.exclude = [".github"] 33 | build-dir = "build/{wheel_tag}" 34 | 35 | [tool.mypy] 36 | files = ["tests", "src/spherely.pyi"] 37 | show_error_codes = true 38 | warn_unused_ignores = true 39 | 40 | [tool.pixi.project] 41 | channels = ["conda-forge"] 42 | platforms = ["linux-64", "osx-arm64", "osx-64", "win-64"] 43 | 44 | [tool.pixi.environments] 45 | default = {features = [], solve-group = "default"} 46 | dev = {features = ["dev"], solve-group = "default"} 47 | test = {features = ["test", "dev"], solve-group = "default"} 48 | doc = {features = ["doc", "dev"], solve-group = "default"} 49 | lint = {features = ["lint", "dev"], solve-group = "default"} 50 | all = {features = ["lint", "doc", "test", "dev"], solve-group = "default"} 51 | 52 | [tool.pixi.dependencies] 53 | numpy = "*" 54 | 55 | [tool.pixi.feature.dev.dependencies] 56 | cxx-compiler = ">=1.9.0,<2" 57 | cmake = ">=3.31.5,<4" 58 | ninja = ">=1.12.1,<2" 59 | pybind11 = ">=2.13.6,<3" 60 | scikit-build-core = ">=0.10.7,<0.11" 61 | s2geometry = ">=0.11.1,<0.12" 62 | s2geography = ">=0.2.0,<0.3" 63 | 64 | [tool.pixi.feature.dev.pypi-dependencies] 65 | spherely = { path = ".", editable = true } 66 | 67 | [tool.pixi.feature.dev.pypi-options] 68 | no-build-isolation = ["spherely"] 69 | 70 | [tool.pixi.feature.test.dependencies] 71 | pytest = ">=8.3.4,<9" 72 | geoarrow-pyarrow = ">=0.1.2,<0.2" 73 | 74 | [tool.pixi.feature.doc.dependencies] 75 | sphinx = ">=8.1.3,<9" 76 | pydata-sphinx-theme = ">=0.16.1,<0.17" 77 | sphinx-book-theme = ">=1.1.3,<2" 78 | myst-nb = ">=1.1.2,<2" 79 | 80 | [tool.pixi.feature.lint.dependencies] 81 | pre-commit = ">=4.1.0,<5" 82 | mypy = ">=1.14.1,<2" 83 | 84 | [tool.pixi.feature.test.tasks] 85 | tests = "pytest tests --color=yes" 86 | 87 | [tool.pixi.feature.lint.tasks] 88 | precommit-install = "pre-commit install" 89 | mypy = "python -m mypy" 90 | 91 | [tool.pixi.feature.doc.tasks] 92 | build-doc = "sphinx-build -M html docs docs/build" 93 | 94 | [tool.pixi.feature.dev.tasks.compile-commands] 95 | cmd = "cmake -GNinja -S. -Bbuild/compile-commands -DCMAKE_EXPORT_COMPILE_COMMANDS=ON && cp build/compile-commands/compile_commands.json ." 96 | inputs = ["CMakeLists.txt"] 97 | outputs = ["compile_commands.json"] 98 | 99 | [tool.pixi.feature.dev.tasks.configure] 100 | cmd = "cmake -GNinja -S. -Bbuild/python -DSKBUILD_PROJECT_NAME=spherely -DSKBUILD_PROJECT_VERSION=0.0.0" 101 | inputs = ["CMakeLists.txt"] 102 | outputs = ["build/python/CMakeFiles/"] 103 | 104 | [tool.pixi.feature.dev.tasks.compile] 105 | cmd = "cmake --build build/python --config Debug" 106 | depends-on = ["configure"] 107 | inputs = ["src/*"] 108 | outputs = ["build/python/spherely*"] 109 | -------------------------------------------------------------------------------- /src/accessors-geog.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include "constants.hpp" 5 | #include "creation.hpp" 6 | #include "geography.hpp" 7 | #include "pybind11.hpp" 8 | 9 | namespace py = pybind11; 10 | namespace s2geog = s2geography; 11 | using namespace spherely; 12 | 13 | PyObjectGeography centroid(PyObjectGeography a) { 14 | const auto& a_ptr = a.as_geog_ptr()->geog(); 15 | auto s2_point = s2geog::s2_centroid(a_ptr); 16 | return make_py_geography(s2_point); 17 | } 18 | 19 | PyObjectGeography boundary(PyObjectGeography a) { 20 | const auto& a_ptr = a.as_geog_ptr()->geog(); 21 | return make_py_geography(s2geog::s2_boundary(a_ptr)); 22 | } 23 | 24 | PyObjectGeography convex_hull(PyObjectGeography a) { 25 | const auto& a_ptr = a.as_geog_ptr()->geog(); 26 | return make_py_geography(s2geog::s2_convex_hull(a_ptr)); 27 | } 28 | 29 | double distance(PyObjectGeography a, 30 | PyObjectGeography b, 31 | double radius = numeric_constants::EARTH_RADIUS_METERS) { 32 | const auto& a_index = a.as_geog_ptr()->geog_index(); 33 | const auto& b_index = b.as_geog_ptr()->geog_index(); 34 | return s2geog::s2_distance(a_index, b_index) * radius; 35 | } 36 | 37 | double area(PyObjectGeography a, double radius = numeric_constants::EARTH_RADIUS_METERS) { 38 | return s2geog::s2_area(a.as_geog_ptr()->geog()) * radius * radius; 39 | } 40 | 41 | double length(PyObjectGeography a, double radius = numeric_constants::EARTH_RADIUS_METERS) { 42 | return s2geog::s2_length(a.as_geog_ptr()->geog()) * radius; 43 | } 44 | 45 | double perimeter(PyObjectGeography a, double radius = numeric_constants::EARTH_RADIUS_METERS) { 46 | return s2geog::s2_perimeter(a.as_geog_ptr()->geog()) * radius; 47 | } 48 | 49 | void init_accessors(py::module& m) { 50 | m.attr("EARTH_RADIUS_METERS") = py::float_(numeric_constants::EARTH_RADIUS_METERS); 51 | 52 | m.def("centroid", 53 | py::vectorize(¢roid), 54 | py::arg("geography"), 55 | py::pos_only(), 56 | R"pbdoc(centroid(geography, /) 57 | 58 | Computes the centroid of each geography. 59 | 60 | Parameters 61 | ---------- 62 | geography : :py:class:`Geography` or array_like 63 | Geography object(s). 64 | 65 | Returns 66 | ------- 67 | Geography or array 68 | A single or an array of POINT Geography object(s). 69 | 70 | )pbdoc"); 71 | 72 | m.def("boundary", 73 | py::vectorize(&boundary), 74 | py::arg("geography"), 75 | py::pos_only(), 76 | R"pbdoc(boundary(geography, /) 77 | 78 | Computes the boundary of each geography. 79 | 80 | Parameters 81 | ---------- 82 | geography : :py:class:`Geography` or array_like 83 | Geography object(s). 84 | 85 | Returns 86 | ------- 87 | Geography or array 88 | A single or an array of either (MULTI)POINT or (MULTI)LINESTRING 89 | Geography object(s). 90 | 91 | )pbdoc"); 92 | 93 | m.def("convex_hull", 94 | py::vectorize(&convex_hull), 95 | py::arg("geography"), 96 | py::pos_only(), 97 | R"pbdoc(convex_hull(geography, /) 98 | 99 | Computes the convex hull of each geography. 100 | 101 | Parameters 102 | ---------- 103 | geography : :py:class:`Geography` or array_like 104 | Geography object(s). 105 | 106 | Returns 107 | ------- 108 | Geography or array 109 | A single or an array of POLYGON Geography object(s). 110 | 111 | )pbdoc"); 112 | 113 | m.def("distance", 114 | py::vectorize(&distance), 115 | py::arg("a"), 116 | py::arg("b"), 117 | py::arg("radius") = numeric_constants::EARTH_RADIUS_METERS, 118 | R"pbdoc(distance(a, b, radius=spherely.EARTH_RADIUS_METERS) 119 | 120 | Calculate the distance between two geographies. 121 | 122 | Parameters 123 | ---------- 124 | a : :py:class:`Geography` or array_like 125 | Geography object(s). 126 | b : :py:class:`Geography` or array_like 127 | Geography object(s). 128 | radius : float, optional 129 | Radius of Earth in meters, default 6,371,010. 130 | 131 | Returns 132 | ------- 133 | float or array 134 | Distance value(s), in meters. 135 | 136 | )pbdoc"); 137 | 138 | m.def("area", 139 | py::vectorize(&area), 140 | py::arg("geography"), 141 | py::pos_only(), 142 | py::arg("radius") = numeric_constants::EARTH_RADIUS_METERS, 143 | R"pbdoc(area(geography, /, radius=spherely.EARTH_RADIUS_METERS) 144 | 145 | Calculate the area of the geography. 146 | 147 | Parameters 148 | ---------- 149 | geography : :py:class:`Geography` or array_like 150 | Geography object(s). 151 | radius : float, optional 152 | Radius of Earth in meters, default 6,371,010. 153 | 154 | Returns 155 | ------- 156 | float or array 157 | Area value(s), in square meters. 158 | 159 | )pbdoc"); 160 | 161 | m.def("length", 162 | py::vectorize(&length), 163 | py::arg("geography"), 164 | py::pos_only(), 165 | py::arg("radius") = numeric_constants::EARTH_RADIUS_METERS, 166 | R"pbdoc(length(geography, /, radius=spherely.EARTH_RADIUS_METERS) 167 | 168 | Calculates the length of a line geography, returning zero for other types. 169 | 170 | Parameters 171 | ---------- 172 | geography : :py:class:`Geography` or array_like 173 | Geography object(s). 174 | radius : float, optional 175 | Radius of Earth in meters, default 6,371,010. 176 | 177 | Returns 178 | ------- 179 | float or array 180 | Length value(s), in meters. 181 | 182 | )pbdoc"); 183 | 184 | m.def("perimeter", 185 | py::vectorize(&perimeter), 186 | py::arg("geography"), 187 | py::pos_only(), 188 | py::arg("radius") = numeric_constants::EARTH_RADIUS_METERS, 189 | R"pbdoc(perimeter(geography, /, radius=spherely.EARTH_RADIUS_METERS) 190 | 191 | Calculates the perimeter of a polygon geography, returning zero for other types. 192 | 193 | Parameters 194 | ---------- 195 | geography : :py:class:`Geography` or array_like 196 | Geography object(s). 197 | radius : float, optional 198 | Radius of Earth in meters, default 6,371,010. 199 | 200 | Returns 201 | ------- 202 | float or array 203 | Perimeter value(s), in meters. 204 | 205 | )pbdoc"); 206 | } 207 | -------------------------------------------------------------------------------- /src/arrow_abi.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #ifdef __cplusplus 6 | extern "C" { 7 | #endif 8 | 9 | // Extra guard for versions of Arrow without the canonical guard 10 | #ifndef ARROW_FLAG_DICTIONARY_ORDERED 11 | 12 | #ifndef ARROW_C_DATA_INTERFACE 13 | #define ARROW_C_DATA_INTERFACE 14 | 15 | #define ARROW_FLAG_DICTIONARY_ORDERED 1 16 | #define ARROW_FLAG_NULLABLE 2 17 | #define ARROW_FLAG_MAP_KEYS_SORTED 4 18 | 19 | struct ArrowSchema { 20 | // Array type description 21 | const char* format; 22 | const char* name; 23 | const char* metadata; 24 | int64_t flags; 25 | int64_t n_children; 26 | struct ArrowSchema** children; 27 | struct ArrowSchema* dictionary; 28 | 29 | // Release callback 30 | void (*release)(struct ArrowSchema*); 31 | // Opaque producer-specific data 32 | void* private_data; 33 | }; 34 | 35 | struct ArrowArray { 36 | // Array data description 37 | int64_t length; 38 | int64_t null_count; 39 | int64_t offset; 40 | int64_t n_buffers; 41 | int64_t n_children; 42 | const void** buffers; 43 | struct ArrowArray** children; 44 | struct ArrowArray* dictionary; 45 | 46 | // Release callback 47 | void (*release)(struct ArrowArray*); 48 | // Opaque producer-specific data 49 | void* private_data; 50 | }; 51 | 52 | #endif // ARROW_C_DATA_INTERFACE 53 | #endif // ARROW_FLAG_DICTIONARY_ORDERED 54 | 55 | #ifdef __cplusplus 56 | } 57 | #endif 58 | -------------------------------------------------------------------------------- /src/boolean-operations.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include "constants.hpp" 5 | #include "creation.hpp" 6 | #include "geography.hpp" 7 | #include "pybind11.hpp" 8 | 9 | namespace py = pybind11; 10 | namespace s2geog = s2geography; 11 | using namespace spherely; 12 | 13 | class BooleanOp { 14 | public: 15 | BooleanOp(S2BooleanOperation::OpType op_type) 16 | : m_op_type(op_type), m_options(s2geog::GlobalOptions()) { 17 | // TODO make this configurable 18 | // m_options.polyline_layer_action = s2geography::GlobalOptions::OUTPUT_ACTION_IGNORE; 19 | } 20 | 21 | PyObjectGeography operator()(PyObjectGeography a, PyObjectGeography b) const { 22 | const auto& a_index = a.as_geog_ptr()->geog_index(); 23 | const auto& b_index = b.as_geog_ptr()->geog_index(); 24 | std::unique_ptr geog_out = 25 | s2geog::s2_boolean_operation(a_index, b_index, m_op_type, m_options); 26 | 27 | return make_py_geography(std::move(geog_out)); 28 | } 29 | 30 | private: 31 | S2BooleanOperation::OpType m_op_type; 32 | s2geog::GlobalOptions m_options; 33 | }; 34 | 35 | void init_boolean_operations(py::module& m) { 36 | m.def("union", 37 | py::vectorize(BooleanOp(S2BooleanOperation::OpType::UNION)), 38 | py::arg("a"), 39 | py::arg("b"), 40 | R"pbdoc(union(a, b) 41 | 42 | Computes the union of both geographies. 43 | 44 | Parameters 45 | ---------- 46 | a, b : :py:class:`Geography` or array_like 47 | Geography object(s). 48 | 49 | Returns 50 | ------- 51 | Geography or array 52 | New Geography object(s) representing the union of the input geographies. 53 | 54 | )pbdoc"); 55 | 56 | m.def("intersection", 57 | py::vectorize(BooleanOp(S2BooleanOperation::OpType::INTERSECTION)), 58 | py::arg("a"), 59 | py::arg("b"), 60 | R"pbdoc(intersection(a, b) 61 | 62 | Computes the intersection of both geographies. 63 | 64 | Parameters 65 | ---------- 66 | a, b : :py:class:`Geography` or array_like 67 | Geography object(s). 68 | 69 | Returns 70 | ------- 71 | Geography or array 72 | New Geography object(s) representing the intersection of the input geographies. 73 | 74 | )pbdoc"); 75 | 76 | m.def("difference", 77 | py::vectorize(BooleanOp(S2BooleanOperation::OpType::DIFFERENCE)), 78 | py::arg("a"), 79 | py::arg("b"), 80 | R"pbdoc(difference(a, b) 81 | 82 | Computes the difference of both geographies. 83 | 84 | Parameters 85 | ---------- 86 | a, b : :py:class:`Geography` or array_like 87 | Geography object(s). 88 | 89 | Returns 90 | ------- 91 | Geography or array 92 | New Geography object(s) representing the difference of the input geographies. 93 | 94 | )pbdoc"); 95 | 96 | m.def("symmetric_difference", 97 | py::vectorize(BooleanOp(S2BooleanOperation::OpType::SYMMETRIC_DIFFERENCE)), 98 | py::arg("a"), 99 | py::arg("b"), 100 | R"pbdoc(symmetric_difference(a, b) 101 | 102 | Computes the symmetric difference of both geographies. 103 | 104 | Parameters 105 | ---------- 106 | a, b : :py:class:`Geography` or array_like 107 | Geography object(s). 108 | 109 | Returns 110 | ------- 111 | Geography or array 112 | New Geography object(s) representing the symmetric difference of 113 | the input geographies. 114 | 115 | )pbdoc"); 116 | } 117 | -------------------------------------------------------------------------------- /src/constants.hpp: -------------------------------------------------------------------------------- 1 | #include "s2/s2earth.h" 2 | 3 | namespace spherely { 4 | 5 | struct numeric_constants { 6 | static constexpr double EARTH_RADIUS_METERS = S2Earth::RadiusMeters(); 7 | }; 8 | 9 | } // namespace spherely 10 | -------------------------------------------------------------------------------- /src/creation.hpp: -------------------------------------------------------------------------------- 1 | #ifndef SPHERELY_CREATION_H_ 2 | #define SPHERELY_CREATION_H_ 3 | 4 | #include 5 | #include 6 | 7 | #include "geography.hpp" 8 | #include "pybind11.hpp" 9 | 10 | namespace spherely { 11 | 12 | // 13 | // ---- S2geometry / S2Geography / Spherely object wrapper utility functions 14 | // 15 | 16 | /* 17 | ** Wrap one or more s2geometry objects into a spherely::Geography object. 18 | ** 19 | ** Example: 20 | ** 21 | ** S2Point s2_obj(S2LatLng::FromDegrees(40.0, 5.0).ToPoint()); 22 | ** auto geog_ptr = make_geography(s2_obj); 23 | ** 24 | ** @tparam T The corresponding s2geography type (e.g., PointGeography for S2Point, etc) 25 | ** @tparam S The type of the s2geometry (vector of) object(s) 26 | ** @param s2_obj A single or a vector of s2geometry objects (e.g., S2Point, S2Polyline, etc.) 27 | ** @returns A new spherely::Geography object 28 | */ 29 | template , bool> = true> 30 | inline std::unique_ptr make_geography(S &&s2_obj) { 31 | auto s2geog_ptr = std::make_unique(std::forward(s2_obj)); 32 | return std::make_unique(std::move(s2geog_ptr)); 33 | } 34 | 35 | /* 36 | ** Wrap a s2geography::Geography object into a spherely::Geography object. 37 | ** 38 | ** @tparam T The s2geography type 39 | ** @param s2geog_ptr a pointer to the s2geography::Geography object 40 | ** @returns A new spherely::Geography object 41 | */ 42 | template 43 | inline std::unique_ptr make_geography(std::unique_ptr s2geog_ptr) { 44 | return std::make_unique(std::move(s2geog_ptr)); 45 | } 46 | 47 | /* 48 | ** Helper to create a Spherely Python Geography object directly from one or more 49 | ** S2Geometry objects. 50 | * 51 | ** Example: 52 | ** 53 | ** S2Point s2_obj(S2LatLng::FromDegrees(40.0, 5.0).ToPoint()); 54 | ** auto py_geog = make_py_geography(s2_obj); 55 | ** 56 | ** @tparam T The corresponding s2geography type (e.g., PointGeography for S2Point, etc) 57 | ** @tparam S The type of the s2geometry (vector of) object(s) 58 | ** @param s2_obj A single or a vector of s2geometry objects (e.g., S2Point, S2Polyline, etc.) 59 | ** @returns A new Python Geography object (pybind11::object) 60 | */ 61 | template , bool> = true> 62 | inline PyObjectGeography make_py_geography(S &&s2_obj) { 63 | auto geog_ptr = make_geography(std::forward(s2_obj)); 64 | return PyObjectGeography::from_geog(std::move(geog_ptr)); 65 | } 66 | 67 | /* 68 | ** Helper to create a shperely::Geography object from one s2geography::Geography 69 | ** object. 70 | * 71 | ** @tparam T The S2Geography type 72 | ** @param s2geog_ptr a pointer to the s2geography::Geography object 73 | ** @returns A new Python Geography object (pybind11::object) 74 | */ 75 | template 76 | inline PyObjectGeography make_py_geography(std::unique_ptr s2geog_ptr) { 77 | auto geog_ptr = make_geography(std::move(s2geog_ptr)); 78 | return PyObjectGeography::from_geog(std::move(geog_ptr)); 79 | } 80 | 81 | } // namespace spherely 82 | 83 | #endif // SPHERELY_CREATION_H_ 84 | -------------------------------------------------------------------------------- /src/generate_spherely_vfunc_types.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import string 3 | from pathlib import Path 4 | 5 | from spherely import EARTH_RADIUS_METERS 6 | 7 | 8 | VFUNC_TYPE_SPECS = { 9 | "_VFunc_Nin1_Nout1": {"n_in": 1}, 10 | "_VFunc_Nin2_Nout1": {"n_in": 2}, 11 | "_VFunc_Nin2optradius_Nout1": {"n_in": 2, "radius": ("float", EARTH_RADIUS_METERS)}, 12 | "_VFunc_Nin1optradius_Nout1": {"n_in": 1, "radius": ("float", EARTH_RADIUS_METERS)}, 13 | "_VFunc_Nin1optprecision_Nout1": {"n_in": 1, "precision": ("int", 6)}, 14 | } 15 | 16 | STUB_FILE_PATH = Path(__file__).parent / "spherely.pyi" 17 | BEGIN_MARKER = "# /// Begin types" 18 | END_MARKER = "# /// End types" 19 | 20 | 21 | def update_stub_file(path, **type_specs): 22 | stub_text = path.read_text(encoding="utf-8") 23 | try: 24 | start_idx = stub_text.index(BEGIN_MARKER) 25 | end_idx = stub_text.index(END_MARKER) 26 | except ValueError: 27 | raise SystemExit( 28 | f"Error: Markers '{BEGIN_MARKER}' and '{END_MARKER}' " 29 | f"were not found in stub file '{path}'" 30 | ) from None 31 | 32 | header = f"{BEGIN_MARKER}\n" 33 | code = "\n\n".join( 34 | _vfunctype_factory(name, **args) for name, args in type_specs.items() 35 | ) 36 | updated_stub_text = stub_text[:start_idx] + header + code + stub_text[end_idx:] 37 | path.write_text(updated_stub_text, encoding="utf-8") 38 | 39 | 40 | def _vfunctype_factory(class_name, n_in, **optargs): 41 | """Create new VFunc types. 42 | 43 | Based on the number of input arrays and optional arguments and their types. 44 | """ 45 | arg_names = list(string.ascii_lowercase[:n_in]) 46 | if n_in == 1: 47 | arg_names[0] = "geography" 48 | 49 | class_code = [ 50 | f"class {class_name}(", 51 | " Generic[_NameType, _ScalarReturnType, _ArrayReturnDType]", 52 | "):", 53 | " @property", 54 | " def __name__(self) -> _NameType: ...", 55 | "", 56 | ] 57 | optarg_str = ", ".join( 58 | f"{arg_name}: {arg_type} = {arg_value}" 59 | for arg_name, (arg_type, arg_value) in optargs.items() 60 | ) 61 | 62 | geog_types = ["Geography", "Iterable[Geography]"] 63 | for arg_types in itertools.product(geog_types, repeat=n_in): 64 | arg_str = ", ".join( 65 | f"{arg_name}: {arg_type}" 66 | for arg_name, arg_type in zip(arg_names, arg_types) 67 | ) 68 | if n_in == 1: 69 | arg_str += ", /" 70 | return_type = ( 71 | "_ScalarReturnType" 72 | if all(t == geog_types[0] for t in arg_types) 73 | else "npt.NDArray[_ArrayReturnDType]" 74 | ) 75 | class_code.extend( 76 | [ 77 | " @overload", 78 | " def __call__(", 79 | ( 80 | f" self, {arg_str}, {optarg_str}" 81 | if optarg_str 82 | else f" self, {arg_str}" 83 | ), 84 | f" ) -> {return_type}: ...", 85 | "", 86 | ] 87 | ) 88 | return "\n".join(class_code) 89 | 90 | 91 | if __name__ == "__main__": 92 | update_stub_file(path=STUB_FILE_PATH, **VFUNC_TYPE_SPECS) 93 | -------------------------------------------------------------------------------- /src/geography.hpp: -------------------------------------------------------------------------------- 1 | #ifndef SPHERELY_GEOGRAPHY_H_ 2 | #define SPHERELY_GEOGRAPHY_H_ 3 | 4 | #include 5 | #include 6 | 7 | #include 8 | #include 9 | #include 10 | 11 | namespace py = pybind11; 12 | namespace s2geog = s2geography; 13 | 14 | namespace spherely { 15 | 16 | using S2GeographyPtr = std::unique_ptr; 17 | using S2GeographyIndexPtr = std::unique_ptr; 18 | 19 | /* 20 | ** The registered Geography types 21 | */ 22 | enum class GeographyType : std::int8_t { 23 | None = -1, 24 | Point, 25 | LineString, 26 | Polygon, 27 | MultiPoint, 28 | MultiLineString, 29 | MultiPolygon, 30 | GeometryCollection 31 | }; 32 | 33 | /* 34 | ** Thin wrapper around s2geography::Geography. 35 | ** 36 | ** This wrapper implements the following specific features (that might 37 | ** eventually move into s2geography::Geography?): 38 | ** 39 | ** - Implement move semantics only. 40 | ** - add ``clone()`` method for explicit copy 41 | ** - Add ``geog_type()`` method for getting the geography type 42 | ** - Eagerly infer the geography type as well as other properties 43 | ** - Encapsulate a lazy ``s2geography::ShapeIndexGeography`` accessible via ``geog_index()``. 44 | ** 45 | */ 46 | class Geography { 47 | public: 48 | Geography(const Geography&) = delete; 49 | Geography(Geography&& geog) 50 | : m_s2geog_ptr(std::move(geog.m_s2geog_ptr)), 51 | m_is_empty(geog.is_empty()), 52 | m_geog_type(geog.geog_type()) {} 53 | 54 | Geography(S2GeographyPtr&& s2geog_ptr) : m_s2geog_ptr(std::move(s2geog_ptr)) { 55 | // TODO: template constructors with s2geography Geography subclass constraints (e.g., using 56 | // SFINAE or "if constexpr") may be more efficient than dynamic casting like done in 57 | // extract_geog_properties. 58 | extract_geog_properties(); 59 | } 60 | 61 | Geography& operator=(const Geography&) = delete; 62 | Geography& operator=(Geography&& other) { 63 | m_s2geog_ptr = std::move(other.m_s2geog_ptr); 64 | m_is_empty = other.m_is_empty; 65 | m_geog_type = other.m_geog_type; 66 | return *this; 67 | } 68 | 69 | inline GeographyType geog_type() const noexcept { 70 | return m_geog_type; 71 | } 72 | 73 | inline const s2geog::Geography& geog() const noexcept { 74 | return *m_s2geog_ptr; 75 | } 76 | 77 | template 78 | inline const T* cast_geog() const noexcept { 79 | return reinterpret_cast(&geog()); 80 | } 81 | 82 | inline const s2geog::ShapeIndexGeography& geog_index() { 83 | if (!m_s2geog_index_ptr) { 84 | m_s2geog_index_ptr = std::make_unique(geog()); 85 | } 86 | 87 | return *m_s2geog_index_ptr; 88 | } 89 | inline void reset_index() { 90 | m_s2geog_index_ptr.reset(); 91 | } 92 | inline bool has_index() const noexcept { 93 | return m_s2geog_index_ptr != nullptr; 94 | } 95 | 96 | inline int dimension() const { 97 | return m_s2geog_ptr->dimension(); 98 | } 99 | inline int num_shapes() const { 100 | return m_s2geog_ptr->num_shapes(); 101 | } 102 | inline bool is_empty() const noexcept { 103 | return m_is_empty; 104 | } 105 | 106 | Geography clone() const; 107 | std::unique_ptr clone_geog() const; 108 | 109 | py::tuple encode() const; 110 | static Geography decode(const py::tuple& encoded); 111 | 112 | private: 113 | S2GeographyPtr m_s2geog_ptr; 114 | S2GeographyIndexPtr m_s2geog_index_ptr; 115 | bool m_is_empty = false; 116 | GeographyType m_geog_type; 117 | 118 | // We don't want Geography to be default constructible, except internally via `clone()` 119 | // where there is no need to infer geography properties as we already know them. 120 | Geography() : m_is_empty(true) {} 121 | 122 | void extract_geog_properties(); 123 | }; 124 | 125 | /** 126 | * Custom exception that may be thrown when an empty Geography is found. 127 | */ 128 | class EmptyGeographyException : public std::exception { 129 | private: 130 | std::string message; 131 | 132 | public: 133 | EmptyGeographyException(const char* msg) : message(msg) {} 134 | 135 | const char* what() const throw() { 136 | return message.c_str(); 137 | } 138 | }; 139 | 140 | // TODO: cleaner way? Already implemented elsewhere? 141 | inline std::string format_geog_type(GeographyType geog_type) { 142 | if (geog_type == GeographyType::Point) { 143 | return "POINT"; 144 | } else if (geog_type == GeographyType::MultiPoint) { 145 | return "MULTIPOINT"; 146 | } else if (geog_type == GeographyType::LineString) { 147 | return "LINESTRING"; 148 | } else if (geog_type == GeographyType::MultiLineString) { 149 | return "MULTILINESTRING"; 150 | } else if (geog_type == GeographyType::Polygon) { 151 | return "POLYGON"; 152 | } else if (geog_type == GeographyType::MultiPolygon) { 153 | return "MULTIPOLYGON"; 154 | } else if (geog_type == GeographyType::GeometryCollection) { 155 | return "GEOMETRYCOLLECTION"; 156 | } else { 157 | return "UNKNOWN"; 158 | } 159 | } 160 | 161 | /** 162 | * Check the type of a Geography object and maybe raise an exception. 163 | */ 164 | inline void check_geog_type(const Geography& geog_obj, GeographyType geog_type) { 165 | if (geog_obj.geog_type() != geog_type) { 166 | auto expected = format_geog_type(geog_type); 167 | auto actual = format_geog_type(geog_obj.geog_type()); 168 | 169 | throw py::type_error("invalid Geography type (expected " + expected + ", found " + actual + 170 | ")"); 171 | } 172 | } 173 | 174 | } // namespace spherely 175 | 176 | #endif // SPHERELY_GEOGRAPHY_H_ 177 | -------------------------------------------------------------------------------- /src/io.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include "constants.hpp" 5 | #include "creation.hpp" 6 | #include "geography.hpp" 7 | #include "pybind11.hpp" 8 | 9 | namespace py = pybind11; 10 | namespace s2geog = s2geography; 11 | using namespace spherely; 12 | 13 | class FromWKT { 14 | public: 15 | FromWKT(bool oriented, bool planar, float tessellate_tolerance = 100.0) { 16 | s2geog::geoarrow::ImportOptions options; 17 | options.set_oriented(oriented); 18 | if (planar) { 19 | auto tol = 20 | S1Angle::Radians(tessellate_tolerance / numeric_constants::EARTH_RADIUS_METERS); 21 | options.set_tessellate_tolerance(tol); 22 | } 23 | m_reader = std::make_shared(options); 24 | } 25 | 26 | PyObjectGeography operator()(py::str string) const { 27 | return make_py_geography(m_reader->read_feature(string)); 28 | } 29 | 30 | private: 31 | std::shared_ptr m_reader; 32 | }; 33 | 34 | class ToWKT { 35 | public: 36 | ToWKT(int precision = 6) { 37 | m_writer = std::make_shared(precision); 38 | } 39 | 40 | py::str operator()(PyObjectGeography obj) const { 41 | auto res = m_writer->write_feature(obj.as_geog_ptr()->geog()); 42 | return py::str(res); 43 | } 44 | 45 | private: 46 | std::shared_ptr m_writer; 47 | }; 48 | 49 | class FromWKB { 50 | public: 51 | FromWKB(bool oriented, bool planar, float tessellate_tolerance = 100.0) { 52 | s2geog::geoarrow::ImportOptions options; 53 | options.set_oriented(oriented); 54 | if (planar) { 55 | auto tol = 56 | S1Angle::Radians(tessellate_tolerance / numeric_constants::EARTH_RADIUS_METERS); 57 | options.set_tessellate_tolerance(tol); 58 | } 59 | m_reader = std::make_shared(options); 60 | } 61 | 62 | PyObjectGeography operator()(py::bytes bytes) const { 63 | return make_py_geography(m_reader->ReadFeature(bytes)); 64 | } 65 | 66 | private: 67 | std::shared_ptr m_reader; 68 | }; 69 | 70 | class ToWKB { 71 | public: 72 | ToWKB() { 73 | m_writer = std::make_shared(); 74 | } 75 | 76 | py::bytes operator()(PyObjectGeography obj) const { 77 | return m_writer->WriteFeature(obj.as_geog_ptr()->geog()); 78 | } 79 | 80 | private: 81 | std::shared_ptr m_writer; 82 | }; 83 | 84 | void init_io(py::module& m) { 85 | m.def( 86 | "from_wkt", 87 | [](py::array_t string, bool oriented, bool planar, float tessellate_tolerance) { 88 | return py::vectorize(FromWKT(oriented, planar, tessellate_tolerance))( 89 | std::move(string)); 90 | }, 91 | py::arg("geography"), 92 | py::pos_only(), 93 | py::kw_only(), 94 | py::arg("oriented") = false, 95 | py::arg("planar") = false, 96 | py::arg("tessellate_tolerance") = 100.0, 97 | R"pbdoc(from_wkt(geography, /, *, oriented=False, planar=False, tessellate_tolerance=100.0) 98 | 99 | Creates geographies from the Well-Known Text (WKT) representation. 100 | 101 | Parameters 102 | ---------- 103 | geography : str or array_like 104 | The WKT string(s) to convert. 105 | oriented : bool, default False 106 | Set to True if polygon ring directions are known to be correct 107 | (i.e., exterior rings are defined counter clockwise and interior 108 | rings are defined clockwise). 109 | By default (False), it will return the polygon with the smaller 110 | area. 111 | planar : bool, default False 112 | If set to True, the edges of linestrings and polygons are assumed 113 | to be linear on the plane. In that case, additional points will 114 | be added to the line while creating the geography objects, to 115 | ensure every point is within 100m of the original line. 116 | By default (False), it is assumed that the edges are spherical 117 | (i.e. represent the shortest path on the sphere between two points). 118 | tessellate_tolerance : float, default 100.0 119 | The maximum distance in meters that a point must be moved to 120 | satisfy the planar edge constraint. This is only used if `planar` 121 | is set to True. 122 | 123 | Returns 124 | ------- 125 | Geography or array 126 | A single or an array of geography objects. 127 | 128 | )pbdoc"); 129 | 130 | m.def( 131 | "to_wkt", 132 | [](py::array_t obj, int precision) { 133 | return py::vectorize(ToWKT(precision))(std::move(obj)); 134 | }, 135 | py::arg("geography"), 136 | py::pos_only(), 137 | py::arg("precision") = 6, 138 | R"pbdoc(to_wkt(geography, /, precision=6) 139 | 140 | Returns the WKT representation of each geography. 141 | 142 | Parameters 143 | ---------- 144 | geography : :py:class:`Geography` or array_like 145 | Geography object(s). 146 | precision : int, default 6 147 | The number of decimal places to include in the output. 148 | 149 | Returns 150 | ------- 151 | str or array 152 | A string or an array of strings. 153 | 154 | )pbdoc"); 155 | 156 | m.def( 157 | "from_wkb", 158 | [](py::array_t bytes, bool oriented, bool planar, float tessellate_tolerance) { 159 | return py::vectorize(FromWKB(oriented, planar, tessellate_tolerance))(std::move(bytes)); 160 | }, 161 | py::arg("geography"), 162 | py::pos_only(), 163 | py::kw_only(), 164 | py::arg("oriented") = false, 165 | py::arg("planar") = false, 166 | py::arg("tessellate_tolerance") = 100.0, 167 | R"pbdoc(from_wkb(geography, /, *, oriented=False, planar=False, tessellate_tolerance=100.0) 168 | 169 | Creates geographies from the Well-Known Bytes (WKB) representation. 170 | 171 | Parameters 172 | ---------- 173 | geography : bytes or array_like 174 | The WKB byte object(s) to convert. 175 | oriented : bool, default False 176 | Set to True if polygon ring directions are known to be correct 177 | (i.e., exterior rings are defined counter clockwise and interior 178 | rings are defined clockwise). 179 | By default (False), it will return the polygon with the smaller 180 | area. 181 | planar : bool, default False 182 | If set to True, the edges of linestrings and polygons are assumed 183 | to be linear on the plane. In that case, additional points will 184 | be added to the line while creating the geography objects, to 185 | ensure every point is within 100m of the original line. 186 | By default (False), it is assumed that the edges are spherical 187 | (i.e. represent the shortest path on the sphere between two points). 188 | tessellate_tolerance : float, default 100.0 189 | The maximum distance in meters that a point must be moved to 190 | satisfy the planar edge constraint. This is only used if `planar` 191 | is set to True. 192 | 193 | Returns 194 | ------- 195 | Geography or array 196 | A single or an array of geography objects. 197 | 198 | )pbdoc"); 199 | 200 | m.def("to_wkb", 201 | py::vectorize(ToWKB()), 202 | py::arg("geography"), 203 | py::pos_only(), 204 | R"pbdoc(to_wkb(geography, /) 205 | 206 | Returns the WKB representation of each geography. 207 | 208 | Parameters 209 | ---------- 210 | geography : :py:class:`Geography` or array_like 211 | Geography object(s). 212 | 213 | Returns 214 | ------- 215 | bytes or array 216 | A bytes object or an array of bytes. 217 | 218 | )pbdoc"); 219 | } 220 | -------------------------------------------------------------------------------- /src/predicates.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include 5 | 6 | #include "geography.hpp" 7 | #include "pybind11.hpp" 8 | 9 | namespace py = pybind11; 10 | namespace s2geog = s2geography; 11 | using namespace spherely; 12 | 13 | /* 14 | ** Functor for predicate bindings. 15 | */ 16 | class Predicate { 17 | public: 18 | using FuncType = std::function; 21 | 22 | template 23 | Predicate(F&& func) : m_func(std::forward(func)) {} 24 | 25 | template 26 | Predicate(F&& func, const S2BooleanOperation::Options& options) 27 | : m_func(std::forward(func)), m_options(options) {} 28 | 29 | bool operator()(PyObjectGeography a, PyObjectGeography b) const { 30 | const auto& a_index = a.as_geog_ptr()->geog_index(); 31 | const auto& b_index = b.as_geog_ptr()->geog_index(); 32 | return m_func(a_index, b_index, m_options); 33 | } 34 | 35 | private: 36 | FuncType m_func; 37 | S2BooleanOperation::Options m_options; 38 | }; 39 | 40 | /* 41 | ** A specialization of the `Predicate` class for the touches operation, 42 | ** as two `S2BooleanOpteration::Options` objects are necessary. 43 | */ 44 | class TouchesPredicate { 45 | public: 46 | TouchesPredicate() : m_closed_options(), m_open_options() { 47 | m_closed_options.set_polyline_model(S2BooleanOperation::PolylineModel::CLOSED); 48 | m_closed_options.set_polygon_model(S2BooleanOperation::PolygonModel::CLOSED); 49 | 50 | m_open_options.set_polyline_model(S2BooleanOperation::PolylineModel::OPEN); 51 | m_open_options.set_polygon_model(S2BooleanOperation::PolygonModel::OPEN); 52 | } 53 | 54 | bool operator()(PyObjectGeography a, PyObjectGeography b) const { 55 | const auto& a_index = a.as_geog_ptr()->geog_index(); 56 | const auto& b_index = b.as_geog_ptr()->geog_index(); 57 | 58 | return s2geog::s2_intersects(a_index, b_index, m_closed_options) && 59 | !s2geog::s2_intersects(a_index, b_index, m_open_options); 60 | } 61 | 62 | private: 63 | S2BooleanOperation::Options m_closed_options; 64 | S2BooleanOperation::Options m_open_options; 65 | }; 66 | 67 | void init_predicates(py::module& m) { 68 | m.def("intersects", 69 | py::vectorize(Predicate(s2geog::s2_intersects)), 70 | py::arg("a"), 71 | py::arg("b"), 72 | R"pbdoc(intersects(a, b) 73 | 74 | Returns True if A and B share any portion of space. 75 | 76 | Intersects implies that overlaps, touches and within are True. 77 | 78 | Parameters 79 | ---------- 80 | a, b : :py:class:`Geography` or array_like 81 | Geography object(s). 82 | 83 | Returns 84 | ------- 85 | bool or array 86 | 87 | )pbdoc"); 88 | 89 | m.def("equals", 90 | py::vectorize(Predicate(s2geog::s2_equals)), 91 | py::arg("a"), 92 | py::arg("b"), 93 | R"pbdoc(equals(a, b) 94 | 95 | Returns True if A and B are spatially equal. 96 | 97 | If A is within B and B is within A, A and B are considered equal. The 98 | ordering of points can be different. 99 | 100 | Parameters 101 | ---------- 102 | a, b : :py:class:`Geography` or array_like 103 | Geography object(s). 104 | 105 | Returns 106 | ------- 107 | bool or array 108 | 109 | )pbdoc"); 110 | 111 | m.def("contains", 112 | py::vectorize(Predicate(s2geog::s2_contains)), 113 | py::arg("a"), 114 | py::arg("b"), 115 | R"pbdoc(contains(a, b) 116 | 117 | Returns True if B is completely inside A. 118 | 119 | Parameters 120 | ---------- 121 | a, b : :py:class:`Geography` or array_like 122 | Geography object(s). 123 | 124 | Returns 125 | ------- 126 | bool or array 127 | 128 | )pbdoc"); 129 | 130 | m.def("within", 131 | py::vectorize(Predicate([](const s2geog::ShapeIndexGeography& a_index, 132 | const s2geog::ShapeIndexGeography& b_index, 133 | const S2BooleanOperation::Options& options) { 134 | return s2geog::s2_contains(b_index, a_index, options); 135 | })), 136 | py::arg("a"), 137 | py::arg("b"), 138 | R"pbdoc(within(a, b) 139 | 140 | Returns True if A is completely inside B. 141 | 142 | Parameters 143 | ---------- 144 | a, b : :py:class:`Geography` or array_like 145 | Geography object(s). 146 | 147 | Returns 148 | ------- 149 | bool or array 150 | 151 | )pbdoc"); 152 | 153 | m.def("disjoint", 154 | py::vectorize(Predicate([](const s2geog::ShapeIndexGeography& a_index, 155 | const s2geog::ShapeIndexGeography& b_index, 156 | const S2BooleanOperation::Options& options) { 157 | return !s2geog::s2_intersects(a_index, b_index, options); 158 | })), 159 | py::arg("a"), 160 | py::arg("b"), 161 | R"pbdoc(disjoint(a, b) 162 | 163 | Returns True if A boundaries and interior does not intersect at all 164 | with those of B. 165 | 166 | Parameters 167 | ---------- 168 | a, b : :py:class:`Geography` or array_like 169 | Geography object(s). 170 | 171 | Returns 172 | ------- 173 | bool or array 174 | 175 | )pbdoc"); 176 | 177 | m.def("touches", 178 | py::vectorize(TouchesPredicate()), 179 | py::arg("a"), 180 | py::arg("b"), 181 | R"pbdoc(touches(a, b) 182 | 183 | Returns True if A and B intersect, but their interiors do not intersect. 184 | 185 | A and B must have at least one point in common, where the common point 186 | lies in at least one boundary. 187 | 188 | Parameters 189 | ---------- 190 | a, b : :py:class:`Geography` or array_like 191 | Geography object(s). 192 | 193 | Returns 194 | ------- 195 | bool or array 196 | 197 | )pbdoc"); 198 | 199 | S2BooleanOperation::Options closed_options; 200 | closed_options.set_polyline_model(S2BooleanOperation::PolylineModel::CLOSED); 201 | closed_options.set_polygon_model(S2BooleanOperation::PolygonModel::CLOSED); 202 | 203 | m.def("covers", 204 | py::vectorize(Predicate( 205 | [](const s2geog::ShapeIndexGeography& a_index, 206 | const s2geog::ShapeIndexGeography& b_index, 207 | const S2BooleanOperation::Options& options) { 208 | return s2geog::s2_contains(a_index, b_index, options); 209 | }, 210 | closed_options)), 211 | py::arg("a"), 212 | py::arg("b"), 213 | R"pbdoc(covers(a, b) 214 | 215 | Returns True if every point in B lies inside the interior or boundary of A. 216 | 217 | Parameters 218 | ---------- 219 | a, b : :py:class:`Geography` or array_like 220 | Geography object(s). 221 | 222 | Returns 223 | ------- 224 | bool or array 225 | 226 | Notes 227 | ----- 228 | If A and B are both polygons and share co-linear edges, 229 | `covers` currently returns expected results only when those 230 | shared edges are identical. 231 | 232 | )pbdoc"); 233 | 234 | m.def("covered_by", 235 | py::vectorize(Predicate( 236 | [](const s2geog::ShapeIndexGeography& a_index, 237 | const s2geog::ShapeIndexGeography& b_index, 238 | const S2BooleanOperation::Options& options) { 239 | return s2geog::s2_contains(b_index, a_index, options); 240 | }, 241 | closed_options)), 242 | py::arg("a"), 243 | py::arg("b"), 244 | R"pbdoc(covered_by(a, b) 245 | 246 | Returns True if every point in A lies inside the interior or boundary of B. 247 | 248 | Parameters 249 | ---------- 250 | a, b : :py:class:`Geography` or array_like 251 | Geography object(s). 252 | 253 | Returns 254 | ------- 255 | bool or array 256 | 257 | See Also 258 | -------- 259 | covers 260 | 261 | )pbdoc"); 262 | } 263 | -------------------------------------------------------------------------------- /src/projections.cpp: -------------------------------------------------------------------------------- 1 | #include "projections.hpp" 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | namespace py = pybind11; 8 | 9 | void init_projections(py::module& m) { 10 | py::class_ projection(m, "Projection", R"pbdoc( 11 | Lightweight wrapper for selecting common reference systems used to 12 | project Geography points or vertices. 13 | 14 | Cannot be instantiated directly. 15 | 16 | )pbdoc"); 17 | 18 | projection 19 | .def_static("lnglat", &Projection::lnglat, R"pbdoc(lnglat() 20 | 21 | Selects the "plate carree" projection. 22 | 23 | This projection maps coordinates on the sphere to (longitude, latitude) pairs. 24 | The x coordinates (longitude) span [-180, 180] and the y coordinates (latitude) 25 | span [-90, 90]. 26 | 27 | )pbdoc") 28 | .def_static("pseudo_mercator", &Projection::pseudo_mercator, R"pbdoc(pseudo_mercator() 29 | 30 | Selects the spherical Mercator projection. 31 | 32 | When used together with WGS84 coordinates, known as the "Web 33 | Mercator" or "WGS84/Pseudo-Mercator" projection. 34 | 35 | )pbdoc") 36 | .def_static("orthographic", 37 | &Projection::orthographic, 38 | py::arg("longitude"), 39 | py::arg("latitude"), 40 | R"pbdoc(orthographic(longitude, latitude) 41 | 42 | Selects an orthographic projection with the given centre point. 43 | 44 | The resulting coordinates depict a single hemisphere of the globe as 45 | it appears from outer space, centred on the given point. 46 | 47 | Parameters 48 | ---------- 49 | longitude : float 50 | Longitude coordinate of the center point, in degrees. 51 | latitude : float 52 | Latitude coordinate of the center point, in degrees. 53 | 54 | )pbdoc"); 55 | } 56 | -------------------------------------------------------------------------------- /src/projections.hpp: -------------------------------------------------------------------------------- 1 | #ifndef SPHERELY_PROJECTIONS_H_ 2 | #define SPHERELY_PROJECTIONS_H_ 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | namespace s2geog = s2geography; 9 | 10 | class Projection { 11 | public: 12 | Projection(std::shared_ptr projection) 13 | : m_s2_projection(std::move(projection)) {} 14 | 15 | std::shared_ptr s2_projection() { 16 | return m_s2_projection; 17 | } 18 | 19 | static Projection lnglat() { 20 | return Projection(std::move(s2geog::lnglat())); 21 | } 22 | static Projection pseudo_mercator() { 23 | return Projection(std::move(s2geog::pseudo_mercator())); 24 | } 25 | static Projection orthographic(double longitude, double latitude) { 26 | return Projection( 27 | std::move(s2geog::orthographic(S2LatLng::FromDegrees(latitude, longitude)))); 28 | } 29 | 30 | private: 31 | std::shared_ptr m_s2_projection; 32 | }; 33 | 34 | #endif // SPHERELY_PROJECTIONS_H_ 35 | -------------------------------------------------------------------------------- /src/pybind11.hpp: -------------------------------------------------------------------------------- 1 | #ifndef SPHERELY_PYBIND11_H_ 2 | #define SPHERELY_PYBIND11_H_ 3 | 4 | /* 5 | ** Pybind11 patches and workarounds for operating on Python wrapped Geography 6 | ** objects through Numpy arrays and universal functions (using numpy.object 7 | ** dtype). 8 | ** 9 | ** Somewhat hacky! 10 | */ 11 | 12 | #include 13 | #include 14 | 15 | #include "geography.hpp" 16 | 17 | namespace py = pybind11; 18 | 19 | namespace spherely { 20 | 21 | // A ``pybind11::object`` that maybe points to a ``Geography`` C++ object. 22 | // 23 | // The main goal of this class is to be used as argument and/or return type of 24 | // spherely's vectorized functions that operate on Geography objects via the 25 | // numpy.object dtype. 26 | // 27 | // Instead of relying on Pybind11's implicit conversion mechanisms (copy), we 28 | // require explicit conversion from / to ``pybind11::object``. 29 | // 30 | // 31 | class PyObjectGeography : public py::object { 32 | public: 33 | static py::detail::type_info *geography_tinfo; 34 | 35 | bool check_type(bool throw_if_invalid = true) const { 36 | PyObject *source = ptr(); 37 | 38 | // TODO: case of Python `None` and/or `NaN` (empty geography) 39 | 40 | // cache Geography type_info for performance 41 | if (!geography_tinfo) { 42 | // std::cout << "set pytype" << std::endl; 43 | geography_tinfo = py::detail::get_type_info(typeid(Geography)); 44 | } 45 | 46 | PyTypeObject *source_type = Py_TYPE(source); 47 | if (!PyType_IsSubtype(source_type, geography_tinfo->type)) { 48 | if (throw_if_invalid) { 49 | throw py::type_error("not a Geography object"); 50 | } else { 51 | return false; 52 | } 53 | } 54 | 55 | return true; 56 | } 57 | 58 | // Python -> C++ conversion 59 | // 60 | // Raises a ``TypeError`` on the Python side if the cast fails. 61 | // 62 | // Note: a raw pointer is used here because Pybind11's 63 | // `type_caster>` doesnt't support Python->C++ 64 | // conversion (no `load` method) as it would imply that Python needs to give 65 | // up ownership of an object, which is not possible (the object might be 66 | // referenced elsewhere) 67 | // 68 | // Conversion shouldn't involve any copy. The cast is dynamic, though, as 69 | // needed since the numpy.object dtype can refer to any Python object. 70 | // 71 | Geography *as_geog_ptr() const { 72 | PyObject *source = ptr(); 73 | 74 | // TODO: case of Python `None` and/or `NaN` (empty geography) 75 | 76 | check_type(); 77 | 78 | auto inst = reinterpret_cast(source); 79 | return reinterpret_cast(inst->simple_value_holder[0]); 80 | } 81 | 82 | // C++ -> Python conversion 83 | // 84 | // Note: pybind11's `type_caster>` implements 85 | // move semantics (Python takes ownership). 86 | // 87 | template ::value, bool> = true> 88 | static PyObjectGeography from_geog(std::unique_ptr geog_ptr) { 89 | auto pyobj = py::cast(std::move(geog_ptr)); 90 | auto pyobj_geog = static_cast(pyobj); 91 | return std::move(pyobj_geog); 92 | } 93 | 94 | // Just check whether the object is a Geography 95 | // 96 | bool is_geog_ptr() const { 97 | return check_type(false); 98 | } 99 | }; 100 | } // namespace spherely 101 | 102 | namespace pybind11 { 103 | namespace detail { 104 | 105 | // Force pybind11 to allow PyObjectGeography and str as argument of vectorized 106 | // functions. 107 | // 108 | // Pybind11 doesn't support non-POD types as arguments for vectorized 109 | // functions because of its internal conversion mechanisms and also 110 | // because direct memory access requires a standard layout type. 111 | // 112 | // Here it is probably fine to make an exception since we require 113 | // explicit Python object <-> C++ Geography conversion and also because 114 | // with the numpy.object dtype the data are actually references to Python 115 | // objects (not the objects themselves). 116 | // 117 | // Caveat: be careful and use PyObjectGeography cast methods! 118 | // 119 | template <> 120 | struct vectorize_arg { 121 | using T = spherely::PyObjectGeography; 122 | // The wrapped function gets called with this type: 123 | using call_type = T; 124 | // Is this a vectorized argument? 125 | static constexpr bool vectorize = true; 126 | // Accept this type: an array for vectorized types, otherwise the type 127 | // as-is: 128 | using type = conditional_t, array::forcecast>, T>; 129 | }; 130 | 131 | template <> 132 | struct vectorize_arg { 133 | using T = py::str; 134 | // The wrapped function gets called with this type: 135 | using call_type = T; 136 | // Is this a vectorized argument? 137 | static constexpr bool vectorize = true; 138 | // Accept this type: an array for vectorized types, otherwise the type 139 | // as-is: 140 | using type = conditional_t, array::forcecast>, T>; 141 | }; 142 | 143 | template <> 144 | struct vectorize_arg { 145 | using T = py::bytes; 146 | // The wrapped function gets called with this type: 147 | using call_type = T; 148 | // Is this a vectorized argument? 149 | static constexpr bool vectorize = true; 150 | // Accept this type: an array for vectorized types, otherwise the type 151 | // as-is: 152 | using type = conditional_t, array::forcecast>, T>; 153 | }; 154 | 155 | // Register PyObjectGeography and str as a valid numpy dtype (numpy.object alias) 156 | // from: https://github.com/pybind/pybind11/pull/1152 157 | template <> 158 | struct npy_format_descriptor { 159 | static constexpr auto name = _("object"); 160 | enum { value = npy_api::NPY_OBJECT_ }; 161 | static pybind11::dtype dtype() { 162 | if (auto ptr = npy_api::get().PyArray_DescrFromType_(value)) { 163 | return reinterpret_borrow(ptr); 164 | } 165 | pybind11_fail("Unsupported buffer format!"); 166 | } 167 | }; 168 | 169 | template <> 170 | struct npy_format_descriptor { 171 | static constexpr auto name = _("object"); 172 | enum { value = npy_api::NPY_OBJECT_ }; 173 | static pybind11::dtype dtype() { 174 | if (auto ptr = npy_api::get().PyArray_DescrFromType_(value)) { 175 | return reinterpret_borrow(ptr); 176 | } 177 | pybind11_fail("Unsupported buffer format!"); 178 | } 179 | }; 180 | 181 | template <> 182 | struct npy_format_descriptor { 183 | static constexpr auto name = _("object"); 184 | enum { value = npy_api::NPY_OBJECT_ }; 185 | static pybind11::dtype dtype() { 186 | if (auto ptr = npy_api::get().PyArray_DescrFromType_(value)) { 187 | return reinterpret_borrow(ptr); 188 | } 189 | pybind11_fail("Unsupported buffer format!"); 190 | } 191 | }; 192 | 193 | // Override signature type hint for vectorized Geography arguments 194 | template 195 | struct handle_type_name> { 196 | static constexpr auto name = _("Geography | array_like"); 197 | }; 198 | 199 | } // namespace detail 200 | 201 | // Specialization of ``pybind11::cast`` for PyObjectGeography and str (just a pass 202 | // through). 203 | // 204 | // Allows using PyObjectGeography and str as return type of vectorized functions. 205 | // 206 | template < 207 | typename T, 208 | typename detail::enable_if_t::value, int> = 0> 209 | object cast(T &&value) { 210 | return value; 211 | } 212 | 213 | template ::value, int> = 0> 214 | object cast(T &&value) { 215 | return value; 216 | } 217 | 218 | template ::value, int> = 0> 219 | object cast(T &&value) { 220 | return value; 221 | } 222 | 223 | } // namespace pybind11 224 | 225 | #endif // SPHERELY_PYBIND11_H_ 226 | -------------------------------------------------------------------------------- /src/spherely.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #define STRINGIFY(x) #x 4 | #define MACRO_STRINGIFY(x) STRINGIFY(x) 5 | 6 | namespace py = pybind11; 7 | 8 | void init_geography(py::module&); 9 | void init_creation(py::module&); 10 | void init_predicates(py::module&); 11 | void init_boolean_operations(py::module&); 12 | void init_accessors(py::module&); 13 | void init_io(py::module&); 14 | void init_geoarrow(py::module&); 15 | void init_projections(py::module&); 16 | 17 | PYBIND11_MODULE(spherely, m) { 18 | py::options options; 19 | options.disable_function_signatures(); 20 | 21 | m.doc() = R"pbdoc( 22 | Spherely 23 | --------- 24 | .. currentmodule:: spherely 25 | .. autosummary:: 26 | :toctree: _generate 27 | )pbdoc"; 28 | 29 | init_geography(m); 30 | init_creation(m); 31 | init_predicates(m); 32 | init_boolean_operations(m); 33 | init_accessors(m); 34 | init_io(m); 35 | init_projections(m); 36 | init_geoarrow(m); 37 | 38 | #ifdef VERSION_INFO 39 | m.attr("__version__") = MACRO_STRINGIFY(VERSION_INFO); 40 | #else 41 | m.attr("__version__") = "dev"; 42 | #endif 43 | 44 | #ifdef S2GEOGRAPHY_VERSION 45 | m.attr("__s2geography_version__") = MACRO_STRINGIFY(S2GEOGRAPHY_VERSION); 46 | #endif 47 | } 48 | -------------------------------------------------------------------------------- /src/spherely.pyi: -------------------------------------------------------------------------------- 1 | from typing import ( 2 | Annotated, 3 | Any, 4 | ClassVar, 5 | Generic, 6 | Iterable, 7 | Literal, 8 | Protocol, 9 | Sequence, 10 | TypeVar, 11 | overload, 12 | ) 13 | 14 | import numpy as np 15 | import numpy.typing as npt 16 | 17 | __version__: str = ... 18 | __s2geography_version__: str = ... 19 | EARTH_RADIUS_METERS: float = ... 20 | 21 | class Geography: 22 | def __init__(self, *args, **kwargs) -> None: ... 23 | 24 | class GeographyType: 25 | __members__: ClassVar[dict] = ... # read-only 26 | LINESTRING: ClassVar[GeographyType] = ... 27 | NONE: ClassVar[GeographyType] = ... 28 | POINT: ClassVar[GeographyType] = ... 29 | POLYGON: ClassVar[GeographyType] = ... 30 | MULTIPOLYGON: ClassVar[GeographyType] = ... 31 | MULTIPOINT: ClassVar[GeographyType] = ... 32 | MULTILINESTRING: ClassVar[GeographyType] = ... 33 | GEOMETRYCOLLECTION: ClassVar[GeographyType] = ... 34 | __entries: ClassVar[dict] = ... 35 | def __init__(self, value: int) -> None: ... 36 | def __eq__(self, other: object) -> bool: ... 37 | def __getstate__(self) -> int: ... 38 | def __hash__(self) -> int: ... 39 | def __index__(self) -> int: ... 40 | def __int__(self) -> int: ... 41 | def __ne__(self, other: object) -> bool: ... 42 | def __setstate__(self, state: int) -> None: ... 43 | @property 44 | def name(self) -> str: ... 45 | @property 46 | def value(self) -> int: ... 47 | 48 | # Annotated type aliases 49 | 50 | PointGeography = Annotated[Geography, GeographyType.POINT] 51 | LineStringGeography = Annotated[Geography, GeographyType.LINESTRING] 52 | PolygonGeography = Annotated[Geography, GeographyType.POLYGON] 53 | MultiPointGeography = Annotated[Geography, GeographyType.MULTIPOINT] 54 | MultiLineStringGeography = Annotated[Geography, GeographyType.MULTILINESTRING] 55 | MultiPolygonGeography = Annotated[Geography, GeographyType.MULTIPOLYGON] 56 | GeometryCollection = Annotated[Geography, GeographyType.GEOMETRYCOLLECTION] 57 | 58 | # Projection class 59 | 60 | class Projection: 61 | @staticmethod 62 | def lnglat() -> Projection: ... 63 | @staticmethod 64 | def pseudo_mercator() -> Projection: ... 65 | @staticmethod 66 | def orthographic(longitude: float, latitude: float) -> Projection: ... 67 | 68 | # Numpy-like vectorized (universal) functions 69 | 70 | _NameType = TypeVar("_NameType", bound=str) 71 | _ScalarReturnType = TypeVar("_ScalarReturnType", bound=Any) 72 | _ArrayReturnDType = TypeVar("_ArrayReturnDType", bound=Any) 73 | 74 | # TODO: npt.NDArray[Geography] not supported yet 75 | # (see https://github.com/numpy/numpy/issues/24738) 76 | # (unless Geography is passed via Generic[...], see VFunc below) 77 | T_NDArray_Geography = npt.NDArray[Any] 78 | 79 | # The following types are auto-generated. Please don't edit them by hand. 80 | # Instead, update the generate_spherely_vfunc_types.py script and run it 81 | # to update the types. 82 | # 83 | # /// Begin types 84 | class _VFunc_Nin1_Nout1(Generic[_NameType, _ScalarReturnType, _ArrayReturnDType]): 85 | @property 86 | def __name__(self) -> _NameType: ... 87 | @overload 88 | def __call__(self, geography: Geography, /) -> _ScalarReturnType: ... 89 | @overload 90 | def __call__( 91 | self, geography: Iterable[Geography], / 92 | ) -> npt.NDArray[_ArrayReturnDType]: ... 93 | 94 | class _VFunc_Nin2_Nout1(Generic[_NameType, _ScalarReturnType, _ArrayReturnDType]): 95 | @property 96 | def __name__(self) -> _NameType: ... 97 | @overload 98 | def __call__(self, a: Geography, b: Geography) -> _ScalarReturnType: ... 99 | @overload 100 | def __call__( 101 | self, a: Geography, b: Iterable[Geography] 102 | ) -> npt.NDArray[_ArrayReturnDType]: ... 103 | @overload 104 | def __call__( 105 | self, a: Iterable[Geography], b: Geography 106 | ) -> npt.NDArray[_ArrayReturnDType]: ... 107 | @overload 108 | def __call__( 109 | self, a: Iterable[Geography], b: Iterable[Geography] 110 | ) -> npt.NDArray[_ArrayReturnDType]: ... 111 | 112 | class _VFunc_Nin2optradius_Nout1( 113 | Generic[_NameType, _ScalarReturnType, _ArrayReturnDType] 114 | ): 115 | @property 116 | def __name__(self) -> _NameType: ... 117 | @overload 118 | def __call__( 119 | self, a: Geography, b: Geography, radius: float = 6371010.0 120 | ) -> _ScalarReturnType: ... 121 | @overload 122 | def __call__( 123 | self, a: Geography, b: Iterable[Geography], radius: float = 6371010.0 124 | ) -> npt.NDArray[_ArrayReturnDType]: ... 125 | @overload 126 | def __call__( 127 | self, a: Iterable[Geography], b: Geography, radius: float = 6371010.0 128 | ) -> npt.NDArray[_ArrayReturnDType]: ... 129 | @overload 130 | def __call__( 131 | self, a: Iterable[Geography], b: Iterable[Geography], radius: float = 6371010.0 132 | ) -> npt.NDArray[_ArrayReturnDType]: ... 133 | 134 | class _VFunc_Nin1optradius_Nout1( 135 | Generic[_NameType, _ScalarReturnType, _ArrayReturnDType] 136 | ): 137 | @property 138 | def __name__(self) -> _NameType: ... 139 | @overload 140 | def __call__( 141 | self, geography: Geography, /, radius: float = 6371010.0 142 | ) -> _ScalarReturnType: ... 143 | @overload 144 | def __call__( 145 | self, geography: Iterable[Geography], /, radius: float = 6371010.0 146 | ) -> npt.NDArray[_ArrayReturnDType]: ... 147 | 148 | class _VFunc_Nin1optprecision_Nout1( 149 | Generic[_NameType, _ScalarReturnType, _ArrayReturnDType] 150 | ): 151 | @property 152 | def __name__(self) -> _NameType: ... 153 | @overload 154 | def __call__( 155 | self, geography: Geography, /, precision: int = 6 156 | ) -> _ScalarReturnType: ... 157 | @overload 158 | def __call__( 159 | self, geography: Iterable[Geography], /, precision: int = 6 160 | ) -> npt.NDArray[_ArrayReturnDType]: ... 161 | 162 | # /// End types 163 | 164 | # Geography properties 165 | 166 | get_dimension: _VFunc_Nin1_Nout1[Literal["get_dimension"], Geography, Any] 167 | get_type_id: _VFunc_Nin1_Nout1[Literal["get_type_id"], int, np.int8] 168 | 169 | # Geography creation (scalar) 170 | 171 | def create_point( 172 | longitude: float | None = None, latitude: float | None = None 173 | ) -> Geography: ... 174 | def create_multipoint( 175 | points: Iterable[Sequence[float]] | Iterable[PointGeography], 176 | ) -> MultiPointGeography: ... 177 | def create_linestring( 178 | vertices: Iterable[Sequence[float]] | Iterable[PointGeography] | None = None, 179 | ) -> LineStringGeography: ... 180 | def create_multilinestring( 181 | vertices: ( 182 | Iterable[Iterable[Sequence[float]]] 183 | | Iterable[Iterable[PointGeography]] 184 | | Iterable[LineStringGeography] 185 | ), 186 | ) -> MultiLineStringGeography: ... 187 | @overload 188 | def create_polygon( 189 | shell: None = None, 190 | holes: None = None, 191 | oriented: bool = False, 192 | ) -> PolygonGeography: ... 193 | @overload 194 | def create_polygon( 195 | shell: Iterable[Sequence[float]], 196 | holes: Iterable[Iterable[Sequence[float]]] | None = None, 197 | oriented: bool = False, 198 | ) -> PolygonGeography: ... 199 | @overload 200 | def create_polygon( 201 | shell: Iterable[PointGeography], 202 | holes: Iterable[Iterable[PointGeography]] | None = None, 203 | oriented: bool = False, 204 | ) -> PolygonGeography: ... 205 | def create_multipolygon( 206 | polygons: Iterable[PolygonGeography], 207 | ) -> MultiPolygonGeography: ... 208 | def create_collection(geographies: Iterable[Geography]) -> GeometryCollection: ... 209 | 210 | # Geography creation (vectorized) 211 | 212 | def points( 213 | longitude: npt.ArrayLike, latitude: npt.ArrayLike 214 | ) -> PointGeography | T_NDArray_Geography: ... 215 | 216 | # Geography utils 217 | 218 | is_geography: _VFunc_Nin1_Nout1[Literal["is_geography"], bool, bool] 219 | is_prepared: _VFunc_Nin1_Nout1[Literal["is_prepared"], bool, bool] 220 | prepare: _VFunc_Nin1_Nout1[Literal["prepare"], Geography, Any] 221 | destroy_prepared: _VFunc_Nin1_Nout1[Literal["destroy_prepared"], Geography, Any] 222 | is_empty: _VFunc_Nin1_Nout1[Literal["is_empty"], bool, bool] 223 | 224 | # predicates 225 | 226 | intersects: _VFunc_Nin2_Nout1[Literal["intersects"], bool, bool] 227 | equals: _VFunc_Nin2_Nout1[Literal["intersects"], bool, bool] 228 | contains: _VFunc_Nin2_Nout1[Literal["contains"], bool, bool] 229 | within: _VFunc_Nin2_Nout1[Literal["within"], bool, bool] 230 | disjoint: _VFunc_Nin2_Nout1[Literal["disjoint"], bool, bool] 231 | touches: _VFunc_Nin2_Nout1[Literal["touches"], bool, bool] 232 | covers: _VFunc_Nin2_Nout1[Literal["covers"], bool, bool] 233 | covered_by: _VFunc_Nin2_Nout1[Literal["covered_by"], bool, bool] 234 | 235 | # boolean operations 236 | 237 | union: _VFunc_Nin2_Nout1[Literal["union"], Geography, Geography] 238 | intersection: _VFunc_Nin2_Nout1[Literal["intersection"], Geography, Geography] 239 | difference: _VFunc_Nin2_Nout1[Literal["difference"], Geography, Geography] 240 | symmetric_difference: _VFunc_Nin2_Nout1[ 241 | Literal["symmetric_difference"], Geography, Geography 242 | ] 243 | 244 | # coords 245 | 246 | get_x: _VFunc_Nin1_Nout1[Literal["get_x"], float, np.float64] 247 | get_y: _VFunc_Nin1_Nout1[Literal["get_y"], float, np.float64] 248 | 249 | # geography accessors 250 | 251 | centroid: _VFunc_Nin1_Nout1[Literal["centroid"], PointGeography, PointGeography] 252 | boundary: _VFunc_Nin1_Nout1[Literal["boundary"], Geography, Geography] 253 | convex_hull: _VFunc_Nin1_Nout1[ 254 | Literal["convex_hull"], PolygonGeography, PolygonGeography 255 | ] 256 | distance: _VFunc_Nin2optradius_Nout1[Literal["distance"], float, np.float64] 257 | area: _VFunc_Nin1optradius_Nout1[Literal["area"], float, np.float64] 258 | length: _VFunc_Nin1optradius_Nout1[Literal["length"], float, np.float64] 259 | perimeter: _VFunc_Nin1optradius_Nout1[Literal["perimeter"], float, np.float64] 260 | 261 | # io functions 262 | 263 | to_wkt: _VFunc_Nin1optprecision_Nout1[Literal["to_wkt"], str, object] 264 | to_wkb: _VFunc_Nin1_Nout1[Literal["to_wkb"], bytes, object] 265 | 266 | @overload 267 | def from_wkt( 268 | geography: str, 269 | /, 270 | *, 271 | oriented: bool = False, 272 | planar: bool = False, 273 | tessellate_tolerance: float = 100.0, 274 | ) -> Geography: ... 275 | @overload 276 | def from_wkt( 277 | geography: list[str] | npt.NDArray[np.str_], 278 | /, 279 | *, 280 | oriented: bool = False, 281 | planar: bool = False, 282 | tessellate_tolerance: float = 100.0, 283 | ) -> T_NDArray_Geography: ... 284 | @overload 285 | def from_wkb( 286 | geography: bytes, 287 | /, 288 | *, 289 | oriented: bool = False, 290 | planar: bool = False, 291 | tessellate_tolerance: float = 100.0, 292 | ) -> Geography: ... 293 | @overload 294 | def from_wkb( 295 | geography: Iterable[bytes], 296 | /, 297 | *, 298 | oriented: bool = False, 299 | planar: bool = False, 300 | tessellate_tolerance: float = 100.0, 301 | ) -> T_NDArray_Geography: ... 302 | 303 | class ArrowSchemaExportable(Protocol): 304 | def __arrow_c_schema__(self) -> object: ... 305 | 306 | class ArrowArrayExportable(Protocol): 307 | def __arrow_c_array__( 308 | self, requested_schema: object | None = None 309 | ) -> tuple[object, object]: ... 310 | 311 | class ArrowArrayHolder(ArrowArrayExportable): ... 312 | 313 | def to_geoarrow( 314 | geographies: Geography | T_NDArray_Geography, 315 | /, 316 | *, 317 | output_schema: ArrowSchemaExportable | str | None = None, 318 | projection: Projection = Projection.lnglat(), 319 | planar: bool = False, 320 | tessellate_tolerance: float = 100.0, 321 | precision: int = 6, 322 | ) -> ArrowArrayExportable: ... 323 | def from_geoarrow( 324 | geographies: ArrowArrayExportable, 325 | /, 326 | *, 327 | oriented: bool = False, 328 | planar: bool = False, 329 | tessellate_tolerance: float = 100.0, 330 | projection: Projection = Projection.lnglat(), 331 | geometry_encoding: str | None = None, 332 | ) -> T_NDArray_Geography: ... 333 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benbovy/spherely/4f527296b3539865c37e7e477ec6ccba9cd5861e/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_accessors.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import numpy as np 4 | import pytest 5 | 6 | import spherely 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "geog, expected", 11 | [ 12 | (spherely.create_point(0, 0), spherely.create_point(0, 0)), 13 | ( 14 | spherely.create_linestring([(0, 0), (2, 0)]), 15 | spherely.create_point(1, 0), 16 | ), 17 | ( 18 | spherely.create_polygon([(0, 0), (0, 2), (2, 2), (2, 0)]), 19 | spherely.create_point(1, 1), 20 | ), 21 | ], 22 | ) 23 | def test_centroid(geog, expected) -> None: 24 | # scalar 25 | actual = spherely.centroid(geog) 26 | assert spherely.get_type_id(actual) == spherely.GeographyType.POINT.value 27 | # TODO add some way of testing almost equality 28 | # assert spherely.equals(actual, expected) 29 | 30 | # array 31 | actual = spherely.centroid([geog]) 32 | assert isinstance(actual, np.ndarray) 33 | actual = actual[0] 34 | assert spherely.get_type_id(actual) == spherely.GeographyType.POINT.value 35 | # assert spherely.equals(actual, expected) 36 | 37 | 38 | @pytest.mark.parametrize( 39 | "geog, expected", 40 | [ 41 | (spherely.create_point(0, 0), "GEOMETRYCOLLECTION EMPTY"), 42 | ( 43 | spherely.create_linestring([(0, 0), (2, 0), (2, 2)]), 44 | "MULTIPOINT ((0 0), (2 2))", 45 | ), 46 | ( 47 | spherely.create_polygon([(0, 0), (0, 2), (2, 2), (0.5, 1.5)]), 48 | "LINESTRING (0.5 1.5, 2 2, 0 2, 0 0, 0.5 1.5)", 49 | ), 50 | ], 51 | ) 52 | def test_boundary(geog, expected) -> None: 53 | # scalar 54 | actual = spherely.boundary(geog) 55 | assert str(actual) == expected 56 | 57 | # array 58 | actual = spherely.boundary([geog]) 59 | assert isinstance(actual, np.ndarray) 60 | assert str(actual[0]) == expected 61 | 62 | 63 | @pytest.mark.parametrize( 64 | "geog, expected", 65 | [ 66 | ( 67 | spherely.create_linestring([(0, 0), (2, 0), (2, 2)]), 68 | spherely.create_polygon([(0, 0), (2, 0), (2, 2)]), 69 | ), 70 | ( 71 | spherely.create_polygon([(0, 0), (0, 2), (2, 2), (0.5, 1.5)]), 72 | spherely.create_polygon([(0, 0), (0, 2), (2, 2)]), 73 | ), 74 | ], 75 | ) 76 | def test_convex_hull(geog, expected) -> None: 77 | # scalar 78 | actual = spherely.convex_hull(geog) 79 | assert spherely.get_type_id(actual) == spherely.GeographyType.POLYGON.value 80 | assert spherely.equals(actual, expected) 81 | 82 | # array 83 | actual = spherely.convex_hull([geog]) 84 | assert isinstance(actual, np.ndarray) 85 | actual = actual[0] 86 | assert spherely.get_type_id(actual) == spherely.GeographyType.POLYGON.value 87 | assert spherely.equals(actual, expected) 88 | 89 | 90 | def test_is_empty(): 91 | arr = spherely.from_wkt( 92 | [ 93 | "POINT (0 0)", 94 | "POINT EMPTY", 95 | "LINESTRING (0 0, 1 1)", 96 | "LINESTRING EMPTY", 97 | "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))", 98 | "POLYGON EMPTY", 99 | "GEOMETRYCOLLECTION EMPTY", 100 | "GEOMETRYCOLLECTION (POINT EMPTY)", 101 | ] 102 | ) 103 | result = spherely.is_empty(arr) 104 | expected = np.array([False, True, False, True, False, True, True, True]) 105 | np.testing.assert_array_equal(result, expected) 106 | 107 | 108 | @pytest.mark.parametrize( 109 | "geog_a, geog_b, expected", 110 | [ 111 | ( 112 | spherely.create_point(0, 0), 113 | spherely.create_point(0, 90), 114 | np.pi / 2 * spherely.EARTH_RADIUS_METERS, 115 | ), 116 | ( 117 | spherely.create_point(0, 90), 118 | spherely.create_point(90, 30), 119 | np.pi / 3 * spherely.EARTH_RADIUS_METERS, 120 | ), 121 | ( 122 | spherely.create_polygon([(0, 0), (30, 60), (60, -30)]), 123 | spherely.create_point(0, 90), 124 | np.pi / 6 * spherely.EARTH_RADIUS_METERS, 125 | ), 126 | ], 127 | ) 128 | def test_distance(geog_a, geog_b, expected) -> None: 129 | # scalar 130 | actual = spherely.distance(geog_a, geog_b) 131 | assert isinstance(actual, float) 132 | assert actual == pytest.approx(expected, 1e-9) 133 | 134 | # array 135 | actual = spherely.distance([geog_a], [geog_b]) 136 | assert isinstance(actual, np.ndarray) 137 | actual = actual[0] 138 | assert isinstance(actual, float) 139 | assert actual == pytest.approx(expected, 1e-9) 140 | 141 | 142 | def test_distance_with_custom_radius() -> None: 143 | actual = spherely.distance( 144 | spherely.create_point(0, 90), 145 | spherely.create_point(0, 0), 146 | radius=1, 147 | ) 148 | assert isinstance(actual, float) 149 | assert actual == pytest.approx(np.pi / 2) 150 | 151 | 152 | def test_area() -> None: 153 | # scalar 154 | geog = spherely.create_polygon([(0, 0), (90, 0), (0, 90), (0, 0)]) 155 | result = spherely.area(geog, radius=1) 156 | assert isinstance(result, float) 157 | expected = 4 * math.pi / 8 158 | assert result == pytest.approx(expected, 1e-9) 159 | 160 | result = spherely.area(geog) 161 | assert result == pytest.approx(expected * spherely.EARTH_RADIUS_METERS**2, 1e-9) 162 | 163 | # array 164 | actual = spherely.area([geog], radius=1) 165 | assert isinstance(actual, np.ndarray) 166 | actual = actual[0] 167 | assert isinstance(actual, float) 168 | assert actual == pytest.approx(4 * math.pi / 8, 1e-9) 169 | 170 | 171 | @pytest.mark.parametrize( 172 | "geog", 173 | [ 174 | "POINT (-64 45)", 175 | "POINT EMPTY", 176 | "LINESTRING (0 0, 1 1)", 177 | "LINESTRING EMPTY", 178 | "POLYGON EMPTY", 179 | ], 180 | ) 181 | def test_area_empty(geog) -> None: 182 | assert spherely.area(spherely.from_wkt(geog)) == 0 183 | 184 | 185 | def test_length() -> None: 186 | geog = spherely.create_linestring([(0, 0), (1, 0)]) 187 | result = spherely.length(geog, radius=1) 188 | assert isinstance(result, float) 189 | expected = 1.0 * np.pi / 180.0 190 | assert result == pytest.approx(expected, 1e-9) 191 | 192 | actual = spherely.length([geog], radius=1) 193 | assert isinstance(actual, np.ndarray) 194 | actual = actual[0] 195 | assert isinstance(actual, float) 196 | assert actual == pytest.approx(expected, 1e-9) 197 | 198 | 199 | @pytest.mark.parametrize( 200 | "geog", 201 | [ 202 | "POINT (0 0)", 203 | "POINT EMPTY", 204 | "POLYGON EMPTY", 205 | "POLYGON ((0 0, 0 1, 1 0, 0 0))", 206 | ], 207 | ) 208 | def test_length_invalid(geog) -> None: 209 | assert spherely.length(spherely.from_wkt(geog)) == 0.0 210 | 211 | 212 | def test_perimeter() -> None: 213 | geog = spherely.create_polygon([(0, 0), (0, 90), (90, 90), (90, 0), (0, 0)]) 214 | result = spherely.perimeter(geog, radius=1) 215 | assert isinstance(result, float) 216 | expected = 3 * 90 * np.pi / 180.0 217 | assert result == pytest.approx(expected, 1e-9) 218 | 219 | actual = spherely.perimeter([geog], radius=1) 220 | assert isinstance(actual, np.ndarray) 221 | actual = actual[0] 222 | assert isinstance(actual, float) 223 | assert actual == pytest.approx(expected, 1e-9) 224 | 225 | 226 | @pytest.mark.parametrize( 227 | "geog", ["POINT (0 0)", "POINT EMPTY", "LINESTRING (0 0, 1 0)", "POLYGON EMPTY"] 228 | ) 229 | def test_perimeter_invalid(geog) -> None: 230 | assert spherely.perimeter(spherely.from_wkt(geog)) == 0.0 231 | -------------------------------------------------------------------------------- /tests/test_boolean_operations.py: -------------------------------------------------------------------------------- 1 | from packaging.version import Version 2 | 3 | import pytest 4 | 5 | import spherely 6 | 7 | poly1 = spherely.from_wkt("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))") 8 | poly2 = spherely.from_wkt("POLYGON ((5 5, 15 5, 15 15, 5 15, 5 5))") 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "geog1, geog2, expected", 13 | [ 14 | ("POINT (30 10)", "POINT EMPTY", "POINT (30 10)"), 15 | ("POINT EMPTY", "POINT EMPTY", "GEOMETRYCOLLECTION EMPTY"), 16 | ( 17 | "LINESTRING (-45 0, 0 0)", 18 | "LINESTRING (0 0, 0 10)", 19 | "LINESTRING (-45 0, 0 0, 0 10)", 20 | ), 21 | ], 22 | ) 23 | def test_union(geog1, geog2, expected) -> None: 24 | result = spherely.union(spherely.from_wkt(geog1), spherely.from_wkt(geog2)) 25 | assert str(result) == expected 26 | 27 | 28 | def test_union_polygon(): 29 | result = spherely.union(poly1, poly2) 30 | 31 | expected_near = ( 32 | spherely.area(spherely.from_wkt("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))")) 33 | + spherely.area(spherely.from_wkt("POLYGON ((5 5, 15 5, 15 15, 5 15, 5 5))")) 34 | - spherely.area(spherely.from_wkt("POLYGON ((5 5, 10 5, 10 15, 5 10, 5 5))")) 35 | ) 36 | pytest.approx(spherely.area(result), expected_near) 37 | 38 | 39 | @pytest.mark.parametrize( 40 | "geog1, geog2, expected", 41 | [ 42 | ("POINT (30 10)", "POINT (30 10)", "POINT (30 10)"), 43 | ( 44 | "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))", 45 | "LINESTRING (0 5, 10 5)", 46 | "LINESTRING (0 5, 10 5)", 47 | ), 48 | ], 49 | ) 50 | def test_intersection(geog1, geog2, expected) -> None: 51 | result = spherely.intersection(spherely.from_wkt(geog1), spherely.from_wkt(geog2)) 52 | assert str(result) == expected 53 | 54 | 55 | def test_intersection_empty() -> None: 56 | result = spherely.intersection(poly1, spherely.from_wkt("POLYGON EMPTY")) 57 | # assert spherely.is_empty(result) 58 | assert str(result) == "GEOMETRYCOLLECTION EMPTY" 59 | 60 | result = spherely.intersection(spherely.from_wkt("POLYGON EMPTY"), poly1) 61 | assert str(result) == "GEOMETRYCOLLECTION EMPTY" 62 | 63 | result = spherely.intersection( 64 | spherely.from_wkt("POINT (0 1)"), spherely.from_wkt("POINT (1 2)") 65 | ) 66 | assert str(result) == "GEOMETRYCOLLECTION EMPTY" 67 | 68 | 69 | def test_intersection_lines() -> None: 70 | result = spherely.intersection( 71 | spherely.from_wkt("LINESTRING (-45 0, 45 0)"), 72 | spherely.from_wkt("LINESTRING (0 -10, 0 10)"), 73 | ) 74 | assert str(result) == "POINT (0 0)" 75 | assert spherely.distance(result, spherely.from_wkt("POINT (0 0)")) == 0 76 | 77 | 78 | def test_intersection_polygons() -> None: 79 | result = spherely.intersection(poly1, poly2) 80 | # TODO precision could be higher with snap level 81 | precision = 2 if Version(spherely.__s2geography_version__) < Version("0.2.0") else 1 82 | assert ( 83 | spherely.to_wkt(result, precision=precision) 84 | == "POLYGON ((5 5, 10 5, 10 10, 5 10, 5 5))" 85 | ) 86 | 87 | 88 | def test_intersection_polygon_model() -> None: 89 | poly = spherely.from_wkt("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))") 90 | point = spherely.from_wkt("POINT (0 0)") 91 | 92 | result = spherely.intersection(poly, point) 93 | assert str(result) == "GEOMETRYCOLLECTION EMPTY" 94 | 95 | # TODO this will be different depending on the model 96 | 97 | 98 | @pytest.mark.parametrize( 99 | "geog1, geog2, expected", 100 | [ 101 | ("POINT (30 10)", "POINT EMPTY", "POINT (30 10)"), 102 | ("POINT EMPTY", "POINT EMPTY", "GEOMETRYCOLLECTION EMPTY"), 103 | ( 104 | "LINESTRING (0 0, 45 0)", 105 | "LINESTRING (0 0, 45 0)", 106 | "GEOMETRYCOLLECTION EMPTY", 107 | ), 108 | ], 109 | ) 110 | def test_difference(geog1, geog2, expected) -> None: 111 | result = spherely.difference(spherely.from_wkt(geog1), spherely.from_wkt(geog2)) 112 | assert spherely.equals(result, spherely.from_wkt(expected)) 113 | 114 | 115 | def test_difference_polygons() -> None: 116 | result = spherely.difference(poly1, poly2) 117 | expected_near = spherely.area(poly1) - spherely.area( 118 | spherely.from_wkt("POLYGON ((5 5, 10 5, 10 10, 5 10, 5 5))") 119 | ) 120 | pytest.approx(spherely.area(result), expected_near) 121 | 122 | 123 | @pytest.mark.parametrize( 124 | "geog1, geog2, expected", 125 | [ 126 | ("POINT (30 10)", "POINT EMPTY", "POINT (30 10)"), 127 | ("POINT (30 10)", "POINT (30 10)", "GEOMETRYCOLLECTION EMPTY"), 128 | ("POINT (30 10)", "POINT (30 20)", "MULTIPOINT ((30 20), (30 10))"), 129 | ( 130 | "LINESTRING (0 0, 45 0)", 131 | "LINESTRING (0 0, 45 0)", 132 | "GEOMETRYCOLLECTION EMPTY", 133 | ), 134 | ], 135 | ) 136 | def test_symmetric_difference(geog1, geog2, expected) -> None: 137 | result = spherely.symmetric_difference( 138 | spherely.from_wkt(geog1), spherely.from_wkt(geog2) 139 | ) 140 | assert spherely.equals(result, spherely.from_wkt(expected)) 141 | 142 | 143 | def test_symmetric_difference_polygons() -> None: 144 | result = spherely.symmetric_difference(poly1, poly2) 145 | expected_near = 2 * ( 146 | spherely.area(poly1) 147 | - spherely.area(spherely.from_wkt("POLYGON ((5 5, 10 5, 10 10, 5 10, 5 5))")) 148 | ) 149 | pytest.approx(spherely.area(result), expected_near) 150 | -------------------------------------------------------------------------------- /tests/test_geoarrow.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | import pytest 4 | 5 | import spherely 6 | 7 | 8 | pa = pytest.importorskip("pyarrow") 9 | ga = pytest.importorskip("geoarrow.pyarrow") 10 | 11 | 12 | def test_from_geoarrow_wkt() -> None: 13 | 14 | arr = ga.as_wkt(["POINT (1 1)", "POINT(2 2)", "POINT(3 3)"]) 15 | 16 | result = spherely.from_geoarrow(arr) 17 | expected = spherely.points([1, 2, 3], [1, 2, 3]) 18 | # object equality does not yet work 19 | # np.testing.assert_array_equal(result, expected) 20 | assert spherely.equals(result, expected).all() 21 | 22 | # without extension type 23 | arr = pa.array(["POINT (1 1)", "POINT(2 2)", "POINT(3 3)"]) 24 | result = spherely.from_geoarrow(arr, geometry_encoding="WKT") 25 | assert spherely.equals(result, expected).all() 26 | 27 | 28 | def test_from_geoarrow_wkb() -> None: 29 | 30 | arr = ga.as_wkt(["POINT (1 1)", "POINT(2 2)", "POINT(3 3)"]) 31 | arr_wkb = ga.as_wkb(arr) 32 | 33 | result = spherely.from_geoarrow(arr_wkb) 34 | expected = spherely.points([1, 2, 3], [1, 2, 3]) 35 | assert spherely.equals(result, expected).all() 36 | 37 | # without extension type 38 | arr_wkb = ga.as_wkb(["POINT (1 1)", "POINT(2 2)", "POINT(3 3)"]) 39 | arr = arr_wkb.cast(pa.binary()) 40 | result = spherely.from_geoarrow(arr, geometry_encoding="WKB") 41 | assert spherely.equals(result, expected).all() 42 | 43 | 44 | def test_from_geoarrow_native() -> None: 45 | 46 | arr = ga.as_wkt(["POINT (1 1)", "POINT(2 2)", "POINT(3 3)"]) 47 | arr_point = ga.as_geoarrow(arr) 48 | 49 | result = spherely.from_geoarrow(arr_point) 50 | expected = spherely.points([1, 2, 3], [1, 2, 3]) 51 | assert spherely.equals(result, expected).all() 52 | 53 | 54 | polygon_with_bad_hole_wkt = ( 55 | "POLYGON " 56 | "((20 35, 10 30, 10 10, 30 5, 45 20, 20 35)," 57 | "(30 20, 20 25, 20 15, 30 20))" 58 | ) 59 | 60 | 61 | def test_from_geoarrow_oriented() -> None: 62 | # by default re-orients the inner ring 63 | arr = ga.as_geoarrow([polygon_with_bad_hole_wkt]) 64 | 65 | result = spherely.from_geoarrow(arr) 66 | assert ( 67 | str(result[0]) 68 | == "POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (20 15, 20 25, 30 20, 20 15))" 69 | ) 70 | 71 | # if we force to not orient, we get an error 72 | with pytest.raises(ValueError, match="Inconsistent loop orientations detected"): 73 | spherely.from_geoarrow(arr, oriented=True) 74 | 75 | 76 | def test_from_wkt_planar() -> None: 77 | arr = ga.as_geoarrow(["LINESTRING (-64 45, 0 45)"]) 78 | result = spherely.from_geoarrow(arr) 79 | assert spherely.distance(result, spherely.create_point(-30.1, 45)) > 10000.0 80 | 81 | result = spherely.from_geoarrow(arr, planar=True) 82 | assert spherely.distance(result, spherely.create_point(-30.1, 45)) < 100.0 83 | 84 | result = spherely.from_geoarrow(arr, planar=True, tessellate_tolerance=10) 85 | assert spherely.distance(result, spherely.create_point(-30.1, 45)) < 10.0 86 | 87 | 88 | def test_from_geoarrow_projection() -> None: 89 | arr = ga.as_wkt(["POINT (1 0)", "POINT(0 1)"]) 90 | 91 | result = spherely.from_geoarrow( 92 | arr, projection=spherely.Projection.orthographic(0, 0) 93 | ) 94 | expected = spherely.points([90, 0], [0, 90]) 95 | # TODO use equality when we support precision / snapping 96 | # assert spherely.equals(result, expected).all() 97 | assert (spherely.to_wkt(result) == spherely.to_wkt(expected)).all() 98 | 99 | 100 | def test_from_geoarrow_no_extension_type() -> None: 101 | arr = pa.array(["POINT (1 1)", "POINT(2 2)", "POINT(3 3)"]) 102 | 103 | with pytest.raises(ValueError, match="Expected extension type"): 104 | spherely.from_geoarrow(arr) 105 | 106 | 107 | def test_from_geoarrow_invalid_encoding() -> None: 108 | arr = pa.array(["POINT (1 1)", "POINT(2 2)", "POINT(3 3)"]) 109 | 110 | with pytest.raises(ValueError, match="'geometry_encoding' should be one"): 111 | spherely.from_geoarrow(arr, geometry_encoding="point") 112 | 113 | 114 | def test_from_geoarrow_no_arrow_object() -> None: 115 | with pytest.raises(ValueError, match="input should be an Arrow-compatible array"): 116 | spherely.from_geoarrow(np.array(["POINT (1 1)"], dtype=object)) # type: ignore 117 | 118 | 119 | def test_to_geoarrow() -> None: 120 | arr = spherely.points([1, 2, 3], [1, 2, 3]) 121 | res = spherely.to_geoarrow( 122 | arr, output_schema=ga.point().with_coord_type(ga.CoordType.INTERLEAVED) 123 | ) 124 | assert isinstance(res, spherely.ArrowArrayHolder) 125 | assert hasattr(res, "__arrow_c_array__") 126 | 127 | arr_pa = pa.array(res) 128 | coords = np.asarray(arr_pa.storage.values) 129 | expected = np.array([1, 1, 2, 2, 3, 3], dtype="float64") 130 | np.testing.assert_allclose(coords, expected) 131 | 132 | 133 | def test_to_geoarrow_wkt() -> None: 134 | arr = spherely.points([1, 2, 3], [1, 2, 3]) 135 | result = pa.array(spherely.to_geoarrow(arr, output_schema=ga.wkt())) 136 | expected = pa.array(["POINT (1 1)", "POINT (2 2)", "POINT (3 3)"]) 137 | assert result.storage.equals(expected) 138 | 139 | 140 | def test_to_geoarrow_wkb() -> None: 141 | arr = spherely.points([1, 2, 3], [1, 2, 3]) 142 | result = pa.array(spherely.to_geoarrow(arr, output_schema=ga.wkb())) 143 | # the conversion from lon/lat values to S2 points and back gives some floating 144 | # point differences, and output to WKB does not do any rounding, 145 | # therefore checking exact values here 146 | expected = ga.as_wkb( 147 | [ 148 | "POINT (0.9999999999999998 1)", 149 | "POINT (2 1.9999999999999996)", 150 | "POINT (3.0000000000000004 3.0000000000000004)", 151 | ] 152 | ) 153 | assert result.equals(expected) 154 | 155 | 156 | def test_wkt_roundtrip() -> None: 157 | wkt = [ 158 | "POINT (30 10)", 159 | "LINESTRING (30 10, 10 30, 40 40)", 160 | "POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))", 161 | "POLYGON ((35 10, 45 45, 15 40, 10 20, 35 10), (20 30, 35 35, 30 20, 20 30))", 162 | "MULTIPOINT ((10 40), (40 30), (20 20), (30 10))", 163 | "MULTILINESTRING ((10 10, 20 20, 10 40), (40 40, 30 30, 40 20, 30 10))", 164 | "MULTIPOLYGON (((30 20, 45 40, 10 40, 30 20)), ((15 5, 40 10, 10 20, 5 10, 15 5)))", 165 | "MULTIPOLYGON (((40 40, 20 45, 45 30, 40 40)), ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 30 20)))", 166 | "GEOMETRYCOLLECTION (POINT (40 10), LINESTRING (10 10, 20 20, 10 40), POLYGON ((40 40, 20 45, 45 30, 40 40)))", 167 | ] 168 | 169 | arr = spherely.from_geoarrow(ga.as_wkt(wkt)) 170 | result = pa.array(spherely.to_geoarrow(arr, output_schema=ga.wkt())) 171 | np.testing.assert_array_equal(result, wkt) 172 | 173 | 174 | def test_to_geoarrow_no_output_encoding() -> None: 175 | arr = spherely.points([1, 2, 3], [1, 2, 3]) 176 | 177 | with pytest.raises(ValueError, match="'output_schema' should be specified"): 178 | spherely.to_geoarrow(arr) 179 | 180 | 181 | def test_to_geoarrow_invalid_output_schema() -> None: 182 | arr = spherely.points([1, 2, 3], [1, 2, 3]) 183 | with pytest.raises( 184 | ValueError, match="'output_schema' should be an Arrow-compatible schema" 185 | ): 186 | spherely.to_geoarrow(arr, output_schema="WKT") 187 | 188 | with pytest.raises(ValueError, match="Did you pass a valid schema"): 189 | spherely.to_geoarrow(arr, output_schema=pa.schema([("test", pa.int64())])) 190 | 191 | 192 | def test_to_geoarrow_projected() -> None: 193 | arr = spherely.points([1, 2, 3], [1, 2, 3]) 194 | point_schema = ga.point().with_coord_type(ga.CoordType.INTERLEAVED) 195 | result = pa.array( 196 | spherely.to_geoarrow( 197 | arr, output_schema=point_schema, projection=spherely.Projection.lnglat() 198 | ) 199 | ) 200 | 201 | coords = np.asarray(result.storage.values) 202 | expected = np.array([1, 1, 2, 2, 3, 3], dtype="float64") 203 | np.testing.assert_allclose(coords, expected) 204 | 205 | # Output to pseudo mercator - generation of expected result 206 | # import pyproj 207 | # trans = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True) 208 | # trans.transform([1, 2, 3], [1, 2, 3]) 209 | result = pa.array( 210 | spherely.to_geoarrow( 211 | arr, 212 | output_schema=point_schema, 213 | projection=spherely.Projection.pseudo_mercator(), 214 | ) 215 | ) 216 | coords = np.asarray(result.storage.values) 217 | expected = np.array( 218 | [ 219 | 111319.49079327357, 220 | 111325.1428663851, 221 | 222638.98158654713, 222 | 222684.20850554405, 223 | 333958.4723798207, 224 | 334111.1714019596, 225 | ], 226 | dtype="float64", 227 | ) 228 | np.testing.assert_allclose(coords, expected) 229 | 230 | # Output to orthographic 231 | result = pa.array( 232 | spherely.to_geoarrow( 233 | arr, 234 | output_schema=point_schema, 235 | projection=spherely.Projection.orthographic(0.0, 0.0), 236 | ) 237 | ) 238 | coords = np.asarray(result.storage.values) 239 | expected = np.array( 240 | [0.01744975, 0.01745241, 0.03487824, 0.0348995, 0.05226423, 0.05233596], 241 | dtype="float64", 242 | ) 243 | np.testing.assert_allclose(coords, expected, rtol=1e-06) 244 | -------------------------------------------------------------------------------- /tests/test_geography.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | 3 | import pytest 4 | import numpy as np 5 | 6 | import spherely 7 | 8 | 9 | def test_geography_type() -> None: 10 | assert spherely.GeographyType.NONE.value == -1 11 | assert spherely.GeographyType.POINT.value == 0 12 | assert spherely.GeographyType.LINESTRING.value == 1 13 | assert spherely.GeographyType.POLYGON.value == 2 14 | assert spherely.GeographyType.MULTIPOINT.value == 3 15 | assert spherely.GeographyType.MULTILINESTRING.value == 4 16 | assert spherely.GeographyType.MULTIPOLYGON.value == 5 17 | assert spherely.GeographyType.GEOMETRYCOLLECTION.value == 6 18 | 19 | 20 | def test_is_geography() -> None: 21 | arr = np.array([1, 2.33, spherely.create_point(30, 6)]) 22 | 23 | actual = spherely.is_geography(arr) 24 | expected = np.array([False, False, True]) 25 | np.testing.assert_array_equal(actual, expected) 26 | 27 | 28 | def test_not_geography_raise() -> None: 29 | arr = np.array([1, 2.33, spherely.create_point(30, 6)]) 30 | 31 | with pytest.raises(TypeError, match="not a Geography object"): 32 | spherely.get_dimension(arr) 33 | 34 | 35 | def test_get_type_id() -> None: 36 | # array 37 | geog = np.array( 38 | [ 39 | spherely.create_point(45, 50), 40 | spherely.create_multipoint([(5, 50), (6, 51)]), 41 | spherely.create_linestring([(5, 50), (6, 51)]), 42 | spherely.create_multilinestring([[(5, 50), (6, 51)], [(15, 60), (16, 61)]]), 43 | spherely.create_polygon([(5, 50), (5, 60), (6, 60), (6, 51)]), 44 | # with hole 45 | spherely.create_polygon( 46 | shell=[(5, 60), (6, 60), (6, 50), (5, 50)], 47 | holes=[[(5.1, 59), (5.9, 59), (5.9, 51), (5.1, 51)]], 48 | ), 49 | spherely.create_multipolygon( 50 | [ 51 | spherely.create_polygon([(5, 50), (5, 60), (6, 60), (6, 51)]), 52 | spherely.create_polygon( 53 | [(10, 100), (10, 160), (11, 160), (11, 100)] 54 | ), 55 | ] 56 | ), 57 | spherely.create_collection([spherely.create_point(40, 50)]), 58 | ] 59 | ) 60 | actual = spherely.get_type_id(geog) 61 | expected = np.array( 62 | [ 63 | spherely.GeographyType.POINT.value, 64 | spherely.GeographyType.MULTIPOINT.value, 65 | spherely.GeographyType.LINESTRING.value, 66 | spherely.GeographyType.MULTILINESTRING.value, 67 | spherely.GeographyType.POLYGON.value, 68 | spherely.GeographyType.POLYGON.value, 69 | spherely.GeographyType.MULTIPOLYGON.value, 70 | spherely.GeographyType.GEOMETRYCOLLECTION.value, 71 | ] 72 | ) 73 | np.testing.assert_array_equal(actual, expected) 74 | 75 | # scalar 76 | geog2 = spherely.create_point(45, 50) 77 | assert spherely.get_type_id(geog2) == spherely.GeographyType.POINT.value 78 | 79 | 80 | def test_get_dimension() -> None: 81 | # test n-d array 82 | expected = np.array([[0, 0], [1, 0]], dtype=np.int32) 83 | geog = np.array( 84 | [ 85 | [spherely.create_point(5, 40), spherely.create_point(6, 30)], 86 | [ 87 | spherely.create_linestring([(5, 50), (6, 51)]), 88 | spherely.create_point(4, 20), 89 | ], 90 | ] 91 | ) 92 | actual = spherely.get_dimension(geog) 93 | np.testing.assert_array_equal(actual, expected) 94 | 95 | # test scalar 96 | assert spherely.get_dimension(spherely.create_point(5, 40)) == 0 97 | 98 | 99 | @pytest.mark.parametrize( 100 | "empty_geog, expected", 101 | [ 102 | (spherely.create_point(), 0), 103 | (spherely.create_linestring(), 1), 104 | (spherely.create_polygon(), 2), 105 | (spherely.create_collection([]), -1), 106 | ], 107 | ) 108 | def test_get_dimension_empty(empty_geog, expected) -> None: 109 | assert spherely.get_dimension(empty_geog) == expected 110 | 111 | 112 | def test_get_dimension_collection() -> None: 113 | geog = spherely.create_collection( 114 | [spherely.create_point(0, 0), spherely.create_polygon([(0, 0), (1, 1), (2, 0)])] 115 | ) 116 | assert spherely.get_dimension(geog) == 2 117 | 118 | 119 | def test_get_x_y() -> None: 120 | # scalar 121 | a = spherely.create_point(1.5, 2.6) 122 | assert spherely.get_x(a) == pytest.approx(1.5, abs=1e-14) 123 | assert spherely.get_y(a) == pytest.approx(2.6, abs=1e-14) 124 | 125 | # array 126 | arr = np.array( 127 | [ 128 | spherely.create_point(0, 1), 129 | spherely.create_point(1, 2), 130 | spherely.create_point(2, 3), 131 | ] 132 | ) 133 | 134 | actual = spherely.get_x(arr) 135 | expected = np.array([0, 1, 2], dtype="float64") 136 | np.testing.assert_allclose(actual, expected) 137 | 138 | actual = spherely.get_y(arr) 139 | expected = np.array([1, 2, 3], dtype="float64") 140 | np.testing.assert_allclose(actual, expected) 141 | 142 | # only points are supported 143 | with pytest.raises(ValueError): 144 | spherely.get_x(spherely.create_linestring([(0, 1), (1, 2)])) 145 | 146 | with pytest.raises(ValueError): 147 | spherely.get_y(spherely.create_linestring([(0, 1), (1, 2)])) 148 | 149 | 150 | def test_prepare() -> None: 151 | # test array 152 | geog = np.array( 153 | [spherely.create_point(50, 45), spherely.create_linestring([(5, 50), (6, 51)])] 154 | ) 155 | np.testing.assert_array_equal(spherely.is_prepared(geog), np.array([False, False])) 156 | 157 | spherely.prepare(geog) 158 | np.testing.assert_array_equal(spherely.is_prepared(geog), np.array([True, True])) 159 | 160 | spherely.destroy_prepared(geog) 161 | np.testing.assert_array_equal(spherely.is_prepared(geog), np.array([False, False])) 162 | 163 | # test scalar 164 | geog2 = spherely.points(45, 50) 165 | assert spherely.is_prepared(geog2) is False 166 | 167 | spherely.prepare(geog2) 168 | assert spherely.is_prepared(geog2) is True 169 | 170 | spherely.destroy_prepared(geog2) 171 | assert spherely.is_prepared(geog2) is False 172 | 173 | 174 | def test_equality() -> None: 175 | p1 = spherely.create_point(1, 1) 176 | p2 = spherely.create_point(1, 1) 177 | p3 = spherely.create_point(2, 2) 178 | 179 | assert p1 == p1 180 | assert p1 == p2 181 | assert not p1 == p3 182 | 183 | line1 = spherely.create_linestring([(1, 1), (2, 2), (3, 3)]) 184 | line2 = spherely.create_linestring([(3, 3), (2, 2), (1, 1)]) 185 | 186 | assert line1 == line2 187 | 188 | poly1 = spherely.create_polygon([(1, 1), (3, 1), (2, 3)]) 189 | poly2 = spherely.create_polygon([(2, 3), (1, 1), (3, 1)]) 190 | poly3 = spherely.create_polygon([(2, 3), (3, 1), (1, 1)]) 191 | 192 | assert p1 != poly1 193 | assert line1 != poly1 194 | assert poly1 == poly2 195 | assert poly2 == poly3 196 | assert poly1 == poly3 197 | 198 | coll1 = (spherely.create_collection([spherely.create_point(40, 50)]),) 199 | coll2 = (spherely.create_collection([spherely.create_point(40, 50)]),) 200 | 201 | assert coll1 == coll2 202 | 203 | 204 | @pytest.mark.parametrize( 205 | "geog", 206 | [ 207 | spherely.create_point(45, 50), 208 | spherely.create_multipoint([(5, 50), (6, 51)]), 209 | spherely.create_linestring([(5, 50), (6, 51)]), 210 | spherely.create_multilinestring([[(5, 50), (6, 51)], [(15, 60), (16, 61)]]), 211 | spherely.create_polygon([(5, 50), (5, 60), (6, 60), (6, 51)]), 212 | spherely.create_multipolygon( 213 | [ 214 | spherely.create_polygon([(5, 50), (5, 60), (6, 60), (6, 51)]), 215 | spherely.create_polygon([(10, 100), (10, 160), (11, 160), (11, 100)]), 216 | ] 217 | ), 218 | spherely.create_collection([spherely.create_point(40, 50)]), 219 | # empty geography 220 | spherely.create_point(), 221 | spherely.create_linestring(), 222 | spherely.create_polygon(), 223 | ], 224 | ) 225 | def test_pickle_roundtrip(geog): 226 | roundtripped = pickle.loads(pickle.dumps(geog)) 227 | 228 | assert spherely.get_type_id(roundtripped) == spherely.get_type_id(geog) 229 | assert spherely.to_wkt(roundtripped) == spherely.to_wkt(geog) 230 | assert roundtripped == geog 231 | -------------------------------------------------------------------------------- /tests/test_io.py: -------------------------------------------------------------------------------- 1 | import struct 2 | 3 | import numpy as np 4 | import pytest 5 | 6 | import spherely 7 | 8 | 9 | def test_from_wkt() -> None: 10 | result = spherely.from_wkt(["POINT (1 1)", "POINT(2 2)", "POINT(3 3)"]) 11 | expected = spherely.points([1, 2, 3], [1, 2, 3]) 12 | # object equality does not yet work 13 | # np.testing.assert_array_equal(result, expected) 14 | assert spherely.equals(result, expected).all() 15 | 16 | # from explicit object dtype 17 | result = spherely.from_wkt( 18 | np.array(["POINT (1 1)", "POINT(2 2)", "POINT(3 3)"], dtype=object) 19 | ) 20 | assert spherely.equals(result, expected).all() 21 | 22 | # from numpy string dtype 23 | result = spherely.from_wkt( 24 | np.array(["POINT (1 1)", "POINT(2 2)", "POINT(3 3)"], dtype="U") 25 | ) 26 | assert spherely.equals(result, expected).all() 27 | 28 | 29 | def test_from_wkt_invalid() -> None: 30 | # TODO can we provide better error type? 31 | with pytest.raises(RuntimeError): 32 | spherely.from_wkt(["POINT (1)"]) 33 | 34 | 35 | def test_from_wkt_wrong_type() -> None: 36 | with pytest.raises(TypeError, match="expected bytes, int found"): 37 | spherely.from_wkt([1]) # type: ignore 38 | 39 | # TODO support missing values 40 | with pytest.raises(TypeError, match="expected bytes, NoneType found"): 41 | spherely.from_wkt(["POINT (1 1)", None]) # type: ignore 42 | 43 | 44 | polygon_with_bad_hole_wkt = ( 45 | "POLYGON " 46 | "((20 35, 10 30, 10 10, 30 5, 45 20, 20 35)," 47 | "(30 20, 20 25, 20 15, 30 20))" 48 | ) 49 | 50 | 51 | def test_from_wkt_oriented() -> None: 52 | # by default re-orients the inner ring 53 | result = spherely.from_wkt(polygon_with_bad_hole_wkt) 54 | assert ( 55 | str(result) 56 | == "POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (20 15, 20 25, 30 20, 20 15))" 57 | ) 58 | 59 | # if we force to not orient, we get an error 60 | with pytest.raises(RuntimeError, match="Inconsistent loop orientations detected"): 61 | spherely.from_wkt(polygon_with_bad_hole_wkt, oriented=True) 62 | 63 | 64 | def test_from_wkt_planar() -> None: 65 | result = spherely.from_wkt("LINESTRING (-64 45, 0 45)") 66 | assert spherely.distance(result, spherely.create_point(-30.1, 45)) > 10000 67 | 68 | result = spherely.from_wkt("LINESTRING (-64 45, 0 45)", planar=True) 69 | assert spherely.distance(result, spherely.create_point(-30.1, 45)) < 100 70 | 71 | result = spherely.from_wkt( 72 | "LINESTRING (-64 45, 0 45)", planar=True, tessellate_tolerance=10 73 | ) 74 | assert spherely.distance(result, spherely.create_point(-30.1, 45)) < 10 75 | 76 | 77 | def test_to_wkt() -> None: 78 | arr = spherely.points([1.1, 2, 3], [1.1, 2, 3]) 79 | result = spherely.to_wkt(arr) 80 | expected = np.array(["POINT (1.1 1.1)", "POINT (2 2)", "POINT (3 3)"], dtype=object) 81 | np.testing.assert_array_equal(result, expected) 82 | 83 | 84 | def test_to_wkt_precision() -> None: 85 | arr = spherely.points([0.12345], [0.56789]) 86 | result = spherely.to_wkt(arr) 87 | assert result[0] == "POINT (0.12345 0.56789)" 88 | 89 | result = spherely.to_wkt(arr, precision=2) 90 | assert result[0] == "POINT (0.12 0.57)" 91 | 92 | 93 | POINT11_WKB = struct.pack(" None: 104 | result = spherely.from_wkb([POINT11_WKB, POINT_NAN_WKB, MULTIPOINT_NAN_WKB]) 105 | # empty MultiPoint is converted to empty Point 106 | expected = spherely.from_wkt(["POINT (1 1)", "POINT EMPTY", "POINT EMPTY"]) 107 | assert spherely.equals(result, expected).all() 108 | 109 | result2 = spherely.from_wkb(GEOMETRYCOLLECTION_NAN_WKB) 110 | assert str(result2) == "GEOMETRYCOLLECTION (POINT EMPTY)" 111 | 112 | 113 | def test_from_wkb_invalid() -> None: 114 | with pytest.raises(RuntimeError, match="Expected endian byte"): 115 | spherely.from_wkb(b"") 116 | 117 | with pytest.raises(RuntimeError): 118 | spherely.from_wkb([b"\x01\x01\x00\x00\x00\x00"]) 119 | 120 | # TODO should this raise an error? 121 | # with pytest.raises(RuntimeError): 122 | result = spherely.from_wkb(INVALID_WKB) 123 | assert str(result) == "POLYGON ((108.7761 -10.2852, 108.7761 -10.2852))" 124 | 125 | 126 | def test_from_wkb_invalid_type() -> None: 127 | with pytest.raises(TypeError, match="expected bytes, str found"): 128 | spherely.from_wkb("POINT (1 1)") # type: ignore 129 | 130 | 131 | @pytest.mark.parametrize( 132 | "geog", 133 | [ 134 | spherely.create_point(45, 50), 135 | spherely.create_multipoint([(5, 50), (6, 51)]), 136 | spherely.create_linestring([(5, 50), (6, 51)]), 137 | spherely.create_multilinestring([[(5, 50), (6, 51)], [(15, 60), (16, 61)]]), 138 | spherely.create_polygon([(5, 50), (5, 60), (6, 60), (6, 51)]), 139 | # with hole 140 | spherely.create_polygon( 141 | shell=[(5, 60), (6, 60), (6, 50), (5, 50)], 142 | holes=[[(5.1, 59), (5.9, 59), (5.9, 51), (5.1, 51)]], 143 | ), 144 | spherely.create_multipolygon( 145 | [ 146 | spherely.create_polygon([(5, 50), (5, 60), (6, 60), (6, 51)]), 147 | spherely.create_polygon([(10, 100), (10, 160), (11, 160), (11, 100)]), 148 | ] 149 | ), 150 | spherely.create_collection([spherely.create_point(40, 50)]), 151 | spherely.create_collection( 152 | [ 153 | spherely.create_point(0, 0), 154 | spherely.create_linestring([(0, 0), (1, 1)]), 155 | spherely.create_polygon([(0, 0), (1, 0), (1, 1)]), 156 | ] 157 | ), 158 | ], 159 | ) 160 | def test_wkb_roundtrip(geog) -> None: 161 | wkb = spherely.to_wkb(geog) 162 | result = spherely.from_wkb(wkb) 163 | # roundtrip through Geography unit vector is not exact, so equals can fail 164 | # TODO properly test this once `equals` supports snapping/precision 165 | # assert spherely.equals(result, geog) 166 | assert str(result) == str(geog) 167 | 168 | 169 | def test_from_wkb_oriented() -> None: 170 | # WKB for POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0)) -> non-CCW box 171 | wkb = bytes.fromhex( 172 | "010300000001000000050000000000000000000000000000000000000000000000000000000000000000002440000000000000244000000000000024400000000000002440000000000000000000000000000000000000000000000000" 173 | ) # noqa: E501 174 | 175 | result = spherely.from_wkb(wkb) 176 | # by default re-oriented to take the smaller polygon 177 | assert str(result) == "POLYGON ((10 0, 10 10, 0 10, 0 0, 10 0))" 178 | assert spherely.within(spherely.create_point(5, 5), result) 179 | 180 | result = spherely.from_wkb(wkb, oriented=True) 181 | assert str(result) == "POLYGON ((0 0, 0 10, 10 10, 10 0, 0 0))" 182 | assert not spherely.within(spherely.create_point(5, 5), result) 183 | 184 | 185 | def test_from_wkb_planar() -> None: 186 | wkb = spherely.to_wkb(spherely.from_wkt("LINESTRING (-64 45, 0 45)")) 187 | 188 | result = spherely.from_wkb(wkb) 189 | assert spherely.distance(result, spherely.create_point(-30.1, 45)) > 10000 190 | 191 | result = spherely.from_wkb(wkb, planar=True) 192 | assert spherely.distance(result, spherely.create_point(-30.1, 45)) < 100 193 | 194 | result = spherely.from_wkb(wkb, planar=True, tessellate_tolerance=10) 195 | assert spherely.distance(result, spherely.create_point(-30.1, 45)) < 10 196 | -------------------------------------------------------------------------------- /tests/test_predicates.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import numpy as np 4 | import numpy.typing as npt 5 | 6 | import spherely 7 | import pytest 8 | 9 | 10 | def test_intersects() -> None: 11 | # test array + scalar 12 | a = np.array( 13 | [ 14 | spherely.create_linestring([(40, 8), (60, 8)]), 15 | spherely.create_linestring([(20, 0), (30, 0)]), 16 | ] 17 | ) 18 | b = spherely.create_linestring([(50, 5), (50, 10)]) 19 | 20 | actual = spherely.intersects(a, b) 21 | expected = np.array([True, False]) 22 | np.testing.assert_array_equal(actual, expected) 23 | 24 | # two scalars 25 | a2 = spherely.create_point(50, 8) 26 | b2 = spherely.create_point(20, 5) 27 | assert not spherely.intersects(a2, b2) 28 | 29 | 30 | def test_equals() -> None: 31 | # test array + scalar 32 | a = np.array( 33 | [ 34 | spherely.create_linestring([(40, 8), (60, 8)]), 35 | spherely.create_linestring([(20, 0), (30, 0)]), 36 | ] 37 | ) 38 | b = spherely.create_point(50, 8) 39 | 40 | actual = spherely.equals(a, b) 41 | expected = np.array([False, False]) 42 | np.testing.assert_array_equal(actual, expected) 43 | 44 | # two scalars 45 | a2 = spherely.create_point(50, 8) 46 | b2 = spherely.create_point(50, 8) 47 | assert spherely.equals(a2, b2) 48 | 49 | 50 | def test_contains() -> None: 51 | # test array + scalar 52 | a = np.array( 53 | [ 54 | spherely.create_linestring([(40, 8), (60, 8)]), 55 | spherely.create_linestring([(20, 0), (30, 0)]), 56 | ] 57 | ) 58 | b = spherely.create_point(40, 8) 59 | 60 | actual = spherely.contains(a, b) 61 | expected = np.array([True, False]) 62 | np.testing.assert_array_equal(actual, expected) 63 | 64 | # two scalars 65 | a2 = spherely.create_linestring([(50, 8), (60, 8)]) 66 | b2 = spherely.create_point(50, 8) 67 | assert spherely.contains(a2, b2) 68 | 69 | 70 | def test_contains_polygon() -> None: 71 | # plain vs. hole polygon 72 | poly_plain = spherely.create_polygon(shell=[(0, 0), (4, 0), (4, 4), (0, 4)]) 73 | 74 | poly_hole = spherely.create_polygon( 75 | shell=[(0, 0), (4, 0), (4, 4), (0, 4)], 76 | holes=[[(1, 1), (3, 1), (3, 3), (1, 3)]], 77 | ) 78 | 79 | assert spherely.contains(poly_plain, spherely.create_point(2, 2)) 80 | assert not spherely.contains(poly_hole, spherely.create_point(2, 2)) 81 | 82 | 83 | def test_within() -> None: 84 | # test array + scalar 85 | a = spherely.create_point(40, 8) 86 | b = np.array( 87 | [ 88 | spherely.create_linestring([(40, 8), (60, 8)]), 89 | spherely.create_linestring([(20, 0), (30, 0)]), 90 | ] 91 | ) 92 | 93 | actual = spherely.within(a, b) 94 | expected = np.array([True, False]) 95 | np.testing.assert_array_equal(actual, expected) 96 | 97 | # two scalars 98 | a2 = spherely.create_point(50, 8) 99 | b2 = spherely.create_linestring([(50, 8), (60, 8)]) 100 | assert spherely.within(a2, b2) 101 | 102 | 103 | def test_within_polygon() -> None: 104 | # plain vs. hole polygon 105 | poly_plain = spherely.create_polygon(shell=[(0, 0), (4, 0), (4, 4), (0, 4)]) 106 | 107 | poly_hole = spherely.create_polygon( 108 | shell=[(0, 0), (4, 0), (4, 4), (0, 4)], 109 | holes=[[(1, 1), (3, 1), (3, 3), (1, 3)]], 110 | ) 111 | 112 | assert spherely.within(spherely.create_point(2, 2), poly_plain) 113 | assert not spherely.within(spherely.create_point(2, 2), poly_hole) 114 | 115 | 116 | def test_disjoint() -> None: 117 | a = spherely.create_point(40, 9) 118 | b = np.array( 119 | [ 120 | spherely.create_linestring([(40, 8), (60, 8)]), 121 | spherely.create_linestring([(20, 0), (30, 0)]), 122 | ] 123 | ) 124 | 125 | actual = spherely.disjoint(a, b) 126 | expected = np.array([True, True]) 127 | np.testing.assert_array_equal(actual, expected) 128 | 129 | # two scalars 130 | a2 = spherely.create_point(50, 9) 131 | b2 = spherely.create_linestring([(50, 8), (60, 8)]) 132 | assert spherely.disjoint(a2, b2) 133 | 134 | 135 | def test_touches() -> None: 136 | a = spherely.create_polygon([(0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0)]) 137 | b = np.array( 138 | [ 139 | spherely.create_polygon([(1.0, 1.0), (1.0, 2.0), (2.0, 2.0), (2.0, 1.0)]), 140 | spherely.create_polygon([(0.5, 0.5), (0.5, 1.5), (1.5, 1.5), (1.5, 0.5)]), 141 | ] 142 | ) 143 | 144 | actual = spherely.touches(a, b) 145 | expected = np.array([True, False]) 146 | np.testing.assert_array_equal(actual, expected) 147 | 148 | a_p = spherely.create_point(1.0, 1.0) 149 | b_p = spherely.create_point(1.0, 1.0) 150 | # Points do not have a boundary, so they cannot touch per definition 151 | # This is consistent with PostGIS for example 152 | # (cmp. https://postgis.net/docs/ST_Touches.html) 153 | assert not spherely.touches(a_p, b_p) 154 | 155 | b_line = spherely.create_linestring([(1.0, 1.0), (1.0, 2.0)]) 156 | assert spherely.touches(a_p, b_line) 157 | 158 | 159 | @pytest.fixture 160 | def parent_poly() -> spherely.Geography: 161 | return spherely.create_polygon( 162 | [ 163 | (-118.0, 60.0), 164 | (-118.0, 40.0), 165 | (-118.0, 23.0), 166 | (34.0, 23.0), 167 | (34.0, 40.0), 168 | (34.0, 60.0), 169 | ] 170 | ) 171 | 172 | 173 | @pytest.fixture 174 | def geographies_covers_contains() -> npt.NDArray[Any]: 175 | return np.array( 176 | [ 177 | # Basic point covers tests, outside, on boundary and interior 178 | spherely.create_point(-120.0, 70.0), 179 | spherely.create_point(-118.0, 41.0), 180 | spherely.create_point(-116.0, 37.0), 181 | # Basic polyline tests, crossing, on boundary and interior 182 | spherely.create_linestring([(-120.0, 70.0), (-116.0, 37.0)]), 183 | spherely.create_linestring([(-118.0, 41.0), (-118.0, 23.0)]), 184 | spherely.create_linestring([(-117.0, 39.0), (-115.0, 37.0)]), 185 | # Basic polygon test, crossing, shared boundary and interior 186 | spherely.create_polygon( 187 | [(-120.0, 41.0), (-120.0, 35.0), (-115.0, 35.0), (-115.0, 41.0)] 188 | ), 189 | # TODO: This case is currently not fully correct. Supplying a 190 | # polygon `a` and `b` and checking whether `a` covers `b`, 191 | # where `a` and `b` share co-linear edges only works 192 | # when the edges between them are identical. 193 | # An example of this breaking would be a polygon `a`, 194 | # consisting of the edges AB, BC and CA and a polygon `b`, 195 | # consisting of the edges AB*, B*C* and C*A, where B* and C* reside 196 | # somewhere on the edge AB and BC, respectively. 197 | # In this case, s2geometry tries to resolve the co-linearity by 198 | # symbolic perturbation, where B* and C* are moved by 199 | # an infinitesimal amount. However, the resulting geometry may then not 200 | # be covered anymore, even if it would be in reality. Therefor, 201 | # these tests assume identical shared edges between polygons `a` and `b`, 202 | # which does work as intended. 203 | spherely.create_polygon( 204 | [(-118.0, 40.0), (-118.0, 23.0), (34.0, 23.0), (34.0, 40.0)] 205 | ), 206 | spherely.create_polygon( 207 | [(-117.0, 40.0), (-117.0, 35.0), (-115.0, 35.0), (-115.0, 40.0)] 208 | ), 209 | ] 210 | ) 211 | 212 | 213 | @pytest.fixture 214 | def geographies_covers_with_labels( 215 | geographies_covers_contains, 216 | ) -> tuple[npt.NDArray[Any], npt.NDArray[np.bool_]]: 217 | return ( 218 | geographies_covers_contains, 219 | np.array([False, True, True, False, True, True, False, True, True]), 220 | ) 221 | 222 | 223 | @pytest.fixture 224 | def geographies_contains_with_labels( 225 | geographies_covers_contains, 226 | ) -> tuple[npt.NDArray[Any], npt.NDArray[np.bool_]]: 227 | return ( 228 | geographies_covers_contains, 229 | np.array([False, False, True, False, False, True, False, True, True]), 230 | ) 231 | 232 | 233 | @pytest.mark.skip( 234 | reason="Testing whether a polygon contains a points on its boundary \ 235 | currently returns true, although it should be false" 236 | ) 237 | def test_contains_edge_cases(parent_poly, geographies_contains_with_labels) -> None: 238 | polys_to_check, expected_labels = geographies_contains_with_labels 239 | 240 | actual = spherely.contains(parent_poly, polys_to_check) 241 | np.testing.assert_array_equal(actual, expected_labels) 242 | 243 | 244 | def test_covers(parent_poly, geographies_covers_with_labels) -> None: 245 | polys_to_check, expected_labels = geographies_covers_with_labels 246 | 247 | actual = spherely.covers(parent_poly, polys_to_check) 248 | np.testing.assert_array_equal(actual, expected_labels) 249 | 250 | 251 | def test_covered_by(parent_poly, geographies_covers_with_labels) -> None: 252 | polys_to_check, expected_labels = geographies_covers_with_labels 253 | 254 | actual = spherely.covered_by(polys_to_check, parent_poly) 255 | np.testing.assert_array_equal(actual, expected_labels) 256 | --------------------------------------------------------------------------------