Hosted with nbsanity. See source notebook on GitHub.

Generate regular hex and square grids covering area of interest

27 May 2019


The basic approach is: - Define a flat CRS and a projected CRS. So x, y and lat, lng values respectively. - Write the code the create a Shapely Polygon that defines a regular hexagon (or square) with a particular radius in metres. - Define an area (in lat, lng values) over which we would like to create a tiling. - Intersect this area with the geographic area of interest

1.0. Setup

Import all the necessary libraries and then define the Coordinate Reference Systems (CRS) that we will be working with.

import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
import numpy as np

import folium

from shapely.geometry import Polygon, Point, MultiPolygon
from shapely import wkt

%matplotlib inline

1.1. Coordinate Reference Systems

# The CRS that the ABS SA2 shapefile uses
crs_abs = {'init': 'epsg:4283'}

# the projected CRS
crs_vic = {'init': 'epsg:28355'}

# maybe try this one: epsg:3395
# south west most point of melb
p_lower = [143.338683, -39.185049]

# north east most point of melb
p_upper = [146.742224, -36.559378]

# put these in a Geoseries
gs1 = gpd.GeoSeries([Point(p_lower), Point(p_upper)], crs=crs_abs)

# convert each of these points to the 'flat coordinate system'
gs1b = gs1.to_crs(crs_vic)

2.0. Define the regular hexagonal grid

2.1. Define a regular hexagon in the flat geometry

Firstly we define a regular hexagon and then tile this over the given rectangle

# startpoint coords
coords0 = gs1b[0].x, gs1b[0].y

# the x and y extent of the grid
Xdiff = gs1b[1].x - gs1b[0].x
Ydiff = gs1b[1].y - gs1b[0].y

# the cell width and height (in metres)
celllength = 1000
r = celllength

# fundamental hexagon
a, b = np.sqrt(3) / 2, 1 / 2
poly_hex = np.array([(0, r), (-r*a, r*b), (-r*a, -r*b), (0, -r), (r*a, -r*b), (r*a, r*b)])

# calculate the number of cells in the X and Y directions
num_cells_x = int(Xdiff / celllength / 2)
num_cells_y = int(Ydiff / celllength / 2)

2.2. Tile this hexagon over the rectangle covering the specified area.

Clean this up to void using the for loops.

# centres of a regular grid of hexagons of unit radius
polys = []

# using a for loop
for m in range(0, num_cells_x):
    for n in range(0, num_cells_y):
        # generate the two sublattices
        d = np.array([coords0[0] + 2*m*a*r, coords0[1] + 3*2*n*b*r]).reshape(1,-1) # displacement vector
        polys.append(Polygon(d+poly_hex))
        
        d = np.array([coords0[0] + (2*m+1)*a*r, coords0[1] + 3*(2*n+1)*b*r]).reshape(1,-1)
        polys.append(Polygon(d+poly_hex))
        
hexes = gpd.GeoSeries(polys, crs=crs_vic)

# convert to dataframe and change crs
gdf_hex = gpd.GeoDataFrame(geometry=hexes)
gdf_hex = gdf_hex.to_crs(crs_abs)

# add a label for each hexagon for when we do a spatial join
gdf_hex['HexLabel'] = np.arange(gdf_hex.shape[0])

3.0 Define a regular square grid

# startpoint coords
coords0 = gs1b[0].x, gs1b[0].y

# the x and y extent of the grid
Xdiff = gs1b[1].x - gs1b[0].x
Ydiff = gs1b[1].y - gs1b[0].y

# the cell width and height (in metres)
celllength = 1000
r = celllength

# fundamental square
a, b = 1, 1
poly_square = np.array([(0, 0), (a, 0), (a, b), (0, b)])

# calculate the number of cells in the X and Y directions
num_cells_x = int(Xdiff / celllength)
num_cells_y = int(Ydiff / celllength)

3.1. Tile this square over the rectangle covering the specified area.

# centres of a regular grid of hexagons of unit radius
polys = []

# using a for loop
for m in range(0, num_cells_x):
    for n in range(0, num_cells_y):
        # generate the two sublattices
        d = np.array([coords0[0] + a*m*r, coords0[1] + b*n*r]).reshape(1,-1)
        polys.append(Polygon(d+poly_square*r))
        
