def conditional_linemerge_collection( traces: Union[gpd.GeoDataFrame, gpd.GeoSeries], tolerance: float, buffer_value: float, ) -> Tuple[List[LineString], List[int]]: """ Conditionally linemerge within a collection of LineStrings. Returns the linemerged traces and the idxs of traces that were linemerged. E.g. >>> first = LineString([(0, 0), (0, 2)]) >>> second = LineString([(0, 2.001), (0, 4)]) >>> traces = gpd.GeoSeries([first, second]) >>> tolerance = 5 >>> buffer_value = 0.01 >>> new_traces, idx = LineMerge.conditional_linemerge_collection( ... traces, tolerance, buffer_value ... ) >>> [trace.wkt for trace in new_traces], idx (['LINESTRING (0 0, 0 2, 0 4)'], [0, 1]) """ spatial_index = pygeos_spatial_index(traces) new_traces = [] modified_idx = [] for i, trace in enumerate(traces.geometry): assert isinstance(trace, LineString) trace_candidates_idx: List[int] = list( spatial_index.intersection( geom_bounds(safe_buffer(trace, buffer_value * 2)))) trace_candidates_idx.remove(i) if len(trace_candidates_idx) == 0 or i in modified_idx: continue for idx in trace_candidates_idx: if idx in modified_idx or i in modified_idx: continue trace_candidate = traces.geometry.iloc[idx] merged = LineMerge.conditional_linemerge( trace, trace_candidate, tolerance=tolerance, buffer_value=buffer_value, ) if merged is not None: new_traces.append(merged) modified_idx.append(i) modified_idx.append(idx) break return new_traces, modified_idx
def sample_grid( grid: gpd.GeoDataFrame, traces: gpd.GeoDataFrame, nodes: gpd.GeoDataFrame, branches: gpd.GeoDataFrame, snap_threshold: float, resolve_branches_and_nodes: bool = False, ) -> gpd.GeoDataFrame: """ Populate a sample polygon grid with geometrical and topological parameters. """ sample_cell_area = grid.geometry.iloc[0].area assert sample_cell_area != 0 # String identifiers as parameters # dict with lists of parameter values params = dict() # type: Dict[str, list] # Iterate over sample cells # Uses a buffer 1.5 times the size of the sample_cell side length # Make sure index does not cause issues TODO traces_reset, nodes_reset, branches_reset = ( traces.reset_index(drop=True), nodes.reset_index(drop=True), branches.reset_index(drop=True), ) assert isinstance(traces_reset, gpd.GeoDataFrame) assert isinstance(nodes_reset, gpd.GeoDataFrame) assert isinstance(branches_reset, gpd.GeoDataFrame) traces, nodes, branches = traces_reset, nodes_reset, branches_reset # [gdf.reset_index(inplace=True, drop=True) for gdf in (traces, nodes)] traces_sindex = pygeos_spatial_index(traces) # nodes_sindex = pygeos_spatial_index(nodes) params_for_cells = list( map( lambda sample_cell: populate_sample_cell( sample_cell=sample_cell, sample_cell_area=sample_cell_area, traces_sindex=traces_sindex, traces=traces, nodes=nodes, branches=branches, snap_threshold=snap_threshold, resolve_branches_and_nodes=resolve_branches_and_nodes, ), grid.geometry.values, )) for key in [param.value.name for param in Param]: assert isinstance(key, str) try: params[key] = [cell_param[key] for cell_param in params_for_cells] except KeyError: continue for key, value in params.items(): grid[key] = value return grid
def snap_traces( traces: List[LineString], snap_threshold: float, areas: Optional[List[Union[Polygon, MultiPolygon]]] = None, final_allowed_loop=False, ) -> Tuple[List[LineString], bool]: """ Snap traces to end exactly at other traces. """ if len(traces) == 0: return ([], False) # Only handle LineStrings assert all(isinstance(trace, LineString) for trace in traces) # Spatial index for traces traces_spatial_index = pygeos_spatial_index(geodataset=gpd.GeoSeries(traces)) # Collect simply snapped (and non-snapped) traces to list simply_snapped_traces, simple_changes = zip( *[ snap_trace_simple( idx, trace, snap_threshold, traces, traces_spatial_index, final_allowed_loop=final_allowed_loop, ) for idx, trace in enumerate(traces) ] ) assert len(simply_snapped_traces) == len(traces) simply_snapped_traces_list: List[LineString] = list(simply_snapped_traces) # Collect snapped (and non-snapped) traces to list snapped_traces, changes = zip( *[ snap_others_to_trace( idx=idx, trace=trace, snap_threshold=snap_threshold, traces_spatial_index=traces_spatial_index, areas=areas, traces=simply_snapped_traces_list, final_allowed_loop=final_allowed_loop, ) for idx, trace in enumerate(simply_snapped_traces) ] ) assert len(snapped_traces) == len(simply_snapped_traces) assert all(isinstance(ls, LineString) for ls in snapped_traces) return list(snapped_traces), any(changes + simple_changes)
def estimate_censoring(self, ) -> float: """ Estimate the amount of censoring as area float value. Censoring is caused by e.g. vegetation. Returns np.nan if no ``censoring_area`` is passed by the user into ``Network`` creation or if the passed GeoDataFrame is empty. """ # Either censoring_area is passed directly or its passed in Network # creation. Otherwise raise ValueError. if self.censoring_area is None: raise ValueError( "Expected censoring_area as an argument or initialized" f" as Network (name:{self.name}) attribute.") if not isinstance(self.censoring_area, gpd.GeoDataFrame): raise TypeError( "Expected censoring_area to be of type gpd.GeoDataFrame." f" Got type={type(self.censoring_area)}") if self.censoring_area.empty: return np.nan # Determine bounds of Network.area_gdf network_area_bounds = total_bounds(self.area_gdf) # Use spatial index to filter censoring polygons that are not near the # network sindex = pygeos_spatial_index(self.censoring_area) index_intersection = spatial_index_intersection( spatial_index=sindex, coordinates=network_area_bounds) candidate_idxs = list( index_intersection if index_intersection is not None else []) if len(candidate_idxs) == 0: return 0.0 candidates = self.censoring_area.iloc[candidate_idxs] # Clip the censoring areas with the network area and calculate the area # sum of leftover area. clipped = gpd.clip(candidates, self.area_gdf) if not isinstance(clipped, (gpd.GeoDataFrame, gpd.GeoSeries)): vals = type(clipped), clipped raise TypeError( f"Expected that clipped is of geopandas data type. Got: {vals}." ) censoring_value = clipped.area.sum() unpacked_value = numpy_to_python_type(censoring_value) assert isinstance(unpacked_value, float) assert unpacked_value >= 0.0 return unpacked_value
def test_populate_sample_cell(sample_cell, traces, snap_threshold): """ Test populate_sample_cell. """ result = contour_grid.populate_sample_cell( sample_cell, sample_cell.area, pygeos_spatial_index(traces), nodes=gpd.GeoDataFrame(), traces=traces, snap_threshold=snap_threshold, resolve_branches_and_nodes=True, ) assert isinstance(result, dict)
def test_snap_trace_simple( idx, trace, snap_threshold, traces, intersects_idx, ): """ Test snap_trace_simple. """ traces_spatial_index = general.pygeos_spatial_index(gpd.GeoSeries(traces)) result, was_simple_snapped = branches_and_nodes.snap_trace_simple( idx, trace, snap_threshold, traces, traces_spatial_index) if intersects_idx is not None: assert was_simple_snapped assert result.intersects(traces[intersects_idx])
def assess_coverage(target_centroid: Point, radius: float, coverage_gdf: gpd.GeoDataFrame) -> float: """ Determine the coverage within a circle. Based on manually digitized gpkg of coverage. """ circle = safe_buffer(target_centroid, radius=radius) index_intersection = pygeos_spatial_index(coverage_gdf).intersection( circle.bounds) candidate_idxs = list( index_intersection if index_intersection is not None else []) if len(candidate_idxs) == 0: return 0.0 candidates = coverage_gdf.iloc[candidate_idxs] coverage_area = gpd.clip(candidates, circle).area.sum() assert isinstance(coverage_area, float) return coverage_area
def populate_sample_cell( sample_cell: Polygon, sample_cell_area: float, traces_sindex: PyGEOSSTRTreeIndex, traces: gpd.GeoDataFrame, nodes: gpd.GeoDataFrame, snap_threshold: float, resolve_branches_and_nodes: bool, ) -> Dict[str, float]: """ Take a single grid polygon and populate it with parameters. Mauldon determination requires that E-nodes are defined for every single sample circle. If correct Mauldon values are wanted `resolve_branches_and_nodes` must be passed as True. This will result in much longer analysis time. """ _centroid = sample_cell.centroid if not isinstance(_centroid, Point): raise TypeError("Expected Point centroid.") centroid = _centroid sample_circle = safe_buffer(centroid, np.sqrt(sample_cell_area) * 1.5) sample_circle_area = sample_circle.area assert sample_circle_area > 0 # Choose geometries that are either within the sample_circle or # intersect it # Use spatial indexing to filter to only spatially relevant traces, # traces and nodes trace_candidates_idx = spatial_index_intersection( traces_sindex, geom_bounds(sample_circle)) trace_candidates = traces.iloc[trace_candidates_idx] assert isinstance(trace_candidates, gpd.GeoDataFrame) if len(trace_candidates) == 0: return determine_topology_parameters( trace_length_array=np.array([]), node_counts=determine_node_type_counts(np.array([]), branches_defined=True), area=sample_circle_area, ) if resolve_branches_and_nodes: # Solve branches and nodes for each cell if wanted # Only way to make sure Mauldon parameters are correct _, nodes = branches_and_nodes( traces=trace_candidates, areas=gpd.GeoSeries([sample_circle], crs=traces.crs), snap_threshold=snap_threshold, ) # node_candidates_idx = list(nodes_sindex.intersection(sample_circle.bounds)) node_candidates_idx = spatial_index_intersection( spatial_index=pygeos_spatial_index(nodes), coordinates=geom_bounds(sample_circle), ) node_candidates = nodes.iloc[node_candidates_idx] # Crop traces to sample circle # First check if any geometries intersect # If not: sample_features is an empty GeoDataFrame if any( trace_candidate.intersects(sample_circle) for trace_candidate in trace_candidates.geometry.values): sample_traces = crop_to_target_areas( traces=trace_candidates, areas=gpd.GeoSeries([sample_circle]), is_filtered=True, keep_column_data=False, ) else: sample_traces = traces.iloc[0:0] if any(node.intersects(sample_circle) for node in nodes.geometry.values): # if any(nodes.intersects(sample_circle)): # TODO: Is node clipping stable? sample_nodes = gpd.clip(node_candidates, sample_circle) assert sample_nodes is not None assert all( isinstance(val, Point) for val in sample_nodes.geometry.values) else: sample_nodes = nodes.iloc[0:0] assert isinstance(sample_nodes, gpd.GeoDataFrame) assert isinstance(sample_traces, gpd.GeoDataFrame) sample_node_type_values = sample_nodes[CLASS_COLUMN].values assert isinstance(sample_node_type_values, np.ndarray) node_counts = determine_node_type_counts(sample_node_type_values, branches_defined=True) topology_parameters = determine_topology_parameters( trace_length_array=sample_traces.geometry.length.values, node_counts=node_counts, area=sample_circle_area, correct_mauldon=resolve_branches_and_nodes, ) return topology_parameters
def get_branch_identities( branches: gpd.GeoSeries, nodes: gpd.GeoSeries, node_identities: list, snap_threshold: float, ) -> List[str]: """ Determine the types of branches for a GeoSeries of branches. i.e. C-C, C-I or I-I, + (C-E, E-E, I-E) >>> branches = gpd.GeoSeries( ... [ ... LineString([(1, 1), (2, 2)]), ... LineString([(2, 2), (3, 3)]), ... LineString([(3, 0), (2, 2)]), ... LineString([(2, 2), (-2, 5)]), ... ] ... ) >>> nodes = gpd.GeoSeries( ... [ ... Point(2, 2), ... Point(1, 1), ... Point(3, 3), ... Point(3, 0), ... Point(-2, 5), ... ] ... ) >>> node_identities = ["X", "I", "I", "I", "E"] >>> snap_threshold = 0.001 >>> get_branch_identities(branches, nodes, node_identities, snap_threshold) ['C - I', 'C - I', 'C - I', 'C - E'] """ assert len(nodes) == len(node_identities) node_spatial_index = pygeos_spatial_index(nodes) branch_identities = [] for branch in branches.geometry.values: assert isinstance(branch, LineString) node_candidate_idxs = spatial_index_intersection( spatial_index=node_spatial_index, coordinates=geom_bounds(branch)) # node_candidate_idxs = list(node_spatial_index.intersection(branch.bounds)) node_candidates = nodes.iloc[node_candidate_idxs] node_candidate_types = [ node_identities[i] for i in node_candidate_idxs ] # Use distance instead of two polygon buffers inter = [ dist < snap_threshold for dist in node_candidates.distance( MultiPoint(list(get_trace_endpoints(branch)))).values ] assert len(inter) == len(node_candidates) # nodes_that_intersect = node_candidates.loc[inter] nodes_that_intersect_types = list(compress(node_candidate_types, inter)) number_of_E_nodes = sum( [inter_id == E_node for inter_id in nodes_that_intersect_types]) number_of_I_nodes = sum( [inter_id == I_node for inter_id in nodes_that_intersect_types]) number_of_XY_nodes = sum([ inter_id in [X_node, Y_node] for inter_id in nodes_that_intersect_types ]) branch_identities.append( determine_branch_identity(number_of_I_nodes, number_of_XY_nodes, number_of_E_nodes)) return branch_identities
def node_identities_from_branches( branches: gpd.GeoSeries, areas: Union[gpd.GeoSeries, gpd.GeoDataFrame], snap_threshold: float, ) -> Tuple[List[Point], List[str]]: """ Resolve node identities from branch data. >>> branches_list = [ ... LineString([(0, 0), (1, 1)]), ... LineString([(2, 2), (1, 1)]), ... LineString([(2, 0), (1, 1)]), ... ] >>> area_polygon = Polygon([(-5, -5), (-5, 5), (5, 5), (5, -5)]) >>> branches = gpd.GeoSeries(branches_list) >>> areas = gpd.GeoSeries([area_polygon]) >>> snap_threshold = 0.001 >>> nodes, identities = node_identities_from_branches( ... branches, areas, snap_threshold ... ) >>> [node.wkt for node in nodes] ['POINT (0 0)', 'POINT (1 1)', 'POINT (2 2)', 'POINT (2 0)'] >>> identities ['I', 'Y', 'I', 'I'] """ # Get list of all branch endpoints all_endpoints: List[Point] = list( chain(*[ list(get_trace_endpoints(branch)) for branch in branches.geometry.values ])) # Collect into GeoSeries all_endpoints_geoseries = gpd.GeoSeries(all_endpoints) # Get spatial index endpoints_spatial_index = pygeos_spatial_index(all_endpoints_geoseries) # Collect resolved nodes collected_nodes: Dict[str, Tuple[Point, str]] = dict() for idx, endpoint in enumerate(all_endpoints): # Do not resolve nodes that have already been resolved if endpoint.wkt in collected_nodes: continue # Determine node identity identity = node_identity( endpoint=endpoint, idx=idx, areas=areas, endpoints_geoseries=all_endpoints_geoseries, endpoints_spatial_index=endpoints_spatial_index, snap_threshold=snap_threshold, ) # Add to resolved collected_nodes[endpoint.wkt] = (endpoint, identity) if len(collected_nodes) == 0: return [], [] # Collect into two lists values = list(collected_nodes.values()) nodes = [value[0] for value in values] identities = [value[1] for value in values] return nodes, identities
def determine_proximal_traces( traces: Union[gpd.GeoSeries, gpd.GeoDataFrame], buffer_value: float, azimuth_tolerance: float, ) -> gpd.GeoDataFrame: """ Determine proximal traces. Takes an input of GeoSeries or GeoDataFrame of LineString geometries and returns a GeoDataFrame with a new column `Merge` which has values of True or False depending on if nearby proximal traces were found. E.g. >>> lines = [ ... LineString([(0, 0), (0, 3)]), ... LineString([(1, 0), (1, 3)]), ... LineString([(5, 0), (5, 3)]), ... LineString([(0, 0), (-3, -3)]), ... ] >>> traces = gpd.GeoDataFrame({"geometry": lines}) >>> buffer_value = 1.1 >>> azimuth_tolerance = 10 >>> determine_proximal_traces(traces, buffer_value, azimuth_tolerance) geometry Merge 0 LINESTRING (0.00000 0.00000, 0.00000 3.00000) True 1 LINESTRING (1.00000 0.00000, 1.00000 3.00000) True 2 LINESTRING (5.00000 0.00000, 5.00000 3.00000) False 3 LINESTRING (0.00000 0.00000, -3.00000 -3.00000) False """ assert isinstance(traces, (gpd.GeoSeries, gpd.GeoDataFrame)) if isinstance(traces, gpd.GeoSeries): traces_as_gdf: gpd.GeoDataFrame = gpd.GeoDataFrame(geometry=traces) else: traces_as_gdf = traces traces_as_gdf.reset_index(inplace=True, drop=True) spatial_index = pygeos_spatial_index(traces_as_gdf) trace: LineString proximal_traces: List[int] = [] for idx, trace in enumerate(traces_as_gdf.geometry.values): candidate_idxs = spatial_index_intersection( spatial_index, geom_bounds(safe_buffer(trace, buffer_value * 5))) candidate_idxs.remove(idx) candidate_traces: Union[ gpd.GeoSeries, gpd.GeoDataFrame] = traces_as_gdf.iloc[candidate_idxs] candidate_traces = candidate_traces.loc[ # type: ignore [ is_within_buffer_distance(trace, other, buffer_value) and is_similar_azimuth(trace, other, tolerance=azimuth_tolerance) for other in candidate_traces.geometry.values ]] if len(candidate_traces) > 0: proximal_traces.extend([ i for i in list(candidate_traces.index) + [idx] # type: ignore if i not in proximal_traces ]) traces_as_gdf[MERGE_COLUMN] = [ i in proximal_traces for i in traces_as_gdf.index ] return traces_as_gdf