Practical 6 Code Solution

Result Map

"""
Understanding GIS: Practical 6
@author jonnyhuck

Simple implementation of a flood fill model

References:
    https://rasterio.readthedocs.io/en/latest/
    https://docs.python.org/3/tutorial/errors.html
    https://rasterio.readthedocs.io/en/latest/quickstart.html
    https://rasterio.readthedocs.io/en/stable/topics/plotting.html
    https://rasterio.readthedocs.io/en/latest/api/rasterio.plot.html
    https://matplotlib.org/stable/gallery/color/colormap_reference.html
    https://matplotlib.org/api/_as_gen/matplotlib.axes.Axes.contour.html#matplotlib.axes.Axes.contour

Note:
    You *cannot* use a try-except statement to catch an Index Error, as negative numbers are valid 
        and so just wrap around to the other side of the dataset

New Topics:
    Rasters
    Sets
    numpy arrays
    with statements
    continue statements
"""

from numpy import zeros
from math import floor, ceil
from geopandas import GeoSeries
from shapely.geometry import Point
from matplotlib.lines import Line2D
from matplotlib.patches import Patch
from rasterio import open as rio_open
from matplotlib.colors import Normalize
from matplotlib.cm import ScalarMappable
from rasterio.plot import show as rio_show
from matplotlib.pyplot import subplots, savefig
from matplotlib_scalebar.scalebar import ScaleBar
from matplotlib.colors import LinearSegmentedColormap


def flood_fill(depth, x0, y0, d, dem_data):
    """
    Calculate flooding extent from a point location
    """

    # create output array at the same dimensions as the d
    flood_layer = zeros(dem_data.shape)

    # convert from coordinate space to image space
    r0, c0 = d.index(x0, y0)

    # set for cells already assessed
    assessed = set()

    # set for cells to be assessed
    to_assess = set()

    # start with the origin cell
    to_assess.add((r0, c0))

    # calculate the maximum elevation of the flood
    flood_extent = dem_data[(r0, c0)] + depth

    # keep looping as long as there are cells left to be checked
    while to_assess:

        # get the next cell to be assessed (removing it from the to_assess set)
        r, c = to_assess.pop()

        # add it to the register of those already assessed
        assessed.add((r, c))

        # is the elevation low enough for this cell to be flooded?
        if dem_data[(r, c)] <= flood_extent:

            # mark the current cell as flooded
            flood_layer[(r, c)] = 1
            
            # loop through all neighbouring cells
            for r_adj, c_adj in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]:

                # get current neighbour
                neighbour = (r + r_adj, c + c_adj)

                # make sure that the location is wihin the bounds of the dataset and has not been assessed previously...
                if 0 <= neighbour[0] < dem.height and 0 <= neighbour[1] < dem.width and neighbour not in assessed:

                        # ...then add to the set for assessment
                        to_assess.add(neighbour)

    # when complete, return the result
    return flood_layer


# set the depth of the flood
FLOOD_DEPTH = 2

# set origin for the flood as a tuple
LOCATION = (332000, 514000)

# open the raster dataset
# with rio_open('../data/helvellyn/Helvellyn.tif') as dem:   # 5m  resolution
with rio_open("../data/helvellyn/Helvellyn-50.tif") as dem:  # 50m resolution

    # read the data out of band 1 in the dataset
    dem_data = dem.read(1)

    # calculate the flood
    output = flood_fill(FLOOD_DEPTH, LOCATION[0], LOCATION[1], dem, dem_data)

# output image
fig, my_ax = subplots(1, 1, figsize=(16, 10))
my_ax.set_title("Simple Flood Fill Model")

# draw dem
rio_show(
    dem_data,
    ax=my_ax,
    transform = dem.transform,
    cmap = 'cividis',
    )

# draw dem as contours
rio_show(
    dem_data,
    ax=my_ax,
    contour=True,
    transform = dem.transform,
    colors = ['white'],
    linewidths = [0.5],
    )

# add flooding
rio_show(
    output,
    ax=my_ax,
    transform=dem.transform,
    cmap = LinearSegmentedColormap.from_list('binary', [(0, 0, 0, 0), (0, 0.5, 1, 0.5)], N=2)
    )

# add origin point
GeoSeries(Point(LOCATION)).plot(
    ax = my_ax,
    markersize = 50,
    color = 'red',
    edgecolor = 'white'
    )

# add a colour bar
fig.colorbar(ScalarMappable(norm=Normalize(vmin=floor(dem_data.min()), vmax=ceil(dem_data.max())), cmap='cividis'), ax=my_ax, pad=0.01)

# add north arrow
x, y, arrow_length = 0.97, 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 right"))

# add legend for point
my_ax.legend(
    handles=[
        Patch(facecolor=(0, 0.5, 1, 0.5), edgecolor=None, label=f'Flood Zone ({FLOOD_DEPTH}m)'),
        Line2D([0], [0], marker='o', color=(1,1,1,0), label='Flood Origin', markerfacecolor='red', markersize=8)
    ], loc='lower left')

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