diff --git a/.github/workflows/ci-testing.yml b/.github/workflows/ci-testing.yml index 0dd968c6c..a4a4da05f 100644 --- a/.github/workflows/ci-testing.yml +++ b/.github/workflows/ci-testing.yml @@ -1,6 +1,12 @@ name: Tests -on: [push, pull_request] +on: + push: + pull_request: + schedule: + # 10th of each month + - cron: "0 0 10 * *" + workflow_dispatch: jobs: build: @@ -11,12 +17,12 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: [3.9, '3.10', '3.11', '3.12'] - shapely-dev: [false] + python-version: ['3.10', '3.11', '3.12', '3.13-dev'] + use-network: [true] include: - os: ubuntu-latest python-version: '3.11' - shapely-dev: true + use-network: false steps: - uses: actions/checkout@v4 @@ -30,28 +36,30 @@ jobs: cache: 'pip' - name: Minimum packages - if: matrix.python-version == '3.9' && matrix.os == 'ubuntu-latest' + if: | + matrix.python-version == '3.10' && matrix.os == 'ubuntu-latest' && + (github.event_name == 'push' || github.event_name == 'pull_request') id: minimum-packages run: | - pip install cython==0.29.24 matplotlib==3.5.3 numpy==1.21 owslib==0.24.1 pyproj==3.1 scipy==1.6.3 shapely==1.7.1 pyshp==2.3.1 + pip install cython==0.29.28 matplotlib==3.6 numpy==1.23 owslib==0.27 pyproj==3.3.1 scipy==1.9 shapely==2.0 pyshp==2.3.1 - name: Coverage packages id: coverage - # only want the coverage to be run on the latest ubuntu - if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' + # only want the coverage to be run on the latest ubuntu and for code changes i.e. push and pr + if: | + matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' && + (github.event_name == 'push' || github.event_name == 'pull_request') run: | echo "CYTHON_COVERAGE=1" >> $GITHUB_ENV # Also add doctest here to avoid windows runners which expect a different path separator echo "EXTRA_TEST_ARGS=--cov=cartopy -ra --doctest-modules" >> $GITHUB_ENV pip install cython - - name: Install Shapely dev - if: matrix.shapely-dev + - name: Install Nightlies + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' run: | - # Install Shapely from source on ubuntu - sudo apt-get update - sudo apt-get install -yy libgeos-dev - pip install git+https://github.com/shapely/shapely.git@main + # Install Nightly builds from Scientific Python + python -m pip install --pre --extra-index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple matplotlib pyproj scipy shapely - name: Install Cartopy id: install @@ -68,6 +76,7 @@ jobs: - name: Testing id: test + if: matrix.use-network # we need to force bash to use line continuations on Windows shell: bash run: | @@ -79,9 +88,18 @@ jobs: pytest -rfEsX -n 4 \ --color=yes \ --mpl --mpl-generate-summary=html \ - --mpl-results-path="cartopy_test_output-${{ matrix.os }}-${{ matrix.python-version }}-${{ matrix.shapely-dev }}" \ + --mpl-results-path="cartopy_test_output-${{ matrix.os }}-${{ matrix.python-version }}" \ --pyargs cartopy ${EXTRA_TEST_ARGS} + - name: No Network Tests + # Ensure any test that needs network access has been marked as such + if: ${{ ! matrix.use-network }} + run: | + pip install pytest-socket + pytest -rfEsX -n 4 \ + --color=yes \ + --pyargs cartopy -m "not natural_earth and not network" --disable-socket + - name: Coveralls if: steps.coverage.conclusion == 'success' env: @@ -93,5 +111,30 @@ jobs: uses: actions/upload-artifact@v4 if: failure() with: - name: image-failures-${{ matrix.os }}-${{ matrix.python-version }}-${{ matrix.shapely-dev }} - path: cartopy_test_output-${{ matrix.os }}-${{ matrix.python-version }}-${{ matrix.shapely-dev }} + name: image-failures-${{ matrix.os }}-${{ matrix.python-version }} + path: cartopy_test_output-${{ matrix.os }}-${{ matrix.python-version }} + + # Separate dependent job to only upload one issue from the matrix of jobs + create-issue: + if: ${{ failure() && github.event_name == 'schedule' }} + needs: [build] + permissions: + issues: write + runs-on: ubuntu-latest + name: Create issue on failure + + steps: + - name: Create issue on failure + uses: imjohnbo/issue-bot@v3 + with: + title: "[TST] Upcoming dependency test failures" + body: | + The build with nightly wheels from matplotlib, pyproj, scipy, shapely and + their dependencies has failed. Check the logs for any updates that need to + be made in cartopy. + https://github.com/${{github.repository}}/actions/runs/${{github.run_id}} + + pinned: false + close-previous: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47e13cbac..66e42aac8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,4 @@ +--- name: Build and upload to PyPI concurrency: @@ -18,8 +19,22 @@ on: - reopened - labeled +permissions: + contents: read + jobs: build_sdist: + if: >- + github.event_name == 'release' || + (github.event_name == 'pull_request' && ( + ( + github.event.action == 'labeled' && + github.event.label.name == 'CI: build wheels' + ) || + contains(github.event.pull_request.labels.*.name, + 'CI: build wheels') + ) + ) name: Build source distribution runs-on: ubuntu-latest outputs: @@ -56,72 +71,47 @@ jobs: path: dist/*.tar.gz if-no-files-found: error - generate-wheels-matrix: - if: | - github.event_name == 'release' || - (github.event_name == 'pull_request' && ( - ( - github.event.action == 'labeled' && - github.event.label.name == 'CI: build wheels' - ) || - contains(github.event.pull_request.labels.*.name, - 'CI: build wheels') - ) - ) - name: Generate wheels matrix - runs-on: ubuntu-latest - outputs: - include: ${{ steps.set-matrix.outputs.include }} - steps: - - uses: actions/checkout@v4 - - name: Install cibuildwheel - run: pipx install cibuildwheel==2.16.2 - - id: set-matrix - run: | - MATRIX=$( - { - cibuildwheel --print-build-identifiers --platform linux \ - | jq -nRc '{"only": inputs, "os": "ubuntu-latest"}' \ - && cibuildwheel --print-build-identifiers --platform macos \ - | jq -nRc '{"only": inputs, "os": "macos-latest"}' \ - && cibuildwheel --print-build-identifiers --platform windows \ - | jq -nRc '{"only": inputs, "os": "windows-2019"}' - } | jq -sc - ) - echo "include=$MATRIX" >> $GITHUB_OUTPUT - env: - CIBW_BUILD: "cp39-* cp310-* cp311-* cp312-*" - # Skip 32 bit builds and musllinux due to lack of numpy wheels - CIBW_SKIP: "*-win32 *_i686 *-musllinux*" - CIBW_ARCHS_MACOS: x86_64 arm64 - build_wheels: - name: Build ${{ matrix.os }} ${{ matrix.only }} - needs: [generate-wheels-matrix, build_sdist] + needs: build_sdist + name: Build wheels on ${{ matrix.os }} for ${{ matrix.cibw_archs }} + runs-on: ${{ matrix.os }} strategy: matrix: - include: ${{ fromJson(needs.generate-wheels-matrix.outputs.include) }} - runs-on: ${{ matrix.os }} + include: + - os: ubuntu-latest + cibw_archs: "x86_64" + - os: windows-2019 + cibw_archs: "auto64" + - os: macos-latest + cibw_archs: "x86_64" + - os: macos-latest + cibw_archs: "arm64" defaults: run: shell: bash - steps: + steps: - name: Download sdist uses: actions/download-artifact@v4 with: name: cibw-sdist path: dist - - uses: pypa/cibuildwheel@8d945475ac4b1aac4ae08b2fd27db9917158b6ce # v2.17.0 + - name: Build wheels for CPython + uses: pypa/cibuildwheel@7940a4c0e76eb2030e473a5f864f291f63ee879b # v2.21.3 with: - only: ${{ matrix.only }} package-dir: dist/${{ needs.build_sdist.outputs.SDIST_NAME }} + env: + CIBW_BUILD: "cp310-* cp311-* cp312-* cp313-*" + # Skip 32 bit builds and musllinux due to lack of numpy wheels + CIBW_SKIP: "*-win32 *_i686 *-musllinux*" + CIBW_ARCHS: ${{ matrix.cibw_archs }} - uses: actions/upload-artifact@v4 with: - name: cibw-wheels-${{ matrix.os }}-${{ matrix.only }} + name: cibw-wheels-${{ matrix.os }}-${{ matrix.cibw_archs }} path: ./wheelhouse/*.whl + if-no-files-found: error publish: name: Publish to PyPI @@ -144,4 +134,4 @@ jobs: merge-multiple: true - name: Publish Package - uses: pypa/gh-action-pypi-publish@v1.8.14 + uses: pypa/gh-action-pypi-publish@v1.10.3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ff99445a0..f87822edc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ ci: autoupdate_schedule: 'quarterly' repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: "v4.5.0" + rev: "v5.0.0" hooks: # Prevent giant files from being committed - id: check-added-large-files @@ -26,7 +26,7 @@ repos: # Trims trailing whitespace - id: trailing-whitespace - repo: https://github.com/codespell-project/codespell - rev: "v2.2.6" + rev: "v2.3.0" hooks: - id: codespell types_or: [python, markdown, rst] @@ -37,7 +37,7 @@ repos: - id: sort-all types: [file, python] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.1.9' + rev: 'v0.6.9' hooks: - id: ruff args: [--fix] diff --git a/CHANGES b/CHANGES index 900773867..c47248178 100644 --- a/CHANGES +++ b/CHANGES @@ -1 +1 @@ -Please see docs/source/whats_new.rst for a changelog. +Please see docs/source/whatsnew/ for a changelog. diff --git a/INSTALL b/INSTALL index 03609e87a..589ae2090 100644 --- a/INSTALL +++ b/INSTALL @@ -12,7 +12,7 @@ Other binaries Additional pre-built binaries can be found at a variety of sources, including: * `Conda `_ -* Christoph Gohlke (https://www.lfd.uci.edu/~gohlke/pythonlibs/) +* Christoph Gohlke (https://github.com/cgohlke/geospatial-wheels/) maintains unofficial Windows binaries of cartopy. * `OSGeo Live `_. @@ -59,20 +59,20 @@ To use it:: Further information about the required dependencies can be found here: -**Python** 3.9 or later (https://www.python.org/) +**Python** 3.10 or later (https://www.python.org/) Python 2 support was removed in v0.19. -**Matplotlib** 3.5 or later (https://matplotlib.org/) +**Matplotlib** 3.6 or later (https://matplotlib.org/) Python package for 2D plotting. Python package required for any graphical capabilities. -**Shapely** 1.7.1 or later (https://github.com/shapely/shapely) +**Shapely** 2.0 or later (https://github.com/shapely/shapely) Python package for the manipulation and analysis of planar geometric objects. **pyshp** 2.3 or later (https://pypi.python.org/pypi/pyshp) Pure Python read/write support for ESRI Shapefile format. -**pyproj** 3.1.0 or later (https://github.com/pyproj4/pyproj/) +**pyproj** 3.3.1 or later (https://github.com/pyproj4/pyproj/) Python interface to PROJ (cartographic projections and coordinate transformations library). Optional Dependencies @@ -83,17 +83,17 @@ to install these optional dependencies. They are also included in some of the optional groups when installing. For example, use `pip install .[ows]` to install the optional OWS libraries. -**Pillow** 6.1.0 or later (https://python-pillow.org) +**Pillow** 9.1 or later (https://python-pillow.org) A popular fork of PythonImagingLibrary. **pykdtree** 1.2.2 or later (https://github.com/storpipfugl/pykdtree) A fast kd-tree implementation that is used for faster warping of images than SciPy. -**SciPy** 1.6.3 or later (https://www.scipy.org/) +**SciPy** 1.9 or later (https://www.scipy.org/) A Python package for scientific computing. -**OWSLib** 0.24.1 or later (https://pypi.python.org/pypi/OWSLib) +**OWSLib** 0.27 or later (https://pypi.python.org/pypi/OWSLib) A Python package for client programming with the Open Geospatial Consortium (OGC) web service, and which gives access to Cartopy ogc clients. diff --git a/docs/make_projection.py b/docs/make_projection.py index c3bd31918..8f50fd559 100644 --- a/docs/make_projection.py +++ b/docs/make_projection.py @@ -79,7 +79,8 @@ def utm_plot(): COASTLINE_RESOLUTION = {ccrs.OSNI: '10m', ccrs.OSGB: '50m', - ccrs.EuroPP: '50m'} + ccrs.EuroPP: '50m', + ccrs.LambertZoneII: '10m'} PRJ_SORT_ORDER = {'PlateCarree': 1, @@ -91,7 +92,7 @@ def utm_plot(): 'Orthographic': 2, 'UTM': 2, 'AlbersEqualArea': 2, 'AzimuthalEquidistant': 2, 'Sinusoidal': 2, 'InterruptedGoodeHomolosine': 3, 'RotatedPole': 3, - 'OSGB': 4, 'EuroPP': 5, + 'OSGB': 4, 'LambertZoneII': 4.1, 'EuroPP': 5, 'Geostationary': 6, 'NearsidePerspective': 7, 'EckertI': 8.1, 'EckertII': 8.2, 'EckertIII': 8.3, 'EckertIV': 8.4, 'EckertV': 8.5, 'EckertVI': 8.6} diff --git a/docs/source/_static/cartopy.png b/docs/source/_static/cartopy.png index 08b5f6185..43054fe15 100644 Binary files a/docs/source/_static/cartopy.png and b/docs/source/_static/cartopy.png differ diff --git a/docs/source/_static/copyright_license.csv b/docs/source/_static/copyright_license.csv index a84a9a9a4..e819ae767 100644 --- a/docs/source/_static/copyright_license.csv +++ b/docs/source/_static/copyright_license.csv @@ -1,3 +1,3 @@ Data Provider;Cartopy Function;Citation (short);Citation (long);License;Terms of Use Information -OpenStreetMap;:mod:`img_tiles.OSM `;|copy| OpenStreetMap;Map data |copy| OpenStreetMap contributors;`Open Database Licence `_;`Legal FAQ `_ +OpenStreetMap;:mod:`img_tiles.OSM `;|copy| OpenStreetMap;Map data |copy| OpenStreetMap contributors;`Open Database Licence `_;`Legal FAQ `_ Natural Earth raster + vector map data;:class:`NaturalEarthFeature `;Made with Natural Earth.;Made with Natural Earth. Free vector and raster map data @ naturalearthdata.com.;`Public Domain `_;`Terms of Use `_ diff --git a/docs/source/citation.rst b/docs/source/citation.rst index 9aac32f57..ae8875a58 100644 --- a/docs/source/citation.rst +++ b/docs/source/citation.rst @@ -50,10 +50,10 @@ For example:: Cartopy. Met Office. git@github.com:SciTools/cartopy.git. 2015-02-18. 7b2242e. -.. _How to cite and describe software: https://software.ac.uk/so-exactly-what-software-did-you-use +.. _How to cite and describe software: https://www.software.ac.uk/publication/how-cite-and-describe-software -[Jackson] Jackson, M. 2012. `How to cite and describe software`_. Accessed 2013-03-06. +[Jackson] Jackson, M. 2012. `How to cite and describe software`_. Accessed 2024-03-15. .. _referencing_copyright: @@ -87,4 +87,4 @@ The corresponding information for the data providers included in cartopy is list .. |---| unicode:: U+02014 .. em dash :trim: -The `feature_creation example <./examples/feature_creation.html>`_ shows such annotation for Natural Earth data. +The `feature_creation example <./gallery/lines_and_polygons/feature_creation.html>`_ shows such annotation for Natural Earth data. diff --git a/docs/source/conf.py b/docs/source/conf.py index f6b1daef9..f4ab27101 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -45,6 +45,7 @@ 'sphinx.ext.extlinks', 'sphinx.ext.autosummary', 'matplotlib.sphinxext.plot_directive', + 'matplotlib.sphinxext.roles', 'sphinx_gallery.gen_gallery', 'sphinx.ext.napoleon' ] @@ -92,11 +93,15 @@ sphinx_gallery_conf = { 'capture_repr': (), 'examples_dirs': ['../../examples'], - # NASA wmts servers are returning bad content metadata "expected_failing_examples": [ - '../../examples/web_services/reprojected_wmts.py', - '../../examples/web_services/wmts.py', - '../../examples/web_services/wmts_time.py', + # NASA wmts servers frequently return bad content metadata + # uncomment these to fix the doc build failures + # '../../examples/web_services/reprojected_wmts.py', + # '../../examples/web_services/wmts.py', + # '../../examples/web_services/wmts_time.py', + # OSGeo WMS has been shut off + # https://discourse.osgeo.org/t/map-tile-loading-pin-location-issues/6910/2 + '../../examples/web_services/wms.py' ], 'filename_pattern': '^((?!sgskip).)*$', 'gallery_dirs': ['gallery'], diff --git a/docs/source/matplotlib/feature_interface.rst b/docs/source/matplotlib/feature_interface.rst index 598cb623e..50f58e18a 100644 --- a/docs/source/matplotlib/feature_interface.rst +++ b/docs/source/matplotlib/feature_interface.rst @@ -3,7 +3,7 @@ The cartopy Feature interface ============================= -The :ref:`data copyright, license and attribution ` can be blended on the map using `text annotations (mpl docs) `_ as shown in `feature_creation <../gallery/feature_creation.html>`_. +The :ref:`data copyright, license and attribution ` can be blended on the map using `text annotations (mpl docs) `_ as shown in `feature_creation <../gallery/lines_and_polygons/feature_creation.html>`_. Specific Feature subclasses have been defined for common functionality, such as accessing Natural Earth or GSHHS shapefiles. A list of these can be found in :ref:`the reference documentation `. diff --git a/docs/source/reference/crs.rst b/docs/source/reference/crs.rst index 21a9d80cd..07ecdad10 100644 --- a/docs/source/reference/crs.rst +++ b/docs/source/reference/crs.rst @@ -3,10 +3,7 @@ Coordinate reference systems (CRS) ---------------------------------- -.. module:: cartopy.crs - -The :class:`cartopy.crs.CRS` class is the very core of cartopy, all coordinate reference systems -in cartopy have :class:`~cartopy.crs.CRS` as a parent class. +.. automodule:: cartopy.crs Base CRS's ~~~~~~~~~~ @@ -28,13 +25,7 @@ Base CRS's Geodesic calculations ~~~~~~~~~~~~~~~~~~~~~ -.. module:: cartopy.geodesic - -The :mod:`cartopy.geodesic` module defines the :class:`cartopy.geodesic.Geodesic` class which can interface with the Proj -geodesic functions. See the `Proj geodesic page`_ for more background -information. - -.. _Proj geodesic page: https://proj.org/geodesic.html +.. automodule:: cartopy.geodesic .. autosummary:: :toctree: generated/ diff --git a/docs/source/reference/feature.rst b/docs/source/reference/feature.rst index 7ce942e96..c8af1b80e 100644 --- a/docs/source/reference/feature.rst +++ b/docs/source/reference/feature.rst @@ -3,12 +3,7 @@ Feature interface (cartopy.feature) ----------------------------------- -.. module:: cartopy.feature - -The feature interface can be used and extended to add various "features" -to geoaxes, such as Shapely objects and Natural Earth Imagery. The default -zorder for Cartopy features is 1.5, which puts them above images and patches, -but below lines and text. +.. automodule:: cartopy.feature Feature attributes ~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/reference/io.rst b/docs/source/reference/io.rst index 72b69b756..f3825260d 100644 --- a/docs/source/reference/io.rst +++ b/docs/source/reference/io.rst @@ -9,12 +9,10 @@ data formats. .. _api.io.shapereader: -Shapefiles -~~~~~~~~~~ - -.. module:: cartopy.io.shapereader +Shapereader +~~~~~~~~~~~ -:mod:`cartopy.io.shapereader` provides a basic interface for accessing shapefiles. +.. automodule:: cartopy.io.shapereader .. autosummary:: :toctree: generated/ @@ -32,9 +30,7 @@ Shapefiles Image collections ~~~~~~~~~~~~~~~~~ -.. module:: cartopy.io.img_nest - -:mod:`cartopy.io.img_nest` provides an interface for representing images. +.. automodule:: cartopy.io.img_nest .. autosummary:: :toctree: generated/ @@ -46,10 +42,7 @@ Image collections Image tiles ~~~~~~~~~~~ -.. module:: cartopy.io.img_tiles - -Classes in :mod:`cartopy.io.img_tiles` provide an interface to the respective tile resources to -automatically load the proper tile and resolution depending on the desired domain. +.. automodule:: cartopy.io.img_tiles .. autosummary:: :toctree: generated/ @@ -66,12 +59,10 @@ automatically load the proper tile and resolution depending on the desired domai StadiaMapsTiles Stamen -Open Geospatial Consortium (OGC) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Open Geospatial Consortium (OGC) Clients +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. module:: cartopy.io.ogc_clients - -:mod:`cartopy.io.ogc_clients` contains several classes to enable interfacing with OGC clients. +.. automodule:: cartopy.io.ogc_clients .. autosummary:: :toctree: generated/ @@ -83,10 +74,7 @@ Open Geospatial Consortium (OGC) Shuttle Radar Topography Mission (SRTM) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. module:: cartopy.io.srtm - -The SRTM data can be accessed through the :mod:`cartopy.io.srtm` module -using classes and functions defined below. +.. automodule:: cartopy.io.srtm .. autosummary:: :toctree: generated/ @@ -103,10 +91,7 @@ using classes and functions defined below. Base classes and functions ~~~~~~~~~~~~~~~~~~~~~~~~~~ -These are the base classes in :mod:`cartopy.io` that new resources can leverage -to implement a new reader or tile client. - -.. module:: cartopy.io +.. automodule:: cartopy.io .. autosummary:: :toctree: generated/ diff --git a/docs/source/reference/matplotlib.rst b/docs/source/reference/matplotlib.rst index 82cf6ee8e..6ac6a9702 100644 --- a/docs/source/reference/matplotlib.rst +++ b/docs/source/reference/matplotlib.rst @@ -6,17 +6,12 @@ Matplotlib interface (cartopy.mpl) Cartopy extends some Matplotlib capabilities to handle geographic projections, such as non-rectangular axes and spines. -.. module:: cartopy.mpl +.. automodule:: cartopy.mpl Geoaxes ~~~~~~~ -.. module:: cartopy.mpl.geoaxes - -The most primitive extension is the :class:`cartopy.mpl.geoaxes.GeoAxes` class, which -extends a Matplotlib Axes and adds a `transform` keyword -argument to many plotting methods to enable geographic projections and boundary wrapping -to occur on the axes. +.. automodule:: cartopy.mpl.geoaxes .. autosummary:: :toctree: generated/ @@ -27,15 +22,10 @@ to occur on the axes. GeoSpine InterProjectionTransform - Gridlines and ticks ~~~~~~~~~~~~~~~~~~~ -.. module:: cartopy.mpl.gridliner - -Cartopy can produce gridlines and ticks in any projection and add -them to the current geoaxes projection, providing a way to add detailed -location information to the plots. +.. automodule:: cartopy.mpl.gridliner .. autosummary:: :toctree: generated/ @@ -43,7 +33,7 @@ location information to the plots. Gridliner -.. module:: cartopy.mpl.ticker +.. automodule:: cartopy.mpl.ticker .. autosummary:: :toctree: generated/ @@ -57,10 +47,7 @@ location information to the plots. Artist extensions ~~~~~~~~~~~~~~~~~ -.. module:: cartopy.mpl.feature_artist - -Features and images can be added to a :class:`cartopy.mpl.geoaxes.GeoAxes` through -an extension of the Matplotlib Artist interfaces. +.. automodule:: cartopy.mpl.feature_artist .. autosummary:: :toctree: generated/ @@ -68,7 +55,7 @@ an extension of the Matplotlib Artist interfaces. FeatureArtist -.. module:: cartopy.mpl.slippy_image_artist +.. automodule:: cartopy.mpl.slippy_image_artist .. autosummary:: :toctree: generated/ @@ -76,14 +63,10 @@ an extension of the Matplotlib Artist interfaces. SlippyImageArtist +Patch +~~~~~ -Additional extensions -~~~~~~~~~~~~~~~~~~~~~ - -.. module:: cartopy.mpl.patch - -Extra functionality that is primarily intended for developers. They describe -some of the capabilities for transforming between Shapely, and Matplotlib paths. +.. automodule:: cartopy.mpl.patch .. autosummary:: :toctree: generated/ @@ -91,3 +74,14 @@ some of the capabilities for transforming between Shapely, and Matplotlib paths. geos_to_path path_segments path_to_geos + +Path +~~~~ + +.. automodule:: cartopy.mpl.path + +.. autosummary:: + :toctree: generated/ + + path_to_shapely + shapely_to_path diff --git a/docs/source/reference/projections.rst b/docs/source/reference/projections.rst index 35a0bd2db..0721d09b1 100644 --- a/docs/source/reference/projections.rst +++ b/docs/source/reference/projections.rst @@ -341,6 +341,22 @@ OSGB ax.gridlines() +LambertZoneII +------------- + +.. autoclass:: cartopy.crs.LambertZoneII + +.. plot:: + + import matplotlib.pyplot as plt + import cartopy.crs as ccrs + + plt.figure(figsize=(3.2687, 3)) + ax = plt.axes(projection=ccrs.LambertZoneII()) + ax.coastlines(resolution='10m') + ax.gridlines() + + EuroPP ------ diff --git a/docs/source/reference/transformations.rst b/docs/source/reference/transformations.rst index e06eeaac1..38e0b81ca 100644 --- a/docs/source/reference/transformations.rst +++ b/docs/source/reference/transformations.rst @@ -9,9 +9,7 @@ and reshape data when going from one projection to another. Image transformations ~~~~~~~~~~~~~~~~~~~~~ -.. module:: cartopy.img_transform - -:mod:`cartopy.img_transform` contains: +.. automodule:: cartopy.img_transform .. autosummary:: :toctree: generated/ @@ -24,9 +22,7 @@ Image transformations Vector transformations ~~~~~~~~~~~~~~~~~~~~~~ -.. module:: cartopy.vector_transform - -:mod:`cartopy.vector_transform` contains: +.. automodule:: cartopy.vector_transform .. autosummary:: :toctree: generated/ @@ -36,9 +32,7 @@ Vector transformations Longitude wrapping ~~~~~~~~~~~~~~~~~~ -.. module:: cartopy.util - -:mod:`cartopy.util` contains: +.. automodule:: cartopy.util .. autosummary:: :toctree: generated/ @@ -50,9 +44,7 @@ Longitude wrapping LinearRing/LineString projection ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. module:: cartopy.trace - -:mod:`cartopy.trace` contains: +.. automodule:: cartopy.trace .. autosummary:: :toctree: generated/ diff --git a/docs/source/whatsnew/index.rst b/docs/source/whatsnew/index.rst index f8548a820..30649a599 100644 --- a/docs/source/whatsnew/index.rst +++ b/docs/source/whatsnew/index.rst @@ -14,6 +14,9 @@ Versions .. toctree:: :maxdepth: 2 + v0.25 + v0.24 + v0.23 v0.22 v0.21 v0.20 diff --git a/docs/source/whatsnew/v0.23.rst b/docs/source/whatsnew/v0.23.rst new file mode 100644 index 000000000..c9a949383 --- /dev/null +++ b/docs/source/whatsnew/v0.23.rst @@ -0,0 +1,68 @@ +Version 0.23 (April 10, 2024) +============================= + +Cartopy has been relicensed from LGPL-3 to BSD-3-Clause. All contributions +to Cartopy are now under the BSD-3-Clause license. + +Python 3.12 and Numpy 2 are now supported and the new minimum supported versions +of dependencies that have been updated are: + +* Matplotlib v3.5 +* Pyshp v2.3 + +There are several updates to the geometry and feature handling, +making these more compatible with the Matplotlib semantics. + +Features +-------- + +* Xianxiang Li added the ability to modify the properties of the stock images (:pull:`2230`) + +* @lgolston updated the shapefile readers and added documentation. (:pull:`2236`) + +* Ruth Comer turned the ``GridLiner`` into a Matplotlib ``Artist`` making it easier to + add and remove gridlines and fewer internal draws for better performance. (:pull:`2249`, :pull:`2252`) + +* The cartopy feature download script (useful for downloading Natural Earth Features + for offline use) can be invoked within the package directly + using :code:`python -m cartopy.feature.download`, or through the installed command + line interface using :code:`cartopy-feature-download`. (:pull:`2263`) + +* The Stamen Maps API is no longer available. There is a new class ``StadiaMaps`` + that can be used to access the Stadia Maps API which contains the + Stamen styled tiles. (:pull:`2269`) + +* Greg Lucas made it easier to handle projections on non-earth bodies that + would error previously. (:pull:`2283`) + +* Kevin Dungs added the ability to use Levels 5 and 6 in GSHHS features + for Antarctica. (:pull:`2317`) + +* Ruth Comer changed a single geometry that is split into two across a boundary + to be drawn as a compound path rather than two independent paths. This makes + it easier to style the geometry consistently. (:pull:`2325`) + +* Ruth Comer has converted the ``FeatureArtist`` into a Matplotlib ``Collection``. + This makes it easier to set properties on the features and enables arrays to + be used to style a set of features. (:pull:`2323`) + A new example demonstrating this has been added to the gallery + :ref:`sphx_glr_gallery_scalar_data_geometry_data.py` + +Deprecations and Removals +------------------------- + +* The ``cartopy.mpl.style`` module has been deprecated with no replacement + and will be removed in a future release. Users should combine and merge styles + themselves now. +* The **auto_update** keyword argument to ``gridlines`` and ``GridLiner`` is + deprecated and will be removed in a future release. In the future the gridlines + will always be updated when the plot is drawn. +* The gridliner labelling options + ``cartopy.mpl.gridliner.Gridliner.xlabels_top``, + ``cartopy.mpl.gridliner.Gridliner.xlabels_bottom``, + ``cartopy.mpl.gridliner.Gridliner.ylabels_left``, and + ``cartopy.mpl.gridliner.Gridliner.ylabels_right`` have been removed. + Instead, use :attr:`cartopy.mpl.gridliner.Gridliner.top_labels`, + :attr:`cartopy.mpl.gridliner.Gridliner.bottom_labels`, + :attr:`cartopy.mpl.gridliner.Gridliner.left_labels`, or + :attr:`cartopy.mpl.gridliner.Gridliner.right_labels`. diff --git a/docs/source/whatsnew/v0.24.rst b/docs/source/whatsnew/v0.24.rst new file mode 100644 index 000000000..f5891cfdb --- /dev/null +++ b/docs/source/whatsnew/v0.24.rst @@ -0,0 +1,30 @@ +Version 0.24 (October 7, 2024) +============================== + +Python 3.13 and Numpy 2 are supported and the new minimum supported versions +of dependencies that have been updated are: + +* Python 3.10 +* Matplotlib 3.6 + +Features +-------- + +* Ryan May fixed some internal usages of PlateCarree coordinates to use geodetic lat/lon + coordinates of the proper ellipse, improving boundary handling for some projections. (:pull:`2378`) + +* Ruth Comer added more improvements to gridlines, including title adjustments to avoid overlaps. (:pull:`2393`) + +* Eric Matti fixed an issue with gouraud shading when using wrapped coordinates in a pcolormesh. (:pull:`2401`) + +* Raphael Quast added the ability to use multi-path geometries as boundaries of the maps. + This means that an Axes doesn't have to be fully connected anymore and can be separate + land masses and avoid the oceans if a user wants. (:pull:`2362`) + +* Thibault Hallouin added the Lambert Zone II (epsg:27572) projection, which is widely + used for maps of mainland France. (:pull:`2427`) + +Deprecations and Removals +------------------------- + +There are no new deprecations or removals in this release. diff --git a/docs/source/whatsnew/v0.25.rst b/docs/source/whatsnew/v0.25.rst new file mode 100644 index 000000000..fa0b96439 --- /dev/null +++ b/docs/source/whatsnew/v0.25.rst @@ -0,0 +1,30 @@ +Version 0.25 (Date TBD) +======================= + +The new minimum supported versions of dependencies that have been updated are: + +* Shapely 2.0 + + +Features +-------- + +* Ruth Comer introduced `~cartopy.mpl.path.shapely_to_path` and + `~cartopy.mpl.path.path_to_shapely` which map a single Shapely geometry or + collection to a single Matplotlib path and *vice versa*. (:pull:`2455`) + + +Deprecations and Removals +------------------------- + +* `~cartopy.mpl.patch.path_to_geos` and `~cartopy.mpl.patch.geos_to_path` are + deprecated. Use `~cartopy.mpl.path.path_to_shapely` and + `~cartopy.mpl.path.shapely_to_path` instead. + +* `~cartopy.mpl.patch.path_segments` is deprecated without replacement. The + implementation is simply + + .. code-block:: python + + pth = path.cleaned(**kwargs) + return pth.vertices[:-1, :], pth.codes[:-1] diff --git a/docs/source/whatsnew/v0.9.rst b/docs/source/whatsnew/v0.9.rst index b4066564c..c070eeea3 100644 --- a/docs/source/whatsnew/v0.9.rst +++ b/docs/source/whatsnew/v0.9.rst @@ -7,7 +7,7 @@ Version 0.9 (September 12, 2013) "Iris & Cartopy" `_ was voted best talk of the conference. * Other talks and tutorials during this release cycle include Phil Elson's `talk at SciPy'13 - (with video) `_, + (with video) `_, `Thomas Lecocq's tutorial at EuroSciPy `_ and a forthcoming `talk at FOSS4G `_. diff --git a/environment.yml b/environment.yml index 48e394cd3..8c5d5d3b3 100644 --- a/environment.yml +++ b/environment.yml @@ -8,21 +8,21 @@ name: cartopy-dev channels: - conda-forge dependencies: - - cython>=0.29.24 - - numpy>=1.21 - - shapely>=1.7.1 + - cython>=0.29.28 + - numpy>=1.23 + - shapely>=2.0 - pyshp>=2.3 - - pyproj>=3.1.0 + - pyproj>=3.3.1 + - packaging>=21 # The testing label has the proper version of freetype included - - conda-forge/label/testing::matplotlib-base>=3.4 + - conda-forge/label/testing::matplotlib-base>=3.6 # OWS - - owslib>=0.24.1 - - pillow>=6.1.0 + - owslib>=0.27 + - pillow>=9.1 # Plotting - - scipy>=1.6.3 + - scipy>=1.9 # Testing - - packaging>=20 - pytest - pytest-mpl - pytest-xdist diff --git a/examples/miscellanea/logo.py b/examples/miscellanea/logo.py index bb56b6d66..62bdaf3f3 100644 --- a/examples/miscellanea/logo.py +++ b/examples/miscellanea/logo.py @@ -23,8 +23,9 @@ def main(): # generate a matplotlib path representing the word "cartopy" fp = FontProperties(family='DejaVu Sans', weight='bold') - logo_path = matplotlib.textpath.TextPath((-175, -35), 'cartopy', + logo_path = matplotlib.textpath.TextPath((-171.01406, -39.33125), 'cartopy', size=80, prop=fp) + # scale the letters up to sensible longitude and latitude sizes transform = matplotlib.transforms.Affine2D().scale(1, 2).translate(0, 35) diff --git a/examples/scalar_data/geometry_data.py b/examples/scalar_data/geometry_data.py index d4ea3aa7f..b9227ec0c 100644 --- a/examples/scalar_data/geometry_data.py +++ b/examples/scalar_data/geometry_data.py @@ -1,6 +1,6 @@ """ -Associating data with geometries --------------------------------- +Choropleth map: associating data with geometries +------------------------------------------------ This example shows how to colour geometries based on a data array. This functionality is available since Cartopy 0.23. diff --git a/lib/cartopy/_epsg.py b/lib/cartopy/_epsg.py index 481af089b..d966ca7ce 100644 --- a/lib/cartopy/_epsg.py +++ b/lib/cartopy/_epsg.py @@ -24,3 +24,6 @@ def __init__(self, code): def __repr__(self): return f'_EPSGProjection({self.epsg_code})' + + def __reduce__(self): + return self.__class__, (self.epsg_code, ) diff --git a/lib/cartopy/crs.py b/lib/cartopy/crs.py index 2c707bf74..4a8e49f64 100644 --- a/lib/cartopy/crs.py +++ b/lib/cartopy/crs.py @@ -18,6 +18,7 @@ import warnings import numpy as np +import pyproj from pyproj import Transformer from pyproj.exceptions import ProjError import shapely.geometry as sgeom @@ -125,7 +126,9 @@ def to_proj4_params(self): class CRS(_CRS): """ - Define a Coordinate Reference System using proj. + Define a Coordinate Reference System using proj. The :class:`cartopy.crs.CRS` + class is the very core of cartopy, all coordinate reference systems in cartopy + have :class:`~cartopy.crs.CRS` as a parent class. """ #: Whether this projection can handle ellipses. @@ -147,6 +150,8 @@ def __init__(self, proj4_params, globe=None): See :class:`~cartopy.crs.Globe` for details. """ + self.input = (proj4_params, globe) + # for compatibility with pyproj.CRS and rasterio.crs.CRS try: proj4_params = proj4_params.to_wkt() @@ -209,13 +214,17 @@ def __hash__(self): def __reduce__(self): """ - Implement the __reduce__ API so that unpickling produces a stateless - instance of this class (e.g. an empty tuple). The state will then be - added via __getstate__ and __setstate__. - We are forced to this approach because a CRS does not store - the constructor keyword arguments in its state. + Implement the __reduce__ method used when pickling or performing deepcopy. """ - return self.__class__, (), self.__getstate__() + if type(self) is CRS: + # State can be reproduced by the proj4_params and globe inputs. + return self.__class__, self.input + else: + # Produces a stateless instance of this class (e.g. an empty tuple). + # The state will then be added via __getstate__ and __setstate__. + # We are forced to this approach because a CRS does not store + # the constructor keyword arguments in its state. + return self.__class__, (), self.__getstate__() def __getstate__(self): """Return the full state of this instance for reconstruction @@ -658,6 +667,7 @@ class Projection(CRS, metaclass=ABCMeta): 'MultiPoint': '_project_multipoint', 'MultiLineString': '_project_multiline', 'MultiPolygon': '_project_multipolygon', + 'GeometryCollection': '_project_geometry_collection' } # Whether or not this projection can handle wrapped coordinates _wrappable = False @@ -677,7 +687,9 @@ def __init__(self, *args, **kwargs): y1 = self.area_of_use.north lons = np.array([x0, x0, x1, x1]) lats = np.array([y0, y1, y1, y0]) - points = self.transform_points(self.as_geodetic(), lons, lats) + points = self.transform_points( + PlateCarree().as_geodetic(), lons, lats + ) x = points[:, 0] y = points[:, 1] self.bounds = (x.min(), x.max(), y.min(), y.max()) @@ -824,7 +836,8 @@ def _project_line_string(self, geometry, src_crs): def _project_linear_ring(self, linear_ring, src_crs): """ Project the given LinearRing from the src_crs into this CRS and - returns a list of LinearRings and a single MultiLineString. + returns a GeometryCollection containing zero or more LinearRings and + a single MultiLineString. """ debug = False @@ -904,16 +917,13 @@ def _project_linear_ring(self, linear_ring, src_crs): if rings: multi_line_string = sgeom.MultiLineString(line_strings) - return rings, multi_line_string + return sgeom.GeometryCollection([*rings, multi_line_string]) def _project_multipoint(self, geometry, src_crs): geoms = [] for geom in geometry.geoms: geoms.append(self._project_point(geom, src_crs)) - if geoms: - return sgeom.MultiPoint(geoms) - else: - return sgeom.MultiPoint() + return sgeom.MultiPoint(geoms) def _project_multiline(self, geometry, src_crs): geoms = [] @@ -921,10 +931,7 @@ def _project_multiline(self, geometry, src_crs): r = self._project_line_string(geom, src_crs) if r: geoms.extend(r.geoms) - if geoms: - return sgeom.MultiLineString(geoms) - else: - return [] + return sgeom.MultiLineString(geoms) def _project_multipolygon(self, geometry, src_crs): geoms = [] @@ -932,11 +939,12 @@ def _project_multipolygon(self, geometry, src_crs): r = self._project_polygon(geom, src_crs) if r: geoms.extend(r.geoms) - if geoms: - result = sgeom.MultiPolygon(geoms) - else: - result = sgeom.MultiPolygon() - return result + return sgeom.MultiPolygon(geoms) + + def _project_geometry_collection(self, geometry, src_crs): + return sgeom.GeometryCollection( + [self.project_geometry(geom, src_crs) for geom in geometry.geoms]) + def _project_polygon(self, polygon, src_crs): """ @@ -956,7 +964,8 @@ def _project_polygon(self, polygon, src_crs): rings = [] multi_lines = [] for src_ring in [polygon.exterior] + list(polygon.interiors): - p_rings, p_mline = self._project_linear_ring(src_ring, src_crs) + geom_collection = self._project_linear_ring(src_ring, src_crs) + *p_rings, p_mline = geom_collection.geoms if p_rings: rings.extend(p_rings) if len(p_mline.geoms) > 0: @@ -1364,7 +1373,7 @@ def _bbox_and_offset(self, other_plate_carree): bbox = [[lon_lower_bound_0, lon_lower_bound_1], [lon_lower_bound_1, lon_lower_bound_0]] - bbox[1][1] += np.diff(self.x_limits)[0] + bbox[1][1] += self.x_limits[1] - self.x_limits[0] return bbox, lon_0_offset @@ -1782,7 +1791,7 @@ def __init__(self, central_longitude=-96.0, central_latitude=39.0, lons[1:-1] = np.linspace(central_longitude - 180 + 0.001, central_longitude + 180 - 0.001, n) - points = self.transform_points(PlateCarree(globe=globe), lons, lats) + points = self.transform_points(self.as_geodetic(), lons, lats) self._boundary = sgeom.LinearRing(points) mins = np.min(points, axis=0) @@ -1817,6 +1826,20 @@ def y_limits(self): return self._y_limits +class LambertZoneII(Projection): + """ + Lambert zone II (extended) projection (https://epsg.io/27572), a + legacy projection that covers hexagonal France and Corsica. + + """ + def __init__(self): + crs = pyproj.CRS.from_epsg(27572) + super().__init__(crs.to_wkt()) + + # Projected bounds from https://epsg.io/27572 + self.bounds = [-5242.32, 1212512.16, 1589155.51, 2706796.21] + + class LambertAzimuthalEqualArea(Projection): """ A Lambert Azimuthal Equal-Area projection. @@ -1858,7 +1881,7 @@ def __init__(self, central_longitude=0.0, central_latitude=0.0, lon = central_longitude + 180 sign = np.sign(central_latitude) or 1 lat = -central_latitude + sign * 0.01 - x, max_y = self.transform_point(lon, lat, PlateCarree(globe=globe)) + x, max_y = self.transform_point(lon, lat, self.as_geodetic()) coords = _ellipse_boundary(a * 1.9999, max_y - false_northing, false_easting, false_northing, 61) diff --git a/lib/cartopy/feature/__init__.py b/lib/cartopy/feature/__init__.py index 3910352bc..d039a6d81 100644 --- a/lib/cartopy/feature/__init__.py +++ b/lib/cartopy/feature/__init__.py @@ -4,8 +4,13 @@ # See LICENSE in the root of the repository for full licensing details. """ -This module defines :class:`Feature` instances, for use with -ax.add_feature(). +This module defines a :class:`Feature` interface, which can be used and +extended to add various "features" to geoaxes using ax.add_feature(), such as +Shapely objects and Natural Earth Imagery. + +The default zorder for Cartopy features is defined in defined in +:class:`~cartopy.mpl.feature_artist.FeatureArtist` as 1.5, which puts them +above images and patches, but below lines and text. """ diff --git a/lib/cartopy/feature/nightshade.py b/lib/cartopy/feature/nightshade.py index 0285accb6..5a0fa310a 100644 --- a/lib/cartopy/feature/nightshade.py +++ b/lib/cartopy/feature/nightshade.py @@ -47,7 +47,7 @@ def __init__(self, date=None, delta=0.1, refraction=-0.83, # Returns the Greenwich hour angle, # need longitude (opposite direction) - lat, lon = _solar_position(date) + lon, lat = _solar_position(date) pole_lon = lon if lat > 0: pole_lat = -90 + lat @@ -149,7 +149,7 @@ def _solar_position(date): Returns ------- - (latitude, longitude) in degrees + (longitude, latitude) in degrees Note ---- @@ -203,4 +203,4 @@ def _solar_position(date): if lon < -180: lon += 360 - return (delta_sun, lon) + return (lon, delta_sun) diff --git a/lib/cartopy/geodesic.py b/lib/cartopy/geodesic.py index b6c21a24d..0cd8c17e4 100644 --- a/lib/cartopy/geodesic.py +++ b/lib/cartopy/geodesic.py @@ -7,7 +7,8 @@ """ This module defines the Geodesic class which can interface with the Proj -geodesic functions. +geodesic functions. See the `Proj geodesic page `_ +for more background information. """ import numpy as np diff --git a/lib/cartopy/img_transform.py b/lib/cartopy/img_transform.py index 575011a36..9bcd719ad 100644 --- a/lib/cartopy/img_transform.py +++ b/lib/cartopy/img_transform.py @@ -3,8 +3,7 @@ # This file is part of Cartopy and is released under the BSD 3-clause license. # See LICENSE in the root of the repository for full licensing details. """ -This module contains generic functionality to support Cartopy image -transformations. +Generic functionality to support Cartopy image transformations. """ diff --git a/lib/cartopy/io/__init__.py b/lib/cartopy/io/__init__.py index 0ff581a0a..9e192ae83 100644 --- a/lib/cartopy/io/__init__.py +++ b/lib/cartopy/io/__init__.py @@ -4,8 +4,9 @@ # See LICENSE in the root of the repository for full licensing details. """ -Provides a collection of sub-packages for loading, saving and retrieving -various data formats. +These are the base classes in :mod:`cartopy.io` that new resources can leverage +to implement a new reader or tile client. Together they provide a collection of +sub-packages for loading, saving and retrieving various data formats. """ diff --git a/lib/cartopy/io/img_nest.py b/lib/cartopy/io/img_nest.py index 8b2304567..7c07163e9 100644 --- a/lib/cartopy/io/img_nest.py +++ b/lib/cartopy/io/img_nest.py @@ -3,6 +3,10 @@ # This file is part of Cartopy and is released under the BSD 3-clause license. # See LICENSE in the root of the repository for full licensing details. +""" +Provides an interface for representing images. + +""" import collections from pathlib import Path diff --git a/lib/cartopy/io/img_tiles.py b/lib/cartopy/io/img_tiles.py index f96a17f42..a9e92af38 100644 --- a/lib/cartopy/io/img_tiles.py +++ b/lib/cartopy/io/img_tiles.py @@ -4,7 +4,8 @@ # See LICENSE in the root of the repository for full licensing details. """ -Implements image tile identification and fetching from various sources. +Implements image tile identification and fetching from various sources, +automatically loading the proper tile and resolution depending on the desired domain. The Matplotlib interface can make use of tile objects (defined below) via the diff --git a/lib/cartopy/io/shapereader.py b/lib/cartopy/io/shapereader.py index 86d71fc05..73c60eb10 100644 --- a/lib/cartopy/io/shapereader.py +++ b/lib/cartopy/io/shapereader.py @@ -4,7 +4,8 @@ # See LICENSE in the root of the repository for full licensing details. """ -Combine the shapefile access of pyshp with the +This module provides a basic interface for accessing shapefiles. +Combine the shapefile access of pyshp or fiona with the geometry representation of shapely: >>> import cartopy.io.shapereader as shapereader @@ -283,8 +284,8 @@ def natural_earth(resolution='110m', category='physical', To identify valid components for this function, either browse NaturalEarthData.com, or if you know what you are looking for, go to - https://github.com/nvkelso/natural-earth-vector/tree/master/zips to - see the actual files which will be downloaded. + https://github.com/nvkelso/natural-earth-vector/ to see the actual + files which will be downloaded. Note ---- diff --git a/lib/cartopy/io/srtm.py b/lib/cartopy/io/srtm.py index a7c9b9cbe..11d8fe9fc 100644 --- a/lib/cartopy/io/srtm.py +++ b/lib/cartopy/io/srtm.py @@ -11,6 +11,9 @@ - Wikipedia (August 2012) +The SRTM data can be accessed through the :mod:`cartopy.io.srtm` module +using classes and functions defined below. + """ import io diff --git a/lib/cartopy/mpl/__init__.py b/lib/cartopy/mpl/__init__.py index 18595979e..f5de7c8ee 100644 --- a/lib/cartopy/mpl/__init__.py +++ b/lib/cartopy/mpl/__init__.py @@ -8,5 +8,5 @@ _MPL_VERSION = packaging.version.parse(matplotlib.__version__) -_MPL_36 = _MPL_VERSION.release[:2] >= (3, 6) +_MPL_37 = _MPL_VERSION.release[:2] >= (3, 7) _MPL_38 = _MPL_VERSION.release[:2] >= (3, 8) diff --git a/lib/cartopy/mpl/clip_path.py b/lib/cartopy/mpl/clip_path.py index 13ad31d17..ab8904f19 100644 --- a/lib/cartopy/mpl/clip_path.py +++ b/lib/cartopy/mpl/clip_path.py @@ -2,10 +2,17 @@ # # This file is part of Cartopy and is released under the BSD 3-clause license. # See LICENSE in the root of the repository for full licensing details. +import warnings + import matplotlib.path as mpath import numpy as np +warnings.warn('The clip_path module is deprecated and will be removed ' + 'in a future release with no replacement.', + DeprecationWarning, stacklevel=2) + + def intersection_point(p0, p1, p2, p3): """ Returns diff --git a/lib/cartopy/mpl/feature_artist.py b/lib/cartopy/mpl/feature_artist.py index 6d4f42459..04aacd27c 100644 --- a/lib/cartopy/mpl/feature_artist.py +++ b/lib/cartopy/mpl/feature_artist.py @@ -5,7 +5,7 @@ """ This module defines the :class:`FeatureArtist` class, for drawing -:class:`Feature` instances with matplotlib. +:class:`Feature` instances through an extension of the Matplotlib Artist interfaces. """ @@ -14,12 +14,11 @@ import matplotlib.artist import matplotlib.collections -import matplotlib.path as mpath import numpy as np import cartopy.feature as cfeature from cartopy.mpl import _MPL_38 -import cartopy.mpl.patch as cpatch +import cartopy.mpl.path as cpath class _GeomKey: @@ -217,11 +216,7 @@ def draw(self, renderer): else: projected_geom = geom - geom_paths = cpatch.geos_to_path(projected_geom) - - # The transform may have split the geometry into two paths, we only want - # one compound path. - geom_path = mpath.Path.make_compound_path(*geom_paths) + geom_path = cpath.shapely_to_path(projected_geom) mapping[key] = geom_path if self._styler is None: diff --git a/lib/cartopy/mpl/geoaxes.py b/lib/cartopy/mpl/geoaxes.py index 3074f8c97..85028ad2d 100644 --- a/lib/cartopy/mpl/geoaxes.py +++ b/lib/cartopy/mpl/geoaxes.py @@ -4,7 +4,9 @@ # See LICENSE in the root of the repository for full licensing details. """ -This module defines the :class:`GeoAxes` class, for use with matplotlib. +This module defines the :class:`cartopy.mpl.geoaxes.GeoAxes` class, an extension of +matplotlib which adds a `transform` keyword argument to many plotting methods to enable +geographic projections and boundary wrapping to occur on the axes. When a Matplotlib figure contains a GeoAxes the plotting commands can transform plot results from source coordinates to the GeoAxes' target projection. @@ -40,7 +42,7 @@ import cartopy.mpl.contour import cartopy.mpl.feature_artist as feature_artist import cartopy.mpl.geocollection -import cartopy.mpl.patch as cpatch +import cartopy.mpl.path as cpath from cartopy.mpl.slippy_image_artist import SlippyImageArtist @@ -59,7 +61,6 @@ # CARTOPY_USER_BACKGROUNDS environment variable. _USER_BG_IMGS = {} - # XXX call this InterCRSTransform class InterProjectionTransform(mtransforms.Transform): """ @@ -169,26 +170,11 @@ def transform_path_non_affine(self, src_path): if src_path.vertices.shape == (1, 2): return mpath.Path(self.transform(src_path.vertices)) - transformed_geoms = [] - geoms = cpatch.path_to_geos(src_path) - - for geom in geoms: - proj_geom = self.target_projection.project_geometry( - geom, self.source_projection) - transformed_geoms.append(proj_geom) + geom = cpath.path_to_shapely(src_path) + transformed_geom = self.target_projection.project_geometry( + geom, self.source_projection) - if not transformed_geoms: - result = mpath.Path(np.empty([0, 2])) - else: - paths = cpatch.geos_to_path(transformed_geoms) - if not paths: - return mpath.Path(np.empty([0, 2])) - points, codes = list(zip(*[cpatch.path_segments(path, - curves=False, - simplify=False) - for path in paths])) - result = mpath.Path(np.concatenate(points, 0), - np.concatenate(codes)) + result = cpath.shapely_to_path(transformed_geom) # store the result in the cache for future performance boosts key = (self.source_projection, self.target_projection) @@ -227,17 +213,15 @@ def set_transform(self, transform): super().set_transform(self._trans_wrap) def set_boundary(self, path, transform): - self._original_path = path + self._original_path = cpath._ensure_path_closed(path) self.set_transform(transform) self.stale = True - # Can remove and use matplotlib's once we support only >= 3.2 - def set_path(self, path): - self._path = path - def _adjust_location(self): if self.stale: - self.set_path(self._original_path.clip_to_bbox(self.axes.viewLim)) + self.set_path( + cpath._ensure_path_closed( + self._original_path.clip_to_bbox(self.axes.viewLim))) # Some places in matplotlib's transform stack cache the actual # path so we trigger an update by invalidating the transform. self._trans_wrap.invalidate() @@ -255,14 +239,16 @@ def __init__(self, axes, **kwargs): super().__init__(axes, 'geo', self._original_path, **kwargs) def set_boundary(self, path, transform): - self._original_path = path + # Make sure path is closed (required by "Path.clip_to_bbox") + self._original_path = cpath._ensure_path_closed(path) self.set_transform(transform) self.stale = True def _adjust_location(self): if self.stale: - self._path = self._original_path.clip_to_bbox(self.axes.viewLim) - self._path = mpath.Path(self._path.vertices, closed=True) + self._path = cpath._ensure_path_closed( + self._original_path.clip_to_bbox(self.axes.viewLim) + ) def get_window_extent(self, renderer=None): # make sure the location is updated so that transforms etc are @@ -465,7 +451,7 @@ def hold_limits(self, hold=True): self.get_autoscaley_on()) yield - def _draw_preprocess(self, renderer): + def _draw_preprocess(self): """ Perform pre-processing steps shared between :func:`GeoAxes.draw` and :func:`GeoAxes.get_tightbbox`. @@ -483,7 +469,7 @@ def _draw_preprocess(self, renderer): # by `draw` or `get_tightbbox` are positioned and clipped correctly. self.patch._adjust_location() - def get_tightbbox(self, renderer, *args, **kwargs): + def get_tightbbox(self, renderer=None, *args, **kwargs): """ Extend the standard behaviour of :func:`matplotlib.axes.Axes.get_tightbbox`. @@ -492,7 +478,7 @@ def get_tightbbox(self, renderer, *args, **kwargs): calculating the tight bounding box. """ # Shared processing steps - self._draw_preprocess(renderer) + self._draw_preprocess() return super().get_tightbbox(renderer, *args, **kwargs) @@ -505,7 +491,7 @@ def draw(self, renderer=None, **kwargs): A global range is used if no limits have yet been set. """ # Shared processing steps - self._draw_preprocess(renderer) + self._draw_preprocess() # XXX This interface needs a tidy up: # image drawing on pan/zoom; @@ -536,8 +522,10 @@ def _update_title_position(self, renderer): # Get the max ymax of all top labels top = -1 for gl in gridliners: - if gl.has_labels(): - # Both top and geo labels can appear at the top of the axes + # Both top and geo labels can appear at the top of the axes + if gl.top_labels or gl.geo_labels: + # Make sure Gridliner is populated and up-to-date + gl._draw_gridliner(renderer=renderer) for label in (gl.top_label_artists + gl.geo_label_artists): bb = label.get_tightbbox(renderer) @@ -578,18 +566,11 @@ def __clear(self): self.dataLim.intervalx = self.projection.x_limits self.dataLim.intervaly = self.projection.y_limits - if mpl.__version__ >= '3.6': - def clear(self): - """Clear the current Axes and add boundary lines.""" - result = super().clear() - self.__clear() - return result - else: - def cla(self): - """Clear the current Axes and add boundary lines.""" - result = super().cla() - self.__clear() - return result + def clear(self): + """Clear the current Axes and add boundary lines.""" + result = super().clear() + self.__clear() + return result def format_coord(self, x, y): """ @@ -1286,6 +1267,11 @@ def imshow(self, img, *args, **kwargs): if (transform is None or transform == self.transData or same_projection and inside_bounds): + if "regrid_shape" in kwargs: + warnings.warn("ignoring regrid_shape because it doesn't do anything " + "when working in the same projection. To avoid this " + "warning, remove the 'regrid_shape' keyword argument.") + kwargs.pop("regrid_shape") result = super().imshow(img, *args, **kwargs) else: extent = kwargs.pop('extent', None) @@ -1478,7 +1464,7 @@ def gridlines(self, crs=None, draw_labels=False, Keyword Parameters ------------------ - **kwargs: dict + **kwargs: All other keywords control line properties. These are passed through to :class:`matplotlib.collections.Collection`. @@ -1534,7 +1520,7 @@ def _boundary(self): The :data:`.patch` and :data:`.spines['geo']` are updated to match. """ - path, = cpatch.geos_to_path(self.projection.boundary) + path = cpath.shapely_to_path(self.projection.boundary) # Get the outline path in terms of self.transData proj_to_data = self.projection._as_mpl_transform(self) - self.transData @@ -1598,8 +1584,8 @@ def contour(self, *args, **kwargs): """ result = super().contour(*args, **kwargs) - # We need to compute the dataLim correctly for contours. if not _MPL_38: + # We need to compute the dataLim correctly for contours. bboxes = [col.get_datalim(self.transData) for col in result.collections if col.get_paths()] @@ -1607,7 +1593,12 @@ def contour(self, *args, **kwargs): extent = mtransforms.Bbox.union(bboxes) self.update_datalim(extent.get_points()) else: - self.update_datalim(result.get_datalim(self.transData)) + # We need to compute the dataLim correctly for contours and set the + # artist's sticky edges to match. + datalim = result.get_datalim(self.transData) + self.update_datalim(datalim) + result.sticky_edges.x[:] = datalim.xmin, datalim.xmax + result.sticky_edges.y[:] = datalim.ymin, datalim.ymax self.autoscale_view() @@ -1639,8 +1630,8 @@ def contourf(self, *args, **kwargs): """ result = super().contourf(*args, **kwargs) - # We need to compute the dataLim correctly for contours. if not _MPL_38: + # We need to compute the dataLim correctly for contours. bboxes = [col.get_datalim(self.transData) for col in result.collections if col.get_paths()] @@ -1648,7 +1639,12 @@ def contourf(self, *args, **kwargs): extent = mtransforms.Bbox.union(bboxes) self.update_datalim(extent.get_points()) else: - self.update_datalim(result.get_datalim(self.transData)) + # We need to compute the dataLim correctly for contours and set the + # artist's sticky edges to match. + datalim = result.get_datalim(self.transData) + self.update_datalim(datalim) + result.sticky_edges.x[:] = datalim.xmin, datalim.xmax + result.sticky_edges.y[:] = datalim.ymin, datalim.ymax self.autoscale_view() @@ -1779,8 +1775,8 @@ def _wrap_args(self, *args, **kwargs): the data coordinates before passing on to Matplotlib. """ default_shading = mpl.rcParams.get('pcolor.shading') - if not (kwargs.get('shading', default_shading) in - ('nearest', 'auto') and len(args) == 3 and + shading = kwargs.get('shading') or default_shading + if not (shading in ('nearest', 'auto') and len(args) == 3 and getattr(kwargs.get('transform'), '_wrappable', False)): return args, kwargs @@ -1896,7 +1892,7 @@ def _wrap_quadmesh(self, collection, **kwargs): "It is recommended to remove the wrap manually " "before calling pcolormesh.") # With gouraud shading, we actually want an (Ny, Nx) shaped mask - gmask = np.zeros(data_shape, dtype=bool) + gmask = np.zeros((data_shape[0], data_shape[1]), dtype=bool) # If any of the cells were wrapped, apply it to all 4 corners gmask[:-1, :-1] |= mask gmask[1:, :-1] |= mask @@ -2291,13 +2287,12 @@ def add_wms(self, wms, layers, wms_kwargs=None, **kwargs): GeoAxesSubplot.__module__ = GeoAxes.__module__ -def _trigger_patch_reclip(event): +def _trigger_patch_reclip(axes): """ Define an event callback for a GeoAxes which forces the background patch to be re-clipped next time it is drawn. """ - axes = event.axes # trigger the outline and background patches to be re-clipped axes.spines['geo'].stale = True axes.patch.stale = True diff --git a/lib/cartopy/mpl/gridliner.py b/lib/cartopy/mpl/gridliner.py index ba9ddb162..cf06b8b3d 100644 --- a/lib/cartopy/mpl/gridliner.py +++ b/lib/cartopy/mpl/gridliner.py @@ -2,7 +2,14 @@ # # This file is part of Cartopy and is released under the BSD 3-clause license. # See LICENSE in the root of the repository for full licensing details. +""" +Cartopy can produce gridlines and ticks in any projection and add +them to the current geoaxes projection, providing a way to add detailed +location information to the plots. +""" + +import inspect import itertools import operator import warnings @@ -441,10 +448,14 @@ def __init__(self, axes, crs, draw_labels=False, xlocator=None, if auto_update is None: auto_update = True else: + # Note #2394 should be addressed before this deprecation expires. + calling_module = inspect.stack()[1].filename warnings.warn( "The auto_update parameter was deprecated at Cartopy 0.23. In future " "the gridlines and labels will always be updated.", - DeprecationWarning) + DeprecationWarning, + stacklevel=(3 if calling_module.endswith('cartopy/mpl/geoaxes.py') + else 2)) self._auto_update = auto_update def has_labels(self): diff --git a/lib/cartopy/mpl/patch.py b/lib/cartopy/mpl/patch.py index c67cab0d8..3ef107067 100644 --- a/lib/cartopy/mpl/patch.py +++ b/lib/cartopy/mpl/patch.py @@ -3,33 +3,39 @@ # This file is part of Cartopy and is released under the BSD 3-clause license. # See LICENSE in the root of the repository for full licensing details. """ -Provide shapely geometry <-> matplotlib path support. +Extra functionality that is primarily intended for developers, providing support for +transforming between Shapely geometries and Matplotlib paths. -See also `Shapely Geometric Objects `_ -and `Matplotlib Path API `_. - -.. see_also_shapely: - https://shapely.readthedocs.io/en/latest/manual.html#geometric-objects +See also `Shapely Geometric Objects +`_ +and `Matplotlib Path API `_. """ +import warnings + from matplotlib.path import Path import numpy as np import shapely.geometry as sgeom +import cartopy.mpl.path as cpath + def geos_to_path(shape): """ Create a list of :class:`matplotlib.path.Path` objects that describe a shape. + .. deprecated:: 0.25 + Use `cartopy.mpl.path.shapely_to_path` instead. + Parameters ---------- shape A list, tuple or single instance of any of the following types: :class:`shapely.geometry.point.Point`, :class:`shapely.geometry.linestring.LineString`, - :class:`shapely.geometry.linestring.LinearRing`, + :class:`shapely.geometry.polygon.LinearRing`, :class:`shapely.geometry.polygon.Polygon`, :class:`shapely.geometry.multipoint.MultiPoint`, :class:`shapely.geometry.multipolygon.MultiPolygon`, @@ -43,6 +49,9 @@ def geos_to_path(shape): A list of :class:`matplotlib.path.Path` objects. """ + warnings.warn("geos_to_path is deprecated and will be removed in a future release." + " Use cartopy.mpl.path.shapely_to_path instead.", + DeprecationWarning, stacklevel=2) if isinstance(shape, (list, tuple)): paths = [] for shp in shape: @@ -85,6 +94,8 @@ def path_segments(path, **kwargs): Create an array of vertices and a corresponding array of codes from a :class:`matplotlib.path.Path`. + .. deprecated:: 0.25 + Parameters ---------- path @@ -93,7 +104,7 @@ def path_segments(path, **kwargs): Other Parameters ---------------- kwargs - See :func:`matplotlib.path.iter_segments` for details of the keyword + See `matplotlib.path.Path.iter_segments` for details of the keyword arguments. Returns @@ -105,8 +116,10 @@ def path_segments(path, **kwargs): codes and their meanings. """ - pth = path.cleaned(**kwargs) - return pth.vertices[:-1, :], pth.codes[:-1] + warnings.warn( + "path_segments is deprecated and will be removed in a future release.", + DeprecationWarning, stacklevel=2) + return cpath._path_segments(path, **kwargs) def path_to_geos(path, force_ccw=False): @@ -114,6 +127,9 @@ def path_to_geos(path, force_ccw=False): Create a list of Shapely geometric objects from a :class:`matplotlib.path.Path`. + .. deprecated:: 0.25 + Use `cartopy.mpl.path.path_to_shapely` instead. + Parameters ---------- path @@ -133,8 +149,11 @@ def path_to_geos(path, force_ccw=False): :class:`shapely.geometry.multilinestring.MultiLineString`. """ + warnings.warn("path_to_geos is deprecated and will be removed in a future release." + " Use cartopy.mpl.path.path_to_shapely instead.", + DeprecationWarning, stacklevel=2) # Convert path into numpy array of vertices (and associated codes) - path_verts, path_codes = path_segments(path, curves=False) + path_verts, path_codes = cpath._path_segments(path, curves=False) # Split into subarrays such that each subarray consists of connected # line segments based on the start of each one being marked by a diff --git a/lib/cartopy/mpl/path.py b/lib/cartopy/mpl/path.py new file mode 100644 index 000000000..97e7f8ebe --- /dev/null +++ b/lib/cartopy/mpl/path.py @@ -0,0 +1,248 @@ +# Copyright Crown and Cartopy Contributors +# +# This file is part of Cartopy and is released under the BSD 3-clause license. +# See LICENSE in the root of the repository for full licensing details. +""" +Extra functionality that is primarily intended for developers, providing support for +transforming between Shapely geometries and Matplotlib paths. + +See also `Shapely Geometric Objects +`_ +and `Matplotlib Path API `_. + +""" + +from matplotlib.path import Path +import numpy as np +import shapely.geometry as sgeom + +from cartopy.mpl import _MPL_38 + + +def _ensure_path_closed(path): + """ + Method to ensure that a path contains only closed sub-paths. + + Parameters + ---------- + path + A :class:`matplotlib.path.Path` instance. + + Returns + ------- + path + A :class:`matplotlib.path.Path` instance with only closed polygons. + + """ + # Split path into potential sub-paths and close all polygons + # (explicitly disable path simplification applied in to_polygons) + should_simplify = path.should_simplify + try: + path.should_simplify = False + polygons = path.to_polygons() + finally: + path.should_simplify = should_simplify + + codes, vertices = [], [] + for poly in polygons: + vertices.extend([poly[0], *poly]) + codes.extend([Path.MOVETO, *[Path.LINETO]*(len(poly) - 1), Path.CLOSEPOLY]) + + return Path(vertices, codes) + + +def _path_segments(path, **kwargs): + """ + Create an array of vertices and a corresponding array of codes from a + :class:`matplotlib.path.Path`. + + Parameters + ---------- + path + A :class:`matplotlib.path.Path` instance. + + Other Parameters + ---------------- + kwargs + See `matplotlib.path.Path.iter_segments` for details of the keyword + arguments. + + Returns + ------- + vertices, codes + A (vertices, codes) tuple, where vertices is a numpy array of + coordinates, and codes is a numpy array of matplotlib path codes. + See :class:`matplotlib.path.Path` for information on the types of + codes and their meanings. + + """ + pth = path.cleaned(**kwargs) + return pth.vertices[:-1, :], pth.codes[:-1] + + +def shapely_to_path(shape): + """ + Create a :class:`matplotlib.path.Path` object that describes a shape. + + Parameters + ---------- + shape + :class:`shapely.Point`, + :class:`shapely.LineString`, + :class:`shapely.LinearRing`, + :class:`shapely.Polygon`, + :class:`shapely.MultiPoint`, + :class:`shapely.MultiPolygon`, + :class:`shapely.MultiLineString`, + :class:`shapely.GeometryCollection`. + + Returns + ------- + path + :class:`matplotlib.path.Path` + + """ + if shape.is_empty: + return Path(np.empty([0, 2])) + elif isinstance(shape, sgeom.LinearRing): + return Path(np.column_stack(shape.xy), closed=True) + elif isinstance(shape, (sgeom.LineString, sgeom.Point)): + return Path(np.column_stack(shape.xy)) + elif isinstance(shape, sgeom.Polygon): + def poly_codes(poly): + codes = np.ones(len(poly.xy[0])) * Path.LINETO + codes[0] = Path.MOVETO + codes[-1] = Path.CLOSEPOLY + return codes + vertices = np.concatenate([np.array(shape.exterior.xy)] + + [np.array(ring.xy) for ring in + shape.interiors], 1).T + codes = np.concatenate([poly_codes(shape.exterior)] + + [poly_codes(ring) for ring in shape.interiors]) + return Path(vertices, codes) + elif isinstance(shape, (sgeom.MultiPolygon, sgeom.GeometryCollection, + sgeom.MultiLineString, sgeom.MultiPoint)): + paths = [] + for geom in shape.geoms: + path = shapely_to_path(geom) + if _MPL_38 or path.vertices.size > 0: + # make_compound_path handling for empty paths was added at + # https://github.com/matplotlib/matplotlib/pull/25252 + paths.append(path) + return Path.make_compound_path(*paths) + else: + raise ValueError(f'Unsupported shape type {type(shape)}.') + + +def path_to_shapely(path): + """ + Create a Shapely geometric object from a :class:`matplotlib.path.Path`. + + Parameters + ---------- + path + A :class:`matplotlib.path.Path` instance. + + Returns + ------- + One of the following Shapely objects: + + :class:`shapely.Polygon`, + :class:`shapely.LineString` + :class:`shapely.Point`, + :class:`shapely.MultiPolygon` + :class:`shapely.MultiLineString` + :class:`shapely.MultiPoint` + :class:`shapely.GeometryCollection`. + + """ + # Convert path into numpy array of vertices (and associated codes) + path_verts, path_codes = _path_segments(path, curves=False) + + # Split into subarrays such that each subarray consists of connected + # line segments based on the start of each one being marked by a + # matplotlib MOVETO code. + verts_split_inds = np.where(path_codes == Path.MOVETO)[0] + verts_split = np.split(path_verts, verts_split_inds) + codes_split = np.split(path_codes, verts_split_inds) + + # Iterate through the vertices generating a list of + # (external_poly, [internal_polygons]) tuples for the polygons and separate + # lists for linestrings and points. + points = [] + linestrings = [] + polygon_bits = [] + for path_verts, path_codes in zip(verts_split, codes_split): + if len(path_verts) == 0: + continue + + if path_codes[-1] == Path.CLOSEPOLY: + path_verts[-1, :] = path_verts[0, :] + + verts_same_as_first = np.isclose(path_verts[0, :], path_verts[1:, :], + rtol=1e-10, atol=1e-13) + verts_same_as_first = np.logical_and.reduce(verts_same_as_first, + axis=1) + + if all(verts_same_as_first): + points.append(sgeom.Point(path_verts[0, :])) + elif not(path_verts.shape[0] > 4 and path_codes[-1] == Path.CLOSEPOLY): + linestrings.append(sgeom.LineString(path_verts)) + else: + geom = sgeom.Polygon(path_verts[:-1, :]) + # If geom is a Polygon and is contained within the last geom in + # polygon_bits, it usually needs to be an interior to that geom (e.g. a + # lake within a land mass). Sometimes there is a further geom within + # this interior (e.g. an island in a lake, or some instances of + # contours). This needs to be a new external geom in polygon_bits. + if (len(polygon_bits) > 0 and polygon_bits[-1][0].contains(geom.exterior)): + if any(internal.contains(geom) for internal in polygon_bits[-1][1]): + polygon_bits.append((geom, [])) + else: + polygon_bits[-1][1].append(geom) + else: + polygon_bits.append((geom, [])) + + # Convert each (external_polygon, [internal_polygons]) pair into a + # a shapely Polygon that encapsulates the internal polygons. + polygons = [] + for external_poly, internal_polys in polygon_bits: + if internal_polys: + exteriors = [geom.exterior for geom in internal_polys] + geom = sgeom.Polygon(external_poly.exterior, exteriors) + else: + geom = external_poly + + polygons.append(geom) + + # Remove any zero area Polygons + def not_zero_poly(geom): + return (not geom.is_empty and geom.area != 0) + + polygons = list(filter(not_zero_poly, polygons)) + + # Figure out what type of object to return + if not polygons: + if not linestrings: + if not points: + # No geometries. Return an empty point + return sgeom.Point() + elif len(points) > 1: + return sgeom.MultiPoint(points) + else: + return points[0] + elif not points: + if len(linestrings) > 1: + return sgeom.MultiLineString(linestrings) + else: + return linestrings[0] + else: + if not linestrings and not points: + if len(polygons) > 1: + return sgeom.MultiPolygon(polygons) + else: + return polygons[0] + + # If we got to here, we have at least two types of geometry, so return + # a geometry collection. + return sgeom.GeometryCollection(polygons + linestrings + points) diff --git a/lib/cartopy/mpl/ticker.py b/lib/cartopy/mpl/ticker.py index d261c3ab9..cdc2fa4a1 100644 --- a/lib/cartopy/mpl/ticker.py +++ b/lib/cartopy/mpl/ticker.py @@ -2,7 +2,7 @@ # # This file is part of Cartopy and is released under the BSD 3-clause license. # See LICENSE in the root of the repository for full licensing details. -"""This module contains tools for handling tick marks in cartopy.""" +"""Tools for handling tick marks in cartopy.""" import matplotlib as mpl from matplotlib.ticker import Formatter, MaxNLocator diff --git a/lib/cartopy/tests/crs/test_gnomonic.py b/lib/cartopy/tests/crs/test_gnomonic.py index b14b22436..f6037bd61 100644 --- a/lib/cartopy/tests/crs/test_gnomonic.py +++ b/lib/cartopy/tests/crs/test_gnomonic.py @@ -69,7 +69,7 @@ def test_eccentric_globe(): @pytest.mark.parametrize('lat', [-10, 0, 10]) @pytest.mark.parametrize('lon', [-10, 0, 10]) -def test_central_params(lat, lon): +def test_central_params(lon, lat): gnom = ccrs.Gnomonic(central_latitude=lat, central_longitude=lon) other_args = {f'lat_0={lat}', f'lon_0={lon}', 'a=6378137.0'} diff --git a/lib/cartopy/tests/crs/test_lambert_conformal.py b/lib/cartopy/tests/crs/test_lambert_conformal.py index 23bfea68f..1ff2ac3b2 100644 --- a/lib/cartopy/tests/crs/test_lambert_conformal.py +++ b/lib/cartopy/tests/crs/test_lambert_conformal.py @@ -3,6 +3,7 @@ # This file is part of Cartopy and is released under the BSD 3-clause license. # See LICENSE in the root of the repository for full licensing details. +import numpy as np from numpy.testing import assert_array_almost_equal import pyproj import pytest @@ -38,6 +39,17 @@ def test_default_with_cutoff(): (-49788019.81831982, 30793476.08487709)) +def test_sphere(): + """Test LambertConformal with spherical globe. (#2377)""" + globe = ccrs.Globe(ellipse='sphere') + + # This would error creating a boundary + crs = ccrs.LambertConformal(globe=globe) + + assert np.all(np.isfinite(crs.x_limits)) + assert np.all(np.isfinite(crs.y_limits)) + + def test_specific_lambert(): # This projection comes from EPSG Projection 3034 - ETRS89 / ETRS-LCC. crs = ccrs.LambertConformal(central_longitude=10, @@ -102,3 +114,29 @@ def test_single_npole(self): assert_array_almost_equal(n_pole_crs.y_limits, expected_y, decimal=0) + + +class TestLambertZoneII: + def setup_class(self): + self.point_a = (1.4868268900254693, 48.13277955695077) + self.point_b = (-2.3188020040300126, 48.68412929316207) + self.src_crs = ccrs.PlateCarree() + self.nan = float('nan') + + def test_default(self): + proj = ccrs.LambertZoneII() + res = proj.transform_point(*self.point_a, src_crs=self.src_crs) + np.testing.assert_array_almost_equal(res, + (536690.18620, 2348515.62248), + decimal=5) + res = proj.transform_point(*self.point_b, src_crs=self.src_crs) + np.testing.assert_array_almost_equal(res, + (257199.57387, 2419655.71471), + decimal=5) + + def test_nan(self): + proj = ccrs.LambertZoneII() + res = proj.transform_point(0.0, float('nan'), src_crs=self.src_crs) + assert np.all(np.isnan(res)) + res = proj.transform_point(float('nan'), 0.0, src_crs=self.src_crs) + assert np.all(np.isnan(res)) diff --git a/lib/cartopy/tests/crs/test_orthographic.py b/lib/cartopy/tests/crs/test_orthographic.py index dbe939a1e..ea79485da 100644 --- a/lib/cartopy/tests/crs/test_orthographic.py +++ b/lib/cartopy/tests/crs/test_orthographic.py @@ -70,7 +70,7 @@ def test_eccentric_globe(): @pytest.mark.parametrize('lat', [-10, 0, 10]) @pytest.mark.parametrize('lon', [-10, 0, 10]) -def test_central_params(lat, lon): +def test_central_params(lon, lat): ortho = ccrs.Orthographic(central_latitude=lat, central_longitude=lon) other_args = {f'lat_0={lat}', f'lon_0={lon}', 'a=6378137.0'} diff --git a/lib/cartopy/tests/feature/test_nightshade.py b/lib/cartopy/tests/feature/test_nightshade.py index e9baaf7dd..391b70ec5 100644 --- a/lib/cartopy/tests/feature/test_nightshade.py +++ b/lib/cartopy/tests/feature/test_nightshade.py @@ -40,7 +40,7 @@ def test_julian_day(): (datetime(2030, 6, 21, 0, 0), (23 + 26 / 60), -(179 + 34 / 60)) ]) def test_solar_position(dt, true_lat, true_lon): - lat, lon = _solar_position(dt) + lon, lat = _solar_position(dt) assert pytest.approx(true_lat, 0.1) == lat assert pytest.approx(true_lon, 0.1) == lon diff --git a/lib/cartopy/tests/io/test_ogc_clients.py b/lib/cartopy/tests/io/test_ogc_clients.py index 87a9a77a7..ef4ff1267 100644 --- a/lib/cartopy/tests/io/test_ogc_clients.py +++ b/lib/cartopy/tests/io/test_ogc_clients.py @@ -35,6 +35,7 @@ @pytest.mark.network +@pytest.mark.xfail(reason='URL no longer valid') @pytest.mark.skipif(not _OWSLIB_AVAILABLE, reason='OWSLib is unavailable.') class TestWMSRasterSource: URI = 'http://vmap0.tiles.osgeo.org/wms/vmap0' diff --git a/lib/cartopy/tests/mpl/__init__.py b/lib/cartopy/tests/mpl/__init__.py index c50bc775b..0151161bd 100644 --- a/lib/cartopy/tests/mpl/__init__.py +++ b/lib/cartopy/tests/mpl/__init__.py @@ -14,26 +14,24 @@ def show(projection, geometry): if geometry.geom_type == 'MultiPolygon' and 1: multi_polygon = geometry - for polygon in multi_polygon: - import cartopy.mpl.patch as patch - paths = patch.geos_to_path(polygon) - for pth in paths: - patch = mpatches.PathPatch(pth, edgecolor='none', - lw=0, alpha=0.2) - plt.gca().add_patch(patch) + import cartopy.mpl.path as cpath + pth = cpath.shapely_to_path(multi_polygon) + patch = mpatches.PathPatch(pth, edgecolor='none', lw=0, alpha=0.2) + plt.gca().add_patch(patch) + for polygon in multi_polygon.geoms: line_string = polygon.exterior - plt.plot(*zip(*line_string.coords), - marker='+', linestyle='-') + plt.plot(*zip(*line_string.coords), marker='+', linestyle='-') + elif geometry.geom_type == 'MultiPolygon': multi_polygon = geometry - for polygon in multi_polygon: + for polygon in multi_polygon.geoms: line_string = polygon.exterior plt.plot(*zip(*line_string.coords), marker='+', linestyle='-') elif geometry.geom_type == 'MultiLineString': multi_line_string = geometry - for line_string in multi_line_string: + for line_string in multi_line_string.geoms: plt.plot(*zip(*line_string.coords), marker='+', linestyle='-') diff --git a/lib/cartopy/tests/mpl/baseline_images/mpl/test_boundary/multi_path_boundary.png b/lib/cartopy/tests/mpl/baseline_images/mpl/test_boundary/multi_path_boundary.png new file mode 100644 index 000000000..ebfbf8c9f Binary files /dev/null and b/lib/cartopy/tests/mpl/baseline_images/mpl/test_boundary/multi_path_boundary.png differ diff --git a/lib/cartopy/tests/mpl/baseline_images/mpl/test_gridliner/gridliner_labels_title_adjust.png b/lib/cartopy/tests/mpl/baseline_images/mpl/test_gridliner/gridliner_labels_title_adjust.png index 9ed9c4ffe..648f55f6a 100644 Binary files a/lib/cartopy/tests/mpl/baseline_images/mpl/test_gridliner/gridliner_labels_title_adjust.png and b/lib/cartopy/tests/mpl/baseline_images/mpl/test_gridliner/gridliner_labels_title_adjust.png differ diff --git a/lib/cartopy/tests/mpl/baseline_images/mpl/test_mpl_integration/test_global_map_LambertZoneII.png b/lib/cartopy/tests/mpl/baseline_images/mpl/test_mpl_integration/test_global_map_LambertZoneII.png new file mode 100644 index 000000000..c2ea22e45 Binary files /dev/null and b/lib/cartopy/tests/mpl/baseline_images/mpl/test_mpl_integration/test_global_map_LambertZoneII.png differ diff --git a/lib/cartopy/tests/mpl/test_boundary.py b/lib/cartopy/tests/mpl/test_boundary.py new file mode 100644 index 000000000..e06b99d79 --- /dev/null +++ b/lib/cartopy/tests/mpl/test_boundary.py @@ -0,0 +1,88 @@ +# Copyright Crown and Cartopy Contributors +# +# This file is part of Cartopy and is released under the BSD 3-clause license. +# See LICENSE in the root of the repository for full licensing details. + +from matplotlib.path import Path +import matplotlib.pyplot as plt +import numpy as np +import pytest + +import cartopy.crs as ccrs + + +circle_verts = np.array([ + (21.33625905034713, 41.90051020408163), + (20.260167503134653, 36.31721708143521), + (17.186058185078757, 31.533809612693403), + (12.554340705353228, 28.235578526280886), + (7.02857405747471, 26.895042042418392), + (1.4004024147599825, 27.704250959518802), + (-3.5238590865749853, 30.547274662874656), + (-7.038740387110195, 35.0168098237746), + (-8.701144846177232, 41.42387784534415), + (-7.802741418346137, 47.03850217758886), + (-4.224119444187849, 52.606946662776494), + (0.5099122186645388, 55.75656240544797), + (6.075429329647158, 56.92110282927732), + (11.675092899771812, 55.933731058974054), + (16.50667197715324, 52.93590205611499), + (19.87797456957319, 48.357097196578444), + (21.33625905034713, 41.90051020408163) + ]) + +circle_codes = [ + Path.MOVETO, + *[Path.LINETO]*(len(circle_verts)), + Path.CLOSEPOLY + ] + +rectangle_verts = np.array([ + (55.676020408163225, 36.16071428571428), + (130.29336734693877, 36.16071428571428), + (130.29336734693877, -4.017857142857167), + (55.676020408163225, -4.017857142857167), + (55.676020408163225, 36.16071428571428) + ]) + +rectangle_codes = [ + Path.MOVETO, + *[Path.LINETO]*(len(rectangle_verts)), + Path.CLOSEPOLY + ] + + +@pytest.mark.natural_earth +@pytest.mark.mpl_image_compare(filename='multi_path_boundary.png') +def test_multi_path_boundary(): + offsets = np.array([[30, 30], [70, 30], [110, 20]]) + + closed = [True, False, False] + + vertices, codes = [], [] + # add closed and open circles + for offset, close in zip(offsets, closed): + c = circle_verts + offset + if close: + vertices.extend([c[0], *c, c[-1]]) + codes.extend([Path.MOVETO, *[Path.LINETO]*len(c), Path.CLOSEPOLY]) + else: + vertices.extend([c[0], *c]) + codes.extend([Path.MOVETO, *[Path.LINETO]*len(c)]) + + # add rectangle + vertices.extend( + [rectangle_verts[0], *rectangle_verts, rectangle_verts[-1]] + ) + codes.extend( + [Path.MOVETO, *[Path.LINETO]*len(rectangle_verts), Path.CLOSEPOLY] + ) + + bnds = [*map(min, zip(*vertices)), *map(max, zip(*vertices))] + + f, ax = plt.subplots(subplot_kw=dict(projection=ccrs.PlateCarree())) + ax.set_extent((bnds[0], bnds[2], bnds[1], bnds[3])) + ax.coastlines() + ax.set_boundary(Path(vertices, codes)) + + return f diff --git a/lib/cartopy/tests/mpl/test_caching.py b/lib/cartopy/tests/mpl/test_caching.py index 6ca00b163..6559bdd07 100644 --- a/lib/cartopy/tests/mpl/test_caching.py +++ b/lib/cartopy/tests/mpl/test_caching.py @@ -26,7 +26,7 @@ from cartopy.mpl import _MPL_38 from cartopy.mpl.feature_artist import FeatureArtist import cartopy.mpl.geoaxes as cgeoaxes -import cartopy.mpl.patch +import cartopy.mpl.path def sample_data(shape=(73, 145)): @@ -122,7 +122,8 @@ def test_contourf_transform_path_counting(): gc.collect() initial_cache_size = len(cgeoaxes._PATH_TRANSFORM_CACHE) - with mock.patch('cartopy.mpl.patch.path_to_geos') as path_to_geos_counter: + with mock.patch('cartopy.mpl.path.path_to_shapely', + wraps=cartopy.mpl.path.path_to_shapely) as path_to_shapely_counter: x, y, z = sample_data((30, 60)) cs = ax.contourf(x, y, z, 5, transform=ccrs.PlateCarree()) if not _MPL_38: @@ -135,9 +136,9 @@ def test_contourf_transform_path_counting(): # Before the performance enhancement, the count would have been 2 * n_geom, # but should now be just n_geom. - assert path_to_geos_counter.call_count == n_geom, ( + assert path_to_shapely_counter.call_count == n_geom, ( f'The given geometry was transformed too many times (expected: ' - f'{n_geom}; got {path_to_geos_counter.call_count}) - the caching is ' + f'{n_geom}; got {path_to_shapely_counter.call_count}) - the caching is ' f'not working.') # Check the cache has an entry for each geometry. @@ -146,7 +147,7 @@ def test_contourf_transform_path_counting(): # Check that the cache is empty again once we've dropped all references # to the source paths. fig.clf() - del path_to_geos_counter + del path_to_shapely_counter gc.collect() assert len(cgeoaxes._PATH_TRANSFORM_CACHE) == initial_cache_size diff --git a/lib/cartopy/tests/mpl/test_crs.py b/lib/cartopy/tests/mpl/test_crs.py index fd2a0db79..18a9e5225 100644 --- a/lib/cartopy/tests/mpl/test_crs.py +++ b/lib/cartopy/tests/mpl/test_crs.py @@ -7,12 +7,12 @@ import pytest import cartopy.crs as ccrs -from cartopy.mpl import _MPL_36 +from cartopy.mpl import _MPL_37 @pytest.mark.natural_earth @pytest.mark.mpl_image_compare( - filename="igh_land.png", tolerance=0.5 if _MPL_36 else 3.6) + filename="igh_land.png", tolerance=0.5 if _MPL_37 else 3.6) def test_igh_land(): crs = ccrs.InterruptedGoodeHomolosine(emphasis="land") ax = plt.axes(projection=crs) @@ -23,7 +23,7 @@ def test_igh_land(): @pytest.mark.natural_earth @pytest.mark.mpl_image_compare(filename="igh_ocean.png", - tolerance=0.5 if _MPL_36 else 4.5) + tolerance=0.5 if _MPL_37 else 4.5) def test_igh_ocean(): crs = ccrs.InterruptedGoodeHomolosine( central_longitude=-160, emphasis="ocean" diff --git a/lib/cartopy/tests/mpl/test_feature_artist.py b/lib/cartopy/tests/mpl/test_feature_artist.py index 1d243092a..b74842871 100644 --- a/lib/cartopy/tests/mpl/test_feature_artist.py +++ b/lib/cartopy/tests/mpl/test_feature_artist.py @@ -61,6 +61,7 @@ def cached_paths(geom, target_projection): return geom_cache.get(target_projection, None) +@pytest.mark.natural_earth @pytest.mark.mpl_image_compare(filename='feature_artist.png') def test_feature_artist_draw(feature): fig, ax = robinson_map() @@ -69,6 +70,7 @@ def test_feature_artist_draw(feature): return fig +@pytest.mark.natural_earth @pytest.mark.mpl_image_compare(filename='feature_artist.png') def test_feature_artist_draw_facecolor_list(feature): fig, ax = robinson_map() @@ -77,6 +79,7 @@ def test_feature_artist_draw_facecolor_list(feature): return fig +@pytest.mark.natural_earth @pytest.mark.mpl_image_compare(filename='feature_artist.png') def test_feature_artist_draw_cmap(feature): fig, ax = robinson_map() @@ -87,6 +90,7 @@ def test_feature_artist_draw_cmap(feature): return fig +@pytest.mark.natural_earth @pytest.mark.mpl_image_compare(filename='feature_artist.png') def test_feature_artist_draw_styled_feature(feature): geoms = list(feature.geometries()) @@ -98,6 +102,7 @@ def test_feature_artist_draw_styled_feature(feature): return fig +@pytest.mark.natural_earth @pytest.mark.mpl_image_compare(filename='feature_artist.png') def test_feature_artist_draw_styler(feature): geoms = list(feature.geometries()) diff --git a/lib/cartopy/tests/mpl/test_features.py b/lib/cartopy/tests/mpl/test_features.py index 684a05926..4c4608201 100644 --- a/lib/cartopy/tests/mpl/test_features.py +++ b/lib/cartopy/tests/mpl/test_features.py @@ -46,6 +46,7 @@ def test_natural_earth_custom(): return ax.figure +@pytest.mark.network @pytest.mark.skipif(not _HAS_PYKDTREE_OR_SCIPY, reason='pykdtree or scipy is required') @pytest.mark.mpl_image_compare(filename='gshhs_coastlines.png', tolerance=0.95) def test_gshhs(): diff --git a/lib/cartopy/tests/mpl/test_gridliner.py b/lib/cartopy/tests/mpl/test_gridliner.py index 7d82178a6..18fcb4117 100644 --- a/lib/cartopy/tests/mpl/test_gridliner.py +++ b/lib/cartopy/tests/mpl/test_gridliner.py @@ -14,7 +14,6 @@ from shapely.geos import geos_version import cartopy.crs as ccrs -from cartopy.mpl import _MPL_36 from cartopy.mpl.geoaxes import GeoAxes from cartopy.mpl.gridliner import ( LATITUDE_FORMATTER, @@ -336,6 +335,7 @@ def test_grid_labels_inline_usa(proj): return fig +@pytest.mark.natural_earth @pytest.mark.skipif(geos_version == (3, 9, 0), reason="GEOS intersection bug") @pytest.mark.mpl_image_compare(filename='gridliner_labels_bbox_style.png', tolerance=grid_label_tol) @@ -488,7 +488,7 @@ def test_gridliner_count_draws(): gl = ax.gridlines() with mock.patch.object(gl, '_draw_gridliner', return_value=None) as mocked: - ax.get_tightbbox(renderer=None) + ax.get_tightbbox() mocked.assert_called_once() with mock.patch.object(gl, '_draw_gridliner', return_value=None) as mocked: @@ -496,6 +496,7 @@ def test_gridliner_count_draws(): mocked.assert_called_once() +@pytest.mark.natural_earth @pytest.mark.mpl_image_compare( baseline_dir='baseline_images/mpl/test_mpl_integration', filename='simple_global.png') @@ -522,6 +523,7 @@ def test_gridliner_save_tight_bbox(): fig.savefig(io.BytesIO(), bbox_inches='tight') +@pytest.mark.natural_earth @pytest.mark.mpl_image_compare(filename='gridliner_labels_title_adjust.png', tolerance=grid_label_tol) def test_gridliner_title_adjust(): @@ -534,10 +536,7 @@ def test_gridliner_title_adjust(): plt.rcParams['axes.titley'] = None fig = plt.figure(layout='constrained') - if _MPL_36: - fig.get_layout_engine().set(h_pad=1/8) - else: - fig.set_constrained_layout_pads(h_pad=1/8) + fig.get_layout_engine().set(h_pad=1/8) for n, proj in enumerate(projs, 1): ax = fig.add_subplot(2, 2, n, projection=proj) ax.coastlines() @@ -562,6 +561,20 @@ def test_gridliner_title_noadjust(): assert ax.title.get_position() == pos +def test_gridliner_title_adjust_no_layout_engine(): + fig = plt.figure() + ax = fig.add_subplot(projection=ccrs.PlateCarree()) + gl = ax.gridlines(draw_labels=True) + title = ax.set_title("MY TITLE") + + # After first draw, title should be above top labels. + fig.draw_without_rendering() + max_label_y = max([bb.get_tightbbox().ymax for bb in gl.top_label_artists]) + min_title_y = title.get_tightbbox().ymin + + assert min_title_y > max_label_y + + def test_gridliner_labels_zoom(): fig = plt.figure() ax = fig.add_subplot(1, 1, 1, projection=ccrs.PlateCarree()) diff --git a/lib/cartopy/tests/mpl/test_images.py b/lib/cartopy/tests/mpl/test_images.py index 1a538b2ee..a9ec40340 100644 --- a/lib/cartopy/tests/mpl/test_images.py +++ b/lib/cartopy/tests/mpl/test_images.py @@ -139,6 +139,16 @@ def test_imshow_wrapping(): assert ax.get_xlim() == (-180, 180) +def test_imshow_arguments(): + """Smoke test for imshow argument passing in the fast-path""" + ax = plt.axes(projection=ccrs.PlateCarree()) + # Set the regrid_shape parameter to ensure it isn't passed to Axes.imshow() + # in the fast-path call to super() + with pytest.warns(UserWarning, match="ignoring regrid_shape"): + ax.imshow(np.random.random((10, 10)), transform=ccrs.PlateCarree(), + extent=(-180, 180, -90, 90), regrid_shape=500) + + def test_imshow_rgba(): # tests that the alpha of a RGBA array passed to imshow is set to 0 # instead of masked diff --git a/lib/cartopy/tests/mpl/test_mpl_integration.py b/lib/cartopy/tests/mpl/test_mpl_integration.py index 2e65d63d0..6b528b982 100644 --- a/lib/cartopy/tests/mpl/test_mpl_integration.py +++ b/lib/cartopy/tests/mpl/test_mpl_integration.py @@ -172,6 +172,7 @@ def test_simple_global(): pytest.param((ccrs.InterruptedGoodeHomolosine, dict(emphasis='land')), id='InterruptedGoodeHomolosine'), ccrs.LambertCylindrical, + ccrs.LambertZoneII, pytest.param((ccrs.Mercator, dict(min_latitude=-85, max_latitude=85)), id='Mercator'), ccrs.Miller, @@ -654,6 +655,17 @@ def test_pcolormesh_single_column_wrap(): return fig +def test_pcolormesh_wrap_gouraud_shading_failing_mask_creation(): + x_range = np.linspace(-180, 180, 50) + y_range = np.linspace(90, -90, 50) + x, y = np.meshgrid(x_range, y_range) + data = ((np.sin(np.deg2rad(x))) / 10. + np.exp(np.cos(np.deg2rad(y)))) + + fig = plt.figure(figsize=(10, 6)) + ax = fig.add_subplot(1, 1, 1, projection=ccrs.Mercator()) + ax.pcolormesh(x, y, data, transform=ccrs.PlateCarree(), shading='gouraud') + + def test_pcolormesh_diagonal_wrap(): # Check that a cell with the top edge on one side of the domain # and the bottom edge on the other gets wrapped properly @@ -762,9 +774,24 @@ def test_pcolormesh_shading(shading, input_size, expected): d = np.zeros((3, 3)) coll = ax.pcolormesh(x, y, d, shading=shading) - # We can use coll.get_coordinates() once MPL >= 3.5 is required - # For now, we use the private variable for testing - assert coll._coordinates.shape == (expected, expected, 2) + assert coll.get_coordinates().shape == (expected, expected, 2) + + +def test__wrap_args_default_shading(): + # Passing shading=None should give the same as not passing the shading parameter. + x = np.linspace(0, 360, 12) + y = np.linspace(0, 90, 5) + z = np.zeros((12, 5)) + + ax = plt.subplot(projection=ccrs.Orthographic()) + args_ref, kwargs_ref = ax._wrap_args(x, y, z, transform=ccrs.PlateCarree()) + args_test, kwargs_test = ax._wrap_args( + x, y, z, transform=ccrs.PlateCarree(), shading=None) + + for array_ref, array_test in zip(args_ref, args_test): + np.testing.assert_allclose(array_ref, array_test) + + assert kwargs_ref == kwargs_test @pytest.mark.natural_earth @@ -1045,3 +1072,9 @@ def test_annotate(): ) return fig + + +def test_inset_axes(): + fig, ax = plt.subplots() + ax.inset_axes([0.75, 0.75, 0.25, 0.25], projection=ccrs.PlateCarree()) + fig.draw_without_rendering() diff --git a/lib/cartopy/tests/mpl/test_patch.py b/lib/cartopy/tests/mpl/test_patch.py deleted file mode 100644 index 679f194e6..000000000 --- a/lib/cartopy/tests/mpl/test_patch.py +++ /dev/null @@ -1,60 +0,0 @@ -# Copyright Crown and Cartopy Contributors -# -# This file is part of Cartopy and is released under the BSD 3-clause license. -# See LICENSE in the root of the repository for full licensing details. - -from matplotlib.path import Path -import shapely.geometry as sgeom - -import cartopy.mpl.patch as cpatch - - -class Test_path_to_geos: - def test_empty_polygon(self): - p = Path( - [ - [0, 0], [0, 0], [0, 0], [0, 0], - [1, 2], [1, 2], [1, 2], [1, 2], - # The vertex for CLOSEPOLY should be ignored. - [2, 3], [2, 3], [2, 3], [42, 42], - # Very close points should be treated the same. - [193.75, -14.166664123535156], [193.75, -14.166664123535158], - [193.75, -14.166664123535156], [193.75, -14.166664123535156], - ], - codes=[1, 2, 2, 79] * 4) - geoms = cpatch.path_to_geos(p) - assert [type(geom) for geom in geoms] == [sgeom.Point] * 4 - assert len(geoms) == 4 - - def test_non_polygon_loop(self): - p = Path([[0, 10], [170, 20], [-170, 30], [0, 10]], - codes=[1, 2, 2, 2]) - geoms = cpatch.path_to_geos(p) - assert [type(geom) for geom in geoms] == [sgeom.MultiLineString] - assert len(geoms) == 1 - - def test_polygon_with_interior_and_singularity(self): - # A geometry with two interiors, one a single point. - p = Path([[0, -90], [200, -40], [200, 40], [0, 40], [0, -90], - [126, 26], [126, 26], [126, 26], [126, 26], [126, 26], - [114, 5], [103, 8], [126, 12], [126, 0], [114, 5]], - codes=[1, 2, 2, 2, 79, 1, 2, 2, 2, 79, 1, 2, 2, 2, 79]) - geoms = cpatch.path_to_geos(p) - assert [type(geom) for geom in geoms] == [sgeom.Polygon, sgeom.Point] - assert len(geoms[0].interiors) == 1 - - def test_nested_polygons(self): - # A geometry with three nested squares. - vertices = [[0, 0], [0, 10], [10, 10], [10, 0], [0, 0], - [2, 2], [2, 8], [8, 8], [8, 2], [2, 2], - [4, 4], [4, 6], [6, 6], [6, 4], [4, 4]] - codes = [1, 2, 2, 2, 79, 1, 2, 2, 2, 79, 1, 2, 2, 2, 79] - p = Path(vertices, codes=codes) - geoms = cpatch.path_to_geos(p) - - # The first square makes the first geometry with the second square as - # its interior. The third square is its own geometry with no interior. - assert len(geoms) == 2 - assert all(isinstance(geom, sgeom.Polygon) for geom in geoms) - assert len(geoms[0].interiors) == 1 - assert len(geoms[1].interiors) == 0 diff --git a/lib/cartopy/tests/mpl/test_path.py b/lib/cartopy/tests/mpl/test_path.py new file mode 100644 index 000000000..9837ee546 --- /dev/null +++ b/lib/cartopy/tests/mpl/test_path.py @@ -0,0 +1,92 @@ +# Copyright Crown and Cartopy Contributors +# +# This file is part of Cartopy and is released under the BSD 3-clause license. +# See LICENSE in the root of the repository for full licensing details. + +from matplotlib.path import Path +import pytest +import shapely.geometry as sgeom + +import cartopy.mpl.patch as cpatch +import cartopy.mpl.path as cpath + + +@pytest.mark.parametrize('use_legacy_path_to_geos', [False, True]) +class Test_path_to_shapely: + def test_empty_polygon(self, use_legacy_path_to_geos): + p = Path( + [ + [0, 0], [0, 0], [0, 0], [0, 0], + [1, 2], [1, 2], [1, 2], [1, 2], + # The vertex for CLOSEPOLY should be ignored. + [2, 3], [2, 3], [2, 3], [42, 42], + # Very close points should be treated the same. + [193.75, -14.166664123535156], [193.75, -14.166664123535158], + [193.75, -14.166664123535156], [193.75, -14.166664123535156], + ], + codes=[1, 2, 2, 79] * 4) + if use_legacy_path_to_geos: + with pytest.warns(DeprecationWarning, match="path_to_geos is deprecated"): + geoms = cpatch.path_to_geos(p) + assert [type(geom) for geom in geoms] == [sgeom.Point] * 4 + assert len(geoms) == 4 + else: + geoms = cpath.path_to_shapely(p) + assert isinstance(geoms, sgeom.MultiPoint) + assert len(geoms.geoms) == 4 + + def test_non_polygon_loop(self, use_legacy_path_to_geos): + p = Path([[0, 10], [170, 20], [-170, 30], [0, 10]], + codes=[1, 2, 2, 2]) + if use_legacy_path_to_geos: + with pytest.warns(DeprecationWarning, match="path_to_geos is deprecated"): + geoms = cpatch.path_to_geos(p) + + assert [type(geom) for geom in geoms] == [sgeom.MultiLineString] + assert len(geoms) == 1 + else: + geoms = cpath.path_to_shapely(p) + assert isinstance(geoms, sgeom.LineString) + + def test_polygon_with_interior_and_singularity(self, use_legacy_path_to_geos): + # A geometry with two interiors, one a single point. + p = Path([[0, -90], [200, -40], [200, 40], [0, 40], [0, -90], + [126, 26], [126, 26], [126, 26], [126, 26], [126, 26], + [114, 5], [103, 8], [126, 12], [126, 0], [114, 5]], + codes=[1, 2, 2, 2, 79, 1, 2, 2, 2, 79, 1, 2, 2, 2, 79]) + if use_legacy_path_to_geos: + with pytest.warns(DeprecationWarning, match="path_to_geos is deprecated"): + geoms = cpatch.path_to_geos(p) + + assert [type(geom) for geom in geoms] == [sgeom.Polygon, sgeom.Point] + assert len(geoms[0].interiors) == 1 + else: + geoms = cpath.path_to_shapely(p) + assert isinstance(geoms, sgeom.GeometryCollection) + assert [type(geom) for geom in geoms.geoms] == [sgeom.Polygon, sgeom.Point] + assert len(geoms.geoms[0].interiors) == 1 + + def test_nested_polygons(self, use_legacy_path_to_geos): + # A geometry with three nested squares. + vertices = [[0, 0], [0, 10], [10, 10], [10, 0], [0, 0], + [2, 2], [2, 8], [8, 8], [8, 2], [2, 2], + [4, 4], [4, 6], [6, 6], [6, 4], [4, 4]] + codes = [1, 2, 2, 2, 79, 1, 2, 2, 2, 79, 1, 2, 2, 2, 79] + p = Path(vertices, codes=codes) + + # The first square makes the first geometry with the second square as + # its interior. The third square is its own geometry with no interior. + if use_legacy_path_to_geos: + with pytest.warns(DeprecationWarning, match="path_to_geos is deprecated"): + geoms = cpatch.path_to_geos(p) + + assert len(geoms) == 2 + assert all(isinstance(geom, sgeom.Polygon) for geom in geoms) + assert len(geoms[0].interiors) == 1 + assert len(geoms[1].interiors) == 0 + else: + geoms = cpath.path_to_shapely(p) + assert isinstance(geoms, sgeom.MultiPolygon) + assert len(geoms.geoms) == 2 + assert len(geoms.geoms[0].interiors) == 1 + assert len(geoms.geoms[1].interiors) == 0 diff --git a/lib/cartopy/tests/mpl/test_shapely_to_mpl.py b/lib/cartopy/tests/mpl/test_shapely_to_mpl.py index 23444a477..b1b8be0b2 100644 --- a/lib/cartopy/tests/mpl/test_shapely_to_mpl.py +++ b/lib/cartopy/tests/mpl/test_shapely_to_mpl.py @@ -13,13 +13,15 @@ import cartopy.crs as ccrs import cartopy.mpl.patch as cpatch +import cartopy.mpl.path as cpath # Note: Matplotlib is broken here # https://github.com/matplotlib/matplotlib/issues/15946 @pytest.mark.natural_earth @pytest.mark.mpl_image_compare(filename='poly_interiors.png', tolerance=3.1) -def test_polygon_interiors(): +@pytest.mark.parametrize('use_legacy_geos_funcs', [False, True]) +def test_polygon_interiors(use_legacy_geos_funcs): fig = plt.figure() ax = fig.add_subplot(2, 1, 1, projection=ccrs.PlateCarree()) @@ -30,16 +32,29 @@ def test_polygon_interiors(): [10, -20], [10, 20], [40, 20], [40, -20], [10, 20]], [1, 2, 2, 2, 79, 1, 2, 2, 2, 79]) - patches_native = [] - patches = [] - for geos in cpatch.path_to_geos(pth): - for pth in cpatch.geos_to_path(geos): - patches.append(mpatches.PathPatch(pth)) - - # buffer by 10 degrees (leaves a small hole in the middle) - geos_buffered = geos.buffer(10) - for pth in cpatch.geos_to_path(geos_buffered): - patches_native.append(mpatches.PathPatch(pth)) + if use_legacy_geos_funcs: + patches_native = [] + patches = [] + with pytest.warns(DeprecationWarning, match="path_to_geos is deprecated"): + for geos in cpatch.path_to_geos(pth): + with pytest.warns(DeprecationWarning, match="geos_to_path is deprecat"): + for pth in cpatch.geos_to_path(geos): + patches.append(mpatches.PathPatch(pth)) + + # buffer by 10 degrees (leaves a small hole in the middle) + geos_buffered = geos.buffer(10) + with pytest.warns(DeprecationWarning, match="geos_to_path is deprecat"): + for pth in cpatch.geos_to_path(geos_buffered): + patches_native.append(mpatches.PathPatch(pth)) + else: + geom = cpath.path_to_shapely(pth) + assert isinstance(geom, sgeom.Polygon) + path = cpath.shapely_to_path(geom) + patches = [mpatches.PathPatch(path)] + + geom_buffered = geom.buffer(10) + path_buffered = cpath.shapely_to_path(geom_buffered) + patches_native = [mpatches.PathPatch(path_buffered)] # Set high zorder to ensure the polygons are drawn on top of coastlines. collection = PatchCollection(patches_native, facecolor='red', alpha=0.4, @@ -61,9 +76,14 @@ def test_polygon_interiors(): np.array(sgeom.box(1, 8, 2, 9, ccw=False).exterior.coords)] poly = sgeom.Polygon(exterior, interiors) - patches = [] - for pth in cpatch.geos_to_path(poly): - patches.append(mpatches.PathPatch(pth)) + if use_legacy_geos_funcs: + patches = [] + with pytest.warns(DeprecationWarning, match="geos_to_path is deprecated"): + for pth in cpatch.geos_to_path(poly): + patches.append(mpatches.PathPatch(pth)) + else: + path = cpath.shapely_to_path(poly) + patches = [mpatches.PathPatch(path)] collection = PatchCollection(patches, facecolor='yellow', alpha=0.4, transform=ccrs.Geodetic(), zorder=10) diff --git a/lib/cartopy/tests/mpl/test_web_services.py b/lib/cartopy/tests/mpl/test_web_services.py index b6fed16dc..6207e7bbb 100644 --- a/lib/cartopy/tests/mpl/test_web_services.py +++ b/lib/cartopy/tests/mpl/test_web_services.py @@ -31,6 +31,7 @@ def test_wmts(): @pytest.mark.network +@pytest.mark.xfail(reason='URL no longer valid') @pytest.mark.skipif(not _OWSLIB_AVAILABLE, reason='OWSLib is unavailable.') def test_wms_tight_layout(): ax = plt.axes(projection=ccrs.PlateCarree()) @@ -41,6 +42,7 @@ def test_wms_tight_layout(): @pytest.mark.network +@pytest.mark.xfail(reason='URL no longer valid') @pytest.mark.skipif(not _OWSLIB_AVAILABLE, reason='OWSLib is unavailable.') @pytest.mark.mpl_image_compare(filename='wms.png', tolerance=0.02) def test_wms(): diff --git a/lib/cartopy/tests/test_crs.py b/lib/cartopy/tests/test_crs.py index de25374f7..bf433504f 100644 --- a/lib/cartopy/tests/test_crs.py +++ b/lib/cartopy/tests/test_crs.py @@ -36,7 +36,7 @@ def test_osni(self, approx): ll = ccrs.Geodetic() # results obtained by nearby.org.uk. - lat, lon = np.array([54.5622169298669, -5.54159863617957], + lon, lat = np.array([-5.54159863617957, 54.5622169298669], dtype=np.double) east, north = np.array([359000, 371000], dtype=np.double) @@ -65,7 +65,7 @@ def _check_osgb(self, osgb): ll = ccrs.Geodetic() # results obtained by streetmap.co.uk. - lat, lon = np.array([50.462023, -3.478831], dtype=np.double) + lon, lat = np.array([-3.478831, 50.462023], dtype=np.double) east, north = np.array([295132.1, 63512.6], dtype=np.double) # note the handling of precision here... @@ -233,7 +233,7 @@ def test_project_point(self): def test_utm(self): utm30n = ccrs.UTM(30) ll = ccrs.Geodetic() - lat, lon = np.array([51.5, -3.0], dtype=np.double) + lon, lat = np.array([-3.0, 51.5], dtype=np.double) east, north = np.array([500000, 5705429.2], dtype=np.double) assert_arr_almost_eq(utm30n.transform_point(lon, lat, ll), [east, north], @@ -242,7 +242,7 @@ def test_utm(self): [lon, lat], decimal=1) utm38s = ccrs.UTM(38, southern_hemisphere=True) - lat, lon = np.array([-18.92, 47.5], dtype=np.double) + lon, lat = np.array([47.5, -18.92], dtype=np.double) east, north = np.array([763316.7, 7906160.8], dtype=np.double) assert_arr_almost_eq(utm38s.transform_point(lon, lat, ll), [east, north], @@ -254,11 +254,11 @@ def test_utm(self): @pytest.fixture(params=[ [ccrs.PlateCarree, {}], - [ccrs.PlateCarree, dict( - central_longitude=1.23)], - [ccrs.NorthPolarStereo, dict( - central_longitude=42.5, - globe=ccrs.Globe(ellipse="helmert"))], + [ccrs.PlateCarree, dict(central_longitude=1.23)], + [ccrs.NorthPolarStereo, dict(central_longitude=42.5, + globe=ccrs.Globe(ellipse="helmert"))], + [ccrs.CRS, dict(proj4_params="3088")], + [ccrs.epsg, dict(code="3088")] ]) def proj_to_copy(request): cls, kwargs = request.param diff --git a/lib/cartopy/tests/test_img_tiles.py b/lib/cartopy/tests/test_img_tiles.py index be478408c..bc4354cd0 100644 --- a/lib/cartopy/tests/test_img_tiles.py +++ b/lib/cartopy/tests/test_img_tiles.py @@ -34,6 +34,11 @@ def GOOGLE_IMAGE_URL_REPLACEMENT(self, tile): + # TODO: This is a hack to replace the Google image URL with a static image. + # This service has been deprecated by Google, so we need to replace it + # See https://developers.google.com/chart/image for the notice + pytest.xfail(reason="Google has deprecated the tile API used in this test") + x, y, z = tile return (f'https://chart.googleapis.com/chart?chst=d_text_outline&' f'chs=256x256&chf=bg,s,00000055&chld=FFFFFF|16|h|000000|b||||' diff --git a/lib/cartopy/tests/test_line_string.py b/lib/cartopy/tests/test_line_string.py index a0b42ff0e..926dd5b5b 100644 --- a/lib/cartopy/tests/test_line_string.py +++ b/lib/cartopy/tests/test_line_string.py @@ -8,6 +8,7 @@ import numpy as np import pytest +import shapely import shapely.geometry as sgeom import cartopy.crs as ccrs @@ -73,6 +74,15 @@ def test_out_of_domain_efficiency(self): tgt_proj.project_geometry(line_string, src_proj) assert time.time() < cutoff_time, 'Projection took too long' + @pytest.mark.skipif(shapely.__version__ < "2", + reason="Shapely <2 has an incorrect geom_type ") + def test_multi_linestring_return_type(self): + # Check that the return type of project_geometry is a MultiLineString + # and not an empty list + multi_line_string = ccrs.Mercator().project_geometry( + sgeom.MultiLineString(), ccrs.PlateCarree()) + assert isinstance(multi_line_string, sgeom.MultiLineString) + class FakeProjection(ccrs.PlateCarree): def __init__(self, left_offset=0, right_offset=0): diff --git a/lib/cartopy/tests/test_linear_ring.py b/lib/cartopy/tests/test_linear_ring.py index 25f3b813a..1bfa59e1d 100644 --- a/lib/cartopy/tests/test_linear_ring.py +++ b/lib/cartopy/tests/test_linear_ring.py @@ -16,7 +16,7 @@ def test_cuts(self): # original ... ? linear_ring = sgeom.LinearRing([(-10, 30), (10, 60), (10, 50)]) projection = ccrs.Robinson(170.5) - rings, multi_line_string = projection.project_geometry(linear_ring) + *rings, multi_line_string = projection.project_geometry(linear_ring).geoms # The original ring should have been split into multiple pieces. assert len(multi_line_string.geoms) > 1 @@ -58,7 +58,7 @@ def test_out_of_bounds(self): # Try all four combinations of valid/NaN vs valid/NaN. for coords, expected_n_lines in rings: linear_ring = sgeom.LinearRing(coords) - rings, mlinestr = projection.project_geometry(linear_ring) + *rings, mlinestr = projection.project_geometry(linear_ring).geoms if expected_n_lines == -1: assert rings assert not mlinestr @@ -78,7 +78,7 @@ def test_small(self): (-180.0000000000000000, -16.0671326636424396), (-179.7933201090486079, -16.0208822567412312), ]) - rings, multi_line_string = projection.project_geometry(linear_ring) + *rings, multi_line_string = projection.project_geometry(linear_ring).geoms # There should be one, and only one, returned ring. assert isinstance(multi_line_string, sgeom.MultiLineString) assert len(multi_line_string.geoms) == 0 @@ -129,13 +129,13 @@ def test_stitch(self): target_proj = ccrs.Stereographic(80) linear_ring = sgeom.LinearRing(coords) - rings, mlinestr = target_proj.project_geometry(linear_ring, src_proj) + *rings, mlinestr = target_proj.project_geometry(linear_ring, src_proj).geoms assert len(mlinestr.geoms) == 1 assert len(rings) == 0 # Check the stitch works in either direction. linear_ring = sgeom.LinearRing(coords[::-1]) - rings, mlinestr = target_proj.project_geometry(linear_ring, src_proj) + *rings, mlinestr = target_proj.project_geometry(linear_ring, src_proj).geoms assert len(mlinestr.geoms) == 1 assert len(rings) == 0 @@ -162,7 +162,7 @@ def test_at_boundary(self): tcrs = ccrs.PlateCarree() scrs = ccrs.PlateCarree() - rings, mlinestr = tcrs._project_linear_ring(tring, scrs) + *rings, mlinestr = tcrs._project_linear_ring(tring, scrs).geoms # Number of linearstrings assert len(mlinestr.geoms) == 4 diff --git a/lib/cartopy/trace.pyx b/lib/cartopy/trace.pyx index 082f7b293..9d386324a 100644 --- a/lib/cartopy/trace.pyx +++ b/lib/cartopy/trace.pyx @@ -6,7 +6,7 @@ # cython: embedsignature=True """ -This module pulls together proj, GEOS and ``_crs.pyx`` to implement a function +Trace pulls together proj, GEOS and ``_crs.pyx`` to implement a function to project a `~shapely.geometry.LinearRing` / `~shapely.geometry.LineString`. In general, this should never be called manually, instead leaving the processing to be done by the :class:`cartopy.crs.Projection` subclasses. diff --git a/lib/cartopy/util.py b/lib/cartopy/util.py index 1022ce18e..28142de35 100644 --- a/lib/cartopy/util.py +++ b/lib/cartopy/util.py @@ -3,8 +3,7 @@ # This file is part of Cartopy and is released under the BSD 3-clause license. # See LICENSE in the root of the repository for full licensing details. """ -This module contains utilities that are useful in conjunction with -cartopy. +Utilities that are useful in conjunction with cartopy. """ import numpy as np diff --git a/lib/cartopy/vector_transform.py b/lib/cartopy/vector_transform.py index 25dbe9e21..b52212a58 100644 --- a/lib/cartopy/vector_transform.py +++ b/lib/cartopy/vector_transform.py @@ -3,8 +3,7 @@ # This file is part of Cartopy and is released under the BSD 3-clause license. # See LICENSE in the root of the repository for full licensing details. """ -This module contains generic functionality to support Cartopy vector -transforms. +Generic functionality to support Cartopy vector transforms. """ diff --git a/pyproject.toml b/pyproject.toml index 89e776e93..7018e09eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,13 @@ requires = [ "wheel", "setuptools >= 40.6.0", "Cython >= 0.29.24", - "oldest-supported-numpy", + # numpy requirement for wheel builds for distribution on PyPI - building + # against 2.x yields wheels that are also compatible with numpy 1.x at + # runtime. + # Note that building against numpy 1.x works fine too - users and + # redistributors can do this by installing the numpy version they like and + # disabling build isolation. + "numpy>=2.0.0rc1", "setuptools_scm >= 7.0.0", ] build-backend = "setuptools.build_meta" @@ -15,7 +21,7 @@ authors = [ ] description = "A Python library for cartographic visualizations with Matplotlib" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" keywords = ["cartography", "map", "transform", "projection", "pyproj", "shapely", "shapefile"] license = {file = "LICENSE"} classifiers = [ @@ -30,7 +36,6 @@ classifiers = [ 'Programming Language :: C++', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', @@ -40,20 +45,20 @@ classifiers = [ 'Topic :: Scientific/Engineering :: Visualization', ] dependencies = [ - "numpy>=1.21", - "matplotlib>=3.5", - "shapely>=1.7", - "packaging>=20", + "numpy>=1.23", + "matplotlib>=3.6", + "shapely>=2.0", + "packaging>=21", "pyshp>=2.3", - "pyproj>=3.1.0", + "pyproj>=3.3.1", ] dynamic = ["version"] [project.optional-dependencies] doc = ["pydata-sphinx-theme", "sphinx", "sphinx-gallery"] speedups = ["pykdtree", "fiona"] -ows = ["OWSLib>=0.20.0", "pillow>=6.1.0"] -plotting = ["pillow>=6.1.0", "scipy>=1.3.1"] +ows = ["OWSLib>=0.27.0", "pillow>=9.1"] +plotting = ["pillow>=9.1", "scipy>=1.9"] srtm = ["beautifulsoup4"] test = ["pytest>=5.1.2", "pytest-mpl>=0.11", "pytest-xdist", "pytest-cov", "coveralls"] @@ -93,10 +98,9 @@ testpaths = ["lib"] python_files = ["test_*.py"] [tool.ruff] -target-version = "py39" -select = ["E", "F", "I", "W"] +lint.select = ["E", "F", "I", "W"] -[tool.ruff.isort] +[tool.ruff.lint.isort] force-sort-within-sections = true known-first-party = ["cartopy"] lines-after-imports = 2