-
Notifications
You must be signed in to change notification settings - Fork 17
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: generate h3 polygons and create webmap (#46)
* Create points_to_h3.py * Revert "Create points_to_h3.py" This reverts commit 9b33217. * Create points_to_h3.py * Updates to points_to_h3 - add h3pandas to dependencies - add app launch to end of file - use single aggregation (mean) instead of multiple - change order of arguments based on defaults * Add webmap code and template * Tidying and formatting webmap script - adding Typer help and defaults - adding code to generate colours and breakpoints for map - formatting * Fix interpolation breaks - fix interpolation breaks - fix hard coded template path * Fix code to generate polygon colours Hard code colours into dataframe/json rather than using MapLibre interpolation * Delete gvi_webmap.html remove temp file * Applying ruff checks * Update maplibre_template.html - add pop to show gvi score when polygon is clicked * Update create_webmap.py - Round GVI scores to 2 decimal places for neater pop-ups * Generate legend from GVI scores * Update create_webmap.py - Reduce bounding box buffer from 0.5 degrees to 0.25 - Set default zoom to 12 * Update .env.example Update sample .env file to include placeholder for Maptiler api key * Update create_webmap.py Fix line lengths * Update points_to_h3.py Formatting * visualize step described in readme * remove reference to folder not in repo * alphabetize new dependency * Update README.md add info on MapTiler API key * Fix single digit resolution error Add a leading `0` to cell resolution if it is a single digit * Use empy basemap if no Maptiler key provided The Jinja template will use an empty basemap if there is no Maptiler API key in the .env file * Formatting * fix env variable and update template * Combine output path and filename * docs: filename and path is one argument * add logiv for missing env variable * fix for blank env variable * fix: ruff format * fix: ruff fix import block is un-sorted or un-formatted * fix: change web map colormap * fix legend by changing int to float * Ignore linter for import; needed to register accessor --------- Co-authored-by: Dan Joseph <[email protected]> Co-authored-by: Dan Joseph <[email protected]> Co-authored-by: Alexei <[email protected]> Co-authored-by: Jay Qi <[email protected]>
- Loading branch information
1 parent
f960f6d
commit 3a4a8e3
Showing
6 changed files
with
400 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
MAPILLARY_CLIENT_TOKEN = "MY_MAPILLARY_CLIENT_TOKEN" | ||
MAPILLARY_CLIENT_TOKEN = "MY_MAPILLARY_CLIENT_TOKEN" | ||
MAPTILER_API_KEY = "MY_MAPTILER_API_KEY" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,6 +17,7 @@ dependencies = [ | |
"folium", | ||
"geopandas", | ||
"geopy", | ||
"h3pandas", | ||
"loguru", | ||
"mapclassify", | ||
"matplotlib", | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,162 @@ | ||
import os | ||
from pathlib import Path | ||
|
||
from dotenv import load_dotenv | ||
import geopandas as gpd | ||
from jinja2 import Environment, FileSystemLoader | ||
import matplotlib | ||
import numpy as np | ||
from shapely.geometry import box | ||
import typer | ||
|
||
try: | ||
from typing import Annotated | ||
except ImportError: | ||
# for Python 3.9 | ||
from typing_extensions import Annotated | ||
|
||
|
||
app = typer.Typer() | ||
|
||
|
||
def h3_to_webmap(): | ||
return | ||
|
||
|
||
@app.command() | ||
def main( | ||
input_file: Annotated[ | ||
Path, | ||
typer.Argument(help="Path to file containing h3 polygons."), | ||
], | ||
filename: Annotated[ | ||
str, typer.Argument(help="(Optional) Path to file for HTML output.") | ||
] = "./data/processed/gvi_webmap.html", | ||
zoom_level: Annotated[ | ||
int, | ||
typer.Argument( | ||
help="""(Optional) Starting zoom level for webmap. | ||
Takes integer between 0 (small scale) and 20 (large scale). | ||
""" | ||
), | ||
] = 12, | ||
): | ||
gdf = gpd.read_file(input_file) | ||
# if crs is not 4326, convert to 4326 | ||
if gdf.crs == "EPSG:4326": | ||
pass | ||
else: | ||
gdf = gdf.to_crs("EPSG:4326") | ||
|
||
# Round GVI score to make map labels more readable | ||
gdf["gvi_score"] = round(gdf["gvi_score"], 2) | ||
|
||
# get central coordinates of all features | ||
centre = ( | ||
str(gdf.dissolve().centroid.x.values[0]) | ||
+ ", " | ||
+ str(gdf.to_crs("4326").dissolve().centroid.y.values[0]) | ||
) | ||
centre_str = "[" + centre + "]" | ||
|
||
# Calculate datasdet bounds (with a buffer of 0.5 degrees) to limit webmap bounds | ||
gdf_bounds = gdf.total_bounds | ||
bbox = box(*gdf_bounds).buffer(0.25, cap_style="square", join_style="mitre") | ||
bounds = bbox.bounds | ||
bounds_str = ( | ||
"[" | ||
+ str(bounds[0]) | ||
+ "," | ||
+ str(bounds[1]) | ||
+ "], [" | ||
+ str(bounds[2]) | ||
+ "," | ||
+ str(bounds[3]) | ||
+ "]" | ||
) | ||
|
||
# Load API key for map tiles from environment variable | ||
load_dotenv() | ||
|
||
if "MAPTILER_API_KEY" in os.environ: | ||
maptiler_api_key = os.getenv("MAPTILER_API_KEY") | ||
else: | ||
maptiler_api_key = "None" | ||
|
||
if maptiler_api_key in ["MY_MAPTILER_API_KEY", ""]: | ||
maptiler_api_key = "None" | ||
|
||
# Lookup the colourmap values for each GVI score | ||
cmap = matplotlib.colormaps["Greens"] | ||
gdf["gvi_norm"] = (gdf.gvi_score - np.min(gdf.gvi_score)) / ( | ||
np.max(gdf.gvi_score) - np.min(gdf.gvi_score) | ||
) | ||
gdf["html_color"] = gdf["gvi_norm"].apply( | ||
lambda x: matplotlib.colors.rgb2hex(cmap(x)) | ||
) | ||
|
||
# Generate divs for legend | ||
# Pick 4 evenly-spaced values from the gvi scores to use in the legend | ||
legend_gvi = list( | ||
np.arange( | ||
gdf.gvi_score.min(), | ||
gdf.gvi_score.max(), | ||
(gdf.gvi_score.max() - gdf.gvi_score.min()) / 4, | ||
dtype=float, | ||
) | ||
) | ||
|
||
# Generate labels by looking up what the GVI score would be for those values | ||
legend_label_1 = round( | ||
np.linspace(gdf.gvi_score.min(), gdf.gvi_score.max(), 100)[0], 1 | ||
) | ||
legend_label_2 = round( | ||
np.linspace(gdf.gvi_score.min(), gdf.gvi_score.max(), 100)[33], 1 | ||
) | ||
legend_label_3 = round( | ||
np.linspace(gdf.gvi_score.min(), gdf.gvi_score.max(), 100)[66], 1 | ||
) | ||
legend_label_4 = round( | ||
np.linspace(gdf.gvi_score.min(), gdf.gvi_score.max(), 100)[99], 1 | ||
) | ||
|
||
# Normalise the label values to lookup against the colourmap | ||
legend_gvi_norm = (legend_gvi - np.min(legend_gvi)) / ( | ||
np.max(legend_gvi) - np.min(legend_gvi) | ||
) | ||
|
||
# Generate the html colour code from the normalised values | ||
legend_colours = [] | ||
for i in legend_gvi_norm: | ||
legend_colours.append(matplotlib.colors.rgb2hex(cmap(i))) | ||
# Assign patch colours to use in HTML template | ||
legend_patch_1, legend_patch_2, legend_patch_3, legend_patch_4 = legend_colours | ||
|
||
# Load the MapLibre HMTL template | ||
environment = Environment(loader=FileSystemLoader("src/templates")) | ||
template = environment.get_template("maplibre_template.html") | ||
|
||
# Generate the HTML file from the template, filling dynamic values | ||
with open(filename, mode="w", encoding="utf-8") as message: | ||
message.write( | ||
template.render( | ||
title="GVI score hex map", | ||
geojson=gdf.to_json(), | ||
centre_coords=centre_str, | ||
zoom=zoom_level, | ||
bounds=bounds_str, | ||
maptiler_api_key=maptiler_api_key, | ||
legend_label_1=legend_label_1, | ||
legend_label_2=legend_label_2, | ||
legend_label_3=legend_label_3, | ||
legend_label_4=legend_label_4, | ||
legend_patch_1=legend_patch_1, | ||
legend_patch_2=legend_patch_2, | ||
legend_patch_3=legend_patch_3, | ||
legend_patch_4=legend_patch_4, | ||
) | ||
) | ||
|
||
|
||
if __name__ == "__main__": | ||
app() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,94 @@ | ||
import os | ||
from pathlib import Path | ||
|
||
import geopandas as gpd | ||
import h3pandas # noqa: F401 | ||
import typer | ||
|
||
try: | ||
from typing import Annotated | ||
except ImportError: | ||
# for Python 3.9 | ||
from typing_extensions import Annotated | ||
|
||
app = typer.Typer() | ||
|
||
|
||
@app.command() | ||
def main( | ||
input_file: Annotated[ | ||
Path, | ||
typer.Argument(help="Path to file containing point layer with GVI scores."), | ||
], | ||
output_file: Annotated[ | ||
Path, | ||
typer.Argument( | ||
help="File to write output data to (can specify any GDAL-supported format)" | ||
), | ||
], | ||
cell_resolution: Annotated[ | ||
int, | ||
typer.Argument( | ||
help="""H3 cell resolution to aggregate to, | ||
between 0 (largest) and 15 (smallest)""" | ||
), | ||
] = 10, | ||
): | ||
""" | ||
Aggregates points to h3 hex cells. | ||
Args: | ||
input_file: Path to file containing point layer with GVI scores. | ||
cell_resolution: H3 cell resolution to aggregate to, | ||
between 0 (largest) and 15 (smallest) | ||
aggregation_operations: | ||
output_file: File to write output data to | ||
(can specify any GDAL-supported format) | ||
Returns: | ||
File containing h3 polygons with aggregated GVI scores | ||
""" | ||
# Check input file exists | ||
if os.path.exists(input_file): | ||
pass | ||
else: | ||
raise ValueError("Input file could not be found") | ||
|
||
# Check input file is a valid file for GeoPandas | ||
try: | ||
gpd.read_file(input_file) | ||
except Exception as e: | ||
raise e | ||
|
||
# Check data contains point features | ||
if "Point" in gpd.read_file(input_file).geometry.type.unique(): | ||
pass | ||
else: | ||
raise Exception("Expected point data in interim data file but none found") | ||
|
||
# Check data contains numeric gvi_score field | ||
|
||
# Load input data | ||
gdf = gpd.read_file(input_file) | ||
|
||
# Exclude points with no GVI score | ||
gdf = gdf[~gdf.gvi_score.isna()] | ||
|
||
# Assign points to h3 cells at the selected resolution | ||
gdf_h3 = gdf.h3.geo_to_h3(cell_resolution).reset_index() | ||
|
||
# Aggregate the points to the assigned h3 cell | ||
gvi_mean = gdf_h3.groupby("h3_" + f"{cell_resolution:02}").agg( | ||
{"gvi_score": "mean"} | ||
) | ||
|
||
# Convert the h3 cells to polygons | ||
gvi_hex = gvi_mean.h3.h3_to_geo_boundary() | ||
|
||
# Export the h3 polygons to the specified output file | ||
gvi_hex.to_file(output_file) | ||
|
||
|
||
if __name__ == "__main__": | ||
app() |
Oops, something went wrong.