backend/services/grid/grid_service.py (134 lines of code) (raw):

import geojson import json from shapely.geometry import MultiPolygon, mapping from shapely.ops import cascaded_union import shapely.geometry from flask import current_app from backend.models.dtos.grid_dto import GridDTO from backend.models.postgis.utils import InvalidGeoJson class GridServiceError(Exception): """ Custom Exception to notify callers an error occurred when handling projects """ def __init__(self, message): if current_app: current_app.logger.error(message) class GridService: @staticmethod def trim_grid_to_aoi(grid_dto: GridDTO) -> geojson.FeatureCollection: """ Removes grid squares not intersecting with the aoi. Optionally leaves partially intersecting task squares complete or clips them exactly to the AOI outline :param grid_dto: the dto containing :return: geojson.FeatureCollection trimmed task grid """ # get items out of the dto grid = grid_dto.grid aoi = grid_dto.area_of_interest clip_to_aoi = grid_dto.clip_to_aoi # create a shapely shape from the aoi aoi_multi_polygon_geojson = GridService.merge_to_multi_polygon( aoi, dissolve=True ) aoi_multi_polygon = shapely.geometry.shape(aoi_multi_polygon_geojson) intersecting_features = [] for feature in grid["features"]: # create a shapely shape for the tile tile = shapely.geometry.shape(feature["geometry"]) if aoi_multi_polygon.contains(tile): # tile is completely within aoi, use as is intersecting_features.append(feature) else: intersection = aoi_multi_polygon.intersection(tile) if intersection.is_empty or intersection.geom_type not in [ "Polygon", "MultiPolygon", ]: continue # this intersections which are not polygons or which are completely outside aoi # tile is partially intersecting the aoi clipped_feature = GridService._update_feature( clip_to_aoi, feature, intersection ) intersecting_features.append(clipped_feature) return geojson.FeatureCollection(intersecting_features) @staticmethod def tasks_from_aoi_features(feature_collection: str) -> geojson.FeatureCollection: """ Creates a geojson feature collection of tasks from an aoi feature collection :param feature_collection: :return: task features """ parsed_geojson = GridService._to_shapely_geometries( json.dumps(feature_collection) ) tasks = [] for feature in parsed_geojson: if not isinstance(feature.geometry, MultiPolygon): feature.geometry = MultiPolygon([feature.geometry]) # put the geometry back to geojson if feature.geometry.has_z: # Strip Z dimension, as can't persist geometry otherwise. Most likely exists in KML data feature.geometry = shapely.ops.transform( GridService._to_2d, feature.geometry ) feature.geometry = shapely.geometry.mapping(feature.geometry) # set default properties # and put any already existing properties in `extra_properties` feature.properties = { "x": None, "y": None, "zoom": None, "isSquare": False, "extra_properties": feature.properties, } tasks.append(feature) return geojson.FeatureCollection(tasks) @staticmethod def merge_to_multi_polygon( feature_collection: str, dissolve: bool ) -> geojson.MultiPolygon: """ Merge all geometries to a single multipolygon :param feature_collection: geojson feature collection str containing features :param dissolve: flag for wther to to dissolve internal boundaries. :return: geojson.MultiPolygon """ parsed_geojson = GridService._to_shapely_geometries( json.dumps(feature_collection) ) multi_polygon = GridService._convert_to_multipolygon(parsed_geojson) if dissolve: multi_polygon = GridService._dissolve(multi_polygon) aoi_multi_polygon_geojson = geojson.loads(json.dumps(mapping(multi_polygon))) # validate the geometry if type(aoi_multi_polygon_geojson) is not geojson.MultiPolygon: raise InvalidGeoJson("Area Of Interest: geometry must be a MultiPolygon") is_valid_geojson = geojson.is_valid(aoi_multi_polygon_geojson) if is_valid_geojson["valid"] == "no": raise InvalidGeoJson( f"Area of Interest: Invalid MultiPolygon - {is_valid_geojson['message']}" ) return aoi_multi_polygon_geojson @staticmethod def _update_feature(clip_to_aoi: bool, feature: dict, new_shape) -> dict: """ Updates the feature with the new shape, and isSquare property :param clip_to_aoi: value for feature's is_square property :param feature: feature to be updated :param new_shape: new shape to use for feature :return: """ if clip_to_aoi: # update the feature with the clipped shape if new_shape.geom_type == "Polygon": # shapely may return a POLYGON rather than a MULTIPOLYGON if there is just one intersection area new_shape = MultiPolygon([new_shape]) feature["geometry"] = mapping(new_shape) feature["properties"]["isSquare"] = False return feature @staticmethod def _to_shapely_geometries(input: str) -> list: """ Parses the input geojson and returns a list of geojson.Feature objects with their geometries adapted to shapely geometries :param input: string of geojson :return: list of geojson.Feature objects with their geometries adapted to shapely geometries """ collection = geojson.loads(input, object_hook=geojson.GeoJSON.to_instance) if not hasattr(collection, "features") or len(collection.features) < 1: raise InvalidGeoJson("Geojson does not contain any features") shapely_features = list( ( filter( lambda x: x is not None, map(GridService._adapt_feature_geometry, collection.features), ) ) ) return shapely_features @staticmethod def _adapt_feature_geometry(feature: geojson.Feature) -> geojson.Feature: """ Adapts the feature geometry to be used as a shapely geometry :param feature: geojson.feature to be adapted :return: feature with geometry adapted """ if isinstance( feature.geometry, (geojson.geometry.Polygon, geojson.geometry.MultiPolygon) ): # adapt the geometry for use as a shapely geometry # http://toblerity.org/shapely/manual.html#shapely.geometry.asShape feature.geometry = shapely.geometry.asShape(feature.geometry) return feature else: return None @staticmethod def _convert_to_multipolygon(features: list) -> MultiPolygon: """ converts a list of (multi)polygon geometries to one single multipolygon :param features: :return: """ rings = [] for feature in features: if isinstance(feature.geometry, MultiPolygon): rings = rings + [geom for geom in feature.geometry.geoms] else: rings = rings + [feature.geometry] geometry = MultiPolygon(rings) # Downsample 3D -> 2D if geometry.has_z: geometry = shapely.ops.transform(GridService._to_2d, geometry) wkt2d = geometry.wkt geom2d = shapely.wkt.loads(wkt2d) return geom2d def _to_2d(x: tuple, y: tuple, z: tuple = None) -> tuple: """ Helper method that can be used to strip out the z-coords from a shapely geometry :param x: tuple containing tuple of x coords :param y: tuple containing tuple of y coords :param z: tuple containing tuple of z coords :return: tuple of containing tuple of x coords and tuple of y coords """ return tuple(filter(None, [x, y])) @staticmethod def _dissolve(geoms: MultiPolygon) -> MultiPolygon: """ dissolves a Multipolygons :return: Multipolygon """ # http://toblerity.org/shapely/manual.html#shapely.ops.cascaded_union geometry = cascaded_union(geoms) if geometry.geom_type == "Polygon": # shapely may return a POLYGON rather than a MULTIPOLYGON if there is just one shape # force Multipolygon geometry = MultiPolygon([geometry]) return geometry