def simple_snap(trace: LineString, trace_candidates: List[LineString], snap_threshold: float) -> Tuple[LineString, bool]: """ Modify conditionally trace to snap to any of trace_candidates. E.g. >>> trace = LineString([(0, 0), (1, 0), (2, 0), (3, 0)]) >>> trace_candidates = gpd.GeoSeries( ... [LineString([(3.0001, -3), (3.0001, 0), (3, 3)])] ... ) >>> snap_threshold = 0.001 >>> snapped = simple_snap(trace, trace_candidates, snap_threshold) >>> snapped[0].wkt, snapped[1] ('LINESTRING (0 0, 1 0, 2 0, 3.0001 0)', True) Do not snap overlapping. >>> trace = LineString([(0, 0), (1, 0), (2, 0), (3.0002, 0)]) >>> trace_candidates = gpd.GeoSeries( ... [LineString([(3.0001, -3), (3.0001, 0), (3, 3)])] ... ) >>> snap_threshold = 0.001 >>> snapped = simple_snap(trace, trace_candidates, snap_threshold) >>> snapped[0].wkt, snapped[1] ('LINESTRING (0 0, 1 0, 2 0, 3.0002 0)', False) """ trace_endpoints = get_trace_endpoints(trace) traces_to_snap_to = [ candidate for candidate in trace_candidates if any( endpoint.distance(candidate) < snap_threshold for endpoint in trace_endpoints) ] replace_endpoint: Dict[str, Point] = dict() for trace_to_snap_to in traces_to_snap_to: assert isinstance(trace_to_snap_to, LineString) # Get coordinate points of trace_to_snap_to expect endpoints coord_points = get_trace_coord_points(trace_to_snap_to)[1:-1] if len(coord_points) == 0: # Cannot snap trace to any point as there are only endpoints continue # Check both trace endpoints for endpoint in trace_endpoints: # Check for already existing snap if any( endpoint.intersects(coord_point) for coord_point in coord_points): # Already snapped continue # Get distances between endpoint in coord_points distances: List[float] = [ coord_point.distance(endpoint) for coord_point in coord_points ] # If not within snap_threshold -> continue if not min(distances) < snap_threshold: continue # Get intersection points intersection_points = line_intersection_to_points( first=trace, second=trace_to_snap_to) # Check for overlapping snap if any( intersection_point.distance(endpoint) < snap_threshold for intersection_point in intersection_points): # Overlapping snap # These are not fixed here and fallback to the other snapping # method continue sorted_points = sorted(zip(coord_points, distances), key=lambda vals: vals[1]) if endpoint.wkt in replace_endpoint: error = "Found endpoint in replace_endpoint dict." logging.error( error, extra=(dict( endpoint_wkt=endpoint.wkt, replace_endpoint_len=len(replace_endpoint), )), ) raise ValueError(error) assert endpoint.wkt not in replace_endpoint replace_endpoint[endpoint.wkt] = sorted_points[0][0] # If not replacing is done -> return original if len(replace_endpoint) == 0: return trace, False # Replace one of the endpoints based on replace_endpoint trace_coords = get_trace_coord_points(trace) trace_coords = [ point if point.wkt not in replace_endpoint else replace_endpoint[point.wkt] for point in trace_coords ] # Return modified modified = LineString(trace_coords) return modified, True
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 snap_others_to_trace( idx: int, trace: LineString, snap_threshold: float, traces: List[LineString], traces_spatial_index: PyGEOSSTRTreeIndex, areas: Optional[List[Union[Polygon, MultiPolygon]]], final_allowed_loop: bool = False, ) -> Tuple[LineString, bool]: """ Determine whether and how to snap `trace` to `traces`. E.g. Trace gets new coordinates to snap other traces to it: >>> idx = 0 >>> trace = LineString([(0, 0), (1, 0), (2, 0), (3, 0)]) >>> snap_threshold = 0.001 >>> traces = [trace, LineString([(1.5, 3), (1.5, 0.00001)])] >>> traces_spatial_index = pygeos_spatial_index(gpd.GeoSeries(traces)) >>> areas = None >>> snapped = snap_others_to_trace( ... idx, trace, snap_threshold, traces, traces_spatial_index, areas ... ) >>> snapped[0].wkt, snapped[1] ('LINESTRING (0 0, 1 0, 1.5 1e-05, 2 0, 3 0)', True) Trace itself is not snapped by snap_others_to_trace: >>> idx = 0 >>> trace = LineString([(0, 0), (1, 0), (2, 0), (3, 0)]) >>> snap_threshold = 0.001 >>> traces = [trace, LineString([(3.0001, -3), (3.0001, 0), (3, 3)])] >>> traces_spatial_index = pygeos_spatial_index(gpd.GeoSeries(traces)) >>> areas = None >>> snapped = snap_others_to_trace( ... idx, trace, snap_threshold, traces, traces_spatial_index, areas ... ) >>> snapped[0].wkt, snapped[1] ('LINESTRING (0 0, 1 0, 2 0, 3 0)', False) """ trace_candidates = resolve_trace_candidates( trace=trace, idx=idx, traces=traces, traces_spatial_index=traces_spatial_index, snap_threshold=snap_threshold, ) if trace in list(trace_candidates): error = "Found trace in trace_candidates." logging.error( error, extra=dict(trace_wkt=trace.wkt, trace_candidates_len=len(trace_candidates)), ) raise ValueError(error) # If no candidates -> no intersecting -> trace is isolated if len(trace_candidates) == 0: return trace, False # Get all endpoints of trace_candidates endpoints: List[Point] = list( chain(*[ list(get_trace_endpoints(trace_candidate)) for trace_candidate in trace_candidates ])) # Filter endpoints out that are near to the area boundary if areas is not None: endpoints = [ ep for ep in endpoints if not is_endpoint_close_to_boundary( ep, areas, snap_threshold=snap_threshold) ] # Add/replace endpoints into trace if they are within snap_threshold trace, was_snapped = snap_trace_to_another(trace_endpoints=endpoints, another=trace, snap_threshold=snap_threshold) # Debugging if final_allowed_loop and was_snapped: logging.error("In final_allowed_loop and still snapping:") logging.error(f"{traces, endpoints}") # Return trace and information if it was changed return trace, was_snapped
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 validation_method( geom: LineString, area: gpd.GeoDataFrame, snap_threshold: float, snap_threshold_error_multiplier: float, area_edge_snap_multiplier: float, **_, ) -> bool: """ Validate for trace underlaps. >>> geom = LineString([(0, 0), (0, 1)]) >>> area = gpd.GeoDataFrame( ... geometry=[Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])] ... ) >>> snap_threshold = 0.01 >>> snap_threshold_error_multiplier = 1.1 >>> area_edge_snap_multiplier = 1.0 >>> TargetAreaSnapValidator.validation_method( ... geom, ... area, ... snap_threshold, ... snap_threshold_error_multiplier, ... area_edge_snap_multiplier, ... ) True >>> geom = LineString([(0.5, 0.5), (0.5, 0.98)]) >>> area = gpd.GeoDataFrame( ... geometry=[Polygon([(0, 0), (0, 1), (1, 1), (1, 0)])] ... ) >>> snap_threshold = 0.01 >>> snap_threshold_error_multiplier = 1.1 >>> area_edge_snap_multiplier = 10 >>> TargetAreaSnapValidator.validation_method( ... geom, ... area, ... snap_threshold, ... snap_threshold_error_multiplier, ... area_edge_snap_multiplier, ... ) False """ endpoints = get_trace_endpoints(geom) for endpoint in endpoints: for area_polygon in area.geometry.values: if TargetAreaSnapValidator.is_candidate_underlapping( endpoint, geom, area_polygon, snap_threshold=snap_threshold, ): # if endpoint.within(area_polygon) and geom.within(area_polygon): # Point and trace completely within the area, does not # intersect its edge. if (snap_threshold <= endpoint.distance( area_polygon.boundary) < snap_threshold * snap_threshold_error_multiplier * area_edge_snap_multiplier): return False return True
def validation_method( cls, geom: LineString, trace_candidates: gpd.GeoSeries, snap_threshold: float, snap_threshold_error_multiplier: float, **_, ) -> bool: """ Validate for UnderlappingSnapValidator errors. >>> snap_threshold = 0.01 >>> snap_threshold_error_multiplier = 1.1 >>> geom = LineString( ... [ ... (0, 0), ... (0, 1 + snap_threshold * snap_threshold_error_multiplier * 0.99), ... ] ... ) >>> trace_candidates = gpd.GeoSeries([LineString([(-1, 1), (1, 1)])]) >>> UnderlappingSnapValidator.validation_method( ... geom, trace_candidates, snap_threshold, snap_threshold_error_multiplier ... ) False >>> snap_threshold = 0.01 >>> snap_threshold_error_multiplier = 1.1 >>> geom = LineString([(0, 0), (0, 1)]) >>> trace_candidates = gpd.GeoSeries([LineString([(-1, 1), (1, 1)])]) >>> UnderlappingSnapValidator.validation_method( ... geom, trace_candidates, snap_threshold, snap_threshold_error_multiplier ... ) True """ if len(trace_candidates) == 0: return True endpoints = get_trace_endpoints(geom) for endpoint in endpoints: # Check that if endpoint is well snapped to one trace another trace # won't have chance to cause a validation error (false positive). if any(trace_candidates.distance(endpoint) < snap_threshold): continue trace: LineString for trace in trace_candidates.geometry.values: if (snap_threshold < trace.distance(endpoint) < snap_threshold * snap_threshold_error_multiplier): # Determine if its underlappings or overlapping is_ul_result = is_underlapping( geom, trace, endpoint, snap_threshold, snap_threshold_error_multiplier, ) if is_ul_result is None: raise ValueError( "Expected is_ul_result to not be None.") if is_ul_result: # Underlapping cls.ERROR = cls._UNDERLAPPING return False cls.ERROR = cls._OVERLAPPING return False return True
def conditional_linemerge(first: LineString, second: LineString, tolerance: float, buffer_value: float) -> Union[None, LineString]: """ Conditionally merge two LineStrings (first and second). Merge occurs if: 1. Their endpoints are within buffer_value of each other. 2. Their total orientations are within tolerance (degrees) of each other. Merges by joining their coordinates. The endpoint (that is within buffer_value of endpoint of first) of the second LineString is trimmed from the resulting coordinates. E.g. with merging: >>> first = LineString([(0, 0), (0, 2)]) >>> second = LineString([(0, 2.001), (0, 4)]) >>> tolerance = 5 >>> buffer_value = 0.01 >>> LineMerge.conditional_linemerge(first, second, tolerance, buffer_value).wkt 'LINESTRING (0 0, 0 2, 0 4)' Without merging: >>> first = LineString([(0, 0), (0, 2)]) >>> second = LineString([(0, 2.1), (0, 4)]) >>> tolerance = 5 >>> buffer_value = 0.01 >>> LineMerge.conditional_linemerge( ... first, second, tolerance, buffer_value ... ) is None True """ assert isinstance(first, LineString) and isinstance(second, LineString) # Get trace endpoints first_start, first_end = get_trace_endpoints(first) second_start, second_end = get_trace_endpoints(second) # Get unit vectors from endpoints first_unit_vector = create_unit_vector(first_start, first_end) second_unit_vector = create_unit_vector(second_start, second_end) # Check if unit vectors are close in orientation are_close = compare_unit_vector_orientation(first_unit_vector, second_unit_vector, threshold_angle=tolerance) are_close_reverse = compare_unit_vector_orientation( -first_unit_vector, second_unit_vector, threshold_angle=tolerance) # Get coordinates first_coords_list = list(first.coords) second_coords_list = list(second.coords) if (first_end.buffer(buffer_value).intersects( second_start.buffer(buffer_value)) and are_close): # First ends in the start of second -> Sequence of coords is correct # Do not include first coordinate of second in new new_coords = first_coords_list + second_coords_list[1:] elif (first_end.buffer(buffer_value).intersects( second_end.buffer(buffer_value)) and are_close_reverse): # First ends in second end new_coords = first_coords_list + list( reversed(second_coords_list))[1:] elif (first_start.buffer(buffer_value).intersects( second_start.buffer(buffer_value)) and are_close_reverse): # First starts from the same as second new_coords = list( reversed(second_coords_list))[:-1] + first_coords_list elif (first_start.buffer(buffer_value).intersects( second_end.buffer(buffer_value)) and are_close): # First starts from end of second new_coords = second_coords_list[:-1] + first_coords_list else: return None return LineString(new_coords)