squares = gpd.GeoSeries(polys, crs=crs_vic)

# convert to dataframe and change crs
gdf_sqr = gpd.GeoDataFrame(geometry=squares)
gdf_sqr = gdf_sqr.to_crs(crs_abs)

# add a label for each hexagon for when we do a spatial join
gdf_sqr['SquareLabel'] = np.arange(gdf_sqr.shape[0])

4.0. Load up the Vic SA2 values and shapefiles

The SA2 files are here: https://www.abs.gov.au/AUSSTATS/[email protected]/DetailsPage/1270.0.55.003July%202016?OpenDocument

# load up the shape files for Vic and filter down to Victoria, removing null values
gdf_vic = gpd.read_file('data/shapefiles/SA2_2016_AUST.shp')
gdf_vic = gdf_vic[gdf_vic['geometry'].isnull() == False] # remove the null values

4.1. Area Of Interest

Define the Area Of Interest that we would like to restrict our grids to

# Greater Melbourne
gdf_gm = gdf_vic[gdf_vic['GCC_NAME16']=='Greater Melbourne']

# The SA4's that make up Greater Melbourne and Geelong
sa4_aoi = ['Melbourne - Inner', 'Melbourne - Inner East',
       'Melbourne - Inner South', 'Melbourne - North East',
       'Melbourne - North West', 'Melbourne - Outer East',
       'Melbourne - South East', 'Melbourne - West',
       'Mornington Peninsula']

gdf_aoi = gdf_vic[gdf_vic['SA4_NAME16'].isin(sa4_aoi)]

4.2. Intersect the hexgrid

# join hexagon grid with AOI
gdf_new = gpd.sjoin(gdf_aoi, gdf_hex, op='intersects', how='inner')
# just get the hexagons that cover melbourne
hex_gm = gdf_hex[gdf_hex['HexLabel'].isin(gdf_new['HexLabel'].values)]
# write to a shapefile
hex_gm.to_file('hexgrid_aoi_1km.shp', index=False)

4.3. Intersect the squaregrid

# join hexagon grid with Greater Melbourne
gdf_new = gpd.sjoin(gdf_aoi, gdf_sqr, op='intersects', how='inner')
# just get the hexagons that cover melbourne
sqr_gm = gdf_sqr[gdf_sqr['SquareLabel'].isin(gdf_new['SquareLabel'].values)]
# write to a shapefile
sqr_gm.to_file('sqrgrid_aoi_1km.shp', index=False)

5.0. Plot the regular hexgrids and square grids covering our AOI

5.1 Hexgrid

# initialize the map
m = folium.Map(location=[-37.8, 145], zoom_start=8.5, tiles='cartodbpositron')

# add the hexagon grid to the map
folium.GeoJson(hex_gm.geometry).add_to(m)

# display all of this
m

5.2. Squaregrid

# initialize the map
m = folium.Map(location=[-37.8, 145], zoom_start=8.5, tiles='cartodbpositron')

# add the hexagon grid to the map
folium.GeoJson(sqr_gm.geometry).add_to(m)

# display all of this
m

6.0. Validate the lengths using the haversine formula

from math import atan2
# from 
# https://community.esri.com/groups/coordinate-reference-systems/blog/2017/10/05/haversine-formula

def haversine(coord1: object, coord2: object):

    # Coordinates in decimal degrees (e.g. 2.89078, 12.79797)
    lon1, lat1 = coord1
    lon2, lat2 = coord2

    R = 6371000  # radius of Earth in meters
    phi_1 = radians(lat1)
    phi_2 = radians(lat2)

    delta_phi = radians(lat2 - lat1)
    delta_lambda = radians(lon2 - lon1)

    a = sin(delta_phi / 2.0) ** 2 + cos(phi_1) * cos(phi_2) * sin(delta_lambda / 2.0) ** 2
    
    c = 2 * atan2(sqrt(a), sqrt(1 - a))

    meters = R * c  # output distance in meters
    km = meters / 1000.0  # output distance in kilometers

    meters = round(meters, 3)
    km = round(km, 3)


    print(f"Distance: {meters} m")
    print(f"Distance: {km} km")