Mapping with cartopy and Colors#

Context#

Yesterday we explored the exciting world of combining geospatial information with our pandas dataframe in the context of analysis and computations. Today we are going to now see what we can do with visualization when we have geospatial information. We are going to use the library cartopy to make maps and we will also explore some more visualization features of matplotlib.

Today are going to use a geodataframe of the NAAMES dataset.

import pandas as pd
import geopandas as gpd
import numpy as np
naames = pd.read_csv('./data/naames-mrg01-c130_merge_20151112_R5_thru20151114.csv', skiprows=223)
# Filter down to just 1 day
naames = naames[naames[' Fractional_Day'] < 317]
# Remove NaN values
naames = naames.replace({-999999: np.nan})
# Create geodataframe
naames_gpd = gpd.GeoDataFrame(naames, 
                            geometry=gpd.points_from_xy(naames[' LONGITUDE'], naames[' LATITUDE']), 
                            crs='epsg:4326')

cartopy#

Yesterday we saw that for a quick visual we could use the .plot() function to look at our data on a latitude/longitude axis. This is nice and it is easy, but we lack context in this simplified view.

naames_gpd.plot(column=' ALTP')
<Axes: >
../../_images/bdd24e5794e19fae944f92d011102b41958b89b789fff03eef3f0b2152ef28f7.png

To expand our map we are going to use cartopy. Some advantages of cartopy are:

  • ability to reproject a dataset

  • ability to plot data from different projections together

  • ability to add features such as coastlines below your data, similar to a basemap

Let’s see an example of using cartopy by re-creating the map above with some features below.

cartopy builds into the matplotlib OOP interface, so let’s start by creating a map with matplotlib OOP.

import matplotlib.pyplot as plt
fig = plt.figure()
ax = plt.axes()

naames_gpd.plot(ax=ax, column=' ALTP')
<Axes: >
../../_images/bdd24e5794e19fae944f92d011102b41958b89b789fff03eef3f0b2152ef28f7.png

Now the plot above doesn’t use cartopy, it’s just the same map as the geopandas version but plotted in a different way. To change to using cartopy we have to:

  1. define our crs

  2. convert our geopandas dataframe to that crs

  3. add the crs as an argument to plt.axes()

The CRS we are going to use is called PlateCarree.

import cartopy.crs as ccrs
# Create the projection
crs = ccrs.PlateCarree()

# This can be converted into a `proj4` string/dict compatible with GeoPandas
naames_platecarree = naames_gpd.to_crs(crs.proj4_init)
# Recreate the plot with the projection as an argument
fig = plt.figure()
ax = plt.axes(projection=crs)

naames_platecarree.plot(ax=ax, column=' ALTP')
<GeoAxes: >
../../_images/06d5bdedfdb0f29bf47dcdd70e3c5dfa86c43a03fa2a2812650af95769999206.png

So far it looks like we’ve taken a step backwards because we have lost our nice axis labels. We can add those back, however, using the .gridlines() method. Additionally, we can use the .coastlines() feature to add in the shape of the nearby continent. I’m also going to use .set_extent() to give us a bit broader view and a title for clarity.

# Recreate the plot with the projection as an argument
fig = plt.figure()
ax = plt.axes(projection=crs)

# Add the data
naames_platecarree.plot(ax=ax, column=' ALTP')

# Change the field of view
ax.set_extent([-63, -38, 45, 57])
# Add labels and gridlines
ax.gridlines(crs=crs, draw_labels=True)
# Add the shape of the coast
ax.coastlines()

# Add a title
ax.set_title('Flight Track Altitude')
Text(0.5, 1.0, 'Flight Track Altitude')
../../_images/b0ee394eda10f825205b7610c6e5d105abcd1aced6648f38630624e4788925cc.png

Looks nice! So with a bit of extra work we have converted out quick geopandas plot into a map with nice labels, gridlines and coastlines.

cartopy Features#

One of the nice built-in features of cartopy is that it can download and render features layers such as coastlines, rivers, and some political boundaries on your map. The example below shows how to use features with the pre-built cartopy features. This example also shows how to create a custom feature.

