Some of the Python scripts that I write would be categorised as “geospatial themed”. These scripts all share a common methodology: use Shapely to store the points/lines/polygons; use Cartopy to handle map projections; and use MatPlotLib to plot the output. Shapely is an excellent and powerful Python module to handle geometries, it is able to perform tasks such as buffering and unions, which I find very handy. Shapely is very generic: it just describes points/lines/polygons as a series of numbers without units. Here lies the problem: if you wanted to define a point using longitude and latitude, and then find the circle on the surface of the Earth that had a 1 kilometre radius around that point, then Shapely cannot do that for you (as that task would involve mixing units). If you wanted to find the circle on the surface of the Earth that had a 1 degree radius around that point, then Shapely can do that for you but I am not sure why anyone would want that.
§2 Euclidean Space -vs- Geodesic Space
One of the tasks that Shapely is able to perform is buffering, which is the process of enlarging an object by a constant distance. Unfortunately, Shapely has no knowledge of degrees or metres, and so the point in the earlier example would be enlarged in all directions by X [units that the point is defined by], in this case: X degrees, rather than X metres. The relationship between degrees and metres on the surface of the Earth changes depending on where you are and what direction you are going. In mathematics, we call these two different spacesEuclidean space and Geodesic space: in the earlier example Shapely buffers the point in Euclidean space by a constant distance (in degrees) in all directions, yet I want to buffer the point in Geodesic space by a constant distance (in metres).
§3 Great Circles
This difference is best highlighted by aeroplanes: aeroplanes fly in a straight line from point A to point B over the surface of the Earth. Depending on the map projection that is used, this line appears curved. These lines are called great circles. For example, consider the flight from London to San Francisco:
Here is the Python script to create the above map:
#!/usr/bin/env python3# Use the proper idiom in the main module ...# NOTE: See https://docs.python.org/3.12/library/multiprocessing.html#the-spawn-and-forkserver-start-methodsif__name__=="__main__":# Import standard modules ...importos# Import special modules ...try:importcartopycartopy.config.update({"cache_dir":os.path.expanduser("~/.local/share/cartopy_cache"),})importcartopy.geodesicexcept:raiseException("\"cartopy\" is not installed; run \"pip install --user Cartopy\"")fromNonetry:importmatplotlibmatplotlib.rcParams.update({"backend":"Agg",# NOTE: See https://matplotlib.org/stable/gallery/user_interfaces/canvasagg.html"figure.dpi":300,"figure.figsize":(9.6,7.2),# NOTE: See https://github.com/Guymer/misc/blob/main/README.md#matplotlib-figure-sizes"font.size":8,})importmatplotlib.pyplotexcept:raiseException("\"matplotlib\" is not installed; run \"pip install --user matplotlib\"")fromNonetry:importnumpyexcept:raiseException("\"numpy\" is not installed; run \"pip install --user numpy\"")fromNonetry:importshapelyimportshapely.geometryexcept:raiseException("\"shapely\" is not installed; run \"pip install --user Shapely\"")fromNone# Import my modules ...try:importpyguymer3importpyguymer3.geoimportpyguymer3.imageexcept:raiseException("\"pyguymer3\" is not installed; you need to have the Python module from https://github.com/Guymer/PyGuymer3 located somewhere in your $PYTHONPATH")fromNone# **************************************************************************# Define point ...lonA=-0.4619# [°]latA=51.4706# [°]# Define point ...lonB=-122.3750# [°]latB=37.6180# [°]# Define interpolation parameters ...npoint=99# **************************************************************************# Create the Euclidean line ...lons=numpy.linspace(lonA,lonB,num=npoint)# [°]lats=numpy.linspace(latA,latB,num=npoint)# [°]points=[]foripointinrange(npoint):points.append((lons[ipoint],lats[ipoint]))euclideanLine=shapely.geometry.linestring.LineString(points)euclideanCoords=numpy.array(euclideanLine.coords)# [°]# Create Geodesic line ...geodesicLine=pyguymer3.geo.great_circle(lonA,latA,lonB,latB,npoint=npoint,)geodesicCoords=numpy.array(geodesicLine.coords)# [°]# **************************************************************************# Find the Euclidean and Geodetic distances of the Euclidean line ...euclideanDist=0.0# [°]geodeticDist=0.0# [m]foripointinrange(npoint-1):euclideanDist+=numpy.hypot(euclideanCoords[ipoint+1,0]-euclideanCoords[ipoint,0],euclideanCoords[ipoint+1,1]-euclideanCoords[ipoint,1],)# [°]geodeticDist+=pyguymer3.geo.calc_dist_between_two_locs(euclideanCoords[ipoint,0],euclideanCoords[ipoint,1],euclideanCoords[ipoint+1,0],euclideanCoords[ipoint+1,1],)[0]# [m]print(f"The shortest line in Euclidean space is {euclideanDist:,.1f}° long and {geodeticDist:,.1f}m long.")# Find the Euclidean and Geodetic distances of the Geodesic line ...euclideanDist=0.0# [°]geodeticDist=0.0# [m]foripointinrange(npoint-1):euclideanDist+=numpy.hypot(geodesicCoords[ipoint+1,0]-geodesicCoords[ipoint,0],geodesicCoords[ipoint+1,1]-geodesicCoords[ipoint,1],)# [°]geodeticDist+=pyguymer3.geo.calc_dist_between_two_locs(geodesicCoords[ipoint,0],geodesicCoords[ipoint,1],geodesicCoords[ipoint+1,0],geodesicCoords[ipoint+1,1],)[0]# [m]print(f"The shortest line in Geodesic space is {euclideanDist:,.1f}° long and {geodeticDist:,.1f}m long.")# **************************************************************************# Create figure ...fg=matplotlib.pyplot.figure()# Create axis ...ax1=pyguymer3.geo.add_axis(fg,index=1,ncols=2,nrows=2,)# Create axis ...ax2=pyguymer3.geo.add_axis(fg,index=2,lat=geodesicCoords[(npoint-1)//2,1],lon=geodesicCoords[(npoint-1)//2,0],ncols=2,nrows=2,)# Create axis ...ax3=fg.add_subplot(2,2,(3,4),)# Configure axis ...pyguymer3.geo.add_map_background(ax1,resolution="large8192px")# Configure axis ...pyguymer3.geo.add_map_background(ax2,resolution="large8192px")# Configure axis ...ax3.grid()ax3.set_aspect("equal")ax3.set_xlabel("Longitude [°]")ax3.set_xlim(-180.0,+180.0)ax3.set_xticks(range(-180,225,45))ax3.set_ylabel("Latitude [°]")ax3.set_ylim(-90.0,+90.0)ax3.set_yticks(range(-90,135,45))# Plot Euclidean line thrice ...ax1.add_geometries([euclideanLine],cartopy.crs.PlateCarree(),edgecolor=(1.0,0.0,0.0,1.0),facecolor="none",linewidth=1.0,)ax2.add_geometries([euclideanLine],cartopy.crs.PlateCarree(),edgecolor=(1.0,0.0,0.0,1.0),facecolor="none",linewidth=1.0,)ax3.plot(euclideanCoords[:,0],euclideanCoords[:,1],color=(1.0,0.0,0.0,1.0),linewidth=1.0,)# Plot Geodesic line thrice ...ax1.add_geometries([geodesicLine],cartopy.crs.PlateCarree(),edgecolor=(0.0,0.0,1.0,1.0),facecolor="none",linewidth=1.0,)ax2.add_geometries([geodesicLine],cartopy.crs.PlateCarree(),edgecolor=(0.0,0.0,1.0,1.0),facecolor="none",linewidth=1.0,)ax3.plot(geodesicCoords[:,0],geodesicCoords[:,1],color=(0.0,0.0,1.0,1.0),linewidth=1.0,)# Configure figure ...fg.suptitle(f"Flight from ({lonA:.1f}°,{latA:.1f}°) to ({lonB:.1f}°,{latB:.1f}°)\nshortest Euclidean distance in red; shortest Geodetic distance in blue")fg.tight_layout()# Save figure ...fg.savefig("flight.png")matplotlib.pyplot.close(fg)# Optimise PNG ...pyguymer3.image.optimize_image("flight.png",strip=True)
In the above map, the red line is the line connecting (-0.4619°,51.4706°) and (-122.3750°,37.6180°) in Euclidean space, i.e., treating all degrees as being of equal size in all directions. The blue line is the line connecting (-0.4619°,51.4706°) and (-122.3750°,37.6180°) in Geodesic space, i.e., having knowledge that the degrees actually describe a location on the surface of a spheroid and that degrees have different physical size depending on where you are and which direction you are pointing. The above script also reports that:
The shortest line in Euclidean space (in red) is 122.7° long and 9,786,917.7m long.
The shortest line in Geodesic space (in blue) is 132.1° long and 8,638,329.4m long.
This demonstrates why aeroplanes fly the large arcs that they do: the red line is 1,148,588.3 metres shorter than the blue line, but it is also 9.4° longer. As a passenger on an aeroplane, which one of those two differences is more important to you?
§4 Tissot’s Indicatrix
In the above map, the red and blue lines appear differently depending on the map projection that is used. No map projection is perfect, just see xkcd for some hilarious examples. As a way of displaying how distorted, or not, a map projection is Tissot’s indicatrix are sometimes displayed on a map. These are simply circles which have a constant radius in metres and which, therefore, appear distorted on the surface of the Earth. Cartopy has an example script which displays Tissot’s indicatrix and produces the below map.
Here is the Python script to create the above map:
#!/usr/bin/env python3"""Tissot's Indicatrix-------------------Visualize Tissot's indicatrix on a map."""importmatplotlib.pyplotaspltimportcartopy.crsasccrsdefmain():fg=plt.figure(figsize=(10,5))ax=fg.add_subplot(1,1,1,projection=ccrs.PlateCarree())# make the map global rather than have it zoom in to# the extents of any plotted dataax.set_global()ax.stock_img()ax.coastlines()ax.tissot(facecolor="orange",alpha=0.4)plt.show()if__name__=="__main__":main()
… and now all the way back to me mentioning Vincenty’s formulae in the introduction. Vincenty’s formulae are iterating functions which perform two tasks:
given two points defined using longitude and latitude in degrees: find the bearing from the first to the second in degrees; find the bearing from the second to the first in degrees; and find the distance between the two in metres (a.k.a “the inverse problem”)
given a starting point defined using longitude and latitude in degrees and a bearing in degrees and a distance in metres: find the end point’s longitude and latitude in degrees and the bearing back to the start in degrees (a.k.a “the direct problem”)
If you wanted to create your own Tissot’s indicatrix manually then all you would need to do is pick a starting location and call “the direct problem” of Vincenty’s formulae for a list of bearings (with a constant distance) to obtain a list of end locations. These end locations are the circumference of a circle used in Tissot’s indicatrix. You can then repeat the process for different starting locations to populate the whole map projection.
#!/usr/bin/env python3# Use the proper idiom in the main module ...# NOTE: See https://docs.python.org/3.12/library/multiprocessing.html#the-spawn-and-forkserver-start-methodsif__name__=="__main__":# Import standard modules ...importos# Import special modules ...try:importcartopycartopy.config.update({"cache_dir":os.path.expanduser("~/.local/share/cartopy_cache"),})importcartopy.geodesicexcept:raiseException("\"cartopy\" is not installed; run \"pip install --user Cartopy\"")fromNonetry:importmatplotlibmatplotlib.rcParams.update({"backend":"Agg",# NOTE: See https://matplotlib.org/stable/gallery/user_interfaces/canvasagg.html"figure.dpi":300,"figure.figsize":(9.6,7.2),# NOTE: See https://github.com/Guymer/misc/blob/main/README.md#matplotlib-figure-sizes"font.size":8,})importmatplotlib.pyplotexcept:raiseException("\"matplotlib\" is not installed; run \"pip install --user matplotlib\"")fromNonetry:importnumpyexcept:raiseException("\"numpy\" is not installed; run \"pip install --user numpy\"")fromNonetry:importshapelyimportshapely.geometryexcept:raiseException("\"shapely\" is not installed; run \"pip install --user Shapely\"")fromNone# Import my modules ...try:importpyguymer3importpyguymer3.geoimportpyguymer3.imageexcept:raiseException("\"pyguymer3\" is not installed; you need to have the Python module from https://github.com/Guymer/PyGuymer3 located somewhere in your $PYTHONPATH")fromNone# **************************************************************************# Define point ...lon=-9.144020# [°]lat=38.719144# [°]# Define buffering parameters ...dist=1000.0e3# [m]nang=9# Create an array based off the scalars ...pnt=numpy.zeros((1,2),dtype=numpy.float64)# [°]pnt[0,0]=lon# [°]pnt[0,1]=lat# [°]# **************************************************************************# Buffer point using Cartopy ...# NOTE: Cartopy does not close the loop, so I must use one fewer points# during the buffer and then manually add the 12 o'clock position on# the end.# NOTE: Cartopy just passes the list of point directly to Shapely when# making a Tissot circle, see:# * https://scitools.org.uk/cartopy/docs/latest/_modules/cartopy/mpl/geoaxes.html#GeoAxes.tissotring1=cartopy.geodesic.Geodesic().circle(lon,lat,dist,n_samples=nang-1,)# [°]ring1=numpy.concatenate([ring1,[ring1[0,:]]])# [°]poly1=shapely.geometry.Polygon(ring1)# Buffer point using PyGuymer3 ...ring2=pyguymer3.geo._buffer_points_crudely(# pylint: disable=W0212pnt,dist,nang,)[0,:,:]# [°]poly2=shapely.geometry.Polygon(ring2)# **************************************************************************# Create figure ...fg=matplotlib.pyplot.figure()# Create axis ...ax1=pyguymer3.geo.add_axis(fg,index=1,ncols=2,nrows=2,)# Create axis ...ax2=pyguymer3.geo.add_axis(fg,index=2,lat=lat,lon=lon,ncols=2,nrows=2,)# Create axis ...ax3=fg.add_subplot(2,2,(3,4),)# Configure axis ...pyguymer3.geo.add_map_background(ax1,resolution="large8192px")# Configure axis ...pyguymer3.geo.add_map_background(ax2,resolution="large8192px")# Configure axis ...ax3.grid()ax3.set_aspect("equal")ax3.set_xlabel("Longitude [°]")ax3.set_xlim(-180.0,+180.0)ax3.set_xticks(range(-180,225,45))ax3.set_ylabel("Latitude [°]")ax3.set_ylim(-90.0,+90.0)ax3.set_yticks(range(-90,135,45))# Plot Cartopy buffer thrice ...ax1.plot(ring1[:,0],ring1[:,1],color=(1.0,0.0,0.0,1.0),linestyle="--",marker="o",transform=cartopy.crs.PlateCarree(),)ax1.add_geometries([poly1],cartopy.crs.PlateCarree(),edgecolor="none",facecolor=(1.0,0.0,0.0,0.5),linewidth=1.0,)ax2.plot(ring1[:,0],ring1[:,1],color=(1.0,0.0,0.0,1.0),linestyle="--",marker="o",transform=cartopy.crs.PlateCarree(),)ax2.add_geometries([poly1],cartopy.crs.PlateCarree(),edgecolor="none",facecolor=(1.0,0.0,0.0,0.5),linewidth=1.0,)ax3.plot(ring1[:,0],ring1[:,1],color=(1.0,0.0,0.0,1.0),linestyle="--",marker="o",)# Plot PyGuymer3 buffer thrice ...ax1.plot(ring2[:,0],ring2[:,1],color=(0.0,0.0,1.0,1.0),transform=cartopy.crs.PlateCarree(),)ax1.add_geometries([poly2],cartopy.crs.PlateCarree(),edgecolor="none",facecolor=(0.0,0.0,1.0,0.5),linewidth=1.0,)ax2.plot(ring2[:,0],ring2[:,1],color=(0.0,0.0,1.0,1.0),transform=cartopy.crs.PlateCarree(),)ax2.add_geometries([poly2],cartopy.crs.PlateCarree(),edgecolor="none",facecolor=(0.0,0.0,1.0,0.5),linewidth=1.0,)ax3.plot(ring2[:,0],ring2[:,1],color=(0.0,0.0,1.0,1.0),)# Configure figure ...fg.suptitle(f"({lon:.1f}°,{lat:.1f}°) buffered by {0.001*dist:,.1f} km\nCartopy in red; PyGuymer3 in blue")fg.tight_layout()# Save figure ...fg.savefig("lisbon.png")matplotlib.pyplot.close(fg)# Optimise PNG ...pyguymer3.image.optimize_image("lisbon.png",strip=True)
#!/usr/bin/env python3# Use the proper idiom in the main module ...# NOTE: See https://docs.python.org/3.12/library/multiprocessing.html#the-spawn-and-forkserver-start-methodsif__name__=="__main__":# Import standard modules ...importos# Import special modules ...try:importcartopycartopy.config.update({"cache_dir":os.path.expanduser("~/.local/share/cartopy_cache"),})importcartopy.geodesicexcept:raiseException("\"cartopy\" is not installed; run \"pip install --user Cartopy\"")fromNonetry:importmatplotlibmatplotlib.rcParams.update({"backend":"Agg",# NOTE: See https://matplotlib.org/stable/gallery/user_interfaces/canvasagg.html"figure.dpi":300,"figure.figsize":(9.6,7.2),# NOTE: See https://github.com/Guymer/misc/blob/main/README.md#matplotlib-figure-sizes"font.size":8,})importmatplotlib.pyplotexcept:raiseException("\"matplotlib\" is not installed; run \"pip install --user matplotlib\"")fromNonetry:importnumpyexcept:raiseException("\"numpy\" is not installed; run \"pip install --user numpy\"")fromNonetry:importshapelyimportshapely.geometryexcept:raiseException("\"shapely\" is not installed; run \"pip install --user Shapely\"")fromNone# Import my modules ...try:importpyguymer3importpyguymer3.geoimportpyguymer3.imageexcept:raiseException("\"pyguymer3\" is not installed; you need to have the Python module from https://github.com/Guymer/PyGuymer3 located somewhere in your $PYTHONPATH")fromNone# **************************************************************************# Define point ...lon=170.0# [°]lat=10.0# [°]# Define buffering parameters ...dist=4000.0e3# [m]nang=9# Create an array based off the scalars ...pnt=numpy.zeros((1,2),dtype=numpy.float64)# [°]pnt[0,0]=lon# [°]pnt[0,1]=lat# [°]# **************************************************************************# Buffer point using Cartopy ...# NOTE: Cartopy does not close the loop, so I must use one fewer points# during the buffer and then manually add the 12 o'clock position on# the end.# NOTE: Cartopy just passes the list of point directly to Shapely when# making a Tissot circle, see:# * https://scitools.org.uk/cartopy/docs/latest/_modules/cartopy/mpl/geoaxes.html#GeoAxes.tissotring1=cartopy.geodesic.Geodesic().circle(lon,lat,dist,n_samples=nang-1,)# [°]ring1=numpy.concatenate([ring1,[ring1[0,:]]])# [°]poly1=shapely.geometry.Polygon(ring1)# Buffer point using PyGuymer3 ...ring2=pyguymer3.geo._buffer_points_crudely(# pylint: disable=W0212pnt,dist,nang,)[0,:,:]# [°]poly2=shapely.geometry.Polygon(ring2)# **************************************************************************# Create figure ...fg=matplotlib.pyplot.figure()# Create axis ...ax1=pyguymer3.geo.add_axis(fg,index=1,ncols=2,nrows=2,)# Create axis ...ax2=pyguymer3.geo.add_axis(fg,index=2,lat=lat,lon=lon,ncols=2,nrows=2,)# Create axis ...ax3=fg.add_subplot(2,2,(3,4),)# Configure axis ...pyguymer3.geo.add_map_background(ax1,resolution="large8192px")# Configure axis ...pyguymer3.geo.add_map_background(ax2,resolution="large8192px")# Configure axis ...ax3.grid()ax3.set_aspect("equal")ax3.set_xlabel("Longitude [°]")ax3.set_xlim(-180.0,+180.0)ax3.set_xticks(range(-180,225,45))ax3.set_ylabel("Latitude [°]")ax3.set_ylim(-90.0,+90.0)ax3.set_yticks(range(-90,135,45))# Plot Cartopy buffer thrice ...ax1.plot(ring1[:,0],ring1[:,1],color=(1.0,0.0,0.0,1.0),linestyle="--",marker="o",transform=cartopy.crs.PlateCarree(),)ax1.add_geometries([poly1],cartopy.crs.PlateCarree(),edgecolor="none",facecolor=(1.0,0.0,0.0,0.5),linewidth=1.0,)ax2.plot(ring1[:,0],ring1[:,1],color=(1.0,0.0,0.0,1.0),linestyle="--",marker="o",transform=cartopy.crs.PlateCarree(),)ax2.add_geometries([poly1],cartopy.crs.PlateCarree(),edgecolor="none",facecolor=(1.0,0.0,0.0,0.5),linewidth=1.0,)ax3.plot(ring1[:,0],ring1[:,1],color=(1.0,0.0,0.0,1.0),linestyle="--",marker="o",)# Plot PyGuymer3 buffer thrice ...ax1.plot(ring2[:,0],ring2[:,1],color=(0.0,0.0,1.0,1.0),transform=cartopy.crs.PlateCarree(),)ax1.add_geometries([poly2],cartopy.crs.PlateCarree(),edgecolor="none",facecolor=(0.0,0.0,1.0,0.5),linewidth=1.0,)ax2.plot(ring2[:,0],ring2[:,1],color=(0.0,0.0,1.0,1.0),transform=cartopy.crs.PlateCarree(),)ax2.add_geometries([poly2],cartopy.crs.PlateCarree(),edgecolor="none",facecolor=(0.0,0.0,1.0,0.5),linewidth=1.0,)ax3.plot(ring2[:,0],ring2[:,1],color=(0.0,0.0,1.0,1.0),)# Configure figure ...fg.suptitle(f"({lon:.1f}°,{lat:.1f}°) buffered by {0.001*dist:,.1f} km\nCartopy in red; PyGuymer3 in blue")fg.tight_layout()# Save figure ...fg.savefig("pacific.png")matplotlib.pyplot.close(fg)# Optimise PNG ...pyguymer3.image.optimize_image("pacific.png",strip=True)
Shapely tries to make a polygon from the list of points along the ring, but it has no idea about the anti-meridian and so whilst it does make a mathematically correct polygon it is certainly not the one that the user desired. In summary, Vincenty’s formulae correctly return the longitudes and latitudes of all of the points along a ring which is a fixed distance (in metres) around a point but assembling these points into a polygon in Shapely is not trivial when the ring crosses either the anti-meridian or one of the poles.
Therefore, a function needs writing which takes the raw list of longitude and latitude pairs and uses them to create a polygon for the user. This function needs to be clever enough to return two individual polygons when the ring around a point crosses either the anti-meridian or one of the poles. I wrote a function to perform this task and it served me well for numerous years, such as:
However, it was not completely robust and as I worked on ever more complex projects I found sets of input values which would break it. I believe that my new function pyguymer3.geo._points2polys() works more robustly than any of the other ones that I have written over the years. For example, see how it fixes the buffered point in the Pacific Ocean (from above) by returning two unconnected polygons:
Here is the Python script to create the above map:
#!/usr/bin/env python3# Use the proper idiom in the main module ...# NOTE: See https://docs.python.org/3.12/library/multiprocessing.html#the-spawn-and-forkserver-start-methodsif__name__=="__main__":# Import standard modules ...importos# Import special modules ...try:importcartopycartopy.config.update({"cache_dir":os.path.expanduser("~/.local/share/cartopy_cache"),})except:raiseException("\"cartopy\" is not installed; run \"pip install --user Cartopy\"")fromNonetry:importmatplotlibmatplotlib.rcParams.update({"backend":"Agg",# NOTE: See https://matplotlib.org/stable/gallery/user_interfaces/canvasagg.html"figure.dpi":300,"figure.figsize":(9.6,7.2),# NOTE: See https://github.com/Guymer/misc/blob/main/README.md#matplotlib-figure-sizes"font.size":8,})importmatplotlib.pyplotexcept:raiseException("\"matplotlib\" is not installed; run \"pip install --user matplotlib\"")fromNonetry:importnumpyexcept:raiseException("\"numpy\" is not installed; run \"pip install --user numpy\"")fromNonetry:importshapelyimportshapely.geometryexcept:raiseException("\"shapely\" is not installed; run \"pip install --user Shapely\"")fromNone# Import my modules ...try:importpyguymer3importpyguymer3.geoimportpyguymer3.imageexcept:raiseException("\"pyguymer3\" is not installed; you need to have the Python module from https://github.com/Guymer/PyGuymer3 located somewhere in your $PYTHONPATH")fromNone# **************************************************************************# Define point ...lon=170.0# [°]lat=10.0# [°]# Define buffering parameters ...dist=4000.0e3# [m]nang=9# Create a point based off the scalars ...pnt=shapely.geometry.point.Point(lon,lat)# **************************************************************************# Buffer point using PyGuymer3 ...multipoly=pyguymer3.geo.buffer(pnt,dist,fill=1.0,nang=nang,simp=-1.0,)# **************************************************************************# Create figure ...fg=matplotlib.pyplot.figure()# Create axis ...ax1=pyguymer3.geo.add_axis(fg,index=1,ncols=2,nrows=2,)# Create axis ...ax2=pyguymer3.geo.add_axis(fg,index=2,lat=lat,lon=lon,ncols=2,nrows=2,)# Create axis ...ax3=fg.add_subplot(2,2,(3,4),)# Configure axis ...pyguymer3.geo.add_map_background(ax1,resolution="large8192px",)# Configure axis ...pyguymer3.geo.add_map_background(ax2,resolution="large8192px",)# Configure axis ...ax3.grid()ax3.set_aspect("equal")ax3.set_xlabel("Longitude [°]")ax3.set_xlim(-180.0,+180.0)ax3.set_xticks(range(-180,225,45))ax3.set_ylabel("Latitude [°]")ax3.set_ylim(-90.0,+90.0)ax3.set_yticks(range(-90,135,45))# Plot PyGuymer3 buffer thrice ...ax1.add_geometries(pyguymer3.geo.extract_polys(multipoly),cartopy.crs.PlateCarree(),edgecolor=(0.0,1.0,0.0,1.0),facecolor=(0.0,1.0,0.0,0.5),linewidth=1.0,)ax2.add_geometries(pyguymer3.geo.extract_polys(multipoly),cartopy.crs.PlateCarree(),edgecolor=(0.0,1.0,0.0,1.0),facecolor=(0.0,1.0,0.0,0.5),linewidth=1.0,)forpolyinpyguymer3.geo.extract_polys(multipoly):coords=numpy.array(poly.exterior.coords)# [°]ax3.plot(coords[:,0],coords[:,1],color=(0.0,1.0,0.0,1.0),)# Configure figure ...fg.suptitle(f"({lon:.1f}°,{lat:.1f}°) buffered by {0.001*dist:,.1f} km\nPyGuymer3 in green")fg.tight_layout()# Save figure ...fg.savefig("working.png")matplotlib.pyplot.close(fg)# Optimise PNG ...pyguymer3.image.optimize_image("working.png",strip=True)
my Python 3 module contains a couple of test scripts (“bufferPoint” and “buffer”) which have slowly ballooned to contain new inputs which demonstrate previous shortcomings and which hope to guard against future regressions in robustness. After growing tired of adding yet more examples to these scripts, I decided to write a third script which just loops over all combinations of longitude and latitude on Earth in an effort to pre-emptively catch further errors: “animateBufferPoint”. I am pleased to include the video below, which has been generated by this testing script of my new function:
As you can see, across all locations on the surface of the Earth, the function is able to take the longitude and latitude pairs and correctly generate one or two Shapely polygons which correctly describe what the user is expecting: a perfect circle around a point with a fixed radius, defined in metres. Whether this point is near to one of the Poles or near to the anti-meridian is irrelevant: when viewed from an orthographic projection above the point then the circle appears as a circle. There are no glitches or weird bumps or excursions in the polygon(s).
Buffering a polygon is similarly straightforward: just split the polygon up into its individual points (on its exterior and its interiors) then return the union of these buffers with the original polygon.
§8 Summary
In summary, after over five years of writing “geospatial themed” Python scripts, I believe that I now have a robust Python function which can return one or two Shapely polygons of the buffer around a point on the surface of the Earth. With the tests scripts that are part of my my Python 3 module module, I believe that this function (and its wrappers) works correctly for all locations on the surface of the Earth.