Practical 5 Code Solution

Result Map

week5.py

"""
Understanding GIS: Practical 5
@author jonnyhuck

Calculate the distortion on a projected map

References:
    Canters et al. (2005):         http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.557.5040&rep=rep1&type=pdf
	Gosling & Symeonakis (2020):   https://www.tandfonline.com/doi/pdf/10.1080/15230406.2020.1717379
    Huck (2025):                   https://www.tandfonline.com/doi/full/10.1080/00087041.2024.2436324
    https://pyproj4.github.io/pyproj/stable/examples.html
    https://pyproj4.github.io/pyproj/stable/api/crs/crs.html
    https://pyproj4.github.io/pyproj/stable/api/transformer.html
    https://pyproj4.github.io/pyproj/stable/api/geod.html

New Topics:
    Translating Equations & Text into code
    Main Function
    Random number generation
    Default arguments 
    Return multiple values
    zip and enumerate
    //
    Multiple Axes / Multi dimensional arrays
    list slicing
"""
from numpy import arange
from geopandas import read_file
from numpy.random import uniform
from shapely.geometry import Polygon
from matplotlib.patches import Patch
from math import hypot, sin, cos, radians
from pyproj import Geod, CRS, Transformer
from matplotlib.pyplot import subplots, savefig
from matplotlib_scalebar.scalebar import ScaleBar

def make_bounds_square(ax):
    """
    * Adjust the bounds of the specified axis to make them for to a square
    """
    # get the current bounds
    ax_bounds_x = ax.get_xlim()
    ax_bounds_y = ax.get_ylim()

    # get the width and height
    ax_width = ax_bounds_x[1] - ax_bounds_x[0]
    ax_height = ax_bounds_y[1] - ax_bounds_y[0]
    
    # if width is larger, expand height to match
    if ax_width > ax_height:
        buffer = (ax_width - ax_height) / 2
        my_axs[axx][axy].set_ylim((ax_bounds_y[0] - buffer, ax_bounds_y[1] + buffer))
    
    # if height is larger expand width to match
    elif ax_width < ax_height:
        buffer = (ax_height - ax_width) / 2
        my_axs[axx][axy].set_xlim((ax_bounds_x[0] - buffer, ax_bounds_x[1] + buffer))


def offset(x, y, distance, direction):
    """
    * Offset a location by a given distance and direction
    """
    x2 = x + cos(radians(direction)) * distance
    y2 = y + sin(radians(direction)) * distance
    return (x2, y2)


def evaluate_distortion(g, transformer, minx, miny, maxx, maxy, minr, maxr, sample_number, vertices=16):
    """
    * Calculate a selection of distortion measures, see Huck (2025)
    """

    ''' FINITE AREAL AND SHAPE DISTORTION '''

    # calculate the required number of random locations (x and y separately) plus radius
    xs = uniform(low=minx, high=maxx, size=sample_number)
    ys = uniform(low=miny, high=maxy, size=sample_number)
    rs = uniform(low=minr, high=maxr, size=sample_number)

    # offset distances
    forward_azimuths = arange(0, 360, 22.5)

    # loop through the points
    area_indices = []
    shape_indices = []
    distance_indices = []
    for x, y, r in zip(xs, ys, rs):

        # construct a circle around the centre point on the ellipsoid
        lons, lats = g.fwd([x]*vertices, [y]*vertices, forward_azimuths, [r]*vertices)[:2]

        # project the result, calculate area, append to the list
        e_coords = [ transformer.transform(lon, lat, direction='FORWARD') for lon, lat in zip(lons, lats) ]
        ellipsoidal_area = Polygon(e_coords).area

        # transform the centre point to the projected CRS
        px, py = transformer.transform(x, y, direction='FORWARD')

        # construct a circle around the projected point on a plane, calculate area
        planar_area = Polygon([ offset(px, py, r, az) for az in forward_azimuths ]).area

        # get area index
        area_indices.append(abs(ellipsoidal_area - planar_area) / abs(ellipsoidal_area + planar_area))

        # get radial distances frpm the centre to each of the 16 points on the circle
        ellipsoidal_radial_distances = [ hypot(px - ex, py - ey) for ex, ey in e_coords ]

        # get the absolute proportional difference between the expected and actual radial distance for each 'spoke'
        shape_distortion = [abs((1 / vertices) - (d / sum(ellipsoidal_radial_distances))) for d in ellipsoidal_radial_distances]
        shape_indices.append(sum(shape_distortion))

    # calculate areal & shape distortion
    Ea = 1 / sample_number * sum(area_indices)      # as per equation
    Es = sum(shape_indices) / len(shape_indices)    # mean value


    ''' FINITE DISTANCE DISTORTION '''

    # loop once per sample required
    for _ in range(sample_number):

        # get two random locations (x and y separately)
        xs = uniform(low=minx, high=maxx, size=2)
        ys = uniform(low=miny, high=maxy, size=2)

        # calculate the distance along the ellipsoid
        ellipsoidal_distance = g.line_length(xs, ys)

        # transform the coordinates
        origin = transformer.transform(xs[0], ys[0], direction='FORWARD')
        destination = transformer.transform(xs[1], ys[1], direction='FORWARD')

        # calculate the planar distance
        planar_distance = hypot(origin[0] - destination[0], origin[1] - destination[1])

        # calculate distance index 
        distance_indices.append(abs(ellipsoidal_distance - planar_distance) / abs (ellipsoidal_distance + planar_distance))

    # calculate distance distortion
    Ep = 1 / sample_number * sum(distance_indices)  # as per equation

    # calibrate and return all of the measures
    return Ep, Es, Ea


# this block will only run if the script is executed directly
if __name__ == "__main__":

    # set the geographical proj string and ellipsoid (should be the same)
    geo_string = "+proj=longlat +datum=WGS84 +no_defs"
    g = Geod(ellps='WGS84')

    # create a list of dictionaries for the projected CRS' to evaluate for distortion
    projections = [
        {'name':"Web Mercator", 'description':"Global Conformal",   'proj':"+proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext  +no_defs"},
        {'name':"Eckert IV",    'description':"Global Equal Area",  'proj':"+proj=eck4 +lon_0=0 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs"},
        {'name':"Albers Conic", 'description':"Local Equal Area",   'proj':"+proj=aea +lon_0=-18.8964844 +lat_1=63.5404797 +lat_2=66.3620426 +lat_0=64.9512612 +datum=WGS84 +units=m +no_defs"}
    ]

    # load the shapefile of countries and extract Iceland
    world = read_file("../data/natural-earth/ne_10m_admin_0_countries.shp")
    iceland = world.loc[world.ISO_A3 == "ISL"]

    # load the shapefile of land cover and extract the glaciers
    land_cover = read_file('../data/iceland/gis_osm_natural_a_free_1.shp')
    ice = land_cover.loc[land_cover.fclass == "glacier"]

    # get the bounds of the country
    minx, miny, maxx, maxy = iceland.total_bounds

    # create a 2x2 figure
    fig, my_axs = subplots(2, 2, figsize=(10, 10), constrained_layout=True)
    fig.suptitle('How much Ice is in Iceland?\n', fontsize=20)

    # loop through each CRS
    text = ""
    for ax_num, projection in enumerate(projections):

        ''' CALCULATE DISTORTION '''

        # get x and y position of current axis
        axx = ax_num // my_axs.shape[0]
        axy = ax_num % my_axs.shape[0]

        # initialise a PyProj Transformer to transform coordinates
        transformer = Transformer.from_crs(CRS.from_proj4(geo_string), CRS.from_proj4(projection['proj']), always_xy=True)

        # calculate the distortion
        Ep, Es, Ea = evaluate_distortion(g, transformer, minx, miny, maxx, maxy, 10000, 1000000, 1000)

        # calculate ice area
        ice_area_km2 = ice.to_crs(projection['proj']).geometry.area.sum() / 1000000

        # report to user
        print(f"\n{projection['name']} ({projection['description']})")
        print(f"   {"Distance distortion (Ep):":<26}{Ep:.6f}")
        print(f"   {"Shape distortion (Es):":<26}{Es:.6f}")
        print(f"   {"Area distortion (Ea):":<26}{Ea:.6f}")
        print(f"   {"Ice Area:":<26}{ice_area_km2:,.0f} km sq.")
        
        # append text for figure
        text += f"{projection['name']+":":<13} $E_p={Ep:.4f}$  $E_s={Es:.4f}$  $E_a={Ea:.4f}$\n\n"


        ''' PLOT ON A MAP '''

        # disable axis, add title
        my_axs[axx][axy].axis('off')
        my_axs[axx][axy].set_facecolor('#000000')
        my_axs[axx][axy].set_title(f"{projection['name']} ({projection['description']})\nIce area: {ice_area_km2:,.0f} km sq.")

        # plot iceland
        iceland.to_crs(projection['proj']).plot(
            ax = my_axs[axx][axy],
            color = "#b2df8a",
            edgecolor = '#33a02c',
            linewidth = 0.2,
            )
        
        # plot ice
        ice.to_crs(projection['proj']).plot(
            ax = my_axs[axx][axy],
            color = "#e6f5f9",
            edgecolor = "#e6f5f9",
            linewidth = 0.1,
            )

        # add scalebar
        my_axs[axx][axy].add_artist(ScaleBar(dx=1, units="m", location="lower right"))

        # adjust the plot bounds to fit a square
        make_bounds_square(my_axs[axx][axy])

    # disable axis on the empty axis
    my_axs[1][1].axis('off')

    # manually draw a legend to the empty axis
    my_axs[1][1].legend([Patch(facecolor='#e6f5f9', edgecolor='#e6f5f9', label='Glacier')], ['Glacier'], loc='lower right')

    # add north arrow to empty axis
    x, y, arrow_length = 0.9, 0.3, 0.15
    my_axs[1][1].annotate('N', xy=(x, y), xytext=(x, y-arrow_length),
        arrowprops=dict(facecolor='black', width=3, headwidth=9),
        ha='center', va='center', fontsize=16, xycoords=my_axs[1][1].transAxes)
    
    # add the results to the empty axis - monospace font ensures table alignment
    my_axs[1][1].text(0.1, 0.4, text, fontfamily='monospace')

    # save the result
    savefig('out/5.png', bbox_inches='tight')
    print("done!")

test.py:

from geopandas import read_file
from week5 import evaluate_distortion
from pyproj import Geod, CRS, Transformer

# set the projected CRS to evaluate for distortion
proj_string = "+proj=eck4 +lon_0=0 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs"  # Eckert IV (Equal Area)

# set the geographical proj string and ellipsoid (should be the same)
geo_string = "+proj=longlat +datum=WGS84 +no_defs"
g = Geod(ellps='WGS84')

# load the shapefile of countries and extract country of interest
world = read_file("../data/natural-earth/ne_10m_admin_0_countries.shp")

# set country using country code
country = world.loc[world.ISO_A3 == "ISL"]

# get the bounds of the country
minx, miny, maxx, maxy = country.total_bounds

# initialise a PyProj Transformer to transform coordinates
transformer = Transformer.from_crs(CRS.from_proj4(geo_string), CRS.from_proj4(proj_string), always_xy=True)

# calculate the distortion
Ep, Es, Ea = evaluate_distortion(g, transformer, minx, miny, maxx, maxy, 10000, 1000000, 1000)

# report to user
print(f"{"Distance distortion (Ep):":<26}{Ep:.6f}")
print(f"{"Shape distortion (Es):":<26}{Es:.6f}")
print(f"{"Area distortion (Ea):":<26}{Ea:.6f}")