import cartopy.feature as cfeature
# Recreate the plot with the projection as an argument
fig = plt.figure()
ax = plt.axes(projection=crs)

# Add the data
naames_platecarree.plot(ax=ax, column=' ALTP')

# Change the field of view
ax.set_extent([-63, -38, 45, 57])
# Add labels and gridlines
ax.gridlines(crs=crs, color='grey', linestyle='--', draw_labels=True)

# Add background features
ax.add_feature(cfeature.LAND)
ax.add_feature(cfeature.OCEAN)
ax.add_feature(cfeature.COASTLINE)
ax.add_feature(cfeature.BORDERS, linestyle=':')
ax.add_feature(cfeature.LAKES, alpha=0.5)
ax.add_feature(cfeature.RIVERS)

# Add a title
ax.set_title('Flight Track Altitude')
Text(0.5, 1.0, 'Flight Track Altitude')
../../_images/2c26eb93cd1e675b1c27c4cbd90ac61f7a6d3da2c9fb4f2c233e6442013a97c2.png

Transforming Data#

One of the nice parts of cartopy is that it lets you change your projection farily easily. To do this we will define a different starting projection, then run the same code again.

# Create the projection
crs = ccrs.AlbersEqualArea(-50, 52)

# This can be converted into a `proj4` string/dict compatible with GeoPandas
naames_albers = naames_gpd.to_crs(crs.proj4_init)
# Recreate the plot with the projection as an argument
fig = plt.figure()
ax = plt.axes(projection=crs)

# Add the data
naames_albers.plot(ax=ax, column=' ALTP')

# Change the field of view
ax.set_extent([-63, -38, 45, 57])
# Add labels and gridlines
ax.gridlines(crs=crs)
# Add the shape of the coast
ax.coastlines()

# Add a title
ax.set_title('Flight Track Altitude')
Text(0.5, 1.0, 'Flight Track Altitude')
../../_images/29fd09afa8f18ceb25b0341199879e4ae3934fbe0eba3c7b51008a05e866c490.png

There are lots of projections and which one you want depends on where in the world you are. A full list of projections can be found here.

For flight track data the change in projection likely won’t matter very much. Where this feature becomes useful is if you are plotting two data sources with different starting projections. If that is the case then we can use these features to convert the different sources to the same projection.

Color in matplotlib#

There are a variety of ways to deal with color and colorbars in matplotlib.

Firstly, color can be specified in several ways. When specifying a single color you can use RGB, hexadecimal, or sometimes by a written name. A full list of the options is in the docs. Another useful tool is an online color picker. There are dozens of these available so try a couple to find your favorite. One I have used before is the W3Schools Color Picker.

naames.plot.scatter(x=' Fractional_Day', y=' ALTP', c='#7eb54e')  # hexadecimal color code
# naames.plot.scatter(x=' Fractional_Day', y=' ALTP', c=(0.49, 0.75, 0.97))  # RGB color code
<Axes: xlabel=' Fractional_Day', ylabel=' ALTP'>
../../_images/888a11e46607c876dffe8f1e80a7ae8e0c285549b905ba74ae8b21a9bbb09a04.png
naames.plot.scatter(x=' Fractional_Day', y=' ALTP', c='blue')
<Axes: xlabel=' Fractional_Day', ylabel=' ALTP'>
../../_images/f1f4c85028d8a1958d7052ccb97c19612bcea1bcc880550939cf17ee8265f64e.png

We have also seen that you can use another column to create the color for a scatter plot.

naames.plot.scatter(x=' Fractional_Day', y=' ALTP', c=' BC_mass')
<Axes: xlabel=' Fractional_Day', ylabel=' ALTP'>
../../_images/9e93774577a38def5da0f5df31c765e2e1c8758d0938801a811eba1bed52e328.png

Adding a color-based legend to a plot is called adding a colorbar. When adding a colorbar there are multiple components:

  1. The values that are used to determine the color at that point

  2. The colors that are going to be used for the range of values These two things are two seperate inputs, c for the first one, and cmap for the second one.

# _r on the colormap reverses. So viridis and viridis_r both work
naames.plot.scatter(x=' Fractional_Day', y=' ALTP', c=' BC_mass', colormap='viridis')
<Axes: xlabel=' Fractional_Day', ylabel=' ALTP'>
../../_images/9e93774577a38def5da0f5df31c765e2e1c8758d0938801a811eba1bed52e328.png

A list of the available colormaps can be found here.

A common parameter you may want to access are vmax and vmin. These don’t change the values in your dataset, but they do change the which values correspond to which colors in your colormap.

naames.plot.scatter(x=' Fractional_Day', y=' ALTP', c=' BC_mass', colormap='viridis', vmax=60)
<Axes: xlabel=' Fractional_Day', ylabel=' ALTP'>
../../_images/48bac5674298528d47ac1c93f42961eb71f35d4029407942a2a264cca94ca1e6.png

It is possible to have your colorbar have a log colormap, you just have to switch into matplotlib.

import matplotlib.colors
plt.scatter(naames[' Fractional_Day'], naames[' ALTP'], c=naames[' BC_mass'],
                    cmap='viridis', norm=matplotlib.colors.LogNorm())

plt.colorbar(label='BC Mass')
<matplotlib.colorbar.Colorbar at 0x7f68d2533810>
../../_images/2188ed91b91d1c6eafa349cbaf807c67e6e5cafab763305bd3924d4e0c4579b9.png

Code Summary#

Preprocessing#

import pandas as pd
import geopandas as gpd
import numpy as np
import matplotlib.pyplot as plt
naames = pd.read_csv('./data/naames-mrg01-c130_merge_20151112_R5_thru20151114.csv', skiprows=223)
# Filter down to just 1 day
naames = naames[naames[' Fractional_Day'] < 317]
# Remove NaN values
naames = naames.replace({-999999: np.nan})
# Create geodataframe
naames_gpd = gpd.GeoDataFrame(naames, 
                            geometry=gpd.points_from_xy(naames[' LONGITUDE'], naames[' LATITUDE']), 
                            crs='epsg:4326')

Mapping#

Using geopandas#

fig = plt.figure()
ax = plt.axes()

naames_gpd.plot(ax=ax, column=' ALTP')
<Axes: >
../../_images/bdd24e5794e19fae944f92d011102b41958b89b789fff03eef3f0b2152ef28f7.png

Using cartopy#

# Recreate the plot with the projection as an argument
fig = plt.figure()
ax = plt.axes(projection=crs)

# Add the data
naames_platecarree.plot(ax=ax, column=' ALTP')

# Change the field of view
ax.set_extent([-63, -38, 45, 57])
# Add labels and gridlines
# ax.gridlines(crs=crs, color='grey', linestyle='--', draw_labels=True)

# Add background features
ax.add_feature(cfeature.LAND)
ax.add_feature(cfeature.OCEAN)
ax.add_feature(cfeature.COASTLINE)
ax.add_feature(cfeature.BORDERS, linestyle=':')
ax.add_feature(cfeature.LAKES, alpha=0.5)
ax.add_feature(cfeature.RIVERS)

# Add a title
ax.set_title('Flight Track Altitude')
Text(0.5, 1.0, 'Flight Track Altitude')
Error in callback <function _draw_all_if_interactive at 0x7f68e6861260> (for post_execute), with arguments args (),kwargs {}:
---------------------------------------------------------------------------
KeyboardInterrupt                         Traceback (most recent call last)
File /srv/conda/envs/notebook/lib/python3.11/site-packages/matplotlib/pyplot.py:197, in _draw_all_if_interactive()
    195 def _draw_all_if_interactive() -> None:
    196     if matplotlib.is_interactive():
--> 197         draw_all()

File /srv/conda/envs/notebook/lib/python3.11/site-packages/matplotlib/_pylab_helpers.py:132, in Gcf.draw_all(cls, force)
    130 for manager in cls.get_all_fig_managers():
    131     if force or manager.canvas.figure.stale:
--> 132         manager.canvas.draw_idle()

File /srv/conda/envs/notebook/lib/python3.11/site-packages/matplotlib/backend_bases.py:1893, in FigureCanvasBase.draw_idle(self, *args, **kwargs)
   1891 if not self._is_idle_drawing:
   1892     with self._idle_draw_cntx():
-> 1893         self.draw(*args, **kwargs)

File /srv/conda/envs/notebook/lib/python3.11/site-packages/matplotlib/backends/backend_agg.py:388, in FigureCanvasAgg.draw(self)
    385 # Acquire a lock on the shared font cache.
    386 with (self.toolbar._wait_cursor_for_draw_cm() if self.toolbar
    387       else nullcontext()):
--> 388     self.figure.draw(self.renderer)
    389     # A GUI class may be need to update a window using this draw, so
    390     # don't forget to call the superclass.
    391     super().draw()

File /srv/conda/envs/notebook/lib/python3.11/site-packages/matplotlib/artist.py:95, in _finalize_rasterization.<locals>.draw_wrapper(artist, renderer, *args, **kwargs)
     93 @wraps(draw)
     94 def draw_wrapper(artist, renderer, *args, **kwargs):
---> 95     result = draw(artist, renderer, *args, **kwargs)
     96     if renderer._rasterizing:
     97         renderer.stop_rasterizing()

File /srv/conda/envs/notebook/lib/python3.11/site-packages/matplotlib/artist.py:72, in allow_rasterization.<locals>.draw_wrapper(artist, renderer)
     69     if artist.get_agg_filter() is not None:
     70         renderer.start_filter()
---> 72     return draw(artist, renderer)
     73 finally:
     74     if artist.get_agg_filter() is not None:

File /srv/conda/envs/notebook/lib/python3.11/site-packages/matplotlib/figure.py:3154, in Figure.draw(self, renderer)
   3151         # ValueError can occur when resizing a window.
   3153 self.patch.draw(renderer)
-> 3154 mimage._draw_list_compositing_images(
   3155     renderer, self, artists, self.suppressComposite)
   3157 for sfig in self.subfigs:
   3158     sfig.draw(renderer)

File /srv/conda/envs/notebook/lib/python3.11/site-packages/matplotlib/image.py:132, in _draw_list_compositing_images(renderer, parent, artists, suppress_composite)
    130 if not_composite or not has_images:
    131     for a in artists:
--> 132         a.draw(renderer)
    133 else:
    134     # Composite any adjacent images together
    135     image_group = []

File /srv/conda/envs/notebook/lib/python3.11/site-packages/matplotlib/artist.py:72, in allow_rasterization.<locals>.draw_wrapper(artist, renderer)
     69     if artist.get_agg_filter() is not None:
     70         renderer.start_filter()
---> 72     return draw(artist, renderer)
     73 finally:
     74     if artist.get_agg_filter() is not None:

File /srv/conda/envs/notebook/lib/python3.11/site-packages/cartopy/mpl/geoaxes.py:535, in GeoAxes.draw(self, renderer, **kwargs)
    530         self.imshow(img, extent=extent, origin=origin,
    531                     transform=factory.crs, *factory_args[1:],
    532                     **factory_kwargs)
    533 self._done_img_factory = True
--> 535 return super().draw(renderer=renderer, **kwargs)

File /srv/conda/envs/notebook/lib/python3.11/site-packages/matplotlib/artist.py:72, in allow_rasterization.<locals>.draw_wrapper(artist, renderer)
     69     if artist.get_agg_filter() is not None:
     70         renderer.start_filter()
---> 72     return draw(artist, renderer)
     73 finally:
     74     if artist.get_agg_filter() is not None:

File /srv/conda/envs/notebook/lib/python3.11/site-packages/matplotlib/axes/_base.py:3070, in _AxesBase.draw(self, renderer)
   3067 if artists_rasterized:
   3068     _draw_rasterized(self.figure, artists_rasterized, renderer)
-> 3070 mimage._draw_list_compositing_images(
   3071     renderer, self, artists, self.figure.suppressComposite)
   3073 renderer.close_group('axes')
   3074 self.stale = False

File /srv/conda/envs/notebook/lib/python3.11/site-packages/matplotlib/image.py:132, in _draw_list_compositing_images(renderer, parent, artists, suppress_composite)
    130 if not_composite or not has_images:
    131     for a in artists:
--> 132         a.draw(renderer)
    133 else:
    134     # Composite any adjacent images together
    135     image_group = []

File /srv/conda/envs/notebook/lib/python3.11/site-packages/matplotlib/artist.py:72, in allow_rasterization.<locals>.draw_wrapper(artist, renderer)
     69     if artist.get_agg_filter() is not None:
     70         renderer.start_filter()
---> 72     return draw(artist, renderer)
     73 finally:
     74     if artist.get_agg_filter() is not None:

File /srv/conda/envs/notebook/lib/python3.11/site-packages/cartopy/mpl/feature_artist.py:186, in FeatureArtist.draw(self, renderer, *args, **kwargs)
    184 if geom_paths is None:
    185     if ax.projection != feature_crs:
--> 186         projected_geom = ax.projection.project_geometry(
    187             geom, feature_crs)
    188     else:
    189         projected_geom = geom

File /srv/conda/envs/notebook/lib/python3.11/site-packages/cartopy/crs.py:817, in Projection.project_geometry(self, geometry, src_crs)
    815 if not method_name:
    816     raise ValueError(f'Unsupported geometry type {geom_type!r}')
--> 817 return getattr(self, method_name)(geometry, src_crs)

File /srv/conda/envs/notebook/lib/python3.11/site-packages/cartopy/crs.py:933, in Projection._project_multipolygon(self, geometry, src_crs)
    931 geoms = []
    932 for geom in geometry.geoms:
--> 933     r = self._project_polygon(geom, src_crs)
    934     if r:
    935         geoms.extend(r.geoms)

File /srv/conda/envs/notebook/lib/python3.11/site-packages/cartopy/crs.py:960, in Projection._project_polygon(self, polygon, src_crs)
    958 multi_lines = []
    959 for src_ring in [polygon.exterior] + list(polygon.interiors):
--> 960     p_rings, p_mline = self._project_linear_ring(src_ring, src_crs)
    961     if p_rings:
    962         rings.extend(p_rings)

File /srv/conda/envs/notebook/lib/python3.11/site-packages/cartopy/crs.py:836, in Projection._project_linear_ring(self, linear_ring, src_crs)
    831 debug = False
    832 # 1) Resolve the initial lines into projected segments
    833 # 1abc
    834 # def23ghi
    835 # jkl41
--> 836 multi_line_string = cartopy.trace.project_linear(linear_ring,
    837                                                  src_crs, self)
    839 # Threshold for whether a point is close enough to be the same
    840 # point as another.
    841 threshold = max(np.abs(self.x_limits + self.y_limits)) * 1e-5

File lib/cartopy/trace.pyx:589, in cartopy.trace.project_linear()

File lib/cartopy/trace.pyx:483, in cartopy.trace._project_segment()

File lib/cartopy/trace.pyx:414, in cartopy.trace.bisect()

File lib/cartopy/trace.pyx:377, in cartopy.trace.straightAndDomain()

File /srv/conda/envs/notebook/lib/python3.11/site-packages/shapely/prepared.py:45, in PreparedGeometry.covers(self, other)
     43 def covers(self, other):
     44     """Returns True if the geometry covers the other, else False"""
---> 45     return self.context.covers(other)

File /srv/conda/envs/notebook/lib/python3.11/site-packages/shapely/geometry/base.py:667, in BaseGeometry.covers(self, other)
    665 def covers(self, other):
    666     """Returns True if the geometry covers the other, else False"""
--> 667     return _maybe_unpack(shapely.covers(self, other))

File /srv/conda/envs/notebook/lib/python3.11/site-packages/shapely/geometry/base.py:72, in _maybe_unpack(result)
     69 def _maybe_unpack(result):
     70     if result.ndim == 0:
     71         # convert numpy 0-d array / scalar to python scalar
---> 72         return result.item()
     73     else:
     74         # >=1 dim array
     75         return result

KeyboardInterrupt: