Practical 9 Code Solution

GA Result

"""
Understanding GIS: Practical 9
@author jonnyhuck

Resolve the North-South divide using a Genetic Algorithm

References:
    https://projectionwizard.org/#

New Topics:
    Lambda Functions
"""

from sys import exit
from shapely.ops import split
from fiona.errors import DriverError
from matplotlib.patches import Patch
from numpy.random import randint, uniform
from geopandas import read_file, GeoSeries
from matplotlib_scalebar.scalebar import ScaleBar
from shapely.geometry import LineString, MultiPolygon
from matplotlib.pyplot import subplots, savefig, Line2D

def group_polygons(polys, individual):
    """
    Convert list of polygons into two multipolygons, grouped above and below the cutline.
    """
    # loop through all polygons
    top = []
    bottom = []
    for poly in polys.geoms:

        # if the max y value, is it above the cutline, then it is part of the top 'half'
        if poly.bounds[3] > individual['y']:
            top.append(poly)
        
        # otherwise, it is part of the bottom 'half'
        else:
            bottom.append(poly)

    # return list of two multipolygons
    return [MultiPolygon(top), MultiPolygon(bottom)]


def get_fitness(population):
    """
    Calculate the fitness of a position on the y-axis by splitting the polygon at
    the desired position on the y-aixs and comparing the area of the two resulting
    polygons.
    """
    # calculate fitness of each individual
    for individual in population:

        # split the polygon into two along the cutline
        polys = group_polygons(split(gb, LineString([(gb.bounds[0], individual['y']), (gb.bounds[2], individual['y'])])), individual)

        # overwrite polys with a sorted version of itself
        polys = sorted(polys, key=lambda poly: poly.area)

        # update fitness as ratio of smallest to largest
        individual['fitness'] = polys[0].area / polys[1].area

    # return the resulting array
    return population


def crossover(parents, offspring_size):
    """
     * Single point crossover function for decimal numbers
    """
    # loop enough times to make the offspring size
    offspring = []
    for i in range(offspring_size):

        # get binary representations of parents' y values
        parent_1 = list(bin(parents[i % len(parents)]['y'])[2:])
        parent_2 = list(bin(parents[(i+1) % len(parents)]['y'])[2:])

        # swap some random chromasomes (bits) in the binary strings
        for r in randint(len(parent_1)-1, size=len(parent_1) // 2):
            parent_1[r] = parent_2[r]

        # convert back to number and store in a dictionary
        offspring.append({'y': int("".join(parent_1), 2), 'fitness': None})

    # return the next generation
    return offspring


def mutation(population, mutation_probability, max_mutation):
    """
    * Mutate a value by +/- max_mutation
    """
    # mutation changes a single gene in each offspring randomly.
    for i in range(len(population)):

        # does this child want to mutate?
        if (uniform() < mutation_probability):

            # apply the random value as a mutation to the child
            population[i]['y'] += int(uniform(-max_mutation, max_mutation))

    # return the resulting offspring
    return population


# define CRS
uk_ea = '+proj=tcea +lon_0=-3 +datum=WGS84 +units=m +no_defs'

# Load the shapefile and project
try:
    world = read_file('../data/natural-earth/ne_10m_admin_0_countries.shp').to_crs(uk_ea)

# if the file does not exist, warn and exit
except DriverError:
    print("Warning, invalid filepath. Exiting.")
    exit()

# extract the geometry of the UK
uk = world[(world.ISO_A3 == 'GBR')]['geometry'].iloc[0]

# extract the largest polygon (mainland Great Britain)
gb = sorted(uk.geoms, key=lambda poly: poly.area, reverse=True)[0]

# settings
pop_size                = 50       # population size
num_parents_mating      = 10       # mating pool size (how many of the pop get to breed)
threshold               = 0.999    # the desired precision of the result
mutation_probability    = 0.1      # probability of a child mutating
max_mutation            = 100000.0 # 10km max mutation

# create the initial population (array of dictionaries) then calculate the fitness for each individual
population = [{'y': int(y), 'fitness': None} for y in uniform(low=gb.bounds[1], high=gb.bounds[3], size=pop_size)]
population = get_fitness(population)

# initialise loop varibles
generation = 0
best_fit = 0
previous_best_fit = None

# loop until we either find a solution to within the threshold, or the solutions stop improving
while best_fit < threshold and best_fit != previous_best_fit:

    # select the best parents in the population for mating
    parents = sorted(population, key=lambda individual: individual['fitness'], reverse=True)[:num_parents_mating]

    # get the next generation, mutate and update fitness values
    population = get_fitness(mutation(crossover(parents, pop_size), mutation_probability, max_mutation))

    # get the current best individual
    best_match = sorted(population, key=lambda individual: individual['fitness'], reverse=True)[0]
    previous_best_fit = best_fit
    best_fit = best_match['fitness']

    # increment generation counter and report current fitness (1 = perfect)
    generation += 1
    print(f"\tgeneration {generation}: {best_fit}")

# report the best match and output
print(f"Best solution : {best_match['y']} (fitness: {best_match['fitness']} generations: {generation})")

# construct the final answer as a linestring
cutline = LineString([(gb.bounds[0], best_match['y']), (gb.bounds[2], best_match['y'])])

# split GB to get the two polygons
polys = group_polygons(split(gb, cutline), best_match)

# setup figure for output
fig, my_ax = subplots(1, 1, figsize=(16, 10))
my_ax.axis('off')
my_ax.set_title("The North-South Divide")

# add layers
GeoSeries(polys[0], crs=uk_ea).plot(
    ax = my_ax,
    color = '#ccebc5',
    edgecolor = '#4daf4a',
    linewidth = 0.3
    )
GeoSeries(polys[1], crs=uk_ea).plot(
    ax = my_ax,
    color = '#b3cde3',
    edgecolor = '#377eb8',
    linewidth = 0.3
    )
GeoSeries(cutline, crs=uk_ea).plot(
    ax = my_ax,
    color = '#e41a1c',
    linewidth = 1
    )

# manually draw a legend
my_ax.legend([
    Patch(facecolor='#ccebc5', edgecolor='#4daf4a', label='The North'),
    Patch(facecolor='#b3cde3', edgecolor='#377eb8', label='The South'),
	Line2D([0], [0], color='#e41a1c', lw=1)],
	['The North', 'The South', 'Cutline'], loc='lower right')

# add north arrow
x, y, arrow_length = 0.98, 0.99, 0.1
my_ax.annotate('N', xy=(x, y), xytext=(x, y-arrow_length),
	arrowprops=dict(facecolor='black', width=5, headwidth=15),
	ha='center', va='center', fontsize=20, xycoords=my_ax.transAxes)

# add scalebar
my_ax.add_artist(ScaleBar(dx=1, units="m", location="lower left"))

# store image
savefig("./out/9.png", bbox_inches='tight')
print("done